Skip to content

[Bugfix][SM70] Stop grouped-page4 null-block padding from leaking E4M3 NaN - #647

Open
areslp wants to merge 2 commits into
1CatAI:mainfrom
areslp:fix/sm70-grouped-page4-null-block-padding-nan
Open

areslp wants to merge 2 commits into
1CatAI:mainfrom
areslp:fix/sm70-grouped-page4-null-block-padding-nan

Conversation

@areslp

@areslp areslp commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

The SM70 grouped-page4 QSA prefill route can emit NaN for every query row and head on a fixed set of head dims, but only when the KV cache is E4M3 and a hybrid (GDN/Mamba) layout co-locates non-attention state in the paged KV pool. The same batch is correct through the XQA route, so the failure looks route-specific rather than data-specific.

Root cause

The grouped planner pads each category up to a multiple of eight with the null block(physical microblock 0, mask 0) — and counts that padding in seq_len. The forward then loads page 0's K/V for those padded rows and masks them multiplicatively: the score is -inf, so the softmax probability is P = 0.

But 0 * NaN = NaN survives the P@V tensor-core reduction. Under E4M3 the null block's bytes decode to NaN (0x7F/0xFF → fp16 0x7e00, and any fp16 state a hybrid layout happens to place at page 0 has ~0.4% of its bytes land on those encodings). Every group's every tile reads the same page-0 microblock, so the whole tile's rows and heads go NaN on identical dims.

XQA encodes unused slots as INT64_MAX, sorts them last, and only counts real tokens in its sequence length — it never reads the null block. That is why XQA (and Triton) stay clean on the same inputs, and why the bug is invisible with an all-zero page 0 (0 * 0 = 0) or with fp16 KV (the null block reads back as finite small values, 0 * finite = 0).

Fingerprint

The padded microblock is page 0's tokens 0–3 × 256 dims = 1024 value bytes. A poisoned/garbage null block turns ~0.3–0.5% of those into E4M3 NaN, and the observed NaN dims are bit-identical across all 16 rows and 6 heads of a tile — matching a single shared source region rather than per-row corruption.

Fix (defense in depth, both default-on)

Kernel — in flash_attention_grouped_verify_e5m2_partial_kernel under SPARSE_PAGE4, after each K/V panel load, zero every row that no query attends. The mask bit layout is query*4 + token_in_microblock, so a row is unattended iff (mask & (0x11111111u << (row & 3))) == 0; zeroing those rows makes the reduction compute 0 * 0 = 0. A leading __syncthreads() before the V-panel zeroing keeps the panel load from racing past the zero and resurrecting the NaN; the trailing one publishes the zeros before P@V.

Python (VLLM_SM70_QSA_GROUPED_PAD_FIX, default 1) — after the planner, repoint every mask == 0 padding entry at the group's first real microblock (column 0; real entries always carry a nonzero mask). torch.where + copy_ has no host sync, so it stays CUDA-graph capturable. This backstops any binary built before the kernel fix.

The grouped route itself stays default-on (VLLM_SM70_QSA_GROUPED_PAGE4=1); this PR only changes what the padded rows contribute.

Verification

Differential harness on V100 (SM70), 32-token physical pages, page 0 as the null block, poisoned with E4M3 NaN bytes on the value plane. grouped vs xqa (clean reference) vs an einsum reference:

geometry poison poisoned dims xqa NaN grouped NaN (unfixed) grouped NaN dims max|grouped−xqa|
rows 19, contiguous, decode-like targeted [102,152,168,170,230] 0 480 [102,152,168,170,230] 1.5e-05
rows 24, 3-req, scatter, causal targeted [102,152,168,170,230] 0 640 [102,152,168,170,230] 2.4e-04
both random page 0 random 0 matches poisoned dims same ≤2.4e-04

With either fix (kernel, or Python pad-fix on the stock kernel) all geometries return grouped NaN = 0, bit-consistent across repeated runs, and equal to XQA to max|grouped−xqa| ≤ 2.4e-04. An unpoisoned regression stays NaN = 0 and matches the reference.

Microbenchmark (grouped route, 8192-token context, 16 rows, shared with other load so noisy): mean 0.187 → 0.233 ms/iter with the kernel fix (one extra __syncthreads() per tile); negligible in a real long-context prefill where the grouped kernel is a small fraction of the step.

End-to-end on 4× V100-PCIE-32GB TP4 with E4M3 KV + MTP4 (the configuration that exposed this): the previously-guaranteed-NaN count probe returns sane, finite logprobs; a 200K needle passes; a tail/K+1 probe passes with zero OOV; single-stream decode is ~97 tok/s at 8K and ~69 tok/s at 240K; outputs are byte-identical between the kernel fix and the Python pad-fix.

