[Fix] SM120 sparse MLA: zero-initialise the 64-token page-split scratch so masked candidates never gather NaN from slot 0 - #39288
Open
avifenesh wants to merge 1 commit into
Conversation
…ch so masked candidates never gather NaN from slot 0 _split_kv_pages_to_64 allocates its persistent, grow-only 64-token page-split scratch with torch.zeros instead of torch.empty. Only the source pages a step references are copied into the scratch, so dst page 0 (slot 0) is written only when source page 0 is referenced; FlashInfer <= 0.6.18's SM120 sparse-MLA prefill and decode kernels clamp every masked (-1) candidate index to slot 0 and gather that slot's bytes with only the score masked, so NaN-encoded fp8 bytes recycled there by the caching allocator gave P(0) * V(NaN) = NaN for every query row with -1 padding (DeepSeek-V4 on RTX PRO 6000: cold prompts of 65+ tokens returned garbage, 64 were correct). One memset per (re)allocation, no per-step cost. The docstring and the decode-path comment no longer say untouched pages are never read. Kernel-side fix: flashinfer-ai/flashinfer#5075 (masked candidates gather a shared zero row), not in the pinned 0.6.18. Orthogonal to sgl-project#38969 (per-call envelope routing, leaves the allocation in place). Test: test/registered/unit/kernels/ops/attention/test_sm120_split_scratch.py (base-a-test-cpu; no GPU, torch or triton needed; 8 tests, 6 of which fail on the torch.empty variant). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
avifenesh
requested review from
BBuf,
DarkSharpness,
HaiShaw,
HydraQYH,
celve and
yuan-luo
as code owners
September 13, 2026 08:31
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.
Motivation
On SM120 (RTX PRO 6000 Blackwell) with the FlashInfer sparse-MLA backend (
SGLANG_SM120_FLASHMLA_BACKEND=flashinfer, the default) and the pinned FlashInfer 0.6.18, DeepSeek-V4 family models return wrong attention for prefill calls with more than 64 query rows. Observed on DeepSeek-V4.1-Flash, TP4, 4x RTX PRO 6000:prompt_tokensconfirmed via/tokenizeandusage);{}, vision descriptions are garbage;SGLANG_SM120_FLASHMLA_BACKEND=triton), which is how the defect was first contained.The boundary is
SM120_DECODE_MAX_TOKENS = 64inpython/sglang/kernels/ops/attention/flash_mla_sm120.py: at most 64 rows go to the FlashInfer decode (split-K) kernel, more rows go to the FlashInfer prefill kernel via_flash_mla_sm120_prefill.Root cause
An interaction between the FlashInfer kernels and the way sglang feeds them the KV pages:
-1) to slot 0 and gather that slot's bytes, masking only the score:include/flashinfer/attention/sparse_mla_sm120/common/kv_cache_io.cuh(io_bulk_gather_tile,io_gather_scales) and.../prefill_kernel.cuh(prefill_kv_entry_base,idx = (idx >= 0) ? idx : 0). With the score masked to-1e30the probability is 0, but0 * NaN = NaNin the value MMA, so whenever slot 0 of the cache the kernel is handed holds NaN-encoded fp8 / ue8m0 bytes (0x7F,0xFF), every query row that carries a-1in its indices comes out NaN. (The scalar RoPE-V path inxv_rope_mmaalready returned 0 foridx < 0with a comment describing exactly this hazard; the bulk NOPE-V gather did not.)_split_kv_pages_to_64re-pages the pool into a persistent, grow-only scratch registered inget_resources().buffers. That buffer is allocated withtorch.empty, and since the touched-page optimization only copies the source pages a step references, dst page 0 (slot 0) keeps whatever the caching allocator recycled into it unless source page 0 happens to be referenced. Every cold prompt has-1padding in its top-k indices until the context exceeds the top-k width, so the NaN reaches every row of the prompt.On upstream
mainthe exposure is on the SWA split and depends on what the allocator recycled into the scratch and on whether the step references source page 0. A downstream variant of this module that also re-pages the ratio-1/2 extra cache through the same splitter hit it deterministically (sglang reserves full-pool page 0, so that scratch's page 0 was never written), which is how the 64/65 boundary was isolated; the fix is the same.Kernel harness confirmation (direct
_sparse_mla_sm120_paged_attentioncalls, single 64-page cache, H=16, indices with-1padding):0xFF0xFF0xFFThe NaN rows are exactly the rows containing a
-1. Every sglang stage of_flash_mla_sm120_prefillis byte-exact in isolation (256->64 split, index identity, gather + dequant through the 64-view, wrapper == direct call), and the kernels are numerically fine for every shape tested (T=8..1024 incl. 64/65/66, H=8..64, topk 128..2048, 1..6000 pages, causal / mixed lengths) once slot 0 is finite.Realistic allocator recycling (a freed
0xFF-filled block is recycled by the caching allocator into the split scratch, then a production-shaped cold prompt runs):torch.empty(current)torch.zeros(this PR)The kernel-level reproduction script (poisons slot 0 of a small paged cache and calls the FlashInfer kernel directly) is available on request.
Modifications
One line in
_split_kv_pages_to_64(python/sglang/kernels/ops/attention/flash_mla_sm120.py): allocate the persistent split scratch withtorch.zerosinstead oftorch.empty, so slot 0 is finite from the first use and stays so (it is only ever overwritten with real page-0 data). The buffer is grow-only, so the re-allocation path goes through the same line. The docstring and the decode-path comment that said untouched pages are "never read" are corrected: they are never addressed by a valid index, but slot 0 is gathered for every masked candidate by FlashInfer <= 0.6.18.New test
test/registered/unit/kernels/ops/attention/test_sm120_split_scratch.py(base-a-test-cpu, no GPU / torch / triton needed: the module is loaded by file path with a recordingtorchstub, the wayunit/tools/loadsci_register, and_split_kv_pages_to_64is driven directly with its two Triton kernels replaced by launch recorders). It pins:torch.zeros((num_dst_pages, 37440), dtype=uint8, device)registered underflash_mla_sm120_split:<device>; the split kernel writes into a slice of that buffer withHAS_MASK=True; the returned view addresses it as 64-token pages;torch.zerosagain; one scratch per device;zero_()ed on every call; a 64-token pool needs no scratch;torch.zerosinside_split_kv_pages_to_64, notorch.emptyuint8 buffer anywhere, and the allocation comment documents the slot-0 gather.8 tests; 6 of them fail on the
torch.emptyvariant (negative control run locally), all pass with the fix.Relation to other changes
zero_row.cuh: masked candidates gather a shared zero row viamask_idx_past_len; merged 2026-09-11) fixes the kernel side. It is not in 0.6.18 (python/pyproject.tomlpinsflashinfer_python[cu13]==0.6.18) nor in any FlashInfer tag as of today (v0.6.18.post1 and the v0.7.0rc1 tag predate it). Either fix alone suffices; this PR makes sglang correct on the pinned FlashInfer and stays harmless once the pin moves past Support benchmarking with presets on multiple configuration combinations #5075 (the scratch can then be left zeroed or dropped with the splitter)._flashinfer_covers()envelope routing and leaves thetorch.emptyallocation in place, so covered shapes would still hand FlashInfer a poisoned slot 0.inference_mode(False)for CUDA-graph capture; [SM120] Use exact query-head widths for DeepSeek-V4 sparse MLA decode #36655 (exact query-head widths for decode) and [Bug] DeepSeek-V4 SM120 decode pads q to 64 heads for an SM90 constraint; removing the pad changes greedy output despite the kernel being bit-identical #39235 (SM120 decode q pad) touch the same dispatch and are unaffected. Fix int32 offset overflow in SM120 page-split kernel #34027 / test: pin the int32 page-offset overflow in _page_split_kernel #35833 (int32 offset overflow in_page_split_kernel) are about kernel indexing, not initialization.Accuracy Tests
End to end (4x RTX PRO 6000, DeepSeek-V4.1-Flash, TP4, FlashInfer 0.6.18, FlashInfer >64-row path, no Triton fallback), same box patched vs unpatched:
torch.empty)torch.zeros)With the fix the FlashInfer prefill path sits at ~3% max row relative error (BF16 P x V) against an fp32 reference, Triton at <1%; both far from garbage.
python test/registered/unit/kernels/ops/attention/test_sm120_split_scratch.pypasses on CPU (8 tests).Speed Tests and Profiling
No per-step cost: one memset per (re)allocation of the grow-only scratch (1.14 GiB at 2,097,152 SWA tokens in 256-token pages), i.e. once at the first split and once per grow. The per-step touched-page copy is unchanged.
Checklist
🤖 Generated with Claude Code
CI States
Latest PR Test (Base): ❌ Run #34747908972
Latest PR Test (Extra): ❌ Run #34747908830
Latest PR Test (AMD ROCm 10): ❌ Run #34747909096