Skip to content

About

DFlash2 speculative decoding for GLM-5.3-Flash at TP4 across 4x DGX Spark (GB10/SM121), full 1,048,576-token context. Single-variable measurement against MTP-4: single stream 65.6 to 102 tok/s on code, parity under concurrency, acceptance splits harder by content type. Plus the --max-num-batched-tokens ladder (-29% TTFT).

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

5 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 

Repository files navigation

GLM-5.3-Flash · DFlash2 · TP4 · 1M Context

by Tony Liu — 4× DGX Spark home cluster

1M context 102 tok/s KV pool GB10

The 102 tok/s badge is an upper bound measured with reasoning suppressed and a maximally predictable prompt — see Benchmark protocol. A default client on a natural prompt sees roughly 30–39 tok/s.

DFlash2 speculative decoding for zai-org/GLM-5.3-Flash at tensor-parallel 4 across four NVIDIA DGX Spark, serving the model's full 1,048,576-token context.

The DFlash2 drafter and the vLLM overlay that makes it run on GB10 are tonyd2wild's, who also run it at TP4. Two things here are different:

A single-variable comparison against MTP-4. Same nodes, same context, same KV pool, same batch budget, only --speculative-config changes — across four content types and a full vllm bench serve latency-throughput curve, so the gain can be attributed and its limits stated rather than asserted.

--max-num-batched-tokens. Left unset, vLLM derives 2048 from the speculative settings and warns that this is suboptimal. Raising it to 8192 is worth −29 % TTFT and +42 % prefill throughput for +1 GiB; the ladder is below.

For KV-pool capacity that recipe is ahead — 5,033,164 tokens fp8 at 32 GiB/rank, and 6,652,112 with the NVFP4 KV lane it ports from drowzeys. This runs 4,545,221 at 28 GiB/rank, which is where head-rank memory tops out on our fleet.

The quant is not interchangeable

Two NVFP4 conversions of the same base model do not behave the same. The ModelOpt-produced ones emit invalid UTF-8 byte-token sequences; a compressed-tensors conversion of the same model, on the same image, kernel, KV layout, parallelism and sampling settings, does not. Reported upstream as vllm#54150 on SM120. It reproduces on SM121.

The failure is quiet. The server returns HTTP 200, throughput looks normal, and English text is mostly fine — it surfaces as U+FFFD inside multi-byte characters, which reads like a client or font problem.

Measured here, 24 generations per column, max_tokens 3000, temperature 0 and 0.7 in equal halves, prompts chosen to force CJK and emoji output:

LibertAIDAI/...-NVFP4 (ModelOpt) RedHatAI/...-NVFP4 (compressed-tensors)
U+FFFD total 40 0
generations affected 7 / 24 0 / 24
Korean 34, in 4 of 4 runs 0 of 4
Chinese + emoji 5, in 2 of 4 0
Traditional Chinese 1, in 1 of 4 0
Japanese, Chinese prose 0 0

Corruption samples from the ModelOpt checkpoint — note that a lost character can leave an ASCII fragment behind, so this is byte-level and not a rendering artefact:

取得<?>面上可互動元素      ("畫" lost mid-word)
| <?>Egg **溏心蛋** |       (emoji collapsed into an ASCII fragment)

On our own gateway logs over four days, 16 of 1,997 real requests (0.80 %) contained U+FFFD in the response while the prompt contained none — so this is not a benchmark artefact, it is a live defect at roughly one request in 125.

Two practical notes on switching:

  • The compressed-tensors checkpoint is 197.9 GB in 21 files and ships an MTP head (model_mtp.safetensors, 7.62 GB, model.language_model.layers.45.* with eh_proj/enorm/hnorm) that the ModelOpt one does not. It is referenced by model.safetensors.index.json, so copy the whole directory — do not cherry-pick the ten shards. Running DFlash2 it is simply not instantiated; loading is warning-free.
  • Everything else is identical. Both config.json files declare the same num_hidden_layers: 45, num_nextn_predict_layers: 1, the same 34 KDA / 11 DSA layer_types, the same mlp_layer_types. No launcher flag changes, and the KV pool is bit-identical at 4,545,221 tokens.

