Skip to content

Commit 413eabf

Browse files
committed
[None][fix] MiniMax-M3 prefill: zero the V tail past kv_len before fmha_sm100
fmha_sm100 masks only the scores, so P @ V still multiplies the V rows past kv_len on a partial last page by zero. An FP8 NaN byte left there by an earlier request on a recycled page turns the whole prefill tile NaN. FA4 already zeroes that tail; do the same before the fmha_sm100 prefill (dense layers on SM100/SM103, SM107 without FA4). Finite results are unchanged. Kill switch: TRTLLM_MSA_PREFILL_V_TAIL_ZERO=0. Signed-off-by: peihengh <259410613+peihu-nv@users.noreply.github.com>
1 parent a521432 commit 413eabf

2 files changed

Lines changed: 144 additions & 0 deletions

File tree

‎tensorrt_llm/_torch/attention/backends/fmha/msa_prefill.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@
5454
# turns the tile's output NaN. Zeroing those rows first leaves every finite result bitwise unchanged. Kill switch:
5555
# TRTLLM_MSA_DENSE_FA4_V_TAIL_ZERO=0.
5656
_DENSE_FA4_V_TAIL_ZERO = os.environ.get("TRTLLM_MSA_DENSE_FA4_V_TAIL_ZERO", "1") != "0"
57+
# The same zeroing before the FP8 / BF16 fmha_sm100 prefill (dense layers on SM100 / SM103, and SM107 without FA4):
58+
# it also masks only the scores, so a NaN byte past kv_len on a recycled partial last page turns its P @ V NaN.
59+
# Kill switch: TRTLLM_MSA_PREFILL_V_TAIL_ZERO=0.
60+
_FMHA_SM100_V_TAIL_ZERO = os.environ.get("TRTLLM_MSA_PREFILL_V_TAIL_ZERO", "1") != "0"
5761
# What the patched flash_fwd_sm100 holds for the FP8 rescale threshold.
5862
_DENSE_FA4_PATCH_MARKER = "0.0 if const_expr(self.q_dtype.width == 8) else"
5963
# SM107: the FP8-KV prefill's E4M3 query is cast from the strided query rows of the fused QKV output by one Triton pass
@@ -807,6 +811,22 @@ def rows_of(lens: Optional[torch.Tensor]) -> Optional[torch.Tensor]:
807811
):
808812
return
809813

