GB300 DeepSeek Flash 4.1 Testing
dsv41-feat branch pins the 189 GiB Engram tables in Grace RAM and reads 60 GiB of routed experts over CUDA UVA (70 until September 12); the in-checkpoint DSpark drafter (mtp.*, block 5) is loaded from the same weights. Decode is content-dependent: 89 tok/s single-stream on prose (the tight, repeatable instrument; was 82), 140–160 on code, shell and tool JSON where the drafter accepts 64–91%. A short request no longer waits behind a long prefill (one scheduler flag; 18 s → 1 s). Under the real Hermes agent loop it called tools on 10 of 10 turns that needed one. September 12 update: the reference moved. A second night on the tight instrument found that decode is bound by expert bytes fetched over the Grace link, and that the cheapest way to cut them is fewer GiB of experts in Grace, not more GPU headroom. OFFGB 70 → 60 with util 0.97 put 12.7 GiB back in HBM and lifted the sweep to 89.2 tok/s at C1 (+12% same-day, +9% vs the old 82.1), +10% at C16, every category up, acceptance unchanged — see night two. The price is KV: 4.9 GiB, 2.3M tokens, 2.2 concurrent full-1M requests. To reproduce it, use the formal recipe (pinned digests, scripts, provenance, gates — v12, updated September 12, is the off60 config); this post is the story. Cold boot is 8 minutes from local NVMe (it was 92 this morning over SMB). Wired into Hermes as an experiment provider and passing real tool-use turns at 1M. Not promoted; the production lane on this Station is untouched.
Release “Sixty” — September 12, 2026: the campaign closes, the lane runs
--cpu-offload-gb 60 --gpu-memory-utilization 0.97, DSpark k=5. 89.6 tok/s single-stream prose (+14.7% over v11 in a same-window pair), 241 aggregate at eight, 312 at sixteen; 145–160 tok/s on shell, code and tool JSON; 10/10 Hermes tool calls; 207K-token prefill in 11.4 s; 2.2 concurrent 1M contexts. Recipe, launcher and this page agree. Named for the lever: 60 GiB of routed experts in Grace is the floor at 1M context, and every other axis was measured against it.
Two nights and a day of measurement end here, on purpose. The remaining levers are either closed by hardware (expert residency), closed by the model (no static speculation depth fits both prose and agent text), or belong upstream (adaptive verification's ~6.5 GiB varlen graph capture; a per-expert-pointer MoE backend). The cudagraph-mode override I filed as vllm#56626 was already fixed on main by #55095; I backported it and tested it the same evening — the override is gone and the memory tax is not (evening three). The lane runs on these flags until one of those moves. What follows is the record of how it got here, newest first.
| Question | Answer | Where |
|---|---|---|
| What is the decode step made of? | MoE expert streaming: 64% of GPU time single-stream, 88% at eight streams. Attention 17% / 6%. Everything else single digits. The Grace fetch is ~9 ms of a 24 ms step. | morning three |
| Can we keep the cold experts in Grace and the hot ones in HBM? | No, not on this hardware. The skew is real (98.7% of routed selections hit 73% of experts) but the GPU reads Grace either through ATS at 340 GB/s with no placement control, or through its own page table at 90 GB/s with control. Managed memory migrates whole ranges on fault and thrashes to 155 GB/s once oversubscribed. Residency axis closed at off60. | night two |
| Is k=5 the right speculation depth? | No single k is. A 6-token verify window touches 3.7× the unique experts of one token (router property, ±3% across categories). Static k=1 wins prose (+8%) and every multi-stream point (+23–33%); k=5 wins agent traffic by a third. vLLM's DSpark adaptive verification lands between with no losing category but misses the promotion bar (prose 104.1 vs 105, shell 145.3 vs 150) on the V2 runner's ~6.5 GiB graph-capture cost. I first blamed the 29 piecewise graphs that model_runner.py forced by overriding cudagraph_mode; with that override fixed (#55095, backported and tested) the piecewise graphs are gone and capture still takes 6.57 GiB — the cost is the varlen FULL decode graphs themselves. Both static points and the adaptive result are in the recipe; k=5 stays. | morning three |
| What did the first two nights buy? | Night one: five levers, nothing adopted, the autotune-hash lesson. Night two: k-sweep (k=0 89.7, k=3 87.7, k=5 79.5 same-day), util ≠ offload, and the residency change that became v12. | night one, night two |
| Upstream | Three comments on vLLM RFC #38256 (expert-offload provider design): C2C measurements, the two-path finding, the 3.7× multiplier, and the adaptive-verification result. One bug filed and closed: adaptive verification silently overrode cudagraph_mode; #55095 had fixed it on main a day earlier, a contributor pointed that out, I verified it by backport and closed the issue with a correction to my own premise (the override was real, the memory attribution was wrong). | #38256, #56626 |
| What would reopen this | A cheaper varlen FULL capture for adaptive verification (adaptive at off60 is the one config that might beat both static points — #56626 turned out not to be the path there); a cuDNN discrete-mode grouped GEMM path in vLLM's MoE backends; or a different box. Not: more offload ladder, more k values, host-memory tricks. | — |
Sections below run newest first. The September 10 head-to-head, knee, 1M-context, prefill, mixed-workload and SGLang material is intact under The first day.
Evening three, September 12: the upstream fix arrives, and it is not the fix
The override I filed as vllm#56626 had already been fixed on main a day earlier by #55095, which a contributor (he-yufeng) pointed out within hours: model_runner.py no longer forces FULL_AND_PIECEWISE; a new resolve_adaptive_cudagraph_mode() returns a user's FULL_DECODE_ONLY unchanged. I could not simply pull a newer image — DeepSeek-V4.1 support lives only on the dsv41-feat branch, 377 commits behind main, and there is no post-#55095 deepseekv41-flash tag — so I backported the PR's three files (model_runner.py, adaptive_verification.py, compilation.py) onto the 0909 image, bind-mounted over site-packages, and booted adaptive verification at the v12 flags (off60, util 0.97, k=5, V2 runner) with cudagraph_mode=FULL_DECODE_ONLY. Same-window control on the v12 reference first: 89.1 C1 / 310 C16, consistent with last night.
| Boot | Mode honored | PIECEWISE graphs | Graph capture | KV at off60 |
|---|---|---|---|---|
| Adaptive, 0909 as shipped (needed off66) | No — forced FULL_AND_PIECEWISE | 29 | 7.07 GiB | — |
| Adaptive, 0909 + #55095 backport | Yes — FULL_DECODE_ONLY | 0 | 6.57 GiB | −0.21 GiB, no boot |
| v12 reference, static k=5, no adaptive | n/a | 29 | 1.08 + 1.52 GiB | 4.89 GiB |
So the fix does what it says and my premise was half wrong. The silent override was real and is gone; the piecewise graphs it forced were about half a gigabyte of the seven. The rest is the varlen FULL decode graphs adaptive verification captures (supports_varlen=True, next_n=6: two FULL captures plus twelve DSpark captures), which the non-adaptive DSpark path does not need. Adaptive still cannot boot at off60 on this box, and the residency verdict from the morning stands. I closed #56626 as fixed with that correction rather than retitle it — the reported bug is fixed; whether a ~6 GiB varlen capture is a bug or the price of the feature is a different question, and I have one data point from a backport on a fork branch, which is not enough to file on.
Two campaign lessons, both self-inflicted: the safety check refused only dsfv-* containers, so a GLM lane on :30001 was up when the first launch went out and had to be stopped by hand; and local tag=$1 logf=…$tag killed the waiter under set -u for the second time this week, after it had already been written into the ledger. The engine had already failed by then, so no data was lost, only the lesson was re-learned.
Morning three, September 12: where the step goes, and speculation depth
With residency closed the remaining question was what the decode step is actually made of. A profiler boot (same v12 flags, torch profiler on, 89.5 tok/s so the overhead is negligible) says: MoE expert streaming is 64% of GPU time single-stream and 88% at eight streams; attention 17% and 6%; everything else single digits. Against the all-HBM two-station reference for the same model, the Grace fetch is about 9 ms of a 24 ms step.
Then a fact from the routing capture that reframes DSpark on this box. A six-token verify window does not touch six times one token's experts — it touches 22.5 unique experts per layer against 6.0 for a single token, 3.7×, and the multiplier is the same ±3% across prose, shell, code, tool JSON and structured output. It is a property of the router, not the workload. A quarter of those experts live in Grace. So speculation at k=5 streams 3.7× the bytes through the slow link, and only categories that accept enough drafts pay it back. That is exactly the T1 split: shell at 91% acceptance doubled, prose at 30% lost 11%.
We measured both ends of that. Static k=1, same window as a k=5 control: 110.8 tok/s single-stream (+23%), 422 aggregate at sixteen (+33%), prose +8% — and code, tool JSON and shell down by a third. No static depth fits the lane. Then vLLM's DSpark adaptive verification — the V2 model runner's confidence-head controller that prices each request's drafts per step and trims the batch before the forward. It works as described: prose gets trimmed to about one draft and lands within 2% of the static k=1 boot; shell keeps its drafts. It is the first configuration with no losing category. It still misses the promotion bar (prose 104 vs ≥105, shell 145 vs ≥150), for two reasons that are upstream's, not the controller's: the V2 runner's graph capture takes about seven gigabytes against one on V1, which forced six more gigabytes of experts into Grace to boot at all (that evening's backport of the upstream fix showed the piecewise graphs were only half a gigabyte of it — evening three); and the cost curves it prices from are profiled on dummy batches that never see expert streaming, so on an offloaded lane it under-prices verification. Both are fixable. Neither is fixable by us today.
v12 static k=5 stays the reference. Three lessons for the ledger: any change to the verify shape — depth, runner — is a new FlashInfer autotune hash and a 75-minute boot, no matter what was cached; the V2 runner costs residency on a memory-bound lane; and a fast-fail check that matches WARNING lines will kill a healthy boot, which one did.
Night two, September 11–12: is decode fetch-bound, and what moves it
The first night taught me to put the bar on the concurrency sweep. The second night asked one question with that instrument: is decode bound by expert bytes fetched over the Grace link, and by how much? Five boots, one axis each, every one re-measured against a same-window reference because the reference itself drifts about 3% day to day (yesterday's 82.1 came back as 79.5 on the same config). Prod on :30003 was not touched; the reference container was restored after every window.
| Boot | Axis | Offloaded | KV | knee C1 / C8 / C16 | Fixture prose / shell | Read |
|---|---|---|---|---|---|---|
| T1a | DSpark off (k=0) | 73.83 GiB | 19.96 GiB | 89.7 / 248 / 352 | 84.6 / 77.9 | Per-step floor with no verify batch. 11.1 ms/step. |
| T1b | DSpark k=3 | 73.83 | 10.64 | 87.7 / 245 / 331 | 95.9 / 129 | Prose +6.8% over k=5; the plan's confirm bar was +25%. Not adopted. |
| T1c | DSpark k=5 (reference, same-day) | 73.83 | 10.05 | 79.5 / 229 / 282 | 89.8 / 146 | Same-day control. 3% under yesterday's 82.1 — the drift band. |
| T2 | util 0.94 → 0.97 | 73.83 | 17.54 | 79.5 / 229 / 278 | 90.3 / 145 | Void. The extra 7.5 GiB went to KV. util does not move experts; only --cpu-offload-gb does. |
| T2b | OFFGB 70 → 60 + util 0.97 | 61.17 | 4.89 | 89.2 / 234 / 311 | 97.7 / 160 | Win. +12.2% C1 same-day, +10% C16, all five categories up 10–12%, acceptance unchanged at 59.6%. |
Read the k-sweep first. With the drafter off, the lane does 89.7 tok/s. catid's two-Station vLLM PP2 run — every weight in HBM, no Grace fetch — does 141.3 autoregressive at C1 on the same model family (their revision dba1be0a, ours df42c109). That is 7.1 ms per step against our 11.1: the expert-fetch tax is about 4 ms, roughly 36% of the step. Real, not dominant. The same comparison explains why DSpark is a prose loss here (k=5 79.5 vs k=0 89.7) and a 79% win in HBM (141 → 253): a k=5 verify batch touches more unique experts per layer than a single token, and over C2C every one of them costs. DSpark stays on because this is an agent lane and shell/tool JSON accept at 88–91% — 160 tok/s on shell vs 78 with it off.
Then the lever. Moving 12.7 GiB of experts from Grace to HBM bought 9.7 tok/s — about 0.76 tok/s per GiB, twice what the linear bytes model predicted. That is the finding: the lane is more fetch-sensitive than the k=0 floor test suggested, and the residency split is the knob. It also closes T3 (--kv-cache-memory) before spending it: KV sizing does not touch the expert set either. The ceiling is set by host memory, not HBM; Grace sits at 432/494 GiB with no swap after this change, and the checkpoint page cache is the only reclaimable pool left.
The boot tax is worth stating plainly: every change to kernel shapes gets a new FlashInfer autotune hash and a 74-minute tune, three times last night. Seeding the previous config into the new directory does not skip it — the tuner ignores entries whose shapes differ. Only an exact hash hit (T1c, T2: 8-minute boots) skips it. Budget it or don't change shapes.
Not adopted, and why: k=3 (prose +7%, shell −20%); util alone (moves KV, not experts); huge pages on shmem (ShmemHugePages stayed 0 through every boot — pinned host copies are not governed by that sysctl); T3 as written; any expert pruning (James's standing rule: all 384 stay). Measured at 02:30 (T5): a per-(layer, expert) selection histogram on the agent fixture via --enable-return-routed-experts — 23 requests, 3,545 decode tokens, 850K selections across 40 MoE layers. The top 73% of experts by frequency take 98.7% of decode selections (uniform would be 73%). If the coldest 24% of each layer's experts — the same byte budget v12 offloads — were chosen by usage instead of by layer position, they would see 2.7% of the traffic instead of 24% (leave-one-request-out). The catch is that the ranking is domain-specific: agent-class and prose expert ranks correlate at 0.15, and a cold set chosen on agent traffic only gets prose to 20.5%. So usage-aware placement is worth most of the remaining fetch tax for the workload it was profiled on, and roughly nothing for a foreign one; a per-token cache is not the answer either (an oracle cache at 3× top_k hits 68% with 384 experts and k=6). The design for the static version was a CUDA VMM mapping (one virtual range per layer, expert rows backed by HBM or Grace physical pages, CUDA PG §4.19) so the kernel, shapes, and autotune hash never change. Spiked at 05:40, and the elegant version is dead on this driver: every mechanical step passes — host-NUMA cuMemCreate on 595.84, mixed backing under one address range, a torch view over it, remapping a row from Grace to HBM in place with the pointer unchanged — but the GPU reads EGM-backed host memory at 91 GB/s against 350 GB/s for the cudaHostAlloc-pinned memory UVA uses today, over the same C2C link. A cold set that sees 2.7% of traffic at four times the per-byte cost is still cheaper than 24% at normal cost, but not by enough to move rows off the path that just bought +14.7%. The fallback is the shape everyone else uses — pinned Grace tensor as-is plus a small HBM hot-row tensor and a row-map inside the kernel — which brings back the autotune hash and the parity gate. Whether that is worth two or three days for a single-stream lever is the next decision, not the next task.
Then the managed-memory spike settled it (06:30). The hope was that Grace-Blackwell's unified memory would do the hot/cold split for free: give the expert tensors to cudaMallocManaged, let hardware access counters pull hot pages into HBM, leave cold ones in Grace, same pointer, same kernel. Measured: every path from the GPU into Grace memory is one of two things. Pinned or cudaHostRegister'd memory goes through ATS — the page table shared with the CPU — at 340 GB/s, the link ceiling, with no per-page placement control. Anything mapped through the GPU's own page table — the VMM rows above, or managed memory told to prefer the CPU — reads at 90 GB/s. Plain managed memory does not do hot-page migration on this driver at all; it faults the whole range into HBM on first touch, and once the working set is bigger than free HBM (which expert tensors always are, by 60 GiB) it settles at 155 GB/s — slower than what UVA does today. There is no mechanism on this box that holds cold experts in Grace at full speed while hot ones sit in HBM under one pointer.
So the residency axis is closed at off60. Not because the skew is not there — it is, 98.7% — but because the hardware offers speed or placement, not both. The one design left is a second HBM tensor for hot rows with a row-map inside the kernel, which brings back the 74-minute autotune and the parity gate for at most another +20% single-stream. Not doing it. What is banked: +14.7% from moving 12.7 GiB, a fetch tax measured at ~27% of the decode step and known to be link-bound, a routed-expert histogram nobody had published for this model, and three negative numbers that anyone building an expert-offload provider for GH200/GB200/GB300 needs before they design one. I also left a data-point comment on vLLM's expert-offload RFC, since its numbers are all PCIe consumer GPUs and the coherent-memory case wants a different provider shape.
Overnight, September 10–11: five levers, nothing adopted, one lesson
With the config settled I wrote a plan and handed the box to a second agent for the night: measure prefix-cache TTFT, then try transparent huge pages, unpinned host memory (VLLM_WEIGHT_OFFLOADING_DISABLE_PIN_MEMORY=1), --language-model-only, and the Rust frontend, each with a mechanism check and a win bar. The operator ran it exactly as written and reported two wins. In the morning I reran the win metric three times and it was noise (previous section). Re-read on the concurrency sweep:
| Experiment | Mechanism check | C1 vs 82.1 | Verdict |
|---|---|---|---|
| Prefix cache, 8.8K-token system prompt | hits 0 → 34k | — | TTFT 0.665 → 0.360 s warm. Measurement, works as advertised. |
THP enabled=always | AnonHugePages 0.5 → 2.5 GB (needed ≥50) | 78.4 | Did not engage. The 354 GB of offloaded weights sit in shmem, and shmem_enabled is never; the enabled knob was never the relevant one. Tried again on the unpinned path: same 2.5 GB, 77.3 tok/s. |
| Unpinned host memory (ATS) | host used 450 → 384 GB | 76.3 (−7%) | Real mechanism, wrong direction. 66 GB of RAM back that nothing needs, at a decode cost. Reverted. |
--language-model-only | KV 10.05 → 11.14 GiB | 76.2 | +1.1 GiB, not the 3 needed to make offload 65 fit. |
VLLM_USE_RUST_FRONTEND=1 | tool parser survived | 76.0 | No decode gain; 6.5K-token TTFT 0.37 → 0.52 s. Reverted. |
The lane went back to last night's reference at 5:56 AM and re-measured 80.1 tok/s at C1. The lesson is not about any of the flags. It is that I set the win bar on the instrument I had, not the one that was tight, and a diligent operator hit the bar honestly. Bars go on the sweep now, and the recipe says so out loud.
The first day, September 10: head-to-head, knee, 1M context, prefill
Everything in this block was measured on the September 10 v11 configuration (OFFGB=70 UTIL=0.94, 82 tok/s prose). Numbers that moved with v12 are called out where they appear; prefill, mixed-workload and harness gates were re-run on v12 on September 12 and did not move.
Head-to-head, same box, same weights, same fixture
| Fixture (131K ctx, temp 0) | SGLang OffloaderV2 | vLLM UVA |
|---|---|---|
| C1 single stream | 3.3 tok/s | 85.2 tok/s |
| C4 aggregate / per stream | 13.0 / 3.25 | 172.7 / 43.2 |
| C8 aggregate / per stream | 25.8 / 3.23 | 183.5 / 22.9 |
| 300-word paragraph (~313 tok) | 94.9 s | 3.5 s |
| Count 1–60 (120 tok) | 36.9 s | 1.5 s |
17 × 19 → 323 | 22.4 s (cold) | 0.09 s |
Tool call get_weather | parsed | parsed |
Thinking, low effort → 36 | 25 reasoning tok | 24 reasoning tok |
Concurrency sweep: where is the knee?
The first head-to-head used 128-token generations, short enough that prefill and scheduling noise dominated at C8. Re-run with 192 forced output tokens, two reps per point, two server configs. max-num-seqs matters: at the default 8, anything above eight streams queues, so C12/C16 read as a flat ~270 that is a scheduler cap, not a bandwidth wall.
| Concurrency | seqs=8 agg / per-stream | seqs=16 agg / per-stream |
|---|---|---|
| 1 | 89.3 / 89.3 | 93.2 / 93.2 |
| 2 | 123.6 / 61.8 | 134.4 / 67.2 |
| 4 | 155.6 / 38.9 | 155.7 / 38.9 |
| 8 | 269.2 / 33.7 | 255.4 / 31.9 |
| 12 | 234.8 / 19.6 (queued) | 239.3 / 19.9 |
| 16 | 270.1 / 16.9 (queued) | 336.4 / 21.0 |
Aggregate is still rising at sixteen streams, so the UVA fetch ceiling is further out than the first C8 number suggested. Two things are not noise and are on the list: C4 is bimodal (122 vs 189 on back-to-back reps in both configs), and C8 dips slightly under seqs=16. Both smell like expert-page locality on the 60 GiB UVA tier between runs rather than kernel behavior.
DSpark: the speedup depends on what you are generating
The V4.1 DSpark drafter ships inside the target checkpoint (mtp.{0,1,2}.*, 2,401 tensors in shards 44–46). vLLM loads it with one flag, --speculative-config '{"method":"dspark","num_speculative_tokens":5}', at a cost of ~4 GiB of HBM for draft weights and graphs. Offload went from 60 to 70 GiB to pay for it, and KV came out ahead (15.56 GiB vs 12.03) because the draft's memory profile is small.
The first sweep was a disappointment: on the paragraph-generator fixture, k=5 was 10–18% slower than no speculation at every concurrency. The /metrics counters explained it. Over 7,998 draft steps, acceptance by position was 59.5 / 30.4 / 13.9 / 6.1 / 2.8%, 22.5% overall, 2.13 tokens per verify step. A verify step runs six tokens through forty MoE layers and fetches six times the expert rows over UVA, so landing two of them is a loss. Meanwhile the "count to sixty" probe went from 79 to 240 tok/s on the same server: ~4.5 tokens per step accepted. Same engine, same flags, 3× apart. The fixture decides.
So I built a fixture shaped like what a Hermes agent actually emits and measured acceptance per category:
| Category (C1, temp 0) | No spec | DSpark k=5 | DSpark k=3 | k=5 acceptance |
|---|---|---|---|---|
| Shell command sequences | 93 | 144–150 | 128 | 90.8% · 4.54 tok/step |
| Code (Python, bash) | 93 | 139–146 | 130 | 63.8% · 3.19 |
Tool-call JSON (real tools=) | 93 | ~130 | ~120 | 87.8% · 4.39 |
| Structured (tables, status bullets) | 93 | 111–116 | 108 | 49.5% · 2.48 |
| Prose | 93 | 89–93 | 95 | 29.8% · 1.49 |
| Weighted acceptance | — | 59.6% | 70.1% |
k=3 accepts a higher fraction of what it drafts (positions 3 and 4 were landing 6% and 3% on prose) but drafts less, so it nets fewer tokens per step on agent text and slightly more on prose. Neither dominates; for an agent lane, k=5 is the right default because the workload is code, tools, and shell. The published 79–86% acceptance numbers from other builds are on synthetic or code-heavy text, and the 4× DGX Spark repo's per-category table (code 73.8 vs prose 24.4 tok/s) is the same phenomenon at a lower floor. Nobody's number is wrong; "C1 tok/s" without a fixture description is now an incomplete statement for this model.
Two things that broke and how: the first k=5 boot re-ran the 74-minute autotune because the new engine-config hash directory was empty for the 60 seconds it took me to copy the cache in; restart with the seeded directory bound in 20 minutes and tuned only the 42 new draft shapes (168 → 210 configs). And each DSpark boot does two full passes over the checkpoint (target, then draft), which over SMB was 35 minutes of loading. That was the argument for the next section.
Local NVMe: cold boot 92 → 8 minutes
Two 8 TB WD SN850X went into the Station's M.2 slots as RAID0 (15 TB XFS at /models). Staged the full 510,313,343,553-byte checkpoint from the SHA-pinned archive, byte-verified every file, and relaunched with the same flags:
| Phase | SMB (this afternoon) | Local NVMe |
|---|---|---|
| Target shards + pinning | 1,246 s | 133 s (shards alone: 21 s) |
| DSpark draft pass | ~600 s | 20 s |
| Graph capture | 31 s | 34 s |
| Autotune (cached) | ~1 min | ~1 min |
| Launch → bound | ~35 min | 8–12 min |
Decode did not change (count 252.7, prose 80.7 tok/s on the smoke, within noise of the SMB build). Boot is now cheap enough that flag experiments are a coffee break, not an afternoon. The staged-shard trick from this morning (Engram shards local, everything else symlinked to the NAS) is retired.
1M context
131K was a defensive choice from when we were 4 GiB short on HBM. The model's max_position_embeddings is 1,048,576 and the KV pool was already holding 3M tokens, so the only question was what the 1M profiling pass would cost:
| 131K | 1M | |
|---|---|---|
| CUDA graphs | 1.00 GiB | 1.52 GiB |
| KV pool | 15.56 GiB | 10.05 GiB |
| KV tokens | 3.07M | 4.79M (sparser layout at 1M) |
| Max concurrent at full length | 23× at 131K | 4.5× at 1M |
| C1 code / shell / prose | 139 / 144 / 89 | 146 / 150 / 93 |
| C16 aggregate (prose) | 276 | 287 |
Costs 5.5 GiB of KV headroom, changes nothing about decode speed, and the Hermes provider now advertises context_length: 1048576. Two real turns at --reasoning low and high came back clean.
Acceptance on real transcripts, not fixtures
The agent fixture above was my guess at what Hermes emits. To check it, I pulled 24 turns from today's actual session (12 that ended in a tool call, 12 text) out of the Hermes session store, replayed each with its preceding context and the real terminal / read_file / web_search tool schemas, temperature 0, thinking off, 400-token cap, and read DSpark acceptance from /metrics deltas per turn.
| Turn type | n | tok/s | Draft acceptance | Tokens/step |
|---|---|---|---|---|
| Ended in tool call (original) | 12 | 79.5 | 51.7% | 2.58 |
| Text | 12 | 98.4 | 46.8% | 2.34 |
| All | 24 | 88.1 | 49.1% | 2.46 |
Per turn the spread is 5–171 tok/s. The turns where V4.1 actually emitted a tool call ran at 138–171 tok/s with 65–86% acceptance, matching the synthetic fixture. The turns that dragged the mean down were ones where the model wrote prose about what it was going to do instead of calling the tool, and prose accepts at 30–45%. Net: on real traffic, DSpark k=5 is a wash against no speculation (88 vs 93), with a large upside on the turns that are genuinely code or JSON. It stays on. The 150 number is real but it describes a fixture, not a day.
The replay also surfaced something that is not a speed result: in this bare setup (two-line system prompt, no Hermes harness), V4.1 produced a tool call on only 4 of the 12 turns where the original model had. That was a flag, not a verdict. Resolved the next morning: under the real harness it is 10/10 (below).
Correction, September 11. The 88.1 tok/s figure in this table should not be read as a decode number. Rerunning the same 24-turn replay three times on the same idle server, same flags, temperature 0, gave 90.6 / 119.4 / 124.3 tok/s. Batched MoE plus speculative decode is not bit-deterministic, the outputs differ by hundreds of tokens between runs, and the mean swings ±17. The acceptance percentages hold up; the tok/s does not. The concurrency sweep above runs each point twice and the pair agrees within about 1% — that is the instrument. This paragraph exists because I published an overnight "win" on the replay number and the sweep contradicted it. Details in the ledger below.
Mixed workload: a short request behind a long prefill
Everything to this point was one request at a time. The failure mode that matters for a shared lane is a 480K-token prompt arriving and every other user waiting. Measured: fire a 480K cold prefill, then a one-sentence question every 5 seconds behind it.
| Short request fired at | Default scheduler | --long-prefill-token-threshold 6144 |
|---|---|---|
| +5 s into the long prefill | 18.2 s TTFT | 0.97 s |
| +10 s | 13.2 s | 1.09 s |
| +15 s | 8.2 s | 0.89 s |
| +20 s | 3.2 s | 0.92 s |
| Long request TTFT | 23.1 s | 26.8 s (+16%) |
| Solo short TTFT | 0.10 s | |
Default vLLM runs the long request's 8192-token chunks back to back and admits nothing else until it finishes; every short request landed at exactly the moment the long prefill completed. Under a 972K prompt that is 85 seconds of dead air for everyone. The fix is one flag: capping a long request's per-step chunk at 6144 leaves 2048 tokens of each step for other requests, and short requests get a first token in about a second. They still share the GPU with the long chunk, so their decode during the overlap is slow (~2.5 tok/s vs 150 solo); interleaved, not isolated. The long request pays 16%. That is the right trade for a shared lane, and it means the 1M config can be the daily lane rather than a separate one. The older --max-num-partial-prefills knobs are gone from this vLLM build.
Offload notch at 1M
With boot at 8 minutes, the 60-vs-70 question was cheap to answer. At 1M with DSpark: offload 60 loads 226.77 GiB into HBM and dies at Available KV cache memory: −2.61 GiB. Offload 70 is the floor for this configuration; the 10 GiB difference buys 12.7 GiB of KV once the draft model, its graphs, and the 1M profile are accounted for.
| Offload | 131K, no spec | 131K + DSpark | 1M + DSpark |
|---|---|---|---|
| 40 GiB | KV −6.96 | — | — |
| 60 GiB | 12.03 ✓ | — | −2.61 ✗ |
| 70 GiB | — | 15.56 ✓ | 10.05 ✓ reference |
Prefill: a 972K-token prompt in 85 seconds
Everything above is decode. Cold prefill measured on the v10 reference config with the skill's probe: a random nonce at the start of the prompt (so no prefix-cache hit), random-word filler, max_tokens=1, thinking off, rate = prompt tokens / wall time. That is effective time-to-first-token, not a kernel counter; the server's --max-num-batched-tokens 8192 chunking is included.
| Prompt tokens | TTFT | Prefill tok/s | Reps |
|---|---|---|---|
| 6,538 | 0.37 s | 17,900 | 2, identical |
| 25,920 | 1.54 s (one cold outlier 4.5 s) | 16,900 | 2 |
| 51,797 | 2.67 s (outlier 5.5 s) | 19,400 | 2 |
| 103,814 | 5.36 s | 19,300 | 2, identical |
| 207,339 | 11.3 s | 18,300 | 2, identical |
| 414,624 | 24.9 s | 16,700 | 2, identical |
| 972,435 | 85.0 s | 11,400 | 1 |
Flat at roughly 18K tok/s from 6K to 200K, easing to 16.7K at 415K and 11.4K at 972K as the sparse indexer's top-k over a longer candidate set starts to cost. Zero preemptions; HBM stayed at 243 GiB through the 972K request, so the 10 GiB KV pool is genuinely holding a full-length sequence. The two outliers at 26K and 52K were first-touch of new UVA expert pages after idle and did not recur.
For scale: the 8× B200 report on the 0731 predecessor measured 1M TTFT of 84.7 s at c=8 under DP8 and 314.6 s under TP8. One Station at c=1 landing at 85 s for 972K with experts on UVA is the same order as an eight-GPU node's best case, which says the encoder half (8B active per prompt token) is doing what the architecture promised. What this does not say: nothing here is measured with concurrent short requests alongside a long prefill, and vLLM's partial-prefill knobs were reported not to help on that class of model. The 1M lane is a one-user-at-a-time lane until that is measured.
reasoning_effort: medium (HTTP 400 … must be low, high, xhigh, max, or an integer within [1, 100]). If your agent's default effort is "medium," every request fails until you override it per-provider.The autotune cache key trap
FlashInfer keys its tuned-config cache on a hash of the engine config. Changing --max-num-seqs from 8 to 16 produced a fresh hash with an empty directory, and the second boot was about to spend another 74 minutes re-tuning kernels whose shapes had not changed (the tuner runs at 8192 prefill tokens and the graph batch sizes, neither of which depends on the sequence cap). Copying autotune_configs.json from the old hash directory into the new one was accepted: Loaded 168 configs, graph capture in 8 s, bind in 20 minutes instead of 92. Worth knowing before touching any launch flag on a Blackwell box. I tripped this three more times today (--max-num-seqs, the speculative config, and --max-model-len each change the hash); the copy has to land before the tuner reads the directory, which is roughly two minutes after the KV gate. Seed it before launch, or disable autotune for smoke boots.
Hermes on it: the tool-calling gate
Added as an experiment provider (custom[dsv41] → :30006). The first smoke was five mixed turns, all clean. The bare replay above then raised the question that matters more than any speed: will it actually call the tool, or narrate calling it? So the gate is the real agent loop, not the raw API: hermes chat --provider custom[dsv41] --reasoning low -t hermes-cli, ten prompts each of which cannot be answered honestly without a tool — count lines in a file, read a secret string, parse a port from JSON, run uname, write a file and confirm it, append a line and report the new count, compute 1234×5678 in a shell. Scored from the harness's own tool_turns= log line plus a ground-truth check of the answer or the file on disk.
| Prompts | Turns that called a tool | Correct answer / side effect | Wall per prompt |
|---|---|---|---|
| 10 | 10 | 10 | 8–14 s incl. agent bootstrap |
The 8-of-12 narration in the bare replay was a property of a two-line system prompt, not of the model. Under the real preamble and tool schemas it just calls the tool. Two things you need for this: --tool-call-parser deepseek_v41 --reasoning-parser deepseek_v41 --enable-auto-tool-choice on the server, and --reasoning low (or high) on the client — the V4.1 chat template rejects medium with a 400. Still not routed as a default; it is a lane you pick, but the reason it couldn't be is gone.
The recipe
All of this is now a formal, machine-checked recipe in J&M Recipes: dgx-station-gb300/deepseek-v4.1-flash-vllm-uva-dspark (merged as PR #6) — pinned image digest and model revision, the launch script with the knobs, every script that produced a number here, the provenance bundle, the five gates with pass dates, and a failure ledger that includes the offload bracket, the four autotune misses, and the overnight above. That is the version to reproduce from; this post is the story.
Why 26×: bytes moved per token
Both engines keep Engram on the Grace side. The difference is how the offloaded experts get to the GPU. SGLang's OffloaderV2 in cpu mode copies the entire expert tensor for each offloaded layer, every token: 15 layers × 6.72 GiB ≈ 100 GiB across the C2C link per decode step. At roughly 330 GB/s that is about 300 ms, which is exactly the 3.3 tok/s we measured. The GPU showed 100% utilization because it was stalled on the copy stream, and batching scaled perfectly because the copy was amortized. The profile said "bandwidth," not "compute."
vLLM's UVA backend maps the pinned host tensor into GPU address space and lets the MoE kernel read the rows the router selected: 6 of 384 experts per layer, about 1.6 GB per token instead of 100 GiB. We had already seen this exact trade on this exact box with full GLM-5.3 (1.4 tok/s SGLang OffloaderV2 → 33 tok/s vLLM UVA), so the prediction was cheap; the day was spent on memory, not on the idea.
The memory ladder
Ten launches to find the envelope. Every failure was a different wall.
| Run | Engine | Offload | Died at |
|---|---|---|---|
| A | SGLang | Engram host table only | HBM OOM at layer construction; the second tier is mandatory |
| b1 | SGLang | 13 expert layers | HBM OOM in the post-load FP4 shuffle, ~4 GiB short |
| b2 | SGLang | 19 layers | Kernel OOM killer: ~490 GB shmem+anon on a 494 GB Grace |
| b3 | SGLang | 15 layers | Bound. 3.3 tok/s. |
| b4 | SGLang | 13 layers + expandable segments | HBM OOM again; the shuffle scratch is real |
| v1 | vLLM | 105 GiB, matcher experts.* | HBM OOM: vLLM nests MoE weights under routed_experts.; the matcher silently hit nothing |
| v2 | vLLM | 105 GiB | An hour of CIFS thrash: pinned host tensors page-fault the checkpoint through a 4 GB page cache, one SMB read at a time. Killed. |
| v3 | vLLM | 105 GiB, Engram shards on NVMe | OOM killer at 486 GB shmem: pinned copies plus mmap'd shards double-count during fill |
| v4 | vLLM | 40 GiB | Cleared everything, then Available KV cache memory: −6.96 GiB |
| v5 | vLLM | 60 GiB | Bound. 85 tok/s. 219 GiB weights, 12 GiB KV, host 430/494 GB. |
| v6 | vLLM | 60 GiB, seqs 16 | Bound. 93 / 336 tok/s (C1 / C16). Autotune hash changed; cache copied. |
| v7 | vLLM | 70 GiB, DSpark k=5 | Bound. KV 15.56 GiB. Agent fixture 131–144, prose 89. |
| v8 | vLLM | 70 GiB, DSpark k=3 | Bound. Higher acceptance, fewer tokens per step; not better on agent text. |
| v9 | vLLM | 70 GiB, k=5, local NVMe | Bound in 12 min cold. Same decode as v7. |
| v10 | vLLM | 70 GiB, k=5, 1M ctx, NVMe | Bound in 8 min. 146–150 code/shell, 93 prose, 287 at C16. KV 4.79M tokens. Short requests starve behind long prefill. |
| v11 | vLLM | v10 + --long-prefill-token-threshold 6144 | Reference through September 11. 82 C1 / 287 C16; short requests 1 s under a 480K prefill. |
| v12 | vLLM | 60 GiB, util 0.97, k=5, 1M ctx | Reference from September 12. 89.2 C1 / 311 C16 (+12% same-window). KV 2.33M tokens, 2.2× at 1M. First boot 82 min (new autotune hash), then 8. |
| v11 | vLLM | v10 + --long-prefill-token-threshold 6144 | Bound. Short TTFT behind a 480K prefill 18 s → 1 s. Current reference. |
| v12 | vLLM | v11 at 60 GiB | KV −2.61 GiB. 70 is the floor at 1M. |
util 0.94 — and, as night two showed, 60 GiB with DSpark once util goes to 0.97, at the cost of half the KV pool. Local NVMe for the whole checkpoint is not optional for a lane you intend to restart; over CIFS the pinned copy never finishes faulting in, and DSpark reads the checkpoint twice.First-boot autotune
FlashInfer autotunes the MXFP4 MoE kernel by running every tactic on the real hardware at 8192 tokens and keeping the fastest. With experts on UVA, each profiled run also pays the C2C fetch, so a single candidate took ~39 ms and the whole pass took 74 minutes with nothing in the log but 100% GPU. A py-spy stack (choose_one → trtllm_fp4_block_scale_moe_op) is what separated "working" from "hung." The 168 tuned configs are cached under /root/.cache/vllm, so this cost is paid once per image.
History: the SGLang first boot (morning of September 10)
Why this was uncertain
The release is 552B backbone plus a 196B Engram table, shipped as a mixed FP4/FP8/BF16 checkpoint. HBM on this Station is 250.7 GiB visible. The published SGLang recipe for GB300 is tensor-parallel across four GPUs; nobody had posted a TP=1 boot on one Station. The arithmetic said it was close: move the Engram tables to host memory as DeepSeek's report intends, and the resident set still overshoots HBM by tens of GiB before any KV cache. So a second offload tier was mandatory, and SGLang's DeepSeek-V4 model file did not wire one.
What the SGLang boot took
| Step | Result |
|---|---|
Archive to Milo-Ark, SHA-pinned df42c109f1def… | 510,313,345,254 bytes, every file byte-matched against the Hub tree. NAS-direct, provenance written first. |
Pull lmsysorg/sglang:dev-dsv41 | 48.5 GB preview image. Ships SGLANG_ENABLE_DSV41_ENGRAM_HOST_TABLE and the grouped expert offloader (V2). |
| Read the model code | deepseek_v4.py builds layers without offloader_kwargs; only the V2/V3 family passes them. Wrote a 23-line patch mirroring the V2 wiring with the TRT-LLM MXFP4 parameter names (w13_weight, w2_weight, *_weight_scale_inv). Bind-mounted read-only into the container; image untouched. |
| Stage A: Engram host table only | CUDA OOM at layer construction. Confirms the second tier is required. |
| b1: offload 13 layers (group 3, 1 per group) | All 475 GiB loaded from the NAS; Engram pinned; then CUDA OOM inside the post-load FP4 weight shuffle, roughly 4 GiB short. Fit is that close. |
| b2: offload 19 layers (group 2, 1 per group) | Kernel OOM killer took the scheduler: ~190 GiB anonymous plus ~280 GiB shared on a 494 GiB Grace. Two pinned Engram shards, 19 layers of pinned expert copies, and loader staging do not co-exist. |
b3: offload 15 layers (group 8, 3 per group), mem fraction 0.85, expandable_segments | Bound. Weights in 1,614 s. 60.9 GB HBM free after weights, 37.3 GB after KV pool, 29.4 GB after decode CUDA graphs (bs 1/2/4/8). Host at 429 of 494 GB. |
SGLang smoke results
| Probe | Result |
|---|---|
| 17 × 19, integer only | 323, 2 tokens, 22.4 s wall (first request; includes JIT and cold path) |
| Count 1–60 | Correct sequence, 120 tokens in 36.9 s → 3.2 tok/s |
| 300-word paragraph | 314 tokens in 94.9 s → 3.3 tok/s, coherent, terminated cleanly |
Tool call (get_weather) | finish_reason=tool_calls, arguments {"location": "Telluride"}, parsed by the auto tool parser |
Thinking on, reasoning_effort=low | 25 reasoning tokens, correct answer 36 for 15% of 240 |
Correctness, tool-call parsing, and reasoning-mode plumbing all pass on the first boot of a day-zero preview image. Speed does not. With 15 of 40 expert layers streaming from LPDDR5X through a synchronous one-step prefetch, decode is bandwidth-bound on the Grace side. The 16B-active design keeps the bytes per token small, but the offloader path as shipped is not built for latency.
Verdict
Next
- Speculation depth is the open lever, and it is per-request. Static k=1 wins prose and every multi-stream point by 23–33%; k=5 wins agent traffic by a third; adaptive verification lands between with no losing category but pays a ~6.5 GiB varlen graph-capture tax that the upstream cudagraph-mode fix does not remove (evening three). See morning three. Cheapest next boot: vLLM's batch-size schedule (k=5 at one or two streams, k=1 above) — takes the aggregate win with no risk to single-stream agent traffic.
- Residency axis: closed at off60. Skewed histogram, but the hardware gives either speed (ATS, 340 GB/s, no placement) or placement (GPU page table, 90 GB/s), not both. Details in night two; scripts and raw JSON in the recipe bundle.
- v12 is the reference, confirmed. Same-window pair at 03:00: v11 control 78.1, v12 89.6 single-stream, +14.7%; 312 aggregate at sixteen. Prefill, starvation and the Hermes harness (10/10) re-run on v12 and did not move.
- Expert pruning is out. James's rule, September 11: no REAP.
- C4 bimodality. Still reproducible, still uncharacterized, still not actionable.
- What would reopen residency: a driver that exposes access-counter migration on GB300, or an MXFP4 MoE kernel that takes per-expert pointers (cuDNN's SM100 grouped GEMM has a discrete mode; FlashInfer's fused kernel does not).
Reproduce (vLLM, the fast path)
The authoritative, machine-checked version of everything below — image digest, model revision, launcher with the knobs, every fixture script, provenance bundle, five gates with pass dates, failure ledger — is the J&M recipe. What follows is the same command with commentary.
docker run -d --name dsv41-vllm --gpus all --ipc host --network host \
--ulimit memlock=-1 --cap-add IPC_LOCK \
-v /models/DeepSeek-V4.1-Flash-df42c109f1defefcbfcedbe7d905718a12266e40:/model:ro \
-v /path/to/vllm-cache:/root/.cache/vllm \
vllm/vllm-openai:deepseekv41-flash-0909 \
--model /model --trust-remote-code --tensor-parallel-size 1 \
--offload-backend uva --cpu-offload-gb 60 \
--cpu-offload-params routed_experts.w13_weight routed_experts.w2_weight \
--engram-config '{"cpu_offload": true}' \
--speculative-config '{"method":"dspark","num_speculative_tokens":5}' \
--max-model-len 1048576 --max-num-seqs 16 --max-num-batched-tokens 8192 \
--long-prefill-token-threshold 6144 \
--gpu-memory-utilization 0.97 \
--tool-call-parser deepseek_v41 --reasoning-parser deepseek_v41 --enable-auto-tool-choice \
--port 30006
Whole checkpoint on local NVMe; pinned host copies page-fault from the checkpoint after load, DSpark reads it twice, and over CIFS neither converges. The matcher is routed_experts.*, not experts.*. Expect ~8–12 min to bind with a warm autotune cache, ~80 cold. Every launch-flag change makes a new autotune hash directory: seed it with an existing autotune_configs.json before launch or you pay the 74 minutes again. This is the v12 config (--cpu-offload-gb 60 --gpu-memory-utilization 0.97, KV 4.89 GiB, 2.2× at 1M). For more than two concurrent full-1M requests use v11: --cpu-offload-gb 70 --gpu-memory-utilization 0.94 at 82 tok/s. Do not send reasoning_effort: medium.
Reproduce (SGLang, the 3.3 tok/s history)
docker run -d --name dsv41-dryrun --gpus all --ipc host --network host \
--ulimit memlock=-1 --cap-add IPC_LOCK \
-v /path/to/DeepSeek-V4.1-Flash:/model:ro \
-v /path/to/patched/deepseek_v4.py:/sgl-workspace/sglang/python/sglang/srt/models/deepseek_v4.py:ro \
-e SGLANG_ENABLE_DSV41_ENGRAM_HOST_TABLE=1 \
-e PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True \
lmsysorg/sglang:dev-dsv41 \
python3 -m sglang.launch_server --trust-remote-code --model-path /model --tp 1 \
--mem-fraction-static 0.85 --context-length 131072 \
--chunked-prefill-size 8192 --max-running-requests 8 \
--cpu-offload-gb 0 --offload-mode cpu \
--offload-group-size 8 --offload-num-in-group 3 --offload-prefetch-step 1 \
--reasoning-parser auto --tool-call-parser auto --port 30005
The patch adds offloader_kwargs to the make_layers call in DeepseekV4Model, copied from DeepseekV2Model, whitelisting the fused-MoE weight and scale tensors. Drop the page cache before launch; the Engram pre-fault needs the RAM. Expect roughly 27 minutes to first token over 10GbE storage.
Credits
- 0xSero — the 4× RTX PRO 6000 build with a 45-case published sweep and DSpark acceptance numbers; the receipt that DSpark works on the shipped weights.
- vLLM — the
dsv41-featbranch and the recipe page:EngramConfig.cpu_offloadand the UVA offloader are the whole reason the second number exists. - DeepSeek — weights, tech report, and the Engram design that makes host placement legitimate rather than a hack.
- SGLang / LMSYS — day-zero support, the Engram host-table path, and the grouped offloader.
- Tony (tonyd2wild) — the parallel 4× DGX Spark vLLM effort, which framed the memory math we checked against.