If you want to check your own deployment, the cheapest test is a long Korean generation — that was the most sensitive probe here by a wide margin. Counting U+FFFD in the response while asserting the prompt has none separates model-side corruption from echoed input.

What the numbers say

Two regimes, and they differ enough that a single headline number would mislead.

Single stream — DFlash2 wins large

Four content types, three runs each, median. Prompts from MiaAI-Lab/sparkDash so the figures are comparable to that project.

content type MTP k=4 DFlash2 k=7 change
code (comment-free repeated helpers) 65.6 tok/s · 99.8 % accept 102.0 · 99.2 % +55 %
structured (count 1→200) 57.9 · 91.3 % 95.5 · 95.2 % +65 %
json (metrics array) 57.1 · 85.5 % 80.7 · 76.8 % +41 %
prose (hash-map explainer) 38.2 · 47.9 % 42.5 · 34.3 % +11 %

Both columns were measured on the ModelOpt quant, before the swap described above; the comparison is single-variable in --speculative-config, which is what it is for. What the quant swap itself did to these numbers is at the end of this section.

ms/step is 76.1–80.3 → 77.9–80.2, i.e. unchanged. DFlash2 is a block-diffusion drafter: its 7 positions are produced in one parallel pass, not seven autoregressive ones. The step gets no longer, it just carries more accepted tokens — 2.9–5.0 → 5.38 tokens per step.

What the quant swap did to these numbers

Same launcher, same flags, same effective page size — only the checkpoint changed. Three boots on the compressed-tensors quant against the one ModelOpt boot the table above reports:

content type ModelOpt (1 boot) compressed-tensors (3 boots) verdict
code 102.0 112.3 / 106.2 / 100.7 within noise — the old value sits inside the new spread
structured 95.5 100.6 / 106.2 / 108.1 higher in all three
json 80.7 90.1 / 90.3 / 94.8 higher in all three
prose 42.5 42.7 / 43.9 / 48.7 higher in all three, but the floor is +0.5 %
json acceptance 76.8 % 84.5 / 84.8 / 84.9 % +8 points, tight

The honest headline is no regression, with json and structured clearly ahead. We are not claiming the rest, and the reason is in that table: two boots of the identical configuration differ by up to 12 % (code 112.3 vs 100.7, prose 48.7 vs 42.7). Every boot recaptures CUDA graphs and lands a different memory layout, and bench-v3's three repetitions all live inside one boot, so they average away request-level noise and leave boot-level noise untouched.

If a configuration change requires a restart, the repetitions have to be at the boot level, not the request level. Ours were not, which is why two of these four rows stay unresolved rather than being reported as a gain. Acceptance rate is the exception worth trusting here: it clusters tightly across boots because it is a property of the weights, not of the allocator.

Benchmark protocol, and what it costs you to read these numbers

The single-stream table is an upper bound under a stated protocol, not what a default client sees. Two of the settings matter enough to spell out:

temperature: 0, top_p: 1                              # sparkDash protocol
chat_template_kwargs: {"reasoning_effort": "low"}     # OUR addition — not sparkDash's

GLM-5.3-Flash reasons by default. Suppressing that is our own deviation from the sparkDash protocol, and it is the single largest reason the headline number is what it is. The code prompt is also deliberately maximally predictable — 50 functions of identical shape, no comments, no docstrings — which is what drives 99.2 % acceptance.

Decomposed on the same engine, same day, idle cluster, max_tokens: 2000:

end-to-end accept thinking share
benchmark prompt + temperature 0 + reasoning_effort: low 99.3 tok/s 99.2 % 1.2 %
benchmark prompt, default reasoning 91.4 96.8 % 5.5 %
natural coding prompt + reasoning_effort: low 61.6 62.1 % 0.0 %
natural coding prompt, default reasoning, temperature 0.6 38.7 31.5 % 100 %

Prompt shape costs 38 % (99.3 → 61.6). Reasoning costs a further 37 % (61.6 → 38.7). Together, −61 %. On the benchmark prompt alone, reasoning looks nearly free (−8 %) — because that prompt is rigid enough that the model barely reasons at all. That is precisely why the headline number is fragile: it sidesteps both effects at once.

A default client on a natural prompt sees roughly 30–39 tok/s. Our production monitoring, which observes real gateway traffic rather than benchmark runs, reports TPOT p50 in the same region. Both figures are correct; they describe different populations.

