Serve the full 262,144-token context of Qwen3.6 35B-A3B on ONE DGX Spark at 106 tok/s single-stream, using the checkpoint's own MTP head for speculative decode at k=3 — a 57% gain over the same config with no draft. Nothing needs patching and nothing is tight: the whole working set is 36 of 114 usable GiB.
* 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 | 88.0×2 | 89.4×2 | 80.6×2 | 71.9×2 |
| ttft | 322ms×2 | 1.19s×2 | 5.88s×2 | 45.20s×2 |
| prefill tok/s | 6.3k×2 | 6.9k×2 | 5.6k×2 | 2.9k×2 |
| power | 34W | 39W | 45W | 51W |
includes 2 community runs · @Fogle, @joemuller
median per context · o256
Contributors
Overview
Qwen3.6 35B-A3B is a 35B sparse MoE (about 3B active per token) with a hybrid attention stack and a vision tower, and it fits comfortably on a single DGX Spark once quantized. This recipe serves Unsloth's NVFP4-Fast build, which quantizes only the expert FFNs to NVFP4 and leaves attention at FP8, and drives speculative decode from the MTP head that ships inside the checkpoint. The shape of the config is set by one fact: on this model KV is nearly free, so context costs almost nothing and the tuning question is not 'how much context can I afford' but 'how many speculative tokens should I draft'. Sweeping that — no draft, then k=1 through k=4 with every other flag held identical — is the whole recipe. Decode climbs 67.9 → 83.7 → 93.4 → 106.5 tok/s and then falls back to 97.2 at k=4, so k=3 is the peak. Everything runs on stock vLLM 0.24.0 with no source patches, no --moe-backend override, and no quantization workarounds. One caveat worth reading before you plan around the context number: the 262K window is real and was exercised at 250,223 tokens, but it is prefill-bound. Decode decays gently with prompt length (100.1 tok/s at 8K, 94.4 at 33K, 75.5 at 131K, 62.9 at 250K) while TTFT does not — it goes 1.2 s → 6.1 s → 46 s → 140 s, because prefill throughput falls from 6,567 to 1,791 tok/s as the attention window grows. A quarter-million-token prompt costs well over two minutes before the first token appears. That is a fine trade for a long document you ask several questions about, and a bad one for interactive work.
- 1 × DGX Spark (GB10, sm_121) — no tensor parallelism, no second node
- Full 262,144-token context, actually served — largest prompt measured was 250,223 tokens
- But long prompts are prefill-bound: TTFT is 1.2 s at 8K and 140 s at 250K — see the curve below
- Measured 106.5 tok/s single-stream decode at k=3, against 67.9 tok/s with no draft — +57%
- MTP speculative decode from the checkpoint's own draft layer: 79% acceptance, 3.37 tokens/step
- The full k sweep is published below — k=4 is slower than k=3, so the peak is real, not the end of the range
- Mixed precision: NVFP4 expert planes, FP8 attention/lm_head, BF16 embeddings and vision tower
- vLLM auto-selects the FLASHINFER_CUTLASS NVFP4 MoE backend on sm_121 — do not force marlin
- Whole working set is ~36 of 114 usable GiB; ~78 GiB stays free
- No vLLM patch needed — the MTP layer is unquantized in the checkpoint and loads as-is
- Warm start ~4 min; always warm up before measuring — cold, decode reads ~25% low
Software requirements
- 1 × DGX Spark (GB10, sm_121), NVIDIA driver ≥ 580 (CUDA-13 capable)
- vLLM 0.24.0 with torch 2.11.0+cu130 — CUDA 13 is required for the FP4 MoE kernels; a cu129 build fails outright
- The `hf` CLI to fetch the checkpoint (~24 GB)
- No patches, no custom backend flags, no sitecustomize workarounds
Quick start
- 1
Free the node first
Anything still holding unified memory — a previous vLLM, a leftover Ray cluster — comes out of the same pool. Check before you start, and drop the page cache the checkpoint download left behind, or vLLM reports 'free memory less than desired GPU memory utilization' at startup.
pgrep -af 'vllm serve|ray::' || echo 'node is clear' ray stop --force 2>/dev/null || true python3 ~/Dev/vLLM-Moet/spark/purge-cache.py ~/models/hf free -gbash - 2
Download the checkpoint
5 safetensors shards, ~24 GB. Only this node needs it — there is no second rank to mirror to.
hf download unsloth/Qwen3.6-35B-A3B-NVFP4-Fast \ --local-dir ~/models/hf/Qwen3.6-35B-A3B-NVFP4-FastbashVerify 5 shards and zero *.incomplete files before serving; a partial tree fails deep into load.
- 3
Serve with MTP speculative decode at k=3
This is the shipped configuration. Note what is NOT here: no --moe-backend, no --tensor-parallel-size, no --enforce-eager, no quantization flag. vLLM reads the compressed-tensors config out of the checkpoint and picks the backend itself.
PATH="$HOME/venvs/vllm/bin:$PATH" VLLM_USE_DEEP_GEMM=0 \ TORCHINDUCTOR_COMPILE_THREADS=2 MAX_JOBS=4 \ vllm serve ~/models/hf/Qwen3.6-35B-A3B-NVFP4-Fast \ --served-model-name unsloth/Qwen3.6-35B-A3B-NVFP4-Fast \ --max-model-len 262144 \ --kv-cache-memory-bytes 4294967296 \ --gpu-memory-utilization 0.85 \ --max-num-seqs 4 \ --max-num-batched-tokens 8192 \ --port 8000 \ --speculative-config '{"method":"mtp","num_speculative_tokens":3}'bashWrite "mtp", not "qwen3_5_mtp" — vLLM accepts the latter but logs 'deprecated and replaced with mtp' and rewrites it anyway.
- 4
Confirm what it actually chose
Three lines in the log tell you the run is healthy. If the MoE backend line says EMULATION, or the KV pool is far off 343,699 tokens, stop and read the troubleshooting section rather than benchmarking.
grep -E "NvFp4 MoE backend|attention backend|Model loading took|GPU KV cache size|Maximum concurrency" /tmp/vllm.logbashExpect FLASHINFER_CUTLASS, FLASHINFER, 22.15 GiB, 343,699 tokens, 1.31x.
- 5
Warm it up before you trust any number
The first requests after a start pay Triton JIT for the speculative-decode kernels (eagle_prepare_next_token_padded_kernel, rejection_greedy_sample_kernel and friends — vLLM warns about each one). This is not a small effect and it is not optional: our first pass at k=2 measured 76.3 tok/s on the 512-token scenario and 91.2 on the identical rerun.
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":"unsloth/Qwen3.6-35B-A3B-NVFP4-Fast", "messages":[{"role":"user","content":"Write an LRU cache in Python."}], "max_tokens":256,"temperature":0}' > /dev/null donebash - 6
Check the memory you actually used
Read this with the API up and idle, not during weight loading — mid-load the KV pool does not exist yet and the figure flatters you by exactly the amount you are about to allocate.
free -g # expect ~40 GiB used of 121 — weights 22.15 + KV 4 + overhead ~10 + OS ~4bash
Key vLLM parameters
| Parameter | Value | Purpose |
|---|---|---|
| --speculative-config | {"method":"mtp","num_speculative_tokens":3} | The single biggest lever, and it is already inside the checkpoint (mtp_num_hidden_layers: 1) — no external draft model to download, no vLLM patch. Measured warm at 2048/256: 67.9 tok/s no draft, 83.7 at k=1, 93.4 at k=2, 106.5 at k=3, 97.2 at k=4. k=3 is the peak. |
| num_speculative_tokens = 4 | tried, reverted | Drafting a fourth token loses. Mean acceptance length does keep rising (3.37 → 3.71) but the marginal position is barely better than a coin flip — per-position acceptance runs 0.874 / 0.734 / 0.614 / 0.493 — and the wasted draft compute costs more than the extra accepted token returns. Warm measurement: 97.2 tok/s at k=4 vs 106.5 at k=3. Published so nobody re-runs it. |
| --kv-cache-memory-bytes | 4294967296 (4 GiB) | Pins the KV pool deterministically instead of nudging the utilization fraction, which is unreliable on GB10 because vLLM's utilization math counts system-wide usage. 4 GiB buys 343,699 tokens = 1.31x concurrency at the full context. Left uncapped at util 0.85 the pool takes 75.12 GiB for 29.1x concurrency — 28 multiples a single user can never spend. |
| --max-model-len | 262144 | The model's full native context, and it genuinely serves — the pool is divided to 1.31x, not left idle at 29x. Do not inherit a smaller value from another config; on this model context is close to free. |
| --max-num-seqs | 4 | Left at 4 rather than dropped to 1-2. vLLM derives max_cudagraph_capture_size from max_num_seqs × (spec_tokens + 1) — 32 at k=3 — so lowering it shrinks graph capture and costs decode. On a speculative config this is a decode knob, not just a scheduler knob. |
| --max-num-batched-tokens | 8192 | Without it, enabling speculative decode drops max_num_scheduled_tokens to 2048 and vLLM warns that it 'may lead to suboptimal performance'. Raising it restores prefill throughput at long prompts; KV is abundant here so the extra scheduling budget costs nothing that matters. |
| --moe-backend | not set (deliberately) | Leave it alone. vLLM auto-selects FLASHINFER_CUTLASS for NVFP4 MoE on sm_121 and it works. Older DGX Spark guidance says marlin is the only backend that runs on GB10 — that was true of a different checkpoint and vLLM build; forcing marlin here would trade a working fused kernel for a dequant-to-FP16 path. |
| --tensor-parallel-size | not set (1 node) | The whole working set is ~36 GiB. Splitting it across two Sparks would add RoCE/NCCL coordination and the GID-index and Gloo-interface gotchas that come with it, in exchange for memory that is not scarce. |
| --enforce-eager | not set (deliberately) | The usual Spark reflex is to disable CUDA graphs when transients are tight. They are not tight here — graph capture took 2 seconds and 0.01 GiB — and the captured graphs are worth real decode speed on a speculative config. |
| chat_template_kwargs.enable_thinking | false (per request) | This is a thinking model and there is no vLLM serve flag for it; the toggle is per-request. Leave it on for real work, but turn it off when benchmarking or the measured output is <think> prose rather than the workload. |
API usage
Chat completion
curl -s http://127.0.0.1:8000/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{
"model": "unsloth/Qwen3.6-35B-A3B-NVFP4-Fast",
"messages": [{"role": "user", "content": "Explain MoE routing in three sentences."}],
"max_tokens": 512
}' | jq -r '.choices[0].message.content'bashMeasure decode correctly
Count tokens from usage.completion_tokens, never by counting SSE frames — speculative decoding bundles several tokens into one chunk, so frame counting under-reports by the acceptance length (~3.4x here).
import json, time, urllib.request, uuid
body = {
"model": "unsloth/Qwen3.6-35B-A3B-NVFP4-Fast",
"messages": [{"role": "user",
"content": f"[{uuid.uuid4().hex[:8]}] Write an LRU cache in Python."}],
"max_tokens": 256, "temperature": 0, "stream": True,
"stream_options": {"include_usage": True}, "ignore_eos": True,
"chat_template_kwargs": {"enable_thinking": False},
}
req = urllib.request.Request("http://127.0.0.1:8000/v1/chat/completions",
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"})
t0 = time.perf_counter(); t_first = None; usage = None
with urllib.request.urlopen(req) as res:
for raw in res:
line = raw.decode().strip()
if not line.startswith("data: "):
continue
if line[6:] == "[DONE]":
break
ch = json.loads(line[6:])
usage = ch.get("usage") or usage
if ch.get("choices") and ch["choices"][0]["delta"].get("content") and not t_first:
t_first = time.perf_counter()
t_end = time.perf_counter()
print(f"ttft {1000*(t_first-t0):.0f} ms")
print(f"decode {(usage['completion_tokens']-1)/(t_end-t_first):.1f} tok/s")pythonRead the speculative-decode acceptance rate
Acceptance is what explains the speedup and tells you whether a different k is worth trying. When the last per-position rate falls near 0.5, you are drafting one token too deep — that is exactly what rules out k=4 here.
grep 'SpecDecoding metrics' /tmp/vllm.log | tail -1
# Mean acceptance length: 3.37 ... Per-position acceptance rate: 0.917, 0.784,
# 0.668, Avg Draft acceptance rate: 79.0%bashTroubleshooting
- Decode looks ~25% slower than expected, and the 512-token prompt benchmarks slower than the 2048-token one.
- You are measuring cold, and on this config that is a big enough effect to reverse a conclusion. Speculative decoding brings its own Triton kernels that JIT on first use — vLLM logs 'Triton kernel JIT compilation during inference: eagle_prepare_next_token_padded_kernel' (and rejection_greedy_sample_kernel, _causal_conv1d_update_kernel, fused_sigmoid_gating_delta_rule_update_kernel). Our first k=2 pass measured 76.3 / 92.1 / 91.0 tok/s across the three scenarios; the identical rerun gave 91.2 / 93.4 / 91.7. A first scenario slower than the second is the tell. Worse, an early cold sweep made k=3 look erratic (probes scattered 79-105 tok/s) and nearly got it rejected in favour of k=2 — warm, k=3 is both the fastest and among the tightest (104.3-106.5, ~1% spread). Send a few hundred tokens of traffic after every restart before recording anything.
- Which num_speculative_tokens should I use?
- k=3 on this model. Measured warm at 2048/256: no draft 67.9 tok/s, k=1 83.7, k=2 93.4, k=3 106.5, k=4 97.2. Acceptance falls as you draft deeper (93.2% at k=1, 86.3% at k=2, 79.0% at k=3, 67.9% at k=4) but through k=3 the extra accepted tokens still outrun the wasted draft compute. At k=4 they do not — the fourth position is accepted 49% of the time. Do not assume more drafting is monotonically better, and do not stop at the first k that improves.
- The context is 262K but a long prompt takes minutes to answer.
- Expected — this config is prefill-bound at length, and the KV headroom that makes 262K affordable does nothing for prefill compute. Measured at k=3, single stream: 8,208-token prompt → 1.25 s TTFT at 6,567 tok/s prefill; 32,780 → 6.1 s at 5,408; 131,292 → 46.4 s at 2,832; 250,223 → 139.7 s at 1,791. Decode holds up far better than TTFT over the same range (100.1 → 62.9 tok/s). If you need interactive latency, keep prompts under ~32K; the long window is for documents you load once and then ask several questions about. Note prefill throughput more than halves by 131K, so the cost is superlinear in prompt length, not linear.
- 'free memory less than desired GPU memory utilization' at startup, even though nothing else is running.
- Page cache from the 24 GB checkpoint download is squatting in the unified pool. Run `python3 ~/Dev/vLLM-Moet/spark/purge-cache.py ~/models/hf` before serving. Check `free -g` — a large buff/cache figure with little 'free' is the signature.
- vLLM logs "method `qwen3_5_mtp` is deprecated and replaced with mtp".
- Harmless — it rewrites the method and proceeds. Write "method": "mtp" to keep the log clean. Both produce an identical SpeculativeConfig(method='mtp', num_spec_tokens=N).
- Should I force --moe-backend marlin, as other DGX Spark NVFP4 write-ups say?
- No. On vLLM 0.24.0 + torch 2.11.0+cu130 this checkpoint auto-selects FLASHINFER_CUTLASS out of ['FLASHINFER_TRTLLM', 'FLASHINFER_CUTEDSL', 'FLASHINFER_CUTEDSL_BATCHED', 'FLASHINFER_CUTLASS', 'VLLM_CUTLASS', 'MARLIN', 'EMULATION'] and serves correctly. The TRT-LLM fused-MoE autotuner logs 'Skipped N unsupported tactic(s)' for trtllm::fused_moe::gemm1/gemm2 — that is it pruning tactics with no sm_121 kernel and keeping the ones that work, not an error. The marlin-only advice belongs to a different checkpoint and build.
- The KV pool comes up around 7.6 million tokens and 29x concurrency. Is that a misconfiguration?
- No — that is what this model does when you let it. Only 10 of 40 layers are full attention (full_attention_interval: 4) with 2 KV heads at head_dim 256, so KV runs ~10.3 KiB/token. The pool is genuinely that big; it is just useless to one user. Pin it with --kv-cache-memory-bytes 4294967296. The usual Spark instinct — raise --max-model-len until concurrency approaches 1.0x — is already satisfied here at the model's full native context, so the leftover memory simply goes unused, and that is fine.
- The log says "Setting attention block size to 2128 tokens to ensure that attention page size is >= mamba page size".
- Expected on this architecture, not a warning to act on. The linear-attention (mamba-style) layers carry a per-sequence state whose page must fit alongside the attention pages, which forces the unusually large block size. It is why per-token KV lands where it does.
- A benchmark reports far fewer tokens than max_tokens, or decode looks impossibly high.
- Two separate traps on this model. First, it is a thinking model: without "chat_template_kwargs": {"enable_thinking": false} the output budget is spent on reasoning prose, and thinking tokens arrive as reasoning_content rather than content, so a naive client sees an empty stream. Second, never count SSE frames to get token counts — speculative decoding emits ~3.4 tokens per chunk at k=3, so frame counting under-reports decode by that factor. Read usage.completion_tokens.
- Engine dies at load with a shape mismatch on an mtp.* parameter.
- Should not happen with this checkpoint — the quant config's `ignore` list contains `re:^mtp.*`, so the MTP layer is stored unquantized, and vLLM 0.24.0 has native Qwen3_5MoeMTP support. If you hit it on a different Qwen3.6 quant, check whether that repo quantized the MTP weights; a 4-bit packed parameter is half the logical width, which reads exactly like a tensor-parallel shard but is not.
Memory budget
This is the rare Spark recipe where memory is not the constraint — the interesting number is how much is left over. The steady-state working set is ~36 GiB against 114 usable, so nothing here is a compromise. Two properties make that possible. First, the artifact is mixed precision, not uniform NVFP4: the 256 routed experts and the shared expert are NVFP4 (E2M1 with FP8-E4M3 scales per 16 weights), while attention q/k/v/o, the linear-attention projections and lm_head stay FP8, and the embeddings, norms, router gates and vision tower stay BF16. That lands the model at a measured 20.58 GiB resident, 22.15 GiB once the MTP draft layer loads — so the draft costs ~1.6 GiB. Second, the model is a hybrid: full_attention_interval is 4, so only 10 of 40 layers hold a real KV cache, and those have just 2 KV heads at head_dim 256. Measured at --gpu-memory-utilization 0.85 with no cap, the KV pool came up at 75.12 GiB = 7,634,689 tokens — 29.1x concurrency at the full 262,144-token context. For a single user everything above ~1.0x is memory bought and not used, so this recipe pins the pool at 4 GiB with --kv-cache-memory-bytes instead, which still yields 343,699 tokens = 1.31x at full context with k=3 drafting. That reclaims ~71 GiB, which is why the column below has so much white space. Note the draft eats KV as well as weights: the same 4 GiB pool holds 406,424 tokens with no draft and 343,699 at k=3, because the MTP layer adds its own KV-bearing layer and the extra draft slots. Segment totals and the KV figure are measured (free -g at steady state with the API up, minus a ~4 GiB idle OS baseline); the split of the 20.58 GiB between expert planes and dense layers is apportioned from parameter counts, not measured per-tensor.