Skip to content

[New Model][Nvidia] Add SM12x support for DeepSeek V4 Flash with essential fixes - #41834

Open
jasl wants to merge 277 commits into
vllm-project:mainfrom
jasl:codex/ds4-sm120-min-enable
Open

[New Model][Nvidia] Add SM12x support for DeepSeek V4 Flash with essential fixes#41834
jasl wants to merge 277 commits into
vllm-project:mainfrom
jasl:codex/ds4-sm120-min-enable

Conversation

@jasl

@jasl jasl commented May 6, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR enables DeepSeek V4 Flash on SM120/SM121 Blackwell client hardware by carrying the SM12x fallback and tuning stack needed for the current vLLM V1 path. It targets RTX PRO 6000 Blackwell Workstation Edition, RTX 5090-class SM120, and GB10 / DGX Spark SM121 users who cannot use SM100-only TMEM / tcgen05 kernels.

The branch is reconciled on top of the merged #43477 and provides the stock-deps path: DeepSeek V4 on SM120/121 that builds and serves on released FlashInfer / DeepGEMM wheels, complementing #43477's route that needs the unreleased FlashInfer #3395 + DeepGEMM #324 dependency branches. It is kept synced onto current upstream/main.

Latest validated head: tag sm120-pr-41834-stable-preview-20260809 (aa0d513027), synced onto upstream/main as of 2026-08-09 (f18e10a7e1) — see Update 2026-08-09 below. The default model runner is now V2; VLLM_USE_V2_MODEL_RUNNER=0 still selects V1, which stays supported.

Model / speculative-decode status. deepseek-ai/DeepSeek-V4-Flash-0731 is the checkpoint this branch is validated on. It removed the MTP heads and folded the DSpark draft into the main checkpoint, so DSpark (method: "dspark", num_speculative_tokens: 5) is the speculative path; MTP is supported only for older checkpoints that still carry those weights. Running without speculation is also fully supported and validated.

Change footprint — model kernels vs. core-vLLM touch points