One thing neither number captures: reasoning arrives before content, and there can be a lot of it. On a natural coding request with max_tokens: 2500, the entire budget went to reasoning — time-to-first-token was 304 ms, but the budget ran out at 89 s with the first content token still to come. The model was not failing; it simply had not finished thinking. Raise the budget and the answer arrives. If you are sizing for felt latency rather than throughput, measure time-to-first-content separately — TTFT will not tell you.

Concurrent — parity

vllm bench serve, sonnet dataset, Poisson arrival, --ignore-eos, p50/p90/p99.

target QPS output tok/s TTFT p50 TTFT p90 TPOT p50
0.25 61.3 → 62.0 605 → 636 ms 937 → 915 ms 36.0 → 32.1 ms
0.5 115.2 → 117.0 753 → 1052 ms 1072 → 5617 ms 55.5 → 61.4 ms
1.0 183.6 → 188.0 1084 → 1143 ms 1451 → 1644 ms 90.5 → 86.9 ms
2.0 208.2 → 209.2 1824 → 1934 ms 16248 → 16066 ms 96.5 → 96.7 ms
4.0 217.3 → 220.3 3195 → 4734 ms 24216 → 22768 ms 100.8 → 96.2 ms
∞ 229.1 → 216.0 7106 → 8655 ms 32800 → 35438 ms 95.2 → 103.7 ms

Throughput lands within ±2.4 % at every rate the server can actually sustain. The QPS 1.0 row is the median of three runs. Saturation is at 1.0 QPS for both methods — past that, throughput gains ~25 % while TTFT p90 grows an order of magnitude, so 1.0 QPS is the number to size capacity against.

Long context

Cold 32,768-token prompt, single stream:

MTP k=4 DFlash2 k=7
TTFT p50 14,595 ms 16,749 ms
TPOT p50 31.27 ms 30.43 ms

Decode is unaffected by the drafter at depth. The TTFT difference is scheduling on the prefill side.

Acceptance is content-driven, and DFlash2 amplifies it

content MTP DFlash2
code 99.8 % 99.2 %
structured 91.3 % 95.2 %
json 85.5 % 76.8 %
prose 47.9 % 34.3 %
sonnet (English prose) ~64 % ~50 %
random tokens 40.4 % 22.2 %

Betting 7 positions in parallel pays in full when the continuation is predictable and returns little when it is not. If your traffic is mostly free-form prose, MTP-4 keeps more of its drafts. If it is code, structured output or tool calls, DFlash2 is the better bet by a wide margin.

The drafter is free

MTP k=4 DFlash2 k=7
GPU KV cache 4,404,019 tokens 4,545,221 tokens
Max concurrency @ 1,048,576 4.20× 4.33×
CUDA graph capture 41–90 s / 1.41 GiB 91 s / 1.80 GiB

The pool grows because the drafter's five sliding-window layers slot-share the MLA tensors the way GLM's own mamba layers and kpool tails already do, and it replaces the MTP head that was there before. Per-block cost is bit-identical to the drafterless model.

Configuration

4× GB10 (SM121, 121 GiB unified memory each), CX-7 RoCE, tensor-parallel 4, vLLM native multi-node (no Ray).

--tensor-parallel-size 4
--max-model-len 1048576
--max-num-batched-tokens 8192
--max-num-seqs 32
--block-size 2304
--moe-backend marlin
--kv-cache-dtype fp8_e4m3
--kv-cache-memory 30064771072
--compilation-config '{"cudagraph_mode":"FULL_DECODE_ONLY"}'
--speculative-config '{"method":"dflash","model":"/models/dflash2-draft","num_speculative_tokens":7}'

num_speculative_tokens is 7, not 8: the drafter is trained for a block of 8 and one position is the target's own verified token.

--block-size is not a free knob on this model, and it fails silently. GLM-5.3-Flash is a hybrid: 34 KDA linear-attention layers alongside 11 sparse-attention ones. vLLM sizes the attention page against the mamba page and raises anything smaller, at INFO level, with no warning:

[interface.py:926] Setting attention block size to 2304 tokens to ensure that
                   attention page size is >= mamba page size.
