[SM12x] Drop the 64-head TP pad on the DSv4 MLA prefill path - #35104
Open
zhendonghua wants to merge 1 commit into
Open
zhendonghua wants to merge 1 commit into
zhendonghua wants to merge 1 commit into
Conversation
zhendonghua
force-pushed
the
perf/sm12x-dsv4-unpad-prefill-mla-heads
branch
2 times, most recently
from
August 17, 2026 07:34
0095740 to
848f9fa
Compare
zhendonghua
commented
Aug 17, 2026
| if _sm120_default_backend != "flashinfer": | ||
| return 0 | ||
| try: | ||
| from flashinfer.mla._sparse_mla_sm120 import _DECODE_MAX_TOKENS |
Contributor
Author
There was a problem hiding this comment.
zhendonghua
force-pushed
the
perf/sm12x-dsv4-unpad-prefill-mla-heads
branch
2 times, most recently
from
August 17, 2026 23:10
a589f43 to
af0866a
Compare
With attn_tp_size=2 the MLA path builds a 64-row query tensor, writes
only [0:n_local_heads] (=32), runs attention on all 64 rows, and throws
half the output away. The pad exists for FlashMLA's fp8 sparse *decode*
kernel, which specializes h_q for {64, 128}, but on SM12x that kernel is
selected by ROW COUNT inside the flashinfer entry
(`B <= _FI_DECODE_MAX_TOKENS`), not by forward mode, so a prefill chunk
never reaches it and pays the pad for nothing.
MLA is the one place where this is pure waste: 64 query heads share a
single latent KV, so head-sharding saves compute without saving KV
bandwidth. Prefill (many query rows, compute-bound) scales with head
count; decode (one row, KV-bandwidth-bound) does not.
Gate the pad off for prefill only, behind
SGLANG_OPT_DSV4_UNPAD_PREFILL_MLA_HEADS (default on, SM12x only). Every
term of the guard is load-bearing:
- graph: the gate reads forward_mode, which is not part of a captured
graph's shape key, so capture and replay could disagree.
- mode: an allowlist, not is_extend(), which is also True for
TARGET_VERIFY; and _original_forward_mode is checked because DP
padding can rewrite a verify batch into EXTEND.
- rows: strictly above the decode-kernel threshold, since that is
where h_q in {64, 128} is actually required.
Supporting changes:
- sm120_flashmla_decode_max_tokens() exposes the threshold instead of
duplicating the constant. It returns 0 when the backend is not
flashinfer, where the triton and torch fallbacks read h_q off the
query shape and there is no floor to respect, and a value no batch
can exceed if the flashinfer symbol moves, which pins callers to the
padded width.
- _local_attn_sink() takes an optional width and returns a prefix view
of the padded sink rather than a second allocation, so a device
pointer baked into a captured decode graph stays valid. Existing
callers pass nothing and keep the padded width.
Measured on 2x DGX Spark (GB10, sm_121), DeepSeek-V4-Flash-0731, TP=2:
MLA prefill lane -49.7%; the kernel instantiation goes from
sparse_mla_prefill_mg_dual_kernel<..., 64, ...> to <..., 32, ...>,
matching the geometry the reference vLLM deployment runs. GSM8K 500q
0.944 -> 0.948.
Only sm120/sm121 hardware was available. The same pad is applied on
other targets, where the prefill path returns early into
flash_mla_sparse_fwd and would tolerate h_q != 64 as well, so this is
scoped conservatively rather than because other targets are unaffected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
zhendonghua
force-pushed
the
perf/sm12x-dsv4-unpad-prefill-mla-heads
branch
from
August 17, 2026 23:24
af0866a to
d02ad99
Compare
zhendonghua
marked this pull request as ready for review
August 17, 2026 23:26
zhendonghua
requested review from
BBuf,
DarkSharpness,
HaiShaw,
HydraQYH,
celve and
yuan-luo
as code owners
August 17, 2026 23:26
Contributor
Author
|
/tag-and-rerun-ci |
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
With
attn_tp_size=2,MQALayerbuilds a 64-row query tensor, writes only[0:n_local_heads](= 32), runs attention on all 64 rows, and discards half the output viao = o[:, tp_slice, :].That comment is true of
sgl_kernel.flash_mla— theelsebranch atdeepseek_v4_backend.py:1774, which SM12x never takes. On SM12x the kernel is FlashInfer's, and the decode/prefill split happens by row count inside the FlashInfer entry (B <= _DECODE_MAX_TOKENS,flash_mla_sm120.py:558), not by forward mode. So a wide prefill chunk is dispatched to the paged/prefill kernel, which takes arbitraryh_q, and the pad buys it nothing.MLA is the one place where this is pure waste rather than a wash: 64 query heads share a single latent KV (
num_key_value_heads = 1), so head-sharding saves compute without saving KV bandwidth. Prefill (many query rows, compute-bound) scales with head count; decode (few rows, KV-bandwidth-bound) does not.Fix
Gate the pad off for wide prefill chunks only, behind
SGLANG_OPT_DSV4_UNPAD_PREFILL_MLA_HEADS(default on, SM12x only)._DECODE_MAX_TOKENS, read from FlashInfer (flashinfer/mla/_sparse_mla_sm120.py; 64 in every flashinfer release to date) rather than hardcoded, so the gate and the dispatch fork atflash_mla_sm120.py:558are provably the same number. If FlashInfer ever raised that cutoff, a hardcoded 64 here would unpad rows that still route to the decode kernel — a silent kernel-family swap, not a crash. That is what the import buys.is_extend_or_draft_extend_or_mixed()(an allowlist, notis_extend(), which is alsoTrueforTARGET_VERIFY), plus_original_forward_modebecause DP padding can rewrite a verify batch intoEXTEND. To be explicit about what these do and do not do: FlashInfer's SM120 decode kernel is instantiated fornum_heads ∈ {8,16,32,64,128}as well (_DECODE_DSV4_DISPATCH), so unpadding is legal on both sides of the row fork. The mode terms exist to keepTARGET_VERIFYand large-bsDECODE— both CUDA-graph-captured — on exactly the kernel instantiation they run today. I would rather change one path at a time here than two.forward_mode, which is not part of a captured graph's shape key, it bails underis_in_breakable_cuda_graph(). That is a consequence of the mode terms, not an independent requirement; a row-count-only gate would not need it (and would also work under--cuda-graph-backend-prefill=breakable, where this PR is a no-op).Supporting changes:
sm120_flashmla_decode_max_tokens()exposes the threshold instead of duplicating the constant. On an import failure it returns a value no batch can exceed, so the gate fails closed (everything keeps the pad). It returns0when the backend is not FlashInfer — there the triton and torch fallbacks readh_qoff the query shape, so there is no floor to respect and the gate is free to fire._local_attn_sink()takes an optionalnum_heads. The narrowed sink is a prefix view of the cached padded tensor, not a second allocation, so a device pointer baked into a captured decode graph stays valid; and whennum_headsisNoneor already equals the stored width, the same object is returned.DSparkAttention(deepseek_v4_dspark.py:246) calls it with no argument and is unchanged — the DSPARK draft block stays padded.Measurements
2× DGX Spark (GB10, sm_121), DeepSeek-V4-Flash-0731, TP=2 across two nodes.
sparse_mla_prefill_mg_dual_kernel<(ModelType)1, (ComputeMode)1, 64, ...>to<..., 32, ...>, verified from an in-server profile rather than inferred from the launch flags. That profile is also the evidence that the gate actually fired.<..., 32, 128, ...>, grid[4072,1,1]vs ours[4096,1,1], block[384,1,1]).Decode,
TARGET_VERIFY, and the DSPARK draft block are untouched and stay padded.Correctness — what is and is not established
Invalid: 0.000. This is the wrong instrument and I am not claiming it as evidence of equivalence. Attention is per-(token, head)independent and the dropped heads were fednew_emptygarbage and then sliced away, so the surviving heads should be bit-identical and the score should have been unchanged. A 2-in-500 delta means either the two runs differed in more than this flag, or theNUM_HEADS=32andNUM_HEADS=64template instantiations are not bit-identical on the heads that survive. A gate-ON/gate-OFF greedy text-exact comparison is owed and I will post it.TVM_FFI_ICHECK_EQ(attn_sink.size(0), num_heads)and a compiledswitch (num_heads)whosedefault:returns false intoTVM_FFI_ICHECK. Nothing on the prefill path fails silently.Known gaps
switch (num_heads). Thecase 8:instantiation landed with feat(sm120): consolidate DSV4 sparse MLA top-k 192/256 support flashinfer-ai/flashinfer#4380 (merged 2026-08-08) — after thev0.6.17release branch was cut, so the version this repo pins (flashinfer_python==0.6.17) ships without it; the first releases carrying it are thev0.6.18nightlies. On the pinned wheel the prefill head set is{16, 32, 64, 128}: atattn_tp_size = 8(n_local_heads = 8) the unpadded path would abort where the padded path worked.attn_tp_size ≥ 16is already non-functional for this model for an unrelated reason (o_groups = 8⇒n_local_groups = 0). An_local_heads in (16, 32, 64, 128)term in the gate would close this (16 as the floor while 0.6.17 is pinned); I would like a view on whether the allowlist or a capability probe is preferred. (Same-breath note, independent of this PR: feat(sm120): consolidate DSV4 sparse MLA top-k 192/256 support flashinfer-ai/flashinfer#4380 is also what adds thetopk ∈ {192, 256}decode instantiations that SM12x DSPARK — topk fixed at 192 — needs, so stockv0.6.17fails that path regardless of head count.)(rows, forward_mode, _original_forward_mode, env, graph flag)and is testable on CPU with_is_sm120and the threshold monkeypatched; happy to add it.deepseek_v4.py:1362attributes the{64,128}restriction to "FlashMLA's fp8 sparse decode kernel" without saying which kernel. It is correct forsgl_kernel.flash_mlaand stale for the FlashInfer kernel SM12x calls. Say the word and I will amend it in this PR.Notes
Only sm120/sm121 hardware was available; the gate is
is_sm120_supported()-scoped and defaults off everywhere else. Note thatis_sm120_supported()iscc_major == 12, so it also covers (12,0) parts I could not test — the row-threshold argument transfers because they take the same FlashInfer entry.CI: the red on
base-b-test-1-gpu-large (4)istest/registered/quant/test_awq.py::TestAWQMarlinBfloat16::test_mmlu(0.82421875 not greater than 0.83), which is unrelated to this change and is currently flaky at its threshold on unrelated branches too — #34477 raisednum_examples64 → 256 without re-calibrating the bound. The CPUbuild-testand AMD failures reproduce onmain.🤖 Generated with Claude Code