187 files, ~+29.1k / −1.3k against upstream/main, of which ~10.7k added lines are tests. The branch splits cleanly into model/kernel code and a small set of core-vLLM integration points:

  • DeepSeek-V4 model + SM12x kernels — the enablement itself. Everything under vllm/models/deepseek_v4/** plus the SM12x sparse-MLA decode / indexer / DeepGEMM kernels that live in shared dirs (v1/attention/backends/mla/sparse_mla_kernels.py, model_executor/layers/sparse_attn_indexer.py, v1/attention/backends/mla/{indexer,sparse_swa}.py, utils/deep_gemm.py, kernels/mhc/tilelang.py), the new DSv4 reasoning parser / tokenizer, and device tuning JSONs.
  • C128A metadata device→host sync removed (models/deepseek_v4/sparse_mla.py, perf) — _c128a_effective_topk_width takes the max position from the CPU-side CommonAttentionMetadata.max_seq_len instead of a per-step int(positions.max().item()) device sync, dropping a launch-stream stall on every C128A metadata step. Decode is identical (max_seq_len-1 == positions.max()); only chunked prefill sees a safe, slightly-wider 128-aligned top-k.
  • Core-vLLM integration — the hooks below. Almost all are gated by model architecture / quant config / an env flag and are inert for other models.
Subsystem Files What it does
KV-cache core single_type_kv_cache_manager.py, kv_cache_coordinator.py, kv_cache_manager.py, sched/scheduler.py (+1) prefix-cache correctness for DSv4 sparse-MLA + speculative decode: an MLA cache-manager with prompt-block protection, a hybrid-coordinator cache_blocks tail-block-reuse rewrite
Speculative decode v1/spec_decode/{dspark,dspark_sampling,llm_base_proposer,dflash}.py, config/speculative.py DSpark self-drafting proposer + sampling; DSv4 probabilistic draft sampling and per-step draft-layer routing in the shared proposer base; DSpark/MTP method detection and validation
MoE quantization fused_moe.py, oracle/mxfp4.py, routed_experts.py, experts/flashinfer_cutlass_moe.py, quantization/mxfp4.py, oracle/nvfp4.py MXFP4 / NVFP4 backend selection; the one-line NVFP4 fix (FLASHINFER_CUTLASS into the SwiGLU-clamp allow-list) lets DSv4-Flash-NVFP4 serve
FP8 / Marlin GEMM quantization/utils/fp8_utils.py, linear/scaled_mm/{cutlass,marlin}.py, csrc/.../marlin_moe_wna16/ops.cu (the only C++) SM12x e8m0→fp32 upcast + Marlin MoE SM12.0a cudagraph hardening
cudagraph / compile / config config/vllm.py, compilation/breakable_cudagraph.py, passes/utility/fix_functionalization.py, config/compilation.py breakable-cudagraph auto-enable gate (MiniMax-only; DSv4 deliberately excluded), DSv4 custom-op defunctionalization + splitting-op registration
OpenAI entrypoints / parsers chat_completion/protocol.py, serve/render/serving.py, tool_parsers/structural_tag_registry.py, chat_utils.py, engine/protocol.py, chat_completion/{serving,batch_serving}.py, reasoning/__init__.py expose DSv4 API semantics — reasoning_content / thinking param / tool-call streaming (jasl#19 instruction-following)
Kernel warmup model_executor/warmup/deepseek_v4_sm12x_warmup.py (new), kernel_warmup.py (+11) DSv4 warmup passes (D512-split prefill precompile, paged-MQA rowwise, draft path) that avoid JIT-during-inference wedges. Kept in a separate module so kernel_warmup.py stays a two-line hook on upstream's file
Weight loading weight_utils.py, default_loader.py fast-safetensors weight filter + EP-skip (lowers DSv4 load overhead on GB10)
env / utils envs.py, utils/flashinfer.py, utils/import_utils.py, v1/worker/{gpu_model_runner,ubatch_utils}.py VLLM_DEEPSEEK_V4_* flags + has_cutedsl / has_flashinfer_trtllm_sparse_mla probes

Two notes for review:

  • The most invasive generic edits were removed in the 2026-06-21 audit cleanup: the scheduler carries a single +1-line change (the prefill-fairness heuristics were dropped) and the prefix-cache write-fence is gone.
  • A few hooks touch code paths shared with non-DSv4 models and are worth a closer look: the kv_cache_coordinator cache_blocks rewrite (affects hybrid-KV models; validated ≥ prior behavior), the proposer base-class change, and the OpenAI-entrypoint plumbing. Everything else (MoE oracle, fp8_utils, cudagraph gate, warmup, envs) is arch / quant / env-gated and inert for other models.

Duplicate-work check

The nearest open/merged PRs are related but not duplicates:

PR Difference
#43477 Merged 2026-06-22. Enables DeepSeek V4 + GLM-5.1 on SM120 via the FlashInfer-SM120 sparse-MLA route, but on its merged form requires the unreleased FlashInfer #3395 + DeepGEMM #324 dependency branches — on released/stock wheels its SM12x path raises at model construction. This PR is reconciled on top of #43477 (merge 42657aca65) and carries the stock-deps DSv4 SM120/121 path that runs on released wheels.
#40929 Earlier WIP Triton fallback effort. This PR is the maintained replacement branch with the broader scheduler, prefix-cache, parser, quant, warmup, and harness-validated fixes carried forward.
#42856 Focused workspace-bound fix that explicitly depends on / references this PR; a subset-style bugfix, not the full DeepSeek V4 SM12x enablement branch.
#49335 mxfp8 activation-scale swizzle after DP/EP dispatch — carried in this branch (unclaimed upstream). Inert at DP=1; taken for this branch's multi-node DP users.
#50686 Consecutive-assistant-message merging in DSv4 prompt encoding — carried in this branch (reproduced here before taking it).
#50693 B300-targeted prefill-workspace fix. Test carried, code not needed: this branch's _prefill_workspace_topk_bound returns early for compress_ratio <= 1 and never reaches the affected buffer.

Upstream PRs whose fixes this branch previously carried as local deltas and has since retired in favour of upstream's own version: #48304, #48911, #48959 (via #49052).

Fixed preview tags

These tags are in jasl/vllm and give users stable pins while the PR is still moving:

Tag Commit Notes
sm120-pr-41834-stable-preview-20260809 aa0d513027 latest validated head — 87 upstream commits incl. FlashInfer 0.6.16.post3; the V2 recall collapse root-caused as a prefix-cache ghost-block race and fixed (port of #42359); default runner switched to V2. See Update 2026-08-09.
sm120-pr-41834-stable-preview-20260804 0f59188db1 35 further upstream commits, four fixes from community reports (DSpark out-of-vocab draft token, eager scratch pool), two contributor PRs. Validated on both SM121 and SM120. See Update 2026-08-04.
sm120-pr-41834-stable-preview-20260802 9a94c54292 234 upstream commits, DeepSeek-V4-Flash-0731 support, two DSpark config fixes, #49335 / #50686 absorbed. See Update 2026-08-02.
sm120-pr-41834-stable-preview-20260727d d64074e6f0 209-commit upstream sync + torch 2.13 (tag …-20260727, 70a33886bd); DSpark VRAM work (jasl#27) merged; bounded block-table gather in compute_global_topk_indices_and_lens.
sm120-pr-41834-stable-preview-20260721 832775efd1 79-commit upstream sync; #48911 dropped in favour of upstream's merged version; compact CPU KV offload (opt-in).
sm120-pr-41834-stable-preview-20260717 f63bfd3d7b 195-commit upstream sync; prefill ctx_pp +4.7% @ d8192.
sm120-pr-41834-stable-preview-20260711 b5c0d43b96 181-commit upstream sync; #48304 MTP unscaled-draft-rope; ~1097-line dead-kernel cleanup.
sm120-pr-41834-stable-preview-20260704 b43470e871 @GanyX19 GB10 fixes: per-shape constexpr→runtime (stops the Triton recompile → unified-memory leak → hard-freeze) + fp8-einsum tl.multiple_of(16) (~24% decode @256k).
sm120-pr-41834-stable-preview-20260703 444fe3ac8b DSpark spec-decode (self-drafting block-5), V2 padded-Q OOM fix (jasl#26), exact non-cooperative persistent_topk for <128 KB-smem parts.

Older tags (…-20260705 back to …-20260612…) remain in jasl/vllm for history.

Update 2026-08-02 — DeepSeek-V4-Flash-0731, 234 upstream commits, two DSpark fixes

Validated head 9a94c54292 (tag sm120-pr-41834-stable-preview-20260802), 234 upstream commits absorbed, level with upstream/main as of 2026-08-02.

What's in it

  • DeepSeek-V4-Flash-0731 support. The new checkpoint ships no MTP headsenorm, hnorm, e_proj, h_proj and shared_head are absent from the weight index, and mtp.{0,1,2}.* now carries the DSpark-style main_norm / main_proj structure (matching dspark_target_layer_ids: [40, 41, 42]). DSpark is the speculative path going forward; the MTP code is retained for older checkpoints.
  • num_speculative_tokens vs dspark_block_sizethis rule was relaxed on
    2026-08-04; see Update 2026-08-04. It now errors only BELOW the block size and warns
    above it.
    The original reasoning and measurements follow.
  • The validator was tightened to require equality. The validator previously accepted >= and its error message recommended exceeding it. The drafter emits exactly one block per pass, so the extra slots are structurally unreachable — measured on a prose workload, the 7th draft position accepted 0.000 in every sample (the 6th in all but one, 0.004 there), and nst=7 drafts 40% more tokens per step for strictly worse acceptance:
configuration mean acceptance length (3 samples) avg draft acceptance rate
nst=5 probabilistic 2.15 / 2.16 / 2.19 22.9 / 23.2 / 23.8%
nst=7 probabilistic 1.61 / 1.75 / 1.95 8.7 / 10.7 / 13.6%
nst=5 greedy 1.82 / 2.06 / 2.23 16.4 / 21.2 / 24.5%
nst=7 greedy 1.57 / 1.66 / 1.75 8.2 / 9.5 / 10.8%

All samples are shown rather than a single figure: the probe reads whatever SpecDecoding metrics lines vLLM flushed inside its window, so a low sample means "not much steady traffic in that slice", not a worse drafter. Both nst=7 runs also hit connection errors partway through, so their spread is noisier.

Validation (GB10 SM121, 2-node TP=2, DeepSeek-V4-Flash-0731, torch 2.13.0, FlashInfer 0.6.15.post1, nccl 2.30.7)

DSpark nst=5 no speculation
GSM8K 8-shot (flexible) 0.9394 0.9500
GSM8K 8-shot (strict) 0.9363 0.9484
instruction-following (jasl#19, JSON-only) PASS PASS
long-context recall (arthur needle, c=1) 2/2 2/2
illegal-access / assertion in serve log 0 0
draft acceptance (prose) mean 2.08, 21.7%

The GSM8K difference (1.06 pp flexible / 1.21 pp strict) is within this gate's measured single-run spread (~1.1 pp). Resolved: three runs per cell were collected and the arms interleave, so it was noise. 0731 is the first checkpoint where the strict and flexible extractors disagree at all; on every prior baseline they were identical.

Perf — pinned llama-benchy standard (fp8 KV, prefix-cache on, FULL_AND_PIECEWISE, mml 49152, util 0.85; C=1, 3 runs), against the full recorded range of the prior MTP2 baselines. This crosses a checkpoint boundary, so read it as a sanity band rather than a controlled A/B:

metric prior MTP2 range (n=10) 0731 + DSpark vs band
pp2048 @ d8192 1339.11 – 1400.81 1432.23 ± 11.74 above
pp2048 @ d16384 1308.77 – 1344.68 1356.56 ± 11.78 above
pp2048 @ d32768 1089.05 – 1226.63 1250.75 ± 2.18 above
ctx_pp @ d8192 1757.16 – 1876.01 1816.97 ± 5.89 inside
ctx_pp @ d16384 1769.85 – 1842.16 1817.43 ± 1.43 inside
ctx_pp @ d32768 1595.87 – 1756.01 1740.22 ± 2.87 inside
tg128 @ d8192 36.27 – 43.08 41.72 ± 5.09 inside
tg128 @ d16384 34.59 – 43.14 37.92 ± 9.92 inside
tg128 @ d32768 32.77 – 42.91 34.88 ± 5.78 inside
ctx_tg @ d8192 38.52 – 43.01 39.37 ± 2.34 inside
ctx_tg @ d16384 39.29 – 43.07 35.07 ± 0.67 below, −10.7%
ctx_tg @ d32768 38.02 – 42.73 40.70 ± 6.85 inside

Batched prefill (pp2048) is above the historical band at all three depths (+2.2% / +0.9% /
+2.0%) — the only consistent directional move here. Clearing the max of ten prior runs at all
three depths says more than any single one of those margins would: +0.9% is inside this metric's
own resolution, so read the consistency rather than the magnitudes. No sign of DSpark being
slower than MTP2 was.

One caveat reported rather than buried: ctx_tg @ d16384 sits 10.7% below its historical
minimum
, the only metric outside its band. It is non-monotonic against our own neighbouring
depths (39.37 at d8192, 40.70 at d32768, where history has d16384 ≈ d8192), which points at a
single-run artifact rather than a depth-specific regression. Resolved: repeated on later
heads and it did not recur.

A measurement caveat for anyone benchmarking this branch: the ± in a benchy row is the spread of the three runs inside one invocation, and it runs 5–30× smaller than the build-to-build spread. This branch's own history spans 31% on tg128 @ d32768 and ~1.3% on ctx_pp, so anything under ~15% on tg or ~2% on ctx_pp is not resolvable this way.

Update 2026-08-04 — four fixes from community reports, 35 upstream commits, and first SM120 validation

Validated head 0f59188db1 (tag sm120-pr-41834-stable-preview-20260804).

This is the first head validated on both SM121 and SM120. Every SM120 discrete-GPU
result on this PR up to now was a contributor's measurement we could not reproduce. We have
since rebuilt a 2× RTX PRO 6000 Blackwell box as a first-party SM120 target.

Fixes

  • DSpark's fused Markov sampler could emit an out-of-vocab token id (e171c51036).
    _dspark_markov_probs_blocks_kernel stores vocab_size as the filler for a block with no
    active lane. On a fully-masked row — every candidate -inf, which structured-output
    constraints can produce — no block has an active lane, so every block stores the filler and
    the reduce kernel returns it verbatim as the sampled token. Nothing downstream bounded it: the
    runner clamped input_ids with min=0 only, and the DSv4 hash-MoE router indexes
    tid2eid[token_id * 6 + lane] on a [vocab_size, 6] table. Result is an illegal memory access
    on every TP rank.

    This is the producer on the V1 path, which is this branch's default. @alexbi29's report
    traced the same class of defect to the V2 samplers ([Bugfix] Bound tile-local argmax to vocab_size in samplers #50843) — a real defect, but a different
    tree. Fixed by folding out-of-range to 0 (matching torch.argmax on such a row, so the fused
    kernel stays bit-identical to the eager reference) and making the runner clamp two-sided.

    Worth stating plainly for anyone with similar gates: our own gates could not have caught
    this
    . The fused path is skipped when all_greedy, and both our long-context recall gate and
    GSM8K are greedy, so they are structurally incapable of executing that kernel. The new
    regression test is explicitly non-greedy.

  • Adopted [Bugfix][DSv4] Bound token_id before the tid2eid gather in hash-MoE routing #50844 (3df857ba50) — bound token_id before the tid2eid gather. Defence in
    depth; prompt_token_ids reach that gather directly when --skip-tokenizer-init disables the
    engine's vocab check. Not taking [Bugfix] Bound tile-local argmax to vocab_size in samplers #50843 (V2-tree only, inert on our default) or [Bugfix][MoE] Bound expert_map gathers on data-derived expert ids #50845,
    which has a defect reported on its own thread.

  • Eager scratch pool is now OFF by default (d42b8d9f55, b1ef3033f4), opt-in via
    VLLM_DEEPSEEK_V4_EAGER_SCRATCH_POOL=1. @tobymao bisected output corruption under concurrent
    mixed prefill+decode to it: pool active 7/7 rounds corrupt, disabled 0/2. We first removed the
    cross-template aliasing (max()sum() sizing with per-family offsets); they tested that
    commit directly and it was still corrupt in round 1
    . Their diagnosis is the useful part: the
    pre-pool code was race-free for free because per-call transients go through the caching
    allocator, whose cross-stream reuse is event-guarded — the pool reuses memory without that
    machinery, so no static partitioning fixes it. Making the cross-layer reuse safe needs
    producer-waits-on-consumer events against the real stream graph; until then, off by default.

  • Two contributor PRs mergedsm12x: add tuned FP8 W8A8 block config for N=4096,K=12288 jasl/vllm#37 (tuned FP8 W8A8 config for N=4096,K=12288 on
    RTX PRO 6000) and sm12x: hoist the E8M0 block-scale upcast out of the FP8 GEMM hot path jasl/vllm#38 (hoist the E8M0 block-scale upcast out of the FP8 GEMM hot path,
    13,561 kernel launches removed per 25 decode steps), both from @alexbi29.

  • num_speculative_tokens rule relaxed. Upstream removed its own assertion in [Bugfix] Remove bad startup assertion #50869 as
    "invalid". They were right that erroring above dspark_block_size is wrong — two users on
    this thread run nst=7 against block_size=5 and it demonstrably works. The two directions are
    not symmetric, so this branch now errors below the block size (that genuinely garbles
    output) and warns above it, quoting the acceptance cost. Strictly more permissive than what
    shipped before.

Validation

Full gate battery on both architectures, same branch:

gate SM121 (2× GB10, 2-node TP=2) SM120 (2× RTX PRO 6000, TP=2)
serve, DSpark nst=5, --block-size 256
instruction-following (jasl#19) PASS PASS
long-context recall, arthur c=1 2/2 2/2
long-context recall, arthur c=12 22, 23, 22 / 24 22, 23 / 24
GSM8K 8-shot flexible 0.9484 / 0.9507 / 0.9492 0.9371
GSM8K 8-shot strict 0.9462 / 0.9477 / 0.9462 0.9303
tool-calling, 135 cases 256/270 (94.8%)
illegal-access / assertion lines 0 0

The ~1.1 pp GSM8K difference between architectures sits inside this gate's measured single-run
spread and spans different silicon, different memory architecture and a 3× smaller KV cache
(6.25 GiB vs ~18.5 GiB). We are not claiming a difference from it.

Check failed: num_tokens > 64 does not reproduce on this branch. @fuzzifikation reported
stock 0.26.0 dying there on SM120 at --block-size 256, correctly tracing it to the DSv4 decode
dispatch requiring page_block_size == 64. On our SM120 box, at the same --block-size 256, the
serve comes up and the assertion never appears — the DSv4 packed KV cache is laid out in 64-token
pages independent of vLLM's logical block size, and FlashInfer derives page_block_size from
tensor geometry rather than the engine config. The SM120 packed decode path is confirmed engaged
in the same run. Note the same assertion has two distinct gates (page_block_size and
(num_heads, topk), the latter being #50720 / flashinfer#3989), so patching one and still seeing
it means checking the other.

Prefill: V1 vs V2 model runner

The 2026-08-02 V1-vs-V2 comparison never measured throughput. It has now been measured, blocked
and pre-registered — 10 blocks, both arms inside each node pair, exact sign-flip permutation test,
Holm-corrected across the six prefill cells, with the decision rule committed before any data was
collected:

metric V2 / V1 95% CI exact p
ctx_pp @ d8192 +1.18% [+0.82, +1.53] 0.0020
ctx_pp @ d16384 +1.11% [+0.35, +1.87] 0.0137
ctx_pp @ d32768 +1.61% [+1.17, +2.04] 0.0020
pp2048 @ d8192 +4.18% [+3.28, +5.09] 0.0020
pp2048 @ d16384 +4.18% [+3.36, +5.00] 0.0020
pp2048 @ d32768 +4.33% [+3.56, +5.11] 0.0020

All six survive Holm; both node pairs agree in direction on every cell. Decode is not resolved
in either direction
tg128 was declared unresolvable before the run (its within-build spread
equals its entire historical range) and is reported for the record only.

V1 was the default when this was written; that was reversed on 2026-08-09 — see Update 2026-08-09. The reasoning below was correct on the evidence available at the time, and the collapse it describes was real; it turned out not to be a property of the runner. Kept unedited because how the conclusion failed is the useful part. V2 is ahead on prefill, KV headroom (+4.70 GiB) and draft acceptance
(+6.6%), but its long-context recall under concurrency is unreliable in a way that is worse than a
consistent deficit: across 14 independent serves on the same build and configuration, roughly two
thirds land in a state that loses most of the needles (arthur c=12 as low as 3/24), while the rest
match V1 at 22–24/24. The mode is fixed at startup and stable within a serve, and nothing we have
found predicts or detects it. A deployment could run clean for days and restart into the bad mode.

The cause is not identified. Eliminated so far: the eager-scratch cross-template aliasing, the
upstream merges, and the eager scratch pool as a whole (pool on 2 good / 6 bad vs pool off 3 good /
3 bad over 14 serves — no effect). The startup logs of a good and a bad serve are structurally
identical, which rules out "a different code path was taken". Anyone opting into V2 with
VLLM_USE_V2_MODEL_RUNNER=1 should know this.

Measurement note

Two errors from our own process, since they affect how the numbers above should be read.

The n=8 sampling that originally established V2's recall deficit took eight gate runs from one
serve
— it measured within-serve variance while the quantity that actually varies is
across-serve. Raising n on the wrong axis. The 14-serve figures above use the inverted design:
many serves, few gates each.

And the ± in a benchy row is the spread within one invocation; it runs 5–30× smaller than the
build-to-build spread. The blocked design above exists because of that: a coarse range screen over
the same 10 blocks returns "no measurable difference" on all six prefill cells, while the paired
test finds all six. Had the screen been the decisive statistic, this section would have concluded
the opposite and been wrong.

Update 2026-08-09 — the V2 recall collapse was a prefix-cache race, not the runner; V2 becomes the default

70 upstream commits (to 643c125fab), and the long-standing reason this branch
pinned V1 is gone: it was an unfixed upstream bug, not a property of the V2
model runner.

The defect

FullAttentionManager.cache_blocks() commits prefix block hashes to the shared
BlockPool at scheduling time, before the forward pass writes their KV. A
request admitted later in the same step can match those hashes and read unwritten
values. MambaManager has guarded this since #29387; no other manager does.

This is #42359, open and unmerged. Two more reports look
like the same triple on different models — #50188 (prefix caching + MTP spec
decode + fp8 KV, byte-identical repeat requests, RTX 5090 / Qwen3.6-27B-NVFP4)
and #43559 (closed without a merged fix). Anyone on --enable-prefix-caching
with speculative decoding is exposed; DeepSeek-V4 is not special here.

What makes it hard to catch: the damage persists. A serve that loses the race
keeps serving from the poisoned blocks for its lifetime, so a later serial
request fails too — which is why it looked like a per-serve "mode" rather than a
race. It is also stochastic, roughly half of cold serves.

Evidence

Same binary, VLLM_ALLOW_SPEC_DEC_SAME_STEP_PREFIX_HIT the only variable, cache
populated by the real gate, 4 fresh serves per arm (a single clean serve
proves nothing at ~50% incidence), 3 arthur c=12 runs each:

serve 1 serve 2 serve 3 serve 4 mean min
guard off 22/23/24 6/5/3 14/9/10 7/8/7 11.5 3
guard on 23/22/21 20/22/20 22/23/23 23/22/23 22.0 20

Mann-Whitney U, p = 0.0043. Every serve's runner and guard state was read back
from the serve log rather than assumed.

It also fixes V1, which was not expected: V1's arthur c=12 goes 20.7 → 23.0
with the guard on (24.0 with prefix caching disabled entirely). V1's own 2–4
needle shortfall was the same defect, not an inherent concurrency margin.

Runner arbitration, re-run on the fixed tree

Same tree, same guard mode, runner the only variable:

V1 V2
arthur c=12, 4 serves × 3 22.3 / 21.7 / 21.7 / 21.0 → 21.67 22.0 / 20.7 / 22.7 / 22.7 → 22.00
pp2048 d8192 / 16384 / 32768 1427 / 1363 / 1216 1472 / 1421 / 1303 (+3.1% / +4.2% / +7.1%)
tg128 mean 39.95 / 41.16 / 35.25 41.56 / 49.61 / 45.18
e2e TTFT 1437 / 1506 / 1689 ms 1393 / 1444 / 1576 ms
GPU KV cache 339,194 tok 423,752 tok (+24.9%)
GSM8K strict / flexible 0.9378 / 0.9401 0.9401 / 0.9439
issue19 · multi-needle · c=1 PASS · 48/48, 0 leaks · 2/2 PASS · 48/48, 0 leaks · 2/2

Recall: p = 0.697, neither side with a single-digit serve. GSM8K differs by
<0.4 pp against ~1.1 pp single-run noise. V2 is not behind anywhere and leads
on throughput, latency and KV headroom, so it becomes the default.

VLLM_USE_V2_MODEL_RUNNER=0 still selects V1, which stays supported.

Correction: the "V2 +6.6% draft acceptance" figure in Update 2026-08-04
does not survive re-measurement — 2.772 (V1) vs 2.710 (V2) on the same
formula and sample size, i.e. a tie. It was measured while V2 was poisoned.

If you are running this branch

Nothing to set — the guard is on by default where it matters.
KVCacheCoordinator enables it whenever prefix caching and speculative decoding
are both active, which is the only configuration in which a block hash can be
published before its KV is written and a second request admitted in the same
step to match it.

This correction matters: an earlier revision of this update shipped V2 as the
default while leaving the guard off by default, which would have handed a plain
serve the exact combination measured at mean 11.5 with a 3/24 floor. Both
changes looked like improvements in isolation. If you pulled c054feedac,
take aa0d513027 instead, or set the variable yourself.

To turn it off (it is a real escape hatch, pinned by a test):

VLLM_ALLOW_SPEC_DEC_SAME_STEP_PREFIX_HIT=0

1 is upstream's semantics, gated on use_eagle; on DeepSeek-V4 that covers
only 2 of 5 managers and leaves the main MLA path unguarded — measured, not
assumed, via a startup log line this branch adds that reports how many managers
are actually guarded. 2 covers every group and is what the engine selects.

Regression sweep on the merged tree: 261 passed, 1 failed, that one failing
identically on the pre-merge tree 4ebd1fb698.

The two endpoints disagreed about the same model, twice

ResponsesRequest.reasoning took its type from the OpenAI SDK, whose
ReasoningEffort stops at xhigh, so DeepSeek's documented top tier max was
rejected by schema validation on /v1/responses while /v1/chat/completions
accepted it.

Worse, and on the default path: with no thinking kwarg,
DeepSeekV4Tokenizer.apply_chat_template defaults thinking on while
DeepSeekV4ReasoningParser defaults it off and selects
IdentityReasoningParser. The model reasoned and its reasoning, with a bare
</think>, came back inside output_text as though it were the answer —
whenever a request omitted reasoning, which is exactly what a stock OpenAI
SDK sends. Chat was immune only because it normalises thinking state at the
protocol boundary, and its own docstring says why: "so the tokenizer and
reasoning parser see the same effective state"
. Responses never called that
hook. The derivation now lives in deepseek_v4_chat_kwargs and both request
types call it, so a third endpoint cannot repeat it.

Measured on both checkpoints with no workaround flag set, 21/21 each:

DeepSeek-V4-Flash-0731 DeepSeek-V4-Flash
silence: reasoning in its own field PASS PASS
silence: no </think> in the answer PASS PASS
effort: none disables thinking PASS PASS
six spellings × two endpoints PASS PASS
high reasons deeper than low +85% / +110% +19% / +35%

26 unit cases accompany it, 11 of which fail on the unpatched tree.
tests/reasoning 440 passed, tests/tokenizers_/test_deepseek_v4.py 45 passed.

Acceptance on the exact published SHA

Everything above was re-measured on d44e224ab9 — the commit this tag points at,
after a second upstream sync (17 further commits, FlashInfer 0.6.16.post3) — not
on an ancestor assumed to be equivalent. 17 of 18 checks pass:

check result
four nodes clean at the SHA, FlashInfer 0.6.16.post3 PASS
tests/v1/core 509 passed, 1 pre-existing failure
default serve with nothing set: boots, runner V2, guard 5/5, no NameError PASS
arthur c=12 ×3 / c=1 22 / 20 / 23 · 2/2
GSM8K strict · issue19 · multi-needle 0.9363 · PASS · 48/48, 0 leaks
pp2048 d8192 1480.77 (arbitration V2 arm 1471.8)
VLLM_USE_V2_MODEL_RUNNER=0 → V1, guard still 5/5, c=1 2/2 PASS
VLLM_ALLOW_SPEC_DEC_SAME_STEP_PREFIX_HIT=0 → guard 0/5, c=1 2/2 PASS

The one non-pass, and what it turned out to be. tests/v1/spec_decode does
not complete on this hardware — it wedges under a 30-minute bound on this head
and on 4ebd1fb698 alike. Narrowed to test_max_len.py and measured both ways:

how it is run result
whole file, one pytest process wedges after ~7 min, 5 of 11 done
each case in its own process 11 of 11 pass, free memory steady at 117 GiB

No individual case is broken. Each stands up a full engine, and repeated
create/tear-down inside one process does not release resources fast enough on a
single-GPU unified-memory node. That also explains why both trees wedge and
why they stop at different points. It remains unverified coverage rather than a
pass
; running one process per case produces a verdict instead of a hang.

test_async_scheduling_pp_allows_rescheduling_with_output_placeholders is the
same class: it builds pipeline_parallel_size=2, and a GB10 node has one GPU, so
it fails at config construction. It is the only case in tests/v1/core that needs
more than one GPU; the other 509 pass.

What this arbitration does and does not cover

Everything above was measured on one configuration: 2-node TP=2,
DeepSeek-V4-Flash-0731, DSpark num_speculative_tokens: 5, fp8 KV,
max_model_len 131072, prefix caching on, GB10 (SM121). The default now applies
to every DSpark config, including shapes not measured here — TP=4, other
context lengths, the NVFP4 checkpoint, single-node setups.

The reasoning generalises better than the numbers do: the race is in block
publication and is not specific to a model shape, and V2's advantage comes from
KV headroom and scheduling rather than anything config-specific. But if you run
a materially different shape and see something worse, VLLM_USE_V2_MODEL_RUNNER=0
returns you to V1 and a report would be welcome — that is a gap in our coverage,
not a claim we have ruled out.

Running DSpark

DSpark is DeepSeek's self-drafting speculative-decode variant; on 0731 the draft weights are carried in the main checkpoint, so no separate --speculative-model is needed.

vllm serve deepseek-ai/DeepSeek-V4-Flash-0731 \
  --trust-remote-code \
  --tokenizer-mode deepseek_v4 \
  --tool-call-parser deepseek_v4 --enable-auto-tool-choice \
  --reasoning-parser deepseek_v4 \
  --tensor-parallel-size 2 \
  --kv-cache-dtype fp8 \
  --block-size 256 \
  --max-model-len 49152 \
  --max-num-seqs 64 \
  --max-num-batched-tokens 8192 \
  --gpu-memory-utilization 0.85 \
  --enable-prefix-caching \
  --speculative-config '{"method":"dspark","num_speculative_tokens":5,"draft_sample_method":"probabilistic"}'
  • num_speculative_tokens must equal the checkpoint's dspark_block_size (5). Larger values are rejected: they are never accepted and only waste draft compute.
  • --kv-cache-dtype fp8 is mandatory — DSv4's fp8_ds_mla attention asserts an fp8 KV layout, so the default auto fails at model construction. Not DSpark-specific.
  • Runs on the V1 runner by default (correct long-context recall). VLLM_USE_V2_MODEL_RUNNER=1 opts into the V2 DSpark speculator; V2's long-context recall is correct after the fix(dspark): reserve V2 padded Q scratch jasl/vllm#26 padded-Q fix.
  • If you measure draft acceptance yourself, use prose. On counting or repeated text the Markov head alone reaches 68–100% acceptance even with the neural draft path degraded, which hides real regressions entirely.

Dependencies (stock-deps path)

Pins on the current head: torch 2.13.0 (triton 3.7.1) · flashinfer-python / flashinfer-cubin 0.6.15.post1 · tilelang 0.1.12 · nvidia-cutlass-dsl[cu13] 4.6.0 · quack-kernels>=0.6.1 · nvidia-nccl-cu13 2.30.7 (multi-node, see below).

  • FlashInfer is pinned in requirements/cuda.txt (flashinfer-python and the GitHub-release flashinfer-cubin, which must be the same version); it ships the SM120 packed sparse-MLA kernels, so a stock build picks them up with no manual install dance.
  • GB10 / multi-node: pin nvidia-nccl-cu13==2.30.7 on every node. A rebuild silently reverts it to torch's bundled version, and a per-node mismatch hangs the NCCL handshake.
  • The SM120 decode (VLLM_DEEPSEEK_V4_FLASHINFER_SM120_DECODE) and prefill (VLLM_DEEPSEEK_V4_FLASHINFER_SM120_PREFILL) FlashInfer sparse-MLA paths default on; set either =0 to fall back to the FlashMLA / Triton path. Both are availability-gated, so stock installs without the kernel degrade gracefully rather than raising.

Running the NVFP4 checkpoint

This branch also serves nvidia/DeepSeek-V4-Flash-NVFP4 on SM12x (RTX PRO 6000 / GB10). The NVFP4 MoE auto-selects the FlashInfer CUTLASS backend (the SwiGLU-clamp model gate accepts it), so no --moe-backend flag and no special FlashInfer build are required:

vllm serve nvidia/DeepSeek-V4-Flash-NVFP4 \
  --trust-remote-code --tensor-parallel-size 2 \
  --kv-cache-dtype fp8 \
  --tokenizer-mode deepseek_v4

Expert-parallel off (plain TP) is the supported path. Accuracy matches MXFP4 (GSM8K 8-shot ~0.96 on both SM120 and SM121). On SM12x NVFP4 is not a memory or throughput win versus MXFP4: NVFP4 weights are ~4 GiB/GPU larger, leaving less KV-cache room; single-stream prefill is marginally faster and aggregate decode marginally slower. Its value here is checkpoint availability / parity with the SM100 datacenter path — MXFP4 remains the better practical choice on consumer Blackwell.

AI assistance disclosure

AI assistants, including OpenAI Codex/GPT models and Anthropic Claude models, were used for code review, refactoring support, regression-script writing, and benchmark analysis. The branch was validated through human review plus the commands and harness artifacts listed above; every performance and accuracy number quoted was measured on real SM120/SM121 hardware.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@mergify mergify Bot added deepseek Related to DeepSeek models nvidia v1 labels May 6, 2026
@jasl

jasl commented May 6, 2026

Copy link
Copy Markdown
Contributor Author

@zyongye
I've cleaned up the old PR, could you help review this one?

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements support for DeepSeek V4 on SM12x (Blackwell) architectures by providing Triton-based fallbacks for DeepGEMM-dependent operations. Key enhancements include the introduction of specialized Triton kernels for sparse MLA, FP8 einsum, and MQA logits, as well as memory optimizations in the sparse attention indexer to compute top-k indices without materializing full logits. Additionally, the PR updates the model loader to support weight name filtering for skipping MTP weights and handles Blackwell-specific FP8 quantization scales. I have no feedback to provide.

@chatgpt-codex-connector

Copy link
Copy Markdown

💡 Codex Review

def _sparse_indexer_requires_deep_gemm() -> bool:
return current_platform.is_cuda() and not (
current_platform.is_device_capability_family(120)
)

P1 Badge Keep DeepGEMM requirement for SM120 FP4 indexer path

This helper now disables the DeepGEMM requirement for every SM120 run, but the FP4 indexer cache path still depends on DeepGEMM kernels (fp8_fp4_*) because the new SM120 fallback only handles q_scale is None (FP8 Q). With use_fp4_cache=True on SM120 and no DeepGEMM installed, construction succeeds and the first prefill/decode call fails at runtime with the DeepGEMM _missing() error instead of being rejected up front.


if self.load_config.load_format == "fastsafetensors":
weights_iterator = fastsafetensors_weights_iterator(
hf_weights_files,
self.load_config.use_tqdm_on_load,
)

P2 Badge Propagate weight_name_filter to fast safetensor loaders

The new pre-load weight_name_filter is only wired into safetensors_weights_iterator; this branch still loads all tensors for fastsafetensors (and similarly other non-default safetensor iterators), so skipped tensors are still materialized. For DeepSeek V4 this defeats the intended early skip of MTP weights and can reintroduce high transient memory use/OOM when these load formats are enabled.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@jasl jasl changed the title [New Model][Nvidia] Add SM12x support for DeepSeek V4 Flash [New Model][Nvidia] Add SM12x support for DeepSeek V4 Flash with essential fixes May 6, 2026
@jasl
jasl force-pushed the codex/ds4-sm120-min-enable branch from 042e366 to df2e6f8 Compare May 6, 2026 16:26
jasl and others added 2 commits August 7, 2026 06:31
The multi-step drafter rewrites CommonAttentionMetadata IN PLACE at loop
entry: the ragged first-pass layout (one row per token) becomes one row
per request. token_to_req_indices() caches its mapping on that same
object, so loop steps hit the first pass's ragged mapping sliced to
batch_size. Pure-decode batches alias the two layouts (identity either
way) -- every pure-decode test and probe was structurally blind. In a
mixed batch, draft rows > 0 inherit an EARLIER request's identity; the
SWA window kernel then computes pos = that request's seq_len - 1 + 1,
one slot past its last written token, and the draft attention reads
unwritten fp8 KV: ~2/256 random byte patterns decode to NaN and one NaN
turns the entire hidden row non-finite.

This was the drafter-side source of the all-NaN draft_probs rows behind
the <|begin of sentence|> leak (vllm-project#41834): drafter NaN row
-> exponential-noise argmax degenerates to token 0 -> rejected ->
recovered sampling used to re-emit it (fixed separately in d8885a3).
Measured signature, all explained: step-1 only (first pass primes its
own cache correctly), rows>0 only (mapping[0]=0 is self), both TP ranks
in the same step (deterministic stale mapping), persists under
--enforce-eager (no graphs involved), window overshoot equal to the
borrowed request's length.

Invalidate both layout-derived caches at the rewrite, and stop replace()
from carrying them into the extended layout in extend_all_queries_by_N
(the parallel-drafting twin of the same hazard).

A/B on the live repro (MTP nst=2, 20 concurrent mixed requests, eager):
unfixed 145-159 NaN-row events per run across three instrumentation
levels; fixed 0 events on both ranks, 20/20 completions.

Co-authored-by: Claude <noreply@anthropic.com>
@jasl

jasl commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Status update: the corruption family reported in this thread now stands as four distinct, separately root-caused and fixed mechanisms, all landed on the branch (codex/ds4-sm120-min-enable == ds4-sm120-preview-dev @ 2649427):

  1. Recovered sampling re-emitting the rejected token (d8885a3335) — NaN draft rows collapsed the residual reduction to index 0 = BOS. A/B: 119 leak sites → 0.
  2. C128A decode stride vs cudagraph capture (@tobymao, [Bugfix][DSv4] Make the C128A decode topk row stride capture-stable jasl/vllm#41 — merged) — the spec-OFF corruption at max_model_len ≫ tested context; his in-graph traces and 39→0 A/B stand on their own.
  3. Drafter layout-cache desync (264942766e) — the SOURCE of the NaN draft rows behind (1): the multi-step drafter's in-place layout rewrite left a stale token→request mapping cache; draft rows > 0 borrowed earlier requests' identities in mixed batches and attended one slot past their written KV. Probe counters 159 → 0, eager and FULL-cudagraph.
  4. Unbounded ids on degenerate paths (@alexbi29) — the tile-argmax token-id clamps (d7bddfeff2) and the V2 DSpark boot blocker (c05aa7e858) are landed with credit; the expert-map gather bounds and the FlashInfer gate version-half are invited as PRs.

(1) and (3) compose as defense in depth: (3) stops NaN rows from being computed, (1) guarantees a degenerate row can never surface a rejected token. Every one of the four traces back to reports in this thread — thank you all. A baseline-of-record on the fixed tree is committed in the repo docs for anyone tracking regressions.

@jasl

jasl commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

@alexbi29 following up specifically on your four groups: 1 and 3 are landed with your authorship — c05aa7e (V2 DSpark hooks; identity verified exactly as you described) and d7bddfe (all three tile-argmax clamp sites; the chi-squared distribution suites pass unchanged). For groups 2 (expert-map bounds) and 4 (FI gate version-half): please do send those as PRs — for group 2 especially, your EP cluster can validate the expert_map.numel() bound under real expert parallelism, which our TP-only rigs structurally cannot, and your positive-stale-bit-pattern observation deserves to live in the commit message verbatim.

jasl and others added 2 commits August 7, 2026 09:06
topk_ids buffers come from torch.empty and the routers do not overwrite
the padded rows of a CUDA-graph batch, so an entry can be arbitrary
POSITIVE data (in practice the bit pattern of whatever float tensor
previously owned the allocation) -- a '< 0' check alone does not gate the
gather, and expert_map[stale_id] reads past the table: an illegal access
that takes down every TP rank at once. Bound all three drifted sites by
the length of the map actually being indexed (the GLOBAL expert count;
under EP the rank-local count is the smaller number and is NOT a safe
bound): _count_expert_num_tokens, moe_fused_mul_sum_kernel, and the
moe_sum pad-aware skip helper (whose sibling get_local_expert_id already
carried exactly this bound -- the sites had drifted from it). Out-of-range
ids now fall out as not-computed, so a producer bug degrades one token
slot instead of killing the engine.

Reported by alexbi29 in vllm-project#41834 with the mechanism and
the positive-stale-bit-pattern observation, verified on their SM120
TP=2+EP production cluster.

Co-authored-by: alexbi29 <alexbi29@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
The symbol probe alone fails open in two real cases: flashinfer-python
0.6.13 exposes nearby sparse-MLA APIs without the SM120 module, and a
flashinfer-cubin mismatched against flashinfer-python passes the import
probe but fails at first kernel call. Add
flashinfer_sm120_sparse_mla_unavailable_reason(): version floor
(>= 0.6.14) plus python<->cubin match plus the existing symbol probe,
returning the reason rather than a bare bool. The nvidia model selector
now fails loudly with that reason instead of silently falling back to
FlashMLA -- the packed path is default-on for SM12x, and a silent
fallback hides broken installs until recall degrades
(VLLM_DEEPSEEK_V4_FLASHINFER_SM120_DECODE=0 opts into the fallback
explicitly).

Reported by alexbi29 in vllm-project#41834 (their group 4).

Co-authored-by: alexbi29 <alexbi29@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
@jasl

jasl commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

@alexbi29 update: all four of your groups are now landed with your authorship — no PRs needed after all. We stood up a real EP environment on our own 4× GB10 (TP=4 + --enable-expert-parallel) to validate group 2 properly:

  • Group 2 (2237d87af0): all three drifted sites bounded by expert_map.numel()_count_expert_num_tokens, moe_fused_mul_sum_kernel, and the moe_sum pad-aware skip helper (plumbed through all three kernel signatures and launch paths). Unit tests feed stale positive ids (including your bit-pattern-of-a-float class, e.g. 2071690107) through both Triton paths — skipped cleanly, counts and sums exact. End-to-end: TP=4+EP serve boots, instruction-following gate passes, long-context coherence 2/2 with the rebuilt kernels on the hot path. Your out-of-range-degrades-one-slot semantics preserved verbatim.
  • Group 4 (7078d6e823): flashinfer_sm120_sparse_mla_unavailable_reason() — version floor (>= 0.6.14) + python↔cubin match + the existing symbol probe, returning the reason; the nvidia selector now fails loudly with it instead of silently falling back to FlashMLA (VLLM_DEEPSEEK_V4_FLASHINFER_SM120_DECODE=0 opts into the fallback explicitly). Tests cover your exact 0.6.13 and cubin-mismatch cases.
  • Groups 1 and 3 were landed earlier today (c05aa7e858, d7bddfeff2).

Both branches carry everything at 7078d6e823. If your local deltas differ anywhere from what landed, a diff-of-diffs review would be very welcome.

@alexbi29

alexbi29 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

2237d87af0 does not compile — three non-pad-aware moe_sum launches were not updated

Rebuilding at 7078d6e823 (SM120, TORCH_CUDA_ARCH_LIST=12.0) fails:

FAILED: CMakeFiles/_moe_C_stable_libtorch.dir/csrc/libtorch_stable/moe/moe_align_sum_kernels.cu.o
moe_align_sum_kernels.cu(889): error: no instance of function template
  "vllm::moe::moe_sum_vec_kernel" matches the argument list
  ... likewise moe_sum_vec_dynamic_kernel and moe_sum_scalar_kernel

The expert-map bound added const int32_t num_global_experts as a trailing parameter to all three kernel signatures, and the pad-aware (true) launches pass it. The three non-pad-aware (false) launches still pass the old argument list, ending nullptr, nullptr, 0, 0:

  • LAUNCH_MOE_SUM_VEC macro — moe_sum_vec_kernel<scalar_t, int32_t, TOPK, false>
  • moe_sum_vec_dynamic_kernel<scalar_t, int32_t, false>
  • moe_sum_scalar_kernel<scalar_t, int32_t, false>

Fix is one trailing 0 on each — those paths pass a null expert_map, so the bound is never consulted, but the argument is still required because the signature is shared with the pad-aware instantiation:

-  stride_topk, nullptr, nullptr, 0, 0)
+  stride_topk, nullptr, nullptr, 0, 0, 0)

Builds clean with that, and nothing else in the file needed changing. No local deltas of ours are involved: when it failed, our copy of this file was byte-identical to yours (git status clean, and neither of our two local commits touches it) — the three-character fix above is the only difference now.

Post-fix on our side: _moe_C_stable_libtorch.abi3.so rebuilt at sm_120 sm_120f sm_80, DSpark ns=5 serve boots, tool calling and multi-step reasoning correct, concurrent mixed prefill+decode clean so far.

Guessing at why it passed on your side: if your rebuild reused an existing _moe_C_stable_libtorch object for this translation unit, the stale .o would link fine and the mismatch would never surface. It reproduces from a cleaned cmake cache.


Unrelated, while rebuilding: four .deps checkouts on this tip were stale against their CMake pins (vllm_flash_attn 28e862d vs pinned f3e1a4f7, plus flashkda, deepgemm, triton_kernels). FetchContent caches by directory existence, so a moved GIT_TAG is silently ignored and the old source compiles. Worth a note in the build docs if others hit stale kernels after a sync — and fixing -src alone is not enough, since the -subbuild gitclone stamp resets it back on the next configure.

2237d87 added the parameter to all three moe_sum kernel signatures
and updated the pad-aware launches, but the three non-pad-aware launch
paths (LAUNCH_MOE_SUM_VEC, moe_sum_vec_dynamic_kernel,
moe_sum_scalar_kernel with PAD_AWARE=false) still passed the old
argument list, so the file did not compile from a clean object. The
argument is required by the shared signature even though those paths
pass a null expert_map and never consult the bound; pass 0.

The miss was on our side: the corrected launches were validated on the
build node but lost from the source tree by a reset --hard while
folding commits, so the pushed commit predated the fix and only a stale
object kept our own rebuild green.

Reported with the exact fix by alexbi29 in vllm-project#41834,
verified building clean at sm_120/sm_120f/sm_80 and serving correctly.

Co-authored-by: alexbi29 <alexbi29@users.noreply.github.com>
@jasl

jasl commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

@alexbi29 confirmed and fixed in 4ebd1fb698 (both branches) — your three-character diagnosis was exact, and your stale-object guess was the right shape: the corrected launches were validated on our build node but then lost from the source tree by a reset --hard while folding commits, so the pushed commit predated the fix and only the node's already-built object kept our rebuild green. The follow-up commit says so plainly; thank you for catching it within hours and for verifying the fix at sm_120/sm_120f/sm_80 with a clean serve.

Your .deps FetchContent note (moved GIT_TAG silently ignored when the -src directory exists, and -subbuild stamps resetting it) is a real trap — we're adding it to the build docs.

@brianmiller

Copy link
Copy Markdown

Confirming the full four-mechanism fix set resolves it on 2× GB10 / TP=2 — thank you all.

We earlier reported that d8885a33 alone cut our MTP BOS leak ~40× but left a ~1/120 residual in long generations. That lines up exactly with the picture that emerged since: the residual was the NaN source (264942766e, drafter layout-cache desync) plus the sibling argmax fall-throughs (d7bddfeff2, @alexbi29), neither of which d8885a33 touches.

Rebuilt on 264942766e (all four: recovered-sampling, C128A stride, layout-cache invalidation, argmax clamps) and re-ran our repro — MTP num_speculative_tokens=2, temp 0.7, 20 concurrent, ~4000-token generations reaching ~18k chars (scanning content and reasoning_content, since this tree correctly routes thinking to reasoning_content):

  • d8885a33 only: ~1/120
  • full tree 264942766e: 0/140

We've turned speculative decoding back on in production on the strength of it (~55% faster decode than spec-off). One small note for anyone rebuilding: the reasoning parser on this tree leaves content empty until the answer, so a corruption scan that only reads content will read empty on long-thinking requests — scan reasoning_content too.

Appreciate the depth here, @jasl @tobymao @alexbi29 — four separately root-caused mechanisms, all landed.

jasl added 8 commits August 8, 2026 15:59
…llm-project#42359)

Ports upstream PR vllm-project#42359 (open, unmerged) behind its own env gate,
VLLM_ALLOW_SPEC_DEC_SAME_STEP_PREFIX_HIT, default OFF.

A block's hash is published into the shared BlockPool at SCHEDULING time --
allocate_slots caches up to total_computed_tokens + num_new_tokens, i.e.
including tokens this step has not computed yet -- so a request admitted later
in the same step can match it and read KV the GPU has not written. MambaManager
has guarded this since vllm-project#29387; no other manager did, including the MLA managers
DeepSeek-V4 runs on. When the gate is on, such a reader is deferred one
scheduling step by returning an impossible block count.

Two adaptations were needed; the port is not upstream's diff verbatim:

- Upstream threads use_eagle in as a constructor argument. This fork assigns
  use_eagle to the manager AFTER construction (KVCacheCoordinator sets it once
  the attention groups are known), so capturing it in __init__ would read False
  forever and leave a guard that looks installed but can never fire. The gate is
  therefore a property evaluated at call time.
- The recording lives in the base cache_blocks rather than FullAttentionManager.
  Every override on the DeepSeek-V4 path -- FullAttention, MLA, SlidingWindowMLA,
  Mamba -- delegates via super(), so the base covers them all. Patching
  FullAttentionManager alone would have missed SlidingWindowMLAManager, which
  extends SlidingWindowManager, and DSv4 uses both.
  CrossAttentionManager does not delegate and is not covered; upstream's patch
  does not cover it either.

MambaManager keeps its always-on behaviour by overriding the gate to True.

Tests cover the negative controls, not just the happy path: the gate is off by
default; it reads use_eagle at call time (a port that captured it in __init__
fails); Mamba ignores the env var; and the defer fires for a tail published this
step while NOT firing for one published earlier -- without that second half a
guard wired to defer unconditionally would pass.

Evidence motivating the port is in
docs/sm120/experiments/2026-08-08-prefix-reuse-defect/ in the harness repo:
prefix caching off gives 24/24 on both runners, on gives 22/24 (V1) and 3-5/24
(V2), and 12 concurrent reads of a populated cache return 1/12 when the cache
was populated by a racing batch versus 12/12 when populated serially.
This port is a DISCRIMINATOR first: default-off, A/B the flag on the same serve
with the cache populated by a racing batch. Note that the June revert of the
equivalent fork-local fence (364fd5c) justified itself with arthur 8/8 at
conc=8 and 16/16 at conc=16, which had no power against this failure because it
never controlled how the cache was populated.
KVCacheBlock.block_hash is a read-only property; the hash is installed via
set_block_hash() and the group id is folded into the key, which is why the
guard's set is typed BlockHashWithGroupId. Assigning to the property raised
AttributeError, so the two behavioural cases errored out while the five gating
cases passed -- a shape that reads like "the guard does not fire" when in fact
the test never got as far as calling it.

Verified by mutation rather than by the tests merely going green: replacing the
gate with `return False` turns 4 of the 9 red, and restoring it returns all 9
to green. A test that cannot fail is worth less than no test.
The guard is `env AND use_eagle`, and use_eagle only reaches managers whose
group is in eagle_group_ids, so setting the flag is not evidence the guard is
live for a given model. Log the per-manager count at startup so an A/B of the
flag is verifiable from the serve log instead of assumed -- with the env off it
must report 0 active, which is the negative control.

(The first version of this used `logger` without importing it, which would have
raised NameError at engine startup and broken every serve in the A/B. Caught
before running by checking that the name was actually defined at module level
rather than trusting that a logger exists in every module.)
The startup log added in the previous commit paid for itself immediately. With
the flag set, DeepSeek-V4 reported:

    Same-step ghost-block guard: 2/5 managers active (SlidingWindowMLAManager)

Upstream gates the guard on `use_eagle`, having framed the race as a spec-decode
problem. But KVCacheCoordinator only sets use_eagle on managers whose group is
in eagle_group_ids, and its fallback to "flag every group" fires only when NO
group is flagged. On DSv4 the sliding-window groups ARE flagged, so the fallback
never runs and MLAAttentionManager -- the main attention path, and the one the
recall gate exercises -- was left completely unguarded.

The race is in block publication: a hash is committed to the shared BlockPool at
scheduling time, before the forward writes the KV. Nothing about that is
specific to speculative decoding. So the env var becomes tri-state:
0 = off, 1 = upstream semantics (use_eagle-gated), 2 = every group.

Without that log line this would have run a 45-minute A/B whose treatment arm
was 40% applied, produced no clear improvement, and invited the conclusion that
the mechanism was wrong -- when the patch simply was not enabled where it
mattered. Reading a condition back from the engine is worth more than setting it
carefully.
Setting the env default to 2 was tried and reverted after measuring what it
costs. tests/v1/core, with the guard defaulted on:

    guard 0 (upstream default)  ->  1 failed, 245 passed
    guard 1 (upstream semantics) ->  3 failed
    guard 2 (every group)        -> 12 failed

The 11 extra failures are all in test_prefix_caching.py, which calls
allocate_slots repeatedly to represent SUCCESSIVE scheduling steps while calling
new_step_starts exactly once in the whole file. With the guard on, those
allocations look like one step and the hits are correctly deferred, so the tests
fail. The tests are step-agnostic rather than wrong -- but flipping the default
here would fork 11 upstream tests and break every future test written the same
way, for no benefit that setting the variable in the deployment does not already
give.

So the default stays 0 and the harness turns it on at serve time. The single
remaining failure, test_async_scheduling_pp_allows_rescheduling_with_output_
placeholders, fails identically on the pre-merge tree 4ebd1fb and is not
related to this work.

Also moves `from vllm import envs` above `vllm.utils.math_utils` so the import
block stays isort-clean.

Verified before committing, rather than assumed:
- new_step_starts is called unconditionally at the top of Scheduler.schedule and
  the coordinator forwards it to every manager, so cached_blocks_this_step
  cannot grow across steps or defer a request permanently.
- envs caching is enabled in the serving path (engine/core.py, multiproc_
  executor.py), so the property does not do an os.getenv per scheduling call.
…hable

Restores upstream's dspark -> V2 routing, which this fork removed on 2026-08-03.
That removal was correct on the evidence available then: V2 lost long-context
recall under concurrency, 8 samples per runner from one serve giving V1 mean
22.25 [20,24] against V2 mean 10.50 [6,13], Mann-Whitney U=0.

That collapse was not a property of the runner. It was the same-step ghost-block
race in the prefix cache (vllm-project#42359): a block's hash becomes
visible to other requests before the forward pass writes its KV, and a serve
that loses the race keeps serving from the poisoned blocks for its lifetime.
V2 was the heavy casualty only because its larger KV pool admits all twelve
concurrent requests at once (Waiting peaks at 0 against V1's 11), which
maximises the number of racing writers.

With the guard on, 4 fresh serves per runner, 3 arthur c=12 runs each, same
tree, same guard mode, runner the only variable:

    V1  serve means 22.3 / 21.7 / 21.7 / 21.0   gate mean 21.67  min 19
    V2  serve means 22.0 / 20.7 / 22.7 / 22.7   gate mean 22.00  min 20
    Mann-Whitney p = 0.697; neither side has a single-digit serve

and on the full suite V2 leads everywhere else it can be measured: pp2048
+3.1/+4.2/+7.1% at d8192/16384/32768, tg128 +4/+20/+28%, TTFT lower at every
depth, KV +24.9%. GSM8K, issue19, multi-needle and arthur c=1 all tie.

Two corrections recorded in the comment rather than quietly dropped: the
"+6.6% draft acceptance" advantage does not survive re-measurement (2.772 V1
vs 2.710 V2 on the same formula and sample size -- it was measured while V2 was
poisoned), and V1's own 2-4 needle shortfall was the same defect, not an
inherent concurrency margin: the guard lifts V1 from 20.7 to 23.0.

V1 remains fully supported. VLLM_USE_V2_MODEL_RUNNER=0 forces it, and the env
check precedes every routing rule. A test pins both halves -- the default and
the escape hatch -- so neither can drift silently.

Regression sweep with V2 defaulted: 261 passed, 1 failed, where that one
failure reproduces identically on the pre-merge tree 4ebd1fb.
The previous two commits were individually defensible and jointly wrong. Making
V2 the default runner while leaving the guard's default at 0 meant a plain serve
-- nothing set -- got V2 + prefix caching + DSpark with no guard, which is
precisely the combination measured at arthur c=12 mean 11.5, 3 of 4 serves
degraded, floor 3/24. Before this work the default was V1 without a guard at
20.7. I had made the out-of-the-box configuration worse while each change looked
like an improvement on its own. Our own serve script set the variable, so our
measurements never saw it; anyone following the PR would have.

The fix couples the two decisions where they belong. The env var now
distinguishes UNSET (None) from an explicit 0/1/2, and KVCacheCoordinator raises
the default to 2 when prefix caching and speculative decoding are both on -- the
only configuration where a hash can be published before its KV is written and a
second request can be admitted in the same step to match it.

Deliberately in the coordinator, not in envs.py: a manager constructed directly
still resolves to OFF, which is what keeps the step-agnostic tests in
test_prefix_caching.py passing. An explicit value always wins, so `=0` remains a
real escape hatch -- pinned by a test, because a guard you cannot switch off is
a guard you cannot rule out when diagnosing something else.

Two eagle tests did start failing, since they build a coordinator rather than a
bare manager: both allocate for req0 and then for req1 expecting a hit, with no
step boundary between them. That is the race, not a cache hit. Each gains one
`new_step_starts()` call, which makes them exercise what they are actually about.

Sweep: 276 passed, 1 failed -- that one failing identically on the pre-merge tree
4ebd1fb.

Verified end to end rather than by inspection: with nothing set, dspark routes to
V2 and the coordinator turns the guard on; with `=0` set, it stays off.
@mergify

mergify Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @jasl.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

Conflicts and how they were taken:

- vllm/v1/core/sched/scheduler.py -> UPSTREAM. Upstream refactored the
  per-method lookahead chain into a single VllmConfig.num_lookahead_tokens
  property (vllm-project#51438). It is semantically identical for DSpark -- use_eagle()
  includes "dspark", so the property returns num_speculative_tokens exactly as
  our chain did -- and upstream's comment is our fork's wording, so the reasoning
  was absorbed rather than lost. Our side also carried a duplicated dspark branch
  that the refactor makes redundant.
- requirements/cuda.txt, docker/{Dockerfile,versions.json} -> UPSTREAM's
  FlashInfer 0.6.16.post3 (we were on 0.6.16). apache-tvm-ffi stays 0.1.11, the
  pin we already run; the tilelang double-registration abort is triggered by
  moving tvm-ffi to 0.1.13.post0, not by the FlashInfer version, and 0.6.16.post1
  was previously verified serving on 0.1.11.
- tests/v1/kv_connector/.../test_scheduler.py -> UPSTREAM's added case.

Verified after resolving, rather than assumed: all four of our changes survive
(guard property, coordinator default, tri-state env, dspark->V2 routing), and
Scheduler.schedule() still calls new_step_starts() unconditionally -- checked by
AST for enclosing control flow, since the guard's per-step set is cleared there
and a conditional call would make it grow without bound and defer permanently.

Upstream commits of note for this branch: vllm-project#51438 reserves spec-decode lookahead
blocks in V2 warmup, vllm-project#50365 drops atomic contention in the sparse-MLA index
remap, vllm-project#48668 preserves prefix-cache stats on zero-output steps.

NOT yet re-tested: the FlashInfer bump changes kernel_warmup.py and
gpu_model_runner.py, and the fleet still has 0.6.16 installed. Acceptance runs
after the fleet is upgraded.
jasl added 2 commits August 10, 2026 07:21
The two endpoints serving the same model disagreed about its own vocabulary.
`ChatCompletionRequest.reasoning_effort` has always accepted `max` -- its
docstring even records that the tier is DeepSeek-V4-specific -- while
`ResponsesRequest.reasoning` took its type straight from the OpenAI SDK, whose
`ReasoningEffort` stops at `xhigh`. So `{"reasoning": {"effort": "max"}}` was
rejected by schema validation on /v1/responses and accepted on
/v1/chat/completions, for the same model, in the same server.

`max` is not a synonym: DeepSeek's V4 encoding ships a distinct prompt for it
(`REASONING_EFFORT_PROMPTS["max"]`), and DeepSeek's own API documents
none/low/high/max as the supported set.

No mapping is added here. `DeepSeekV4Tokenizer.apply_chat_template` already
folds every spelling onto the model's three tiers -- `none` disables thinking,
`minimal`/`medium` become `low`, anything else becomes `high` -- so widening
the schema is the whole fix. A first draft of this change added a second
normalisation table in `deepseek_v4_encoding`; it was dropped once the existing
one was found, rather than left in as a competing source of truth.

Tests pin the behaviour that had none: that both endpoints accept the same
seven spellings, that `max` specifically survives into `chat_template_kwargs`
without thinking being switched off on the way, that `none` still disables
thinking, and that the widened field still rejects a value the model has no
tier for. Against the unpatched tree three of them fail with ValidationError.

Verified end to end on a two-node TP=2 serve: `max` is accepted on the patched
tree and rejected on the unpatched one, with low/minimal/medium/high/xhigh/none
behaving identically on both.
With no thinking kwarg, `DeepSeekV4Tokenizer.apply_chat_template` defaults
thinking ON while `DeepSeekV4ReasoningParser` defaults it OFF and selects
`IdentityReasoningParser`. The model reasoned and the reasoning, with a bare
`</think>`, was returned inside `output_text` as though it were the answer --
on the default path, since omitting `reasoning` is exactly what a stock OpenAI
SDK does.

/v1/chat/completions was immune only because it normalises thinking into the
chat-template kwargs at the protocol boundary, and its docstring says why:
"so the tokenizer and reasoning parser see the same effective state".
/v1/responses never called that hook.

Rather than add a second copy of the derivation -- the duplication is how the
two endpoints came to disagree, twice now, this and `max` -- it moves to
`deepseek_v4_chat_kwargs` and both request types call it. `ChatCompletionRequest`
keeps its public methods, delegating; behaviour there is unchanged by
construction.

Tests: 26 new cases, 11 of which fail on the unpatched tree, covering both
checkpoints (DeepSeek-V4-Flash and -0731), an explicit thinking=false surviving
normalisation, `effort: "none"` still disabling thinking, and every effort
spelling being accepted. No regressions: tests/reasoning 440 passed,
tests/tokenizers_/test_deepseek_v4.py 45 passed,
test_responses_reasoning_effort.py 11 passed. The pre-existing collection
errors (`schemathesis`, `cohere_melody` absent) reproduce on the unpatched
tree.

This also retires the `--default-chat-template-kwargs '{"thinking":true}'`
workaround the deployment was carrying.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: No status
Status: No status
Status: No status

Development

Successfully merging this pull request may close these issues.