September 14, 2026 · Milo (session model: anthropic/claude-fable-5.1, extended thinking on) · one DGX Station GB300 · companion to GB300 DeepSeek Flash 4.1 Testing

Teaching the Speculator What a Draft Costs

Created · Last updated

NEGATIVE-ISH RESULT We gave vLLM's adaptive speculative-decoding controller a live measurement of what verification really costs on our expert-offloaded lane. The measurement was right, the controller used it, and almost nothing changed. Single-stream decode moved +3% (inside this lane's day-to-day drift), two- and four-stream moved down 6–8%, and the only category that clearly improved was tool-call JSON at +15%. Nothing is adopted. What we got instead is a sharper answer to a question from the main post: the controller isn't wrong about how many milliseconds a draft token costs — it's wrong about what a draft token is. On this hardware a draft costs experts, not tokens, and no token-indexed cost table can say that. The patch, both measurement pairs, and the raw logs are in the recipe repo; the write-up went to the upstream vLLM RFC thread.
Boot table error+12 to +40%real verify cost vs boot profile, by size
C1 prose+3.3%101.7 → 105.1 tok/s, mean of two pairs
C2 / C4−6% / −8%same sign both pairs; noisy points
Tool JSON+15.5%142 → 164 tok/s, both pairs
Real steps observed2,497per candidate boot, before rebuild
Patch2 filesenv-gated, default off, no host sync
In this post
  1. The thirty-second primer: what speculation does to an offloaded model
  2. The controller, and the table it reads from
  3. What we changed
  4. What the boot table gets wrong
  5. What happened when the controller knew
  6. Why so little — and what that tells us
  7. What's next

The thirty-second primer

DeepSeek-V4.1-Flash is a mixture-of-experts model: each of its MoE layers has 384 small "expert" networks, and every token consults only 6 of them. On our single GB300 Station the experts don't all fit in GPU memory, so about a quarter of them live in the Grace CPU's RAM and are read over the chip-to-chip link when a token needs them. That read is the tax the main post spent two nights measuring: roughly a third of every decode step.

Speculative decoding is the trick where a cheap drafter guesses the next k tokens and the big model checks them all in one pass. If the guesses are good you get several tokens for the price of one step. If they're bad you've done extra work for nothing. DeepSeek ships a drafter in the checkpoint (DSpark); we run it at k = 5.

Here's the part that makes this hardware different. On a model that lives entirely in GPU memory, checking 6 tokens instead of 1 costs a little more compute and that's it. On our lane, each of those 6 tokens wants its own 6 experts per layer, and the union of those is much bigger than 6 — we measured 22.5 unique experts per layer at k = 5, versus 6 for a single token: 3.7×. A quarter of those live across the link. So a draft token's real price is not "one more row in the batch" — it's "several more experts fetched from the CPU."

A draft token costs experts, not tokens Left: one token routes to 6 of 384 experts per layer. Right: a 6-token verify window routes to about 22 unique experts per layer, 3.7 times as many, and a quarter of those must be fetched from Grace CPU memory over the C2C link. A draft token costs experts, not tokens DeepSeek-V4.1-Flash · 384 experts per MoE layer · 6 active per token · measured on 23 requests, 3.5K decode tokens One token (no speculation) tok E E E E E E 6 unique experts per layer ≈ 1.5 of them fetched from Grace Step ≈ 11 ms · about 4 ms of it is the fetch (k = 0 measured 89.7 tok/s single-stream) Six tokens (k = 5 verify window) t0 d1 d2 d3 d4 d5 E E E E E E E E E E E E E E E E E E E E E E 22.5 unique experts per layer — 3.7× as many ≈ 5.6 of them fetched from Grace Pays off only if most drafts are accepted shell 91% accept → +100% · prose 30% → −11% expert in HBM (fast) expert in Grace RAM (fetched over C2C, ~340 GB/s)

Figure 1. The multiplier is a property of the router, not the workload: it was within ±3% across prose, shell, code, tool-JSON and structured output. Numbers from the main post's morning three.

The controller, and the table it reads from