[interface.py:950] Padding mamba page size by 0.70% to ensure that mamba page size
                   and attention page size are exactly equal.

We passed --block-size 576 and rebooted the fleet to see whether a finer page would buy finer prefix-cache reuse. It bought nothing, because the flag never took effect — the value on the command line and the value in force differ, and only the log says so. Two boots at 576 and one at 2304 were, in fact, three boots of the same configuration.

Read the effective value back out of the engine log before attributing anything to a vLLM flag. The prefix-cache consequence is in the next section.

--max-num-batched-tokens deserves its own note. Leave it unset and vLLM derives 2048 from the speculative settings, then warns that this is suboptimal. Raising it, measured on a 32K prompt:

value TTFT prefill tok/s head-rank memory
2048 (auto) 20,685 ms 1,584 110 GiB
4096 16,435 ms 1,994 109 GiB
8192 14,595 ms 2,245 111 GiB

−29 % TTFT and +42 % prefill throughput for +1 GiB. The slope flattens after 8192 while head-rank headroom drops to 9 GiB, which is where this stops.

Prefix-cache reuse is quantised to 2,304 tokens

A consequence of the page-size floor above, and it is worth knowing before you size a multi-turn agent against this model. Reuse does not land on the true common prefix; it lands on the last whole 2,304-token page minus one:

cached_tokens = max(0, (floor(prompt_tokens / 2304) - 1) * 2304)

Measured on an idle engine, same prompt twice, reading the delta of vllm:prefix_cache_hits_total and vllm:prefix_cache_queries_total:

prompt tokens cached reuse recomputed formula
1,585 0 0.0 % 1,585 0
3,127 0 0.0 % 3,127 0
4,701 2,304 49.0 % 2,397 2,304
6,293 2,304 36.6 % 3,989 2,304
9,394 6,912 73.6 % 2,482 6,912
15,607 11,520 73.8 % 4,087 11,520
31,304 27,648 88.3 % 3,656 27,648

Seven of seven. Two consequences:

  • A prompt under 4,608 tokens gets no reuse at all. Not "a little" — zero. Short agent turns, tool-call round-trips and chat replies all repay full prefill every time.
  • Every request re-computes between 2,304 and 4,607 tokens no matter how long the conversation is. At the prefill rates in the ladder below that is roughly 1–2 s of TTFT you cannot cache away.

--prefix-match-unit does not help: vLLM disables fine-grained lookup outright because KpoolTailManager and SlidingWindowManager require block-aligned lookups, and logs it at kv_cache_coordinator.py. Hit curves with and without the flag are line-for-line identical. --block-size does not help either, for the reason in the previous section. This is structural on hybrid mamba/attention models, not a tuning mistake.

Running it

No image build required — the runtime image is public and anonymous-pullable.

# one node pulls, then fan out over the internal fabric
docker pull ghcr.io/tonyd2wild/vllm-glm53-flash:sm121-v11-dflash2
docker save ghcr.io/tonyd2wild/vllm-glm53-flash:sm121-v11-dflash2 | zstd -3 -T8 \
  | ssh <peer> "zstd -d | docker load"

Pull from one node only. Four nodes pulling the same 31 GB image concurrently hits GHCR rate limits and the last layers retry indefinitely; the internal 200 Gb fabric moves it in about four minutes per node.

# weights
#   target  : RedHatAI/GLM-5.3-Flash-NVFP4     -> ~/dev/models/GLM-5.3-Flash-NVFP4
#             compressed-tensors, NOT the ModelOpt quants -- see "The quant is not
#             interchangeable" below. 197.9 GB, 21 files, ships an MTP head as well.
#   drafter : incoai/GLM-5.3-Flash-DFlash2     -> ~/dev/models/GLM-5.3-Flash-DFlash2  (2.2 GB)