814+
kv_rows = rows_of(metadata.msa_kv_lens_cpu)
815+
if _FMHA_SM100_V_TAIL_ZERO and kv_rows is not None:
816+
if _v_tail_zero_safe(metadata.kv_cache_manager):
817+
page_table = metadata.msa_block_table[row_first : row_first + num_rows]
818+
_zero_partial_page_v_tail(v_paged, page_table, kv_rows)
819+
logger.info_once(
820+
"MiniMax-M3 prefill: fmha_sm100 V tail past kv_len zeroed on partial pages",
821+
key="minimax_m3_prefill_fmha_sm100_v_tail",
822+
)
823+
else:
824+
logger.warning_once(
825+
"MiniMax-M3 prefill: this KV cache can share a partially reused page without copying it, so the "
826+
"fmha_sm100 V tail past kv_len is not zeroed; a NaN left there by an earlier request reaches P @ V",
827+
key="minimax_m3_prefill_fmha_sm100_v_tail_unsafe",
828+
)
829+
810830
run_msa_sparse_gqa(
811831
q_view,
812832
k_paged,
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
"""MiniMax-M3 fmha_sm100 prefill over a partial last page whose V tail past kv_len holds FP8 NaN bytes.
4+
5+
A recycled page keeps what an earlier request wrote past this request's kv_len. fmha_sm100 masks only the scores,
6+
so P @ V multiplies those V rows by zero, and an E4M3 NaN byte (0x7F) turns the tile NaN. run_msa_prefill_gqa
7+
zeroes the V tail first (TRTLLM_MSA_PREFILL_V_TAIL_ZERO), so the output equals the one over a zeroed tail.
8+
"""
9+
10+
import math
11+
from unittest.mock import create_autospec
12+
13+
import pytest
14+
import torch
15+
16+
from tensorrt_llm._torch.attention.backends.fmha import msa_prefill
17+
from tensorrt_llm._torch.attention.backends.sparse.minimax_m3 import (
18+
MiniMaxM3KVCacheManagerV2,
19+
MiniMaxM3MsaSparseAttention,
20+
)
21+
from tensorrt_llm._torch.attention.backends.sparse.minimax_m3.kernels.msa_utils import (
22+
msa_package_available,
23+
)
24+
from tensorrt_llm._torch.attention.backends.sparse.minimax_m3.msa_backend import (
25+
MiniMaxM3MsaSparseAttentionMetadata,
26+
)
27+
28+
HEAD_DIM, PAGE = 128, 128
29+
FP8_NAN_BYTE = 0x7F
30+
31+
pytestmark = pytest.mark.skipif(
32+
not torch.cuda.is_available()
33+
or torch.cuda.get_device_capability()[0] != 10
34+
or not msa_package_available(),
35+
reason="requires SM10x and fmha_sm100",
36+
)
37+
38+
39+
def _case(rows: list, num_kv_heads: int, tail_byte: int):
40+
"""A batch whose rows (qo_len, kv_len) own pages of an FP8 pool filled with tail_byte past each kv_len."""
41+
g = torch.Generator(device="cuda").manual_seed(912)
42+
pages_per_row = [math.ceil(kv / PAGE) for _, kv in rows]
43+
slots = sum(pages_per_row) + 3
44+
pool = torch.full(
45+
(slots, 2, num_kv_heads, PAGE, HEAD_DIM), tail_byte, dtype=torch.uint8, device="cuda"
46+
).view(torch.float8_e4m3fn)
47+
table = torch.zeros(len(rows), max(pages_per_row), dtype=torch.int32, device="cuda")
48+
flat, start = [], 0
49+
for i, ((_, kv), n) in enumerate(zip(rows, pages_per_row)):
50+
pages = torch.arange(start, start + n, dtype=torch.int32, device="cuda")
51+
table[i, :n] = pages
52+
flat.append(pages)
53+
start += n
54+
kv_tokens = torch.randn(2, num_kv_heads, kv, HEAD_DIM, device="cuda", generator=g) * 0.5
55+
padded = torch.zeros(2, num_kv_heads, n * PAGE, HEAD_DIM, device="cuda")
56+
padded[:, :, :kv] = kv_tokens
57+
live = padded.view(2, num_kv_heads, n, PAGE, HEAD_DIM).permute(2, 0, 1, 3, 4)
58+
# Only the first kv_len tokens are written; the rest of the last page keeps tail_byte.
59+
for p in range(n):
60+
count = min(PAGE, kv - p * PAGE)
61+
pool[pages[p], :, :, :count] = live[p, :, :, :count].to(torch.float8_e4m3fn)
62+
qo_lens = torch.tensor([qo for qo, _ in rows], dtype=torch.int32)
63+
kv_lens = torch.tensor([kv for _, kv in rows], dtype=torch.int32)
64+
kv_cache_manager = create_autospec(MiniMaxM3KVCacheManagerV2, instance=True)
65+
kv_cache_manager.get_buffers.return_value = pool
66+
kv_cache_manager.is_nvfp4_layer.return_value = False
67+
metadata = create_autospec(MiniMaxM3MsaSparseAttentionMetadata, instance=True)
68+
metadata.kv_cache_manager = kv_cache_manager
69+
metadata.msa_qo_lens_cpu = qo_lens
70+
metadata.msa_kv_lens_cpu = kv_lens
71+
metadata.msa_qo_offset_cpu = kv_lens - qo_lens
72+
metadata.msa_block_table = table
73+
metadata.msa_kv_indices = torch.cat(flat)
74+
return pool, metadata
75+
76+
77+
def _run(q, metadata, num_heads):
78+
attn = create_autospec(MiniMaxM3MsaSparseAttention, instance=True)
79+
attn.head_dim, attn.num_heads, attn.layer_idx, attn.q_scaling = HEAD_DIM, num_heads, 0, 1.0
80+
out = torch.zeros_like(q)
81+
msa_prefill.run_msa_prefill_gqa(
82+
attn,
83+
q,
84+
metadata,
85+
out,
86+
kv_block_indexes=None,
87+
plan=None,
88+
row_first=0,
89+
num_rows=int(metadata.msa_qo_lens_cpu.shape[0]),
90+
)
91+
torch.cuda.synchronize()
92+
return out
93+
94+
95+
# Rows ending mid-page: short prompts, a short chunk over a long prefix, and a two-request batch.
96+
ROWS = [[(5, 5)], [(38, 38)], [(129, 129)], [(170, 170)], [(5, 900)], [(38, 38), (86, 86)]]
97+
98+
99+
@pytest.mark.parametrize("rows", ROWS, ids=lambda rows: "_".join(f"{q}x{kv}" for q, kv in rows))
100+
@pytest.mark.parametrize("num_heads,num_kv_heads", [(32, 2), (16, 1)], ids=["32x2", "16x1"])
101+
def test_fmha_sm100_prefill_ignores_nan_past_kv_len(
102+
monkeypatch: pytest.MonkeyPatch, rows: list, num_heads: int, num_kv_heads: int
103+
) -> None:
104+
monkeypatch.setattr(msa_prefill, "dense_prefill_fa4_enabled", lambda *_: False)
105+
num_tokens = sum(qo for qo, _ in rows)
106+
g = torch.Generator(device="cuda").manual_seed(7)
107+
q = (torch.randn(num_tokens, num_heads * HEAD_DIM, device="cuda", generator=g) * 0.5).to(
108+
torch.bfloat16
109+
)
110+
111+
pool, metadata = _case(rows, num_kv_heads, FP8_NAN_BYTE)
112+
out = _run(q, metadata, num_heads)
113+
_, zero_tail_metadata = _case(rows, num_kv_heads, 0)
114+
expected = _run(q, zero_tail_metadata, num_heads)
115+
116+
assert torch.isfinite(out).all()
117+
assert torch.equal(out, expected)
118+
# The V tail of each partial last page is zeroed; K keeps its stale bytes (the scores mask them).
119+
for i, (_, kv) in enumerate(rows):
120+
if kv % PAGE:
121+
page = int(metadata.msa_block_table[i, kv // PAGE])
122+
tail = pool.view(torch.uint8)[page, :, :, kv % PAGE :]
123+
assert (tail[1] == 0).all()
124+
assert (tail[0] == FP8_NAN_BYTE).all()

0 commit comments

Comments
 (0)