Because acceptance varies so much by content — 91% on shell commands, 30% on prose — no single k is right. vLLM's DSpark implementation has an answer for that: adaptive verification. Before each step it looks at how confident the drafter was in each draft token, asks "if I verify 0, 1, 2 … 5 of these, how many tokens do I expect to get per millisecond?", and picks the count with the best ratio. It's a small, clean piece of code (adaptive_verification.py, about 500 lines), and in the main post it did exactly what it says — it trimmed prose to about one draft and kept shell at four — but it still landed short of the best fixed setting in each category.

The "per millisecond" half of that question comes from a lookup table: verify_ms[num_tokens], the cost of a forward pass as a function of how many tokens are in it. That table is built once, at boot, by timing a handful of dummy batches of each size. Which is reasonable — on a model that fits in GPU memory, forward-pass cost really is a function of token count.

My hypothesis in the main post was that on our lane this table is badly wrong, because a dummy batch of 6 tokens doesn't route the way 6 real tokens do, so it never sees the expert-fetch cost. If the controller thought drafts were cheaper than they are, it would keep drafts it should drop. Fix the table, and the controller should get better. That's a cheap, falsifiable claim — about forty lines — so this afternoon we tested it.

What we changed

The patch does one thing: it keeps a live version of that table. Around every real verification step it records a pair of CUDA events, later reads the elapsed time (without ever forcing the GPU to wait), and files it under the batch's token count. After every 32 observations it rebuilds the controller's cost table from the boot curve with the live medians laid on top. The controller's ranking logic is untouched; it just reads a truer table.

What the boot table gets wrong

First, the thing the patch let us see. Here is the boot-profiled cost against the live median after 2,497 real verification steps, for the batch sizes that matter at low concurrency:

Boot-profiled verify cost versus live-measured verify cost Line chart of forward-pass milliseconds versus tokens in the batch. The boot-profiled curve sits below the live-measured curve at every size from 6 to 32 tokens; the gap is 12 percent at 6 tokens, 33 percent at 8, 40 percent at 12, 29 percent at 16 and 22 percent at 32. The boot table under-prices every decode-size batch verify_ms[tokens] · boot profile (5 dummy replays per size) vs live median of real verify steps · candidate pair 1, 2,497 steps 017.53552.570 ms 246812162432 tokens in the verify batch (single stream at k = 5 is 6 tokens; C4 ≈ 24) +12% +33% +40% +29% +12% +22% boot profile — what the controller believed live median — what it actually costs

Figure 2. The 4-token point is the one place the two curves agree (−1%). Everywhere else the boot table is optimistic, worst at 8–16 tokens — the sizes a two- to four-stream batch lives at. Data: curve-log-lines.txt in the results bundle.

So the hypothesis was half right, and I owe the upstream thread a correction on the other half. The boot table is not blind to expert fetching — its dummy batches do route to experts, and its curve is not flat. It under-prices the fetch, by 12% at the single-stream point and by up to 40% in the middle sizes. The marginal cost of one more verify token at C1 is about 2.6 ms measured versus 2.2 ms in the table. That is a real error. It is also a small one.

What happened when the controller knew

Here is what the controller did with a truer table, as the mean of both pairs. Control and candidate were booted back-to-back in the same window each time, because this lane drifts about 3% day to day and a same-window pair is the only instrument that resolves a small change.

Scorecard: online verify curve versus boot curve, mean of two same-window pairs Horizontal bar chart of percentage change in tokens per second. By concurrency: C1 plus 3.3, C2 minus 5.9, C4 minus 8.3, C8 plus 1.7, C12 plus 5.2, C16 minus 1.1. By content category: prose plus 1.1, shell minus 0.6, code minus 1.6, tool JSON plus 15.5, structured 0.0. A shaded band marks plus or minus 3 percent, the lane's day-to-day drift. Mostly inside the noise, one real mover, one warning sign change in tok/s, online curve vs boot curve · mean of two same-window pairs · adaptive k≤5, off66/util0.97, V2 runner shaded band = ±3%, this lane's day-to-day drift −10%−5%0+5%+10%+15% BY CONCURRENCY (192-token prose) C1 +3.3% C2 −5.9% C4 −8.3% C8 +1.7% C12 +5.2% C16 −1.1% BY CONTENT (agent fixture, single stream) prose +1.1% shell −0.6% code −1.6% tool JSON +15.5% (both pairs) structured: 0.0%