# patches
mkdir -p ~/glm53-patches && cp patches/*.py ~/glm53-patches/
cp patches/kv_cache_utils.py ~/glm53-patches/kv_cache_utils_dflash2.py
cp scripts/launch-glm53-dflash2-tp4.sh scripts/cache_flusher.sh ~/

# every node: unconditional page-cache flusher for the whole boot, workers first
nohup bash ~/cache_flusher.sh &
bash ~/launch-glm53-dflash2-tp4.sh 3     # worker
bash ~/launch-glm53-dflash2-tp4.sh 2     # worker
bash ~/launch-glm53-dflash2-tp4.sh 1     # worker
bash ~/launch-glm53-dflash2-tp4.sh 0     # head — serves :8000

On GB10 the CPU and GPU share one 121 GB pool, so host page cache consumes CUDA-visible memory one-for-one. The flusher must run unconditionally — a threshold-triggered one can sit below its threshold and still leave the allocator short, which shows up as the same command booting or OOMing depending on the moment.

Edit the rank→IP map at the top of the launcher. Ours: RoCE on 10.10.0.x, HCA rocep1s0f1, NCCL_IB_GID_INDEX=3, RoCEv2.

Verify before you boot

sim_glm5_drafter.py from the upstream overlay drives the patched kv_cache_utils on CPU with the real spec geometry — 34 mamba + 11 MLA + 11 indexer + 11 tail + 5 drafter SWA — and asserts the tensor binding stays in bounds under the runner's kernel-block split. Run it against this repo's merged file before touching a node:

[A] drafter: block 2048->2304, real page == mla_page, no padding
[A] bytes/block unchanged by drafter: 26155008
[A] runner reshape OK under kernel split 2304->64 (x36):
    required 398721024 B == tensor 398721024 B
ALL SIMULATION CHECKS PASSED

The drafter's rescaled block lands exactly on --block-size 2304, so it takes the exact-fit path with no page padding.

Healthy startup

Using Eagle3 auxiliary layers from config: (6, 15, 25, 34, 43)
GPU KV cache size: 4,545,221 tokens, Maximum concurrency for 1,048,576 tokens per request: 4.33x
Warming up spec-decode rejection sampler kernels (vocab=154880, num_spec=7, ...)

Acceptance from /metrics (spec_decode_num_accepted_tokens_total ÷ ..._num_draft_tokens_total) settles between 0.5 and 0.65 on mixed traffic. A ratio near 0.15 means the auxiliary capture or the mHC contraction is wrong — it degrades silently rather than raising. Read it only after a few thousand drafts have accumulated; early samples swing wildly (34 % at 90 drafts, 56 % at 16k, 62 % at 22k on the same deployment).

The merged patch

patches/kv_cache_utils.py carries two independent changes to one file:

change function why
upstream 76fb6d2 (PR #47272) _check_enough_kv_cache_memory, get_kv_cache_configs reserves the KV null block during validation — without it a request near 1M is admitted, chunk-prefilled to ~99 %, then descheduled forever
drafter-group patch _get_kv_cache_groups_glm5_next, _pool_bytes_per_block partitions the drafter's sliding-window layers out before the all-MLA check so GLM keeps its custom KV fast path

They touch different functions and compose cleanly. The drafter's bytes flow into the null-block accounting automatically, which is the behaviour you want.

The other three files in patches/ route NoPE MLA away from the packed fp8_ds_mla layout and add a feasibility fallback to the kpool indexer's persistent_topk.

Hardware

Four GB10 Sparks (SM121, 121 GiB unified memory each), CX-7 RoCE on 10.10.0.0/24 through a MikroTik CRS812. Boot ≈ 20 min: weight load, drafter load, KV allocation, graph capture.

Credits

Model zai-org/GLM-5.3-Flash · quant RedHatAI/GLM-5.3-Flash-NVFP4 (compressed-tensors) · DFlash2 drafter incoai/GLM-5.3-Flash-DFlash2 · the DFlash2 vLLM overlay, the drafter-group KV patch, the simulator and the runtime image are tonyd2wild's — this repo is the TP4 / 1M carry and the measurement · NVFP4 KV lane and the GB10 memory study from drowzeys · decode benchmark prompts from MiaAI-Lab/sparkDash · vLLM PR #53906 authors for the day-0 image · vllm#52520 for the null-block diagnosis.

MIT.

About

DFlash2 speculative decoding for GLM-5.3-Flash at TP4 across 4x DGX Spark (GB10/SM121), full 1,048,576-token context. Single-variable measurement against MTP-4: single stream 65.6 to 102 tok/s on code, parity under concurrency, acceptance splits harder by content type. Plus the --max-num-batched-tokens ladder (-29% TTFT).

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages