Qwen3.6 27B — a dense 27B with hybrid GDN/full attention and a vision tower — in mixed NVFP4 on one DGX Spark, with the checkpoint's own MTP head speculating 5 tokens ahead: 31.8 tok/s single-stream, 2.6× the no-draft baseline, at the full 262,144-token context.
* segment sizes marked with an asterisk are estimates pending a measured run
Eval scores
(compare all)Bench card
| 2K | 8K | 32K | 128K | |
|---|---|---|---|---|
| decode tok/s | 31.7 | 28.7 | 30.5 | 29.4 |
| ttft | 1.94s | 7.46s | 32.21s | 175.58s |
| prefill tok/s | 1.1k | 1.1k | 1.0k | 748 |
| power | — | — | — | — |
includes 1 community run · @joemuller
median per context · o256
Contributors
Overview
Qwen3.6 27B is a dense 27B with a hybrid attention stack — 48 GDN linear-attention layers interleaved with 16 full-attention layers — plus a 27-block vision tower, and NVIDIA's ModelOpt build quantizes it unevenly on purpose: the MLP and lm_head go to NVFP4 (W4A16, group 16), every attention projection stays FP8, and the vision tower, embeddings and the MTP head stay BF16. At 21 GB it fits one Spark with room to spare, so nothing about this recipe is a memory fight. The fight is bandwidth. Because the model is dense, every decoded token re-reads essentially all of it: 17.61 GB, of which the MLP is 44% and the GDN projections another 26%. Against GB10's 273 GB/s that sets a hard ceiling near 15.5 tok/s, and the measured no-draft rate of 12.14 tok/s is a perfectly healthy 78% of it — nothing is misconfigured, the model is just expensive per token in a way a sparse MoE of the same file size is not. Two consequences shape everything below. First, decode is almost flat in context (12.19 tok/s at a 512-token prompt, 12.03 at 8192) because the KV traffic is noise next to the weights. Second, and this is the whole recipe, speculative decoding pays here far better than it does on a cheap MoE: the checkpoint ships its own MTP head that reads only 0.85 GB per draft step, 4.8% of a target pass, so drafting several tokens and verifying them in one pass is a very good trade. Measured across 13 single-stream runs per setting, decode climbs cleanly and tightly from 12.14 tok/s with no draft to 20.00 at k=1, 26.48 at k=2 and 29.61 at k=3 — and then flattens. Beyond k=3 the curve is essentially level and the measurement gets noisy: k=4, k=5 and k=6 pool to 33.10, 31.75 and 35.58 tok/s with 95% intervals of roughly ±1.2 to ±1.5, wide enough that their ordering is not stable between runs or between our two Sparks. This recipe therefore ships k=5 and publishes 31.75 tok/s — 2.6× the no-draft baseline — which is the *lowest* of the three plateau values rather than the most flattering one, because any of k=4 to k=6 is a defensible choice and pretending to a precise optimum here would be false precision. Three honest limits. The figure is the greedy, code-workload number the site's spec-decoding profile prescribes; on open-ended prose at temperature 0.7 acceptance falls to roughly 37-43% and decode to about 23 tok/s, still nearly 2× baseline but not 2.6×. Concurrency is not supported at this setting — four simultaneous requests kill the engine with a CUDA illegal memory access, which is fine for the single-user target this is tuned for but must be known before putting it behind anything that fans out. And it is a throughput claim only: this build's quality is unevaluated here, and the checkpoint asks for fp8 KV while shipping no k/v scaling factors, which is worth an eval before trusting very long answers.
- 31.75 tok/s single-stream at a 2048-token prompt, warm — 2.6× the 12.14 tok/s no-draft baseline on the same node
- The full 262,144-token context, served — measured at a 131,292-token prompt
- Speculative decoding from the checkpoint's own BF16 MTP head — no draft to download, no vLLM patch
- k=1→3 is a clean, tight climb (20.00 / 26.48 / 29.61 tok/s); past k=3 the curve flattens and k=4-6 are within noise of each other
- max-num-seqs 4, not 2: dropping it costs 9.6% of decode and saves no memory
- Every token reads 17.61 GB, so the roofline is ~15.5 tok/s and the no-draft baseline is 78% of it
- Working set ~44 GiB of 114 usable — memory is not the constraint on this model
- Decode is nearly flat in prompt length (12.19 at 512, 12.03 at 8192 with no draft)
- Single-user only: 4 concurrent requests crash the engine with a CUDA illegal memory access
- vLLM 0.26.0 serves it with no port, no custom backend flag and no quantization flag
- Runs on one Spark; the second node is free for something else
Software requirements
- 1 × DGX Spark (GB10, sm_121), NVIDIA driver ≥ 580 (CUDA-13 capable)
- vLLM 0.26.0 with torch 2.11.0+cu130 and flashinfer-python 0.6.14 — `qwen3_5_mtp` support (arch `Qwen3_5MTP`) landed natively, so no patch is needed for the draft head
- transformers 5.14.1 (the checkpoint declares transformers_version 5.7.0)
- The `hf` CLI to fetch the checkpoint (~21 GB)
- No patches, no out-of-tree model, no --moe-backend, no --quantization flag
Quick start
- 1
Free the node first
Nothing else may be holding unified memory. On GB10 the weights, KV, activations and graph pools all share one 121 GiB pool, so a leftover serve or Ray worker is not merely untidy — it changes what will fit.
pkill -f "[v]llm serve" # the [v] matters: pkill -f "vllm serve" kills your own SSH command ray stop --force 2>/dev/null free -g | sed -n 2p # want ~115 GiB available before you startbashPurging page cache first (~/Dev/vLLM-Moet/spark/purge-cache.py ~/models/hf) speeds nothing up here and slows the first load — the cached shards are what make a reboot-to-serving cycle about two minutes.
- 2
Install vLLM 0.26.0 in its own venv
Torch must be the cu130 build; CUDA 13 is what makes the FP4 path work on sm_121. Pulling from the PyTorch cu130 index gets the right wheel on aarch64.
python3 -m venv ~/venvs/vllm-026 ~/venvs/vllm-026/bin/pip install -q --upgrade pip ~/venvs/vllm-026/bin/pip install vllm==0.26.0 \ --extra-index-url https://download.pytorch.org/whl/cu130 ~/venvs/vllm-026/bin/python -c \ "import vllm,torch;print(vllm.__version__, torch.__version__, torch.version.cuda)" # expect: 0.26.0 2.11.0+cu130 13.0bashIf you already have a second Spark set up, do not install twice — rsync the venv over the RoCE link (`rsync -a ~/venvs/vllm-026/ 192.168.100.2:~/venvs/vllm-026/`). Measured at 9 s, because the paths are identical on both nodes.
- 3
Download the checkpoint
hf download nvidia/Qwen3.6-27B-NVFP4 \ --local-dir ~/models/hf/Qwen3.6-27B-NVFP4 # verify before serving: 3 shards, nothing half-written ls ~/models/hf/Qwen3.6-27B-NVFP4/*.safetensors | wc -l # 3 find ~/models/hf/Qwen3.6-27B-NVFP4 -name '*.incomplete' | wc -l # 0bash~21 GB. Confirm the MTP head really arrived — `grep -c '"mtp\.' ~/models/hf/Qwen3.6-27B-NVFP4/model.safetensors.index.json` should print 15. Without those tensors the speculative config below has nothing to load.
- 4
Serve at k=5 — the shipped configuration
Note what is absent: no --quantization, no --moe-backend, no --tensor-parallel-size, no --enforce-eager. vLLM reads the ModelOpt config out of the checkpoint, resolves the architecture to Qwen3_5MTP, and picks its own kernels.
PATH="$HOME/venvs/vllm-026/bin:/usr/local/cuda/bin:$PATH" \ vllm serve ~/models/hf/Qwen3.6-27B-NVFP4 \ --served-model-name qwen3.6-27b \ --max-model-len 262144 \ --gpu-memory-utilization 0.90 \ --kv-cache-memory-bytes 15032385536 \ --max-num-seqs 4 \ --max-num-batched-tokens 8192 \ --host 0.0.0.0 --port 8000 \ --speculative-config '{"method":"mtp","num_speculative_tokens":5}'bash--kv-cache-memory-bytes is not optional on vLLM 0.26.0 — without it the engine can die during memory profiling with no traceback at all. See troubleshooting; it is the single most confusing failure in this recipe. Write "mtp", not "qwen3_5_mtp"; vLLM accepts the latter but rewrites it.
- 5
Confirm what it actually chose
Four lines tell you the whole configuration was understood. If the architecture is not Qwen3_5MTP, the draft is not loaded and you are measuring the baseline.
grep -E "Resolved architecture|Loading drafter|Sharing target model|GPU KV cache size|Maximum concurrency|skipped memory profiling" serve.logbashExpect: `Resolved architecture: Qwen3_5MTP`; `Loading drafter model...`; `Detected MTP model. Sharing target model embedding weights` (and lm_head); `GPU KV cache size: 386,698 tokens`; `Maximum concurrency for 262,144 tokens per request: 1.48x`. You will also see a marlin warning about no native FP4 compute and a `cudagraph_mode=PIECEWISE` downgrade — both expected, both explained below.
- 6
Warm it up before you trust any number
Speculative decoding JITs Triton kernels on first use, and a cold pass can make a slower k look faster. Send real traffic, then benchmark twice and keep the second run.
for i in 1 2 3; do curl -s http://127.0.0.1:8000/v1/chat/completions \ -H 'Content-Type: application/json' \ -d '{"model":"qwen3.6-27b","messages":[{"role":"user","content":"Write a Python quicksort and a binary search tree with type hints."}],"max_tokens":400,"temperature":0,"chat_template_kwargs":{"enable_thinking":false}}' > /dev/null donebashThe tell that a pass was cold: decode at a 512-token prompt comes out *higher* than at 2048 by a wide margin. Warming matters, but do not expect warm runs to agree closely — at k=5 the per-request spread is ~20%, so judge a change by the median of many requests, never by one pass.
- 7
Check the memory you actually used
free -g | sed -n 2p # steady state, AFTER the API is upbashExpect ~49 GiB used, of which ~5 GiB is the idle OS baseline — a ~44 GiB serving working set against 114 usable. Read this only once the API is answering: mid-load the KV pool does not exist yet, so the figure flatters you by exactly what you are about to allocate.
Key vLLM parameters
| Parameter | Value | Purpose |
|---|---|---|
| --speculative-config | {"method":"mtp","num_speculative_tokens":5} | The single biggest lever in this recipe: 12.14 → 31.75 tok/s, 2.6×. The draft is the checkpoint's own MTP layer, so there is nothing to download and nothing to patch — vLLM 0.26.0 maps model_type qwen3_5 to arch Qwen3_5MTP and shares the target's embeddings and lm_head with it. It pays unusually well here because the target is dense and expensive (17.61 GB/token) while the draft reads only 0.85 GB/step, i.e. 4.8% of a target pass. |
| num_speculative_tokens | 5 (but 4-6 are equivalent within noise) | Pooled over 13 single-stream runs per setting at 2048/256 greedy, medians with 95% intervals: no draft 12.14 ±0.01, k=1 20.00 ±0.14, k=2 26.48 ±0.30, k=3 29.61 ±0.50, k=4 33.10 ±1.13, k=5 31.75 ±1.17, k=6 35.58 ±1.48. The useful, reproducible part of that curve is k=1 to k=3: monotonic, large, and tight. Past k=3 it flattens and the intervals widen enough that the ordering of k=4/5/6 is not stable — we measured k=5 both above and below k=4 on the same node, and k=6 both best (35.58 here) and clearly worst (32.78 on our other Spark). Pick anything in 4-6; we ship 5 as the middle of the plateau and publish its number rather than the highest one. If you care about the last couple of tok/s, sweep on your own workload rather than trusting this ordering. |
| Why the high-k numbers are noisy | per-request spread 20-25% at k≥4 | Worth understanding before you tune. The no-draft baseline is essentially noiseless (stdev 0.01 tok/s over 13 runs) because its work per token is fixed. Under speculation, throughput depends on how many of the k drafted tokens survive verification, which varies with the text being generated — and the benchmark harness deliberately gives every request a unique prefix, so each request generates different text. Variance therefore grows with k: stdev 0.93 at k=3, 2.08 at k=4, 2.15 at k=5, 2.71 at k=6. A single benchmark pass at high k can land anywhere in a ~6 tok/s band, so do not conclude anything from one run; take many requests and compare medians with intervals. |
| --max-num-seqs | 4 | Do NOT drop this to 1-2 because there is one user. vLLM derives CUDA-graph capture size from max_num_seqs × (k+1), so under spec-decode it is a decode knob. Measured at k=5: 2 → 33.59 tok/s (capture size 24), 4 → 37.17 (48), 8 → 37.34 (96). Lowering it to 2 costs 9.6% and saves nothing at all — the KV pool is byte-identical at 386,698 tokens either way. Above 4 the gain is +0.5%, inside noise, so 4 is the cheapest place to sit. |
| --kv-cache-memory-bytes | 15032385536 (14 GiB) | Two jobs. It pins the pool deterministically — 386,698 tokens, 1.48x concurrency at the full 262,144 window, with enough margin that a few percent of boot-to-boot variation cannot leave the pool smaller than max-model-len (which refuses to start). And on vLLM 0.26.0 it is what keeps the engine alive at all: it is an early return before a memory-profiling assert that is unsound on unified memory. Uncapped, this hybrid's cheap KV would take a pool many times larger than one user can fill. |
| --max-model-len | 262144 | The model's full native window, and it genuinely serves — measured at a 131,072-token prompt. Cheap here because only 16 of 64 layers hold a KV cache: at fp8 with 4 KV heads and head_dim 256 a token costs 32.8 KiB, so the entire window is about 8.6 GiB of KV. On a dense-attention 27B this would be the number you had to compromise on; here it is free. |
| --max-num-batched-tokens | 8192 | Raised above the default so long prefills are not chunked differently between the draft and no-draft arms — spec-decode derives max_num_scheduled_tokens from max_num_seqs and k, which otherwise confounds a TTFT comparison. Set identically in both arms when measuring a speedup. |
| --kv-cache-dtype | not set (checkpoint asks for fp8) | Left alone deliberately. The checkpoint declares kv_cache_quant_algo: FP8 and vLLM honours it, which is what makes a token cost 32.8 KiB instead of 65.6. But it ships no k/v scaling factors, so vLLM warns "Using KV cache scaling factor 1.0" — an unquantified accuracy risk. If long-context answers look wrong, --kv-cache-dtype auto is a cheap A/B here precisely because KV is so cheap on this architecture. |
| --enforce-eager | not set (deliberately) | This is a single-node recipe, so the cross-node cudagraph-replay deadlock that forces eager on TP2 configs does not apply, and the captured graphs are worth real decode. Spec-decode does downgrade the mode to PIECEWISE on the FlashInfer backend (see troubleshooting), which means the published 2.6× is measured with the draft arm on the *weaker* graph mode — i.e. conservative. |
| --moe-backend / --quantization | not set (deliberately) | Neither applies or helps. There is no MoE in this model — it is dense, with plain gate/up/down MLPs — so --moe-backend is meaningless, and vLLM reads the ModelOpt mixed-precision config straight out of the checkpoint. It selects marlin for the NVFP4 weights, the Triton/FLA GDN kernel for linear attention, and the FlashInfer backend for full attention, all without being told. |
| --tensor-parallel-size | not set (1 node) | 20.8 GiB on a 114 GiB node needs no sharding, and TP2 would add cross-node latency to every token. Worth noting for anyone tempted: TP2 is the *only* configuration that could raise the 15.5 tok/s roofline, since it would halve the bytes each node reads per token. Untested here. |
| chat_template_kwargs.enable_thinking | false (per request) | This is a thinking model and will otherwise open with a visible reasoning preamble that counts against your token budget. There is no vLLM serve flag for it; pass it per request. The benchmark numbers above are measured with thinking off, so leaving it on will change what you see. |
API usage
Chat completion
Drop enable_thinking to let it reason first — useful for hard questions, but the preamble is billed as output tokens and streams at reading pace.
curl -s http://127.0.0.1:8000/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{
"model": "qwen3.6-27b",
"messages": [{"role": "user", "content": "Explain unified memory on GB10 in three sentences."}],
"max_tokens": 400,
"temperature": 0.7,
"chat_template_kwargs": {"enable_thinking": false}
}' | python3 -c 'import json,sys;print(json.load(sys.stdin)["choices"][0]["message"]["content"])'bashMeasure decode correctly
Warm the server first, then take the median of many runs — not two. Under speculation this config's per-request spread is ~20% (stdev 2.15 tok/s at k=5), so a single measurement is nearly meaningless; the no-draft baseline, by contrast, is reproducible to 0.01 tok/s.
# Count tokens from usage, never by counting SSE frames — speculative
# decoding bundles several tokens into one chunk, so frame-counting
# under-reports decode by roughly the acceptance rate.
curl -s http://127.0.0.1:8000/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"qwen3.6-27b","messages":[{"role":"user","content":"Write a red-black tree in Python with insert and delete."}],"max_tokens":512,"temperature":0,"stream":true,"stream_options":{"include_usage":true},"chat_template_kwargs":{"enable_thinking":false}}' \
| grep -o '"completion_tokens":[0-9]*' | tail -1bashRead the speculative-decode acceptance rate
Always check acceptance > 0 after touching the draft. A broken draft is *safe* — verification simply rejects everything — so the only symptom is decode quietly sinking to or below baseline, which is easy to misread as a bad checkpoint. Note these counters are cumulative for the life of the server, so snapshot before and after.
curl -s http://127.0.0.1:8000/metrics | grep -E '^vllm:spec_decode' | grep -v '^#'
# accepted / draft_tokens = mean acceptance
# ..._per_pos_total{position="N"} / ..._num_drafts_total = per-position
# At k=5 on a greedy code workload, expect roughly:
# mean ~0.70, per-position ~0.936 / 0.815 / 0.681 / 0.581 / 0.484bashTroubleshooting
- The engine dies during startup with NO traceback, right after `torch.compile took NN s`. Memory looks fine, there is no OOM in kern.log, and no core dump. It looks exactly like a segfaulting sm_121 kernel.
- It is not a kernel. vLLM 0.26.0 ends its memory profiling with `assert init_snapshot.free_memory >= free_gpu_memory` — free GPU memory may only *decrease* while profiling. That holds for discrete VRAM and is false on GB10, where mem_get_info reports the whole system pool and rises whenever the OS reclaims page cache. Ours aborted having *gained* 2 GiB: `Error in memory profiling. Initial free memory 89.17 GiB, current free memory 91.27 GiB`. Fix: pass --kv-cache-memory-bytes, which is an early return before the assert (the log then says "reserved 14.0 GiB ... and skipped memory profiling"); profile_run still executes, so kernels and graphs still compile and you lose nothing but auto-sizing. Two corollaries. The AssertionError text often does not reach the log at all — the API server's 35 s startup timeout wins the race and truncates it — so grep for `Error in memory profiling` explicitly rather than concluding it is absent. And do not sit in a loop running `free -g` / `du` / `ps` over SSH while a serve is profiling; churning page cache can *cause* this.
- Which num_speculative_tokens should I use?
- Anything from 4 to 6; we ship 5. Pooled over 13 single-stream runs per setting at 2048/256 greedy, the medians and 95% intervals are: no draft 12.14 ±0.01, k=1 20.00 ±0.14, k=2 26.48 ±0.30, k=3 29.61 ±0.50, k=4 33.10 ±1.13, k=5 31.75 ±1.17, k=6 35.58 ±1.48. Two lessons. First, do not stop at the first k that improves — k=1 leaves more than half the available gain on the table. Second, do not believe a precise peak in the flat region: past k=3 the intervals overlap and the ordering is unstable, and we measured k=6 as both the best setting on one Spark (35.58) and clearly the worst on the other (32.78). Mean acceptance is the wrong thing to optimise on regardless — it falls monotonically the whole way (94.2% at k=1 to 60.7% at k=6) while throughput rises. Per-position acceptance is the more useful signal: at k=5 it runs about 0.936 / 0.815 / 0.681 / 0.581 / 0.484, and the marginal position is still near a coin flip, which is why the curve has flattened rather than turned down.
- I ran the benchmark once and got a very different number from the one published.
- Expected at high k, and it is the reason this recipe publishes a pooled median rather than a best run. Under speculation, throughput depends on how many drafted tokens survive verification, which depends on the text being generated — and the harness gives every request a unique cache-busting prefix, so each request generates different text. Measured per-request spread at 2048/256: 11.3% of the median at k=3, but 20-25% at k=4 through k=6 (stdev 2.08, 2.15 and 2.71 tok/s). Four separate passes of the identical k=5 config on the identical node produced 31.18, 33.87, 33.89 and 35.69 tok/s. By contrast the no-draft baseline has a stdev of 0.01 tok/s, because its work per token is fixed. So: run many requests, compare medians with intervals, and never let a single pass decide a tuning question.
- I get nowhere near 30 tok/s on my own prompts.
- Almost certainly workload and temperature, not configuration. Acceptance is strongly content-dependent and collapses as sampling temperature rises. The published figure is the site's spec-decoding convention: greedy (temperature 0) on a code-generation prompt. The same config on open-ended prose at temperature 0.7 shows per-position acceptance around 0.741 / 0.543 / 0.358 / 0.296 / 0.222 — mean roughly 37-43% — and generates at about 23 tok/s. That is still nearly 2× the no-draft baseline, so speculation is well worth keeping, but a chat user should expect the low twenties rather than the low thirties. Also confirm thinking is off; a reasoning preamble is real output tokens.
- The engine dies with `CUDA error: an illegal memory access was encountered` as soon as more than one request is in flight.
- Speculative decoding on this model does not survive concurrency on vLLM 0.26.0. Four simultaneous requests at k=5 killed the engine outright (`EngineDeadError`, every subsequent request 500s and the server shuts down); the fault surfaces in the FlashInfer metadata builder at `backend.py:510 seq_lens_cpu -> self.seq_lens.to('cpu')`, but CUDA errors are reported asynchronously so the real fault is an earlier kernel, and the dumped config points at the draft (`eagle_head`) path. It is specifically the draft: with the same server flags minus --speculative-config, four concurrent requests all complete normally (HTTP 200, nothing in the log). Concurrency 1 is completely solid — hours of sweeps, a dozen serves, hundreds of requests, no faults. So this recipe is safe for the single-user workstation case it is tuned for, but do not put it behind anything that fans out: either keep concurrency at 1, or drop --speculative-config and accept 12 tok/s. We did not establish whether a smaller k is safe under concurrency — do not assume lowering it is a workaround without testing it yourself. This is also why the published numbers use the spec-decoding profile, which has no concurrency row by design.
- vLLM logs "Your GPU does not have native support for FP4 computation ... Weight-only FP4 compression will be used leveraging the Marlin kernel. This may degrade performance for compute-heavy workloads."
- Expected on sm_121 and harmless here. The warning is about compute-bound work; single-stream decode is bandwidth-bound, and the measured 12.14 tok/s no-draft rate is 78% of this checkpoint's 15.5 tok/s bandwidth roofline, so the dequant path is not what is limiting you. Do not go looking for a faster NVFP4 backend to fix a decode number — check the per-token byte count first (this checkpoint reads 17.61 GB per token, 44% of it MLP).
- "CUDAGraphMode.FULL_AND_PIECEWISE is not supported with spec-decode for attention backend FlashInferBackend ...; setting cudagraph_mode=PIECEWISE"
- Expected, and worth understanding before you quote a speedup. The draft arm runs on PIECEWISE graphs while a no-draft baseline gets FULL, so the 2.9× published here is measured *net of* that handicap and is therefore conservative rather than flattering. Nothing to fix; do not add --enforce-eager trying to make the arms match, as that costs both of them.
- Decode barely changes when I raise or lower --max-model-len.
- Correct, and it is a property of the architecture rather than a bug. This is a dense model whose weights dominate per-token traffic so completely that KV reads are noise: with no draft it measures 12.19 tok/s at a 512-token prompt, 12.14 at 2048 and 12.03 at 8192. Prefill and TTFT are where prompt length shows up, not decode. Spend the flat decode curve on context — it is the cheap axis on this model.
- The KV pool comes up far larger than max-model-len and reports several times the concurrency I need.
- That is what an uncapped pool does on a hybrid-attention model, and it is waste rather than headroom for a single user — vLLM's "maximum concurrency" is just pool ÷ max-model-len, and every multiple above about 1.0x is memory you paid for and cannot use. Because only 16 of 64 layers cache anything, this model's window is already affordable many times over. Invert the usual procedure: set --max-model-len to the model's full 262144 first, then *shrink* the pool with --kv-cache-memory-bytes until concurrency is roughly 1.0-1.5x, and hand the rest back. 14 GiB gives 1.48x. Keep some margin rather than sitting at exactly 1.00x, because the pool varies a few percent between boots and the server refuses to start if it comes up smaller than max-model-len.
- The KV pool shrank after I changed k.
- Expected — the draft's state comes out of the same pool. At a fixed 14 GiB this config held 447,637 tokens with no draft, 414,553 at k=1, 386,698 at k=5 and 380,252 at k=6. So re-read `GPU KV cache size` after every change to k, and do not assume a context length that fit at one k still fits at a larger one.
- My benchmark records draftAcceptanceRate as null even though speculation is clearly working.
- The harness reads draft_n / draft_n_accepted out of the SSE `timings` object, which is a llama.cpp convention; vLLM does not put draft counts in the stream, so every vLLM spec-decode row comes back null. Scrape the Prometheus endpoint instead: `curl -s localhost:8000/metrics | grep '^vllm:spec_decode'`. Take the `_per_pos_total` series divided by `_num_drafts_total` — per-position is the number that actually tells you where k should stop, and the counters are cumulative so snapshot before and after.
- A benchmark reports far fewer tokens than max_tokens, or decode looks impossibly high.
- Two separate traps. If you are counting SSE frames, stop — speculative decoding bundles multiple accepted tokens into a single streamed chunk, so frame-counting under-reports decode by about the acceptance rate. Read usage.completion_tokens differentials instead. And if throughput looks too good, check the text: a repetition loop is trivially predictable, so it drives acceptance toward 100% and inflates tok/s. This build passes that gate — six open-ended 1500-token prose generations showed a maximum repeated 8-gram count of 1 and a mean unique-word ratio of 0.528 — but re-check it yourself after any change to the draft or the quantization.
- Serving with a `--speculative-config` that names `qwen3_5_mtp` behaves oddly, or I am unsure the draft loaded at all.
- Write "mtp": vLLM accepts `qwen3_5_mtp` but rewrites it internally, and the canonical spelling avoids the confusion. Then confirm from the log rather than assuming — you want `Resolved architecture: Qwen3_5MTP`, `Loading drafter model...` and `Detected MTP model. Sharing target model embedding weights with the draft model` (plus the same line for lm_head). If the architecture resolves to Qwen3_5ForConditionalGeneration instead, no draft was loaded and you are measuring the 12 tok/s baseline. Also verify the 15 `mtp.*` tensors are present in the checkpoint's index — the config declares mtp_num_hidden_layers: 1 whether or not the weights shipped.
- A very long prompt takes a long time before the first token.
- This is the honest cost of the long window, and it is worth seeing the curve before you promise anyone 262K. Prefill is compute-bound at roughly 750-1,100 tok/s, and decode barely moves, so length shows up almost entirely as time-to-first-token. Measured on the shipped config: 2,041-token prompt → 31.2 tok/s decode, TTFT 1.9 s; 8,206 → 28.7 tok/s, 7.5 s; 32,777 → 30.5 tok/s, 32.2 s; 131,292 → 29.5 tok/s, 175.6 s. So a 131K prompt is nearly three minutes before the first token appears, while generation itself stays around 30 tok/s. The context is real and it serves — but it is a throughput-friendly, latency-hostile window, and the recipe's headline decode number is measured at 2048 tokens, not at 262K.
- The log says "Setting attention block size to 1568 tokens to ensure that attention page size is >= mamba page size" and pads the mamba page.
- Normal for a hybrid model, not a misconfiguration. The 48 GDN linear-attention layers keep a recurrent state whose page must line up with the full-attention layers' KV pages, so vLLM picks an attention block size that makes them equal (and notes a 0.13% padding). Nothing to tune; it is why the effective KV cost per token is 32.8 KiB rather than a round number.
- Graph capture reports a negative memory delta ("took -0.01 GiB").
- A unified-memory accounting artifact, not a leak or an error. Free memory on GB10 is system-wide and moves for reasons unrelated to vLLM, so a short measurement window can straddle an OS reclaim and come out negative. Ignore the sign; if you need the real graph-pool cost, compare steady-state `free -g` with and without cudagraphs. It is the same underlying property that makes the memory-profiling assert above misfire.
- Should I run this across both Sparks with TP2?
- Not for capacity — 20.8 GiB sits on a 114 GiB node with the full context and ~70 GiB to spare, and TP2 adds cross-node latency to every token plus the RoCE/Gloo setup this recipe otherwise avoids entirely. The one genuinely interesting reason to try it is that TP2 would halve the bytes each node reads per token, which is the only way to move the 15.5 tok/s roofline that bounds a dense model. That is untested here. Also treat our two Sparks as possibly not interchangeable: the same k=3 config measured 29.20 tok/s on one and 30.57 on the other. That gap is suggestive rather than proven — it came from single passes, and we later found per-request spread at these settings is large enough to produce it by chance — but the discipline holds either way, because you cannot rule it out cheaply: never pair a baseline measured on one node with a tuned config measured on the other.
Memory budget
The working set is ~44 GiB of the 114 usable, and that is not a mistake — on this model memory is simply not the binding constraint, so the interesting question is what to do with the surplus. Two properties cause it. First the artifact is small: a dense 27B in mixed precision (MLP and lm_head at NVFP4, all attention at FP8) loads in 20.0 GiB, 20.8 GiB with the draft head. Second, and more usefully, the architecture makes context almost free: `full_attention_interval: 4` means only 16 of 64 layers hold a KV cache at all, and with 4 KV heads at head_dim 256 in the checkpoint's requested fp8, a token costs 32.8 KiB. So the full 262,144-token window needs about 8.6 GiB of KV, not the 40-plus a dense-attention 27B would demand. Left uncapped, vLLM would hand this config a pool many times larger than one user can ever fill; instead the pool is pinned to 14 GiB with --kv-cache-memory-bytes, which yields 386,698 tokens — 1.48x concurrency at the full window, comfortably above 1.0x so a boot-to-boot variation of a few percent cannot leave the pool smaller than max-model-len. Everything else is handed back. Note the draft is not free of KV: the same 14 GiB held 447,637 tokens with no draft and 386,698 at k=5, so re-read the pool after changing k.