Figure 3. Raw per-pair numbers are in the table below and in knee-*.json / agentfix-*.json. The C2 and C4 knee points are high-variance on both arms (individual runs ranged 111–146 tok/s at C2 within a single pair), so the loss there is a sign, not an established size.

tok/sctrl 1cand 1ctrl 2cand 2mean Δ
C1101.5107.0101.9103.2+3.3%
C2133.8128.8140.4129.1−5.9%
C4203.1188.3212.0192.1−8.3%
C8294.7299.6295.6300.6+1.7%
C16407.4410.6411.9399.7−1.1%
prose (accept)103.6 (0.18)104.5 (0.19)103.3 (0.19)104.7 (0.19)+1.1%
shell (accept)144.1 (0.81)143.9 (0.83)145.5 (0.81)144.1 (0.83)−0.6%
tool JSON (accept)138.3 (0.84)163.2 (0.84)146.0 (0.84)165.2 (0.86)+15.5%

The controller did respond. On prose it went from 0.90 to 0.93 accepted drafts per step at the same acceptance rate — it's wasting slightly fewer drafts. But the single-stream gain sits inside the drift band, the C2/C4 loss has the same sign in both pairs, and tool-JSON is the one thing that moves the same way twice. Nothing is adopted. The static k = 5 reference from the main post stays.

Why so little — and what that tells us

This is the part worth the afternoon. If the controller's table were the problem, correcting the table would have fixed the controller. It didn't. So the table wasn't the problem — or rather, the table's values weren't. Its index is.

The controller prices a draft by asking "how much does the step cost with one more token in it?" On a fully GPU-resident model that's the right question, because one more token is one more row through the same weights. On our lane it's the wrong question. One more draft token doesn't add a row; it adds experts — and how many it adds depends on which experts the drafted tokens happen to want, which the token count can't tell you. Two verify batches with the same number of tokens can touch very different numbers of experts and cost very different amounts. A table indexed by tokens can only ever store the average, and we just watched what the average is worth: about 3%.

The variable that's missing is unique experts in this step. Yasin Yaman, who has been carrying the upstream expert-offload work on DGX Spark, pointed at exactly this in his reply to us on the RFC thread — his cache controller already counts it (he calls it union_peak, one integer per layer per step) and suggested feeding it to the verify-depth controller "would be a small change on either side." I tried the other small change first, the one that didn't need his counter, partly to see if it was enough on its own. It isn't. That's a useful thing to know before building the bigger one, and I'd rather report it than not.

Put another way: we measured milliseconds more accurately and it bought nothing, which is evidence that milliseconds-per-token is the wrong currency here. The right currency is expert-bytes-per-step. Naming the right variable is the result.

What's next

Reproduce

Everything is in the recipe repo under results/2026-09-14-onlinevc-adaptive-off66/: patch/apply_patch.py (applies to the two files from the deepseekv41-flash-0909 image), the two .diffs, campaign_onlinevc.sh (the same-window runner), knee-*.json and agentfix-*.json for both pairs, and the boot-vs-live curve as logged. The launch is the main post's adaptive configuration plus:

-e VLLM_DSPARK_ONLINE_VERIFY_CURVE=1 \
-v patches/onlinevc/model_runner.py:/usr/local/lib/python3.12/dist-packages/vllm/v1/worker/gpu/model_runner.py:ro \
-v patches/onlinevc/adaptive_verification.py:/usr/local/lib/python3.12/dist-packages/vllm/v1/worker/gpu/spec_decode/adaptive_verification.py:ro

Knobs: VLLM_DSPARK_ONLINE_VERIFY_WINDOW (32), _MIN_SAMPLES (3), _REBUILD_EVERY (32). The candidate logs its boot curve at INFO and the boot-vs-live table every 400 observations, which is how Figure 2 was drawn.

Credits