Repository navigation
[SM120&90] Add CUDA fused Triton sparse-MLA prefill backend for DSA - #32779
Conversation
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
|
Hi maintainers — this is a fork PR so I can't add the run-ci label myself. Could someone add it (or run /tag-and-rerun-ci) to kick off CI? Thanks. |
|
/tag-and-rerun-ci |
|
@Fridge003 Could you help us to review this GLM-5.1/5.2 SM90/SM120 perf optimization? Thanks! |
8844fc9 to
46a9e31
Compare
|
/rerun-failed-ci |
c41a63f to
42536bd
Compare
|
/rerun-failed-ci |
e7f5dd0 to
67345c2
Compare
- Private helpers carry their one-fact comments above the def instead of docstrings, matching the validator next to them in dsa_backend.py. - Kernels are named after the module (_sparse_mla_prefill_kernel, _sparse_mla_prefill_union_kernel); U_CAP is U_STRIDE, which is what it is. - The union gate reads torch.cuda.is_current_stream_capturing() directly; the is_available() guard only served the mocked CPU test, which patches the stream check itself. - The kernel test file uses one _assert_matches helper instead of a method wrapper around it; two test comments state the real reason for their boundary (validated range, not an MMA requirement; fallback, not dropped heads); the SM120 union tile comment quotes the 100 KB budget, not 115. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
@ch-wan thanks for the careful read -- all eleven were real and the two High ones were structural. Everything below is in the branch as of the latest push; one summary comment rather than eleven replies, in your order:
Description rewritten against the diff: one server arg, no dense-prefix material, the union section describing the per-group dedup, and every table re-measured on the current kernel with a rerun control and main's own SM120, launched end to end from this branch (GLM-5.2-NVFP4, TP8, SM90 was re-launched on the same revision as well (SM90, 8 GPUs, TP8): 8-layer dummy configs for the layer-0 tensor check (per-token path within 1.5x of rerun noise, union within 2x) and GLM-5.2-FP8 real weights for the latency and logits tables: 1.53x / 1.58x over |
|
@nvpohanh thanks, all six were right; they are in the latest push, in your order:
|
…omments Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
/tag-and-rerun-ci |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
/rerun-failed-ci |
2 similar comments
|
/rerun-failed-ci |
|
/rerun-failed-ci |
ch-wan
left a comment
There was a problem hiding this comment.
Thanks for the update. Everything from the last round is addressed: the per-group dedup removes the workspace cache, the span budget and the per-layer host sync; the validator now enforces the union tile shape, the head cap and index_kpool; union is off under deterministic inference and under stream capture; and the description now matches the diff. Thanks also for running triton_sparse_mla end to end from this branch on SM120.
This looks ready to me apart from two small items. Details are inline:
- Low: no test covers the union tail (
T % G != 0). A T=1023, G=4 case would. - Nit: the arch gate admits SM100/SM103 with untuned tiles. The description says the validator "rejects what has not been measured", which doesn't cover that. Please reword it or restrict the gate.
Follow-up, not blocking: get_topk_transform_method (python/sglang/srt/layers/attention/dsa_backend.py:3809) keeps MIXED batches on the PAGED transform. So with an FP8 KV cache, --enable-mixed-chunk plus triton_sparse_mla hits the new TypeError on the first mixed batch. That includes SM120, where the allowlist now admits this backend. flashmla_sparse has the same gap on main, so it is better fixed separately.
|
@yunyang1999 Could you address the final comments? After that, I will monitor the CI and merge this. Thanks! |
…rchitectures - test_union_is_exact_on_overlapping_selections adds T=1023 at G=4, so the T % G tail that the per-token kernel writes into out[T_main:] runs in CI. With the hand-off disabled the case fails (cosine 0.9987 < 0.999). - The arch gate stays at SM90+; the docs row and the test comment now say that tiles are tuned on SM90 and SM120 and that other SM90+ architectures run the untuned default tile with a startup warning. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
/tag-and-rerun-ci |
|
@ch-wan thanks for the approval, both items are in the latest push; @nvpohanh over to you for the merge, THX!
|
|
/rerun-failed-ci |
1 similar comment
|
/rerun-failed-ci |
|
We can merge, the failing test is flaky on main (fixing it) |
|
@ch-wan @nvpohanh the mixed-chunk follow-up from the review is up as #44176: with an FP8 KV cache, |
Motivation
DSA prefill on CUDA has no fused Triton path, and the kernels it does dispatch to are a poor fit for the head count the model actually has after TP.
flash_mla_sparse_fwdrequiresnum_heads % 64on SM90 and% 128on SM100+, so_forward_flashmla_sparsezero-pads the head dim before calling it. GLM-5.x at TP8 has 8 real heads, so 7/8 of the tensor-core work on SM90 — 15/16 on SM100+ — is spent on padding, and the split form then writesO(tokens x splits)partials to HBM for a second kernel to merge. What main selects on its own for an FP8 KV cache on SM90 today isflashmla_kv, which is slower still from 4096 tokens up; both are columns in the tables below.Neither cost is necessary at this size. The attention tile is small enough that one Triton program per query token, holding the online softmax in registers, beats a wide cooperative block, and it lets the head dim be padded into a 16-row MMA tile (8 -> 16) instead of into the grid (8 -> 128) — no partials, no merge.
Modifications
Adds
--dsa-prefill-backend triton_sparse_mla, default off, plus one opt-in fast path. The fast path is an algebraic rewrite rather than an approximation, and it is gated against an fp32 reference in the tests.--dsa-triton-union {2,4}In DSA prefill the indexer's scores drift slowly in
t, so neighbouring query tokens select nearly the same KV rows. Measured on captured GLM-5.1 traces:One gather can therefore serve
Gtokens for ~1.1x the rows.Gadjacent tokens share a single gathered index set, and aG * num_heads-row MMA tile then carries real work instead of padding. A per-row ownership bitmask restores each token's own softmax support, so the result is identical to the per-token path.The union set is built per group, on the device, with nothing that depends on the KV span: each token's
topkrows are sorted on their own (aK-wide segment, which torch sorts in shared memory), then one Triton program per group runs a vectorisedlower_boundof every row against the other tokens' sorted lists. A token emits a row if no earlier token of the group selected it, and ORs in the bit of every token that did. Cost isO(G * K * log K)gathers per group from lists that stay in L1, so there is no bytemap, no workspace that outlives the call, and no device-to-host read. The union tile must be a power of two in [16, 32] rows (tl.arangeneeds a power of two,tl.dotneedsM >= 16), which the validator enforces at startup: with 8 heads both group sizes are legal, at 16 heads onlyG=2; on SM90 real indicesG=2is also the better setting (below).Recommended setting:
--dsa-triton-union 2. On SM90 it is the faster of the two at every real-index shape and end to end; on SM120 the two are within 3% of each other at kernel level, and 4 is the one measured end to end there.One measurement note, because it is easy to get wrong: uniformly random indices have none of that structure and understate this path by about 2x. The benchmark added here builds indices as a calibrated random walk instead — token
t+1is tokentwith ~2% of rows swapped, plus the-1padding real prefill has fort < topk— so the synthetic union sizes match the captured ones.Robustness
OutOfResourcesto the request, and remembers the tile that fit per(num_heads, G, device), so the retry runs once per shape rather than on every layer.index_kpool > 1(the pooled indexer tail is only rerouted forflashmla_sparse), and union tiles outside the power-of-two [16, 32] window. Tiles are tuned on SM90 and SM120; other SM90+ architectures run an untuned default tile and log a warning at startup (the SM100 CI lane exercises this path).--enable-deterministic-inference(a token's output would depend on which tokens share its group) and whenever the stream itself is capturing (the FULL prefill graph); the per-token path gives the same result there. Inside the breakable prefill graph, where attention runs eagerly between the graph segments, union stays on.topk_lengthfrom the metadata the backend already holds rather than recomputing it per layer.get_topk_transform_methodkeeps MIXED batches on the PAGED transform, so--enable-mixed-chunkwith an FP8 KV cache reaches this backend with the packed pool and hits that error on the first mixed batch.flashmla_sparsehas the same routing gap on main, so both will be fixed together in a separate change.Files
New kernel module (no environment variables); the dispatch is folded into the existing
flashmla_sparsebranch ofdsa_backend.py(the input preparation is identical; only the final kernel call differs), with a startup validator and the option read at construction; one server arg (--dsa-triton-union) inarg_groups/fields/exec_.py; the SM120 GLM-FP8 allowlist inflash_mla_sm120.pyadmitstriton_sparse_mlafor prefill, backed by the end-to-end launch below; and registration in the DSA variant matrix sotest/registered/attention/unittests/dsa/test_dsa.pyexercises this backend too; and thetriton_sparse_mlarow plus the--dsa-triton-unionentry indocs/docs/advanced_features/attention_backend.mdxandserver_arguments.mdx.Accuracy Tests
Kernel —
test/registered/kernels/ops/attention/test_dsa_triton_sparse_mla_prefill.py, against an fp32 reference: base path at three shapes,-1/ragged padding, union exactness on overlapping-but-unequal selections atG=2andG=4including aT % G != 0tail (T=1023, G=4), the union dedup contract on its own (each row once, ownership bits equal to the OR of its owners,-1dropped — a duplicated row is weighted twice, which can hide under the cosine gate), the fall-back to the per-token path when the union tile is illegal (h=4, G=2), int32-vs-int64 gather addressing equivalence, determinism, two forced shared-memory step-downs (an oversized tile, so the retry path runs on the CI GPU too; the recorded fit must differ from the requested tile), and the backend method itself driving the real kernel with the[tokens, heads, d_v]contract. CPU —test/registered/unit/attention/test_triton_sparse_mla_prefill.py, 20 cases: the validator's boundaries (including the new power-of-two, head-cap andindex_kpoolrejections), the deterministic, stream-capture and breakable-graph gates,topk_lengthplumbing and its row-count guard, the RAGGED top-k routing regression, CLI registration, and that the SM120 GLM FP8 auto-selection still resolves toflashinfer_sparse_mlawith this backend registered.Every timed point below is correctness-gated against the same fp32 reference before it is timed — 5/5 real-index shapes PASS at cosine 1.0000 on each architecture.
Two independent model-level checks, at different tolerances, because they answer different questions.
(a) The kernel's own output tensor — GLM-5.2 config, TP8 on SM120,
--load-format dummy(fixed per-parameter seed, so both runs get identical weights) and a seeded prompt at 4096 tokens. The reference isflashinfer_sparse_mla, the backend SM120 selects on its own (flashmla_sparseis not admitted on that platform). Comparing the layer-0 prefill attention output — the tensor this backend actually computes — in fp64, against a control of the reference rerun against itself:flashinfer_sparse_mlavs itself (rerun)triton_sparse_mla--dsa-triton-union 4--dsa-triton-union 4vs the per-token pathTwo readings. The union path sits on top of the per-token path at the control's own noise level (rel L2 1.0e-03 against 5.9e-04, max abs 6.1e-05 against 3.1e-05): it is the same computation with the rows accumulated in a different order. The 2.5% between this kernel and
flashinfer_sparse_mlais the distance between an FP8-native kernel and a bf16 one, not an error in either: against the bf16flash_mla_sparse_fwdon SM90 (same config, same seed) this kernel is within 1.5x of rerun noise, and against an fp32 reference on the five real captures it scores cosine 1.0000 on every path.flashmla_sparseflashmla_sparsevs itself (rerun)triton_sparse_mla--dsa-triton-union 4--dsa-triton-union 2--dsa-triton-union 4vs the per-token pathAgainst the bf16 reference the per-token path sits within 1.5x of the rerun noise and the union paths within 2x. The pre-review revision measured the per-token row at 0.9999997999 / 6.33e-04 on the same seed, which is what an unchanged kernel should look like; the union rows are the new per-group dedup.
This is the tight measurement: it reads the kernel's output directly, before 78 layers of MoE can amplify anything. GLM-5.1 shares every attention dimension and reproduces these figures.
(b) What the model actually emits, on real weights — GLM-5.2 (78 layers, FP8, TP8 on SM90), seeded prompt, next-token logits over the full 154880-wide vocabulary in fp64, against a rerun control and against main's own default for this platform:
flashmla_sparsevs itselfauto(flashmla_kv, main's pick)triton_sparse_mla--dsa-triton-union 4--dsa-triton-union 2flashmla_sparsevs itselfauto(flashmla_kv, main's pick)triton_sparse_mla--dsa-triton-union 4--dsa-triton-union 2At 2048 tokens every backend emits bit-identical logits: SM90 routes that length to the dense one-shot path and the stack is deterministic there. From 4096 up the tolerances are three orders looser than in (a) — that is 78 layers of 256-expert FP8 MoE, not the kernel, and the control shows it: the reference rerun against itself moves by 3-7% in rel L2. The per-token path sits inside the control's own band at both lengths. The union paths are inside it at 4096 and within 2x of it at 8192, the same order as main's own alternative kernel, which also flips the argmax at 8192. A greedy-token match is therefore not a usable metric on this stack; the layer-0 tensor in (a) is the measurement that isolates the kernel.
(c) SM120, real weights, first-token agreement — GLM-5.2-NVFP4, TP8, greedy, one generated token, comparing the argmax token and its logprob across the three prefill backends on otherwise identical servers. Each row of the control is a separate server instance, so it measures run-to-run noise of the stack itself:
flashinfer_sparse_mlavs its own reruntriton_sparse_mlavsflashinfer_sparse_mla+ --dsa-triton-union 4vstriton_sparse_mlaflashinfer_sparse_mlavs its own reruntriton_sparse_mlavsflashinfer_sparse_mla+ --dsa-triton-union 4vstriton_sparse_mlaUnlike the SM90 FP8 stack in (b), this deployment is bit-reproducible across server instances (the controls are exact), so the differences against
flashinfer_sparse_mlaare real and come from the arithmetic: that kernel consumes the FP8 pool with its scales directly, while this one takes the dequantized bf16 rows the RAGGED transform produces and runs a bf16 MMA. The three natural-prompt disagreements are all near-ties — "A year on Earth has approximately 365" (top-1 logprob −0.48 vs −0.71), "A hexagon has" (−0.94 vs −0.86), "The opposite of hot is" (−0.76 vs −0.44) — and on agreeing tokens the logprob moves by 0.09 on average. Random-word prompts have flat next-token distributions, so the same-sized perturbation flips the argmax far more often; they are shown for completeness, not as an accuracy metric. The union path is bit-identical to the per-token path on every prompt of both sets, and the greedy 48-token continuation of a fixed prompt is identical between them too.Speed Tests and Profiling
Kernel-level numbers are on real captured GLM-5.1 index sets at five (batch, seqlen) points, not synthetic indices; times in ms, median of 15, every variant gated at cosine 1.0000 against the fp32 reference. End-to-end numbers are on real model weights except the two 8-layer dummy-weight configs, which are labelled as such; every end-to-end table below is the current revision.
SM90 — kernel level, vs
flash_mla_sparse_fwd(padded 8 -> 64 heads, as the backend does)The spread is wide but monotone in the right variable: shorter requests favour this kernel most, because more of their rows are
-1padding that the per-token early exit skips and the padded dense kernel does not. On real indicesG=2is the better union setting: the captured selections overlap so tightly that a group of 4 gathers barely fewer rows than a group of 2, while its membership test costsG*(G-1)searches instead of 2. On the calibrated synthetic benchmark (SM90, 8 heads) the per-token path takes 1.16 / 3.15 / 7.14 ms at 2048 / 4096 / 8192 tokens andunion=40.96 / 2.26 / 4.88 ms, i.e. up to 1.46x; at 16 heads onlyG=2is legal and it does not pay off there (7.36 -> 8.92 ms), so union is a TP8 setting.SM120 — kernel level, this kernel's paths on the same five captures
Union pays more on SM120 than on SM90 (1.7-1.9x over the per-token path against 1.3-1.4x) because the gather is a larger share of the kernel on this part. This table deliberately carries no
flashinfer_sparse_mlacolumn: that kernel takes the packed FP8 pool with its scales, which the real-index harness does not synthesise, so the like-for-like comparison against it is the end-to-end table below rather than a kernel-level number.SM120 — end to end, GLM-5.2-NVFP4, real weights, TP8, prefill latency
Launched from this branch with
sglang.launch_server --attention-backend dsa --kv-cache-dtype fp8_e4m3 --quantization modelopt_fp4 --tp 8, batch 1, one generated token, median of 5 requests with fresh random prompts so the radix cache never serves the prefix. The baseline is what mainline selects on SM120 by itself (prefill=flashinfer_sparse_mla, decode=flashinfer_sparse_mla).triton_sparse_mla--dsa-triton-union 4Two things this table says plainly. The per-token path alone barely moves the end-to-end number on this deployment: at TP8 with NVFP4 experts the attention kernel is a small share of the prefill, so the per-token path's kernel-level gain shows up as ~1% of the request. Union is where the end-to-end gain is (1.13-1.14x from 4096 tokens up), because it removes most of the gather rather than shaving the MMA. Every run counts invocations of this kernel (
union=0during CUDA-graph capture,union=4on the served requests), so a silent no-op cannot be mistaken for a win, and the greedy continuation of a fixed prompt is identical between the per-token and union paths.SM90 — end to end, 8-layer configs, TP8, prefill latency at 4096 tokens
Small configs with
--load-format dummy, kept because they cover both model generations side by side and are cheap enough to rerun on every revision; these rows are the current code. Two reference runs are shown to make the run-to-run spread visible and the ratios are against their mean.autois what main selects on its own for an FP8 KV cache on SM90 today (prefill=flashmla_kv), i.e. the number a user who changes nothing gets.auto(flashmla_kv)flashmla_sparsetriton_sparse_mlaFor the record, the same launch in July measured
flashmla_sparseat 128 / 125 ms and this kernel at 82 / 78 ms (1.57x / 1.61x): the reference path has become ~14% faster on main since then while this kernel's time is unchanged, so the ratio on this small config is 1.4x today, not 1.6x.The same kernel serves both generations unchanged — GLM-5.2 has the same DSA shape (
kv_lora_rank512 +qk_rope_head_dim64 = 576,d_v512, topk 2048, 8 heads at TP8) and the sameGlmMoeDsaForCausalLMarchitecture. Its layers are not uniform (indexer_typesmixesfullandshared), so the run counts kernel invocations to confirm coverage rather than infer it: 16 per rank for an 8-layer model over two prefills, i.e. every DSA layer.SM90 — end to end, GLM-5.2 (78 layers, real FP8 weights), TP8, prefill latency
Each length is visited twice in-process and the second visit is reported:
bench_one_batchwarms up only atinput_len[0], so a plain sweep would charge the first visit to each new shape with Triton JIT and allocator growth, which lands on this backend harder than on a precompiled CUDA kernel. Two independent reference runs agree to within 0.8% at every length.autois main's own pick for an FP8 KV cache on SM90 today (prefill=flashmla_kv). Ratios are against theflashmla_sparsemean, then againstauto.auto(flashmla_kv)flashmla_sparse(x2)triton_sparse_mlaThe kernel is not invoked at 2048 tokens on SM90 (next paragraph), so that row is one code path for every column; the union-4 run's first pass measured 0.285 s there and its rerun 0.213 s, with 0.529 / 1.086 s at the other two lengths (within 0.1% of the first pass), so the 0.285 was a one-off and the rerun is shown. Against the bytemap union this PR replaced (0.521 / 1.065 s at 4096 / 8192 in July), the per-group dedup costs 1.5-2% end to end, the price of removing the three defects.
G=2is the better setting on real weights as it was on the captured indices: the real selections overlap so tightly that a group of 4 gathers barely fewer rows than a group of 2, while its membership test does six searches per row instead of two.Short contexts are unchanged by design, not by accident. At 2048 tokens the configurations are identical because the kernel is never called: on SM90 and SM100,
dsa_backend.pyroutes prefill to a dense MHA one-shot path whenmax_kv_len <= SGLANG_DSA_PREFILL_DENSE_ATTN_KV_LEN_THRESHOLD(default 2048). Counting invocations confirms it — 312 calls per rank across the sweep, i.e. 78 layers x the 4 prefills that exceed the threshold, and zero for the rest.A benchmark is included at
test/registered/kernels/benchmark/attention/bench_dsa_triton_sparse_mla_prefill.py. Absolute numbers here are from SM90 and SM120 parts that are not CI runners, so the benchmark is written to make the relative comparison reproduce on1-gpu-large.Checklist
attention_backend.mdx(DSA sub-backend table) andserver_arguments.mdx(--dsa-prefill-backendchoices,--dsa-triton-union)🤖 Generated with Claude Code
CI States
Latest PR Test (Base): 🚫 Run #37255031786⚠️ Not enabled -- add
Latest PR Test (Extra):
run-ci-extralabel to opt in.Latest PR Test (AMD ROCm 10): ❌ Run #37255031958