The E4M3 + MTP enablement that surfaced this path is separate work and is not part of this PR; this PR is only the null-block padding fix and its regression test.

Tests

tests/models/qwen4_exp/test_qsa_e4m3.py adds test_qsa_grouped_page4_null_block_padding_no_nan, an SM70-only regression (skips cleanly off SM70 or without the grouped/XQA page4 kernels). It builds a 32-token-page E4M3 cache whose page 0 is the null block, poisons its value plane with E4M3 NaN, and asserts the grouped route stays finite and matches XQA. It is parametrized over the Python pad-fix being off (kernel-only) and on, so it reproduces the NaN on an unfixed kernel and passes on either fix.

🤖 Generated with Claude Code

…3 NaN

The SM70 grouped-page4 QSA prefill route can emit NaN for every query row
and head on a fixed set of head dims when the KV cache is E4M3 and a hybrid
(GDN/Mamba) layout co-locates non-attention state in the paged KV pool. The
same batch is correct through the XQA route.

Root cause: the grouped planner pads each category to a multiple of eight
with the null block -- (physical microblock 0, mask 0) -- and counts the
padding in seq_len. The forward loads page 0's K/V for those padded rows and
masks them multiplicatively (P = 0). But 0 * NaN = NaN survives the P@V
tensor-core reduction, and under E4M3 the null block's bytes decode to NaN.
Every group's every tile reads the same page-0 microblock, so the whole
tile's rows and heads go NaN on identical dims. XQA never reads the null
block, which is why it stays clean on the same inputs.

Fix (defense in depth, both default-on):
- Kernel: in flash_attention_grouped_verify_e5m2_partial_kernel under
  SPARSE_PAGE4, after loading each K/V panel, zero every row no query
  attends (unattended iff (mask & (0x11111111u << (row & 3))) == 0) so the
  reduction sees 0 * 0 = 0. A leading __syncthreads() before the V-panel
  zeroing keeps the panel load from racing past the zero and resurrecting
  the NaN; the trailing one publishes the zeros before P@V.
- Python (VLLM_SM70_QSA_GROUPED_PAD_FIX, default 1): after the planner,
  repoint every mask==0 padding entry at the group's first real microblock.
  torch.where + copy_ has no host sync, so it stays CUDA-graph capturable.
  This backstops binaries built before the kernel fix.

The grouped route stays default-on (VLLM_SM70_QSA_GROUPED_PAGE4=1); only what
the padded rows contribute changes.

Adds tests/models/qwen4_exp/test_qsa_e4m3.py::test_qsa_grouped_page4_null_block_padding_no_nan,
an SM70-only regression that poisons the null block's value plane with E4M3
NaN and asserts the grouped route stays finite and matches XQA, parametrized
over the Python pad-fix off (kernel-only) and on. It reproduces the NaN on an
unfixed kernel and passes on either fix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

👋 Hi! Thank you for contributing to the vLLM project.

💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in #pr-reviews, coordinate on features in #feat- channels, or join special interest groups in #sig- channels.

PRs do not trigger a full CI run by default. Once the PR is approved and ready to go, your PR reviewer(s) can run CI to test the changes comprehensively before merging.

To run CI, PR reviewers can either: Add ready label to the PR or enable auto-merge.

If you have any questions, please reach out to us on Slack at https://slack.vllm.ai.

Agent Guidelines

IMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban.

🚀

…adding test

The regression added in the previous commit used the XQA page4 route as its
clean reference. On Flash-V100 builds whose XQA E4M3 page4 kernel requires an
fp32 partition buffer, that reference raises "XQA decode tmp_out must be fp32
for E4M3 KV" before the grouped route is exercised, so the test cannot run
against such a build.

Use a kernel-independent einsum ground truth instead: decode the E4M3 K/V to
fp32 and attend only the selected tokens (which map to physical pages >= 1, so
the null block is excluded). This is .so-agnostic and directly checks that the
grouped route stays finite and numerically correct. The grouped route and the
parametrized pad-fix coverage are unchanged.

Verified on V100 (SM70): the grouped null-block NaN reproduces with the stock
kernel and pad-fix off (test fails), and passes with either the kernel fix or
the Python pad-fix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Leonccaa added a commit to Leonccaa/1Cat-vLLM that referenced this pull request Sep 19, 2026
The 1CatAI#647 overlay kept the stricter native E4M3 ABI but lost the matching Python workspace dtype selection. Key the cached workspace by KV dtype and allocate FP32 temporary output for E4M3 while retaining FP16 for the existing path.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Leonccaa <166551845+Leonccaa@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant