[Perf][Attention] Reduce GLM sparse MLA preparation overhead (#57458)

Signed-off-by: Summer Yang <girasoleyang@gmail.com> Co-authored-by: Codex <noreply@openai.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Co-authored-by: Jiangyun Zhu <riverclouds.zhu@qq.com>

Summer Yang committed Sep 21, 2026 at 17:24 UTC a369a7becc20c6c4dda601aedef51b8cc8387971
7 files changed +248 -94
tests/kernels/attention/test_flashinfer_mla_decode.py
+103 -49
@@ -130,69 +130,123 @@ def test_flashinfer_mla_decode(dtype: torch.dtype, bs: int, block_size: int):
130 torch.testing.assert_close(out_ans, out_ref, atol=1e-2, rtol=1e-2)
131
132
133 +@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float8_e4m3fn])
134 +@pytest.mark.parametrize("mode", ["tp", "dcp", "hisparse"])
135 @requires_sm10x
134 -def test_flashinfer_trtllm_sparse_mla_decode_without_rope():
135 - """The native sparse MLA path supports a zero-width rotary tail."""
136 +def test_flashinfer_trtllm_sparse_mla_decode_without_rope(dtype, mode, monkeypatch):
137 + """TP skips preparation; empty DCP contributions and HiSparse outputs vanish."""
138 + import vllm.v1.attention.backends.mla.flashinfer_mla_sparse as backend
139 + from vllm.v1.attention.backends.mla.index_group import HiSparseMLAIndexGroup
140 +
141 torch.set_default_device("cuda")
142 torch.manual_seed(42)
138 -
139 - batch_size = 2
140 - block_size = 64
141 - num_blocks = 4
142 - sparse_topk = 128
143 - valid_lens = torch.tensor([17, 73], dtype=torch.int32)
144 -
145 - query = torch.randn(
146 - batch_size,
147 - 1,
148 - NUM_HEADS,
149 - KV_LORA_RANK,
150 - dtype=torch.bfloat16,
151 - )
152 - kv_cache = torch.randn(
153 - num_blocks,
154 - block_size,
155 - KV_LORA_RANK,
156 - dtype=torch.bfloat16,
157 - )
158 -
143 + batch_size, num_heads, block_size, num_blocks, sparse_topk = 3, 8, 64, 4, 2176
144 + valid_lens = [1 if mode == "tp" else 0, 17, 73]
145 + counts = torch.tensor(valid_lens, dtype=torch.int32)
146 + query = torch.randn(batch_size, 1, num_heads, KV_LORA_RANK).to(dtype)
147 + kv_cache = torch.randn(num_blocks, block_size, KV_LORA_RANK).to(dtype)
148 num_slots = num_blocks * block_size
160 - slot_tables = torch.stack(
161 - [torch.randperm(num_slots)[:sparse_topk] for _ in range(batch_size)]
162 - ).to(torch.int32)
163 - for row, valid_len in zip(slot_tables, valid_lens.tolist()):
164 - row[valid_len:] = -1
165 -
166 - workspace_buffer = torch.empty(
167 - FLASHINFER_WORKSPACE_BUFFER_SIZE,
168 - dtype=torch.int8,
149 + indices = torch.full((batch_size, sparse_topk), -1, dtype=torch.int32)
150 + for token, length in enumerate(valid_lens):
151 + indices[token, :length] = torch.randperm(num_slots)[:length].int()
152 + empty = counts == 0
153 + reference_indices = indices.clone()
154 + reference_indices[:, 0].masked_fill_(empty, 0)
155 + workspace = torch.empty(FLASHINFER_WORKSPACE_BUFFER_SIZE, dtype=torch.int8)
156 +
157 + impl = object.__new__(backend.FlashInferMLASparseImpl)
158 + impl.qk_nope_head_dim = QK_NOPE_HEAD_DIM
159 + impl.kv_lora_rank = KV_LORA_RANK
160 + impl.qk_rope_head_dim = 0
161 + impl.index_group = (
162 + object.__new__(HiSparseMLAIndexGroup) if mode == "hisparse" else None
163 )
170 - out = trtllm_batch_decode_with_kv_cache_mla(
164 + impl._workspace_buffer = workspace
165 + impl.bmm1_scale = QK_NOPE_HEAD_DIM**-0.5
166 + impl.bmm2_scale = 1.0
167 + impl.is_nope_mla = True
168 + impl.need_to_return_lse_for_decode = mode == "dcp"
169 + if mode == "tp":
170 +
171 + def unexpected_helper(*args, **kwargs):
172 + pytest.fail("TP must not launch empty-query preparation")
173 +
174 + monkeypatch.setattr(
175 + backend, "prepare_sparse_mla_safe_lengths", unexpected_helper
176 + )
177 +
178 + out, lse = impl._run_mqa_kernel(query.squeeze(1), kv_cache, indices, counts)
179 + reference = trtllm_batch_decode_with_kv_cache_mla(
180 query=query,
181 kv_cache=kv_cache.unsqueeze(1),
173 - workspace_buffer=workspace_buffer,
182 + workspace_buffer=workspace,
183 qk_nope_head_dim=QK_NOPE_HEAD_DIM,
184 kv_lora_rank=KV_LORA_RANK,
185 qk_rope_head_dim=0,
177 - block_tables=slot_tables.unsqueeze(1),
178 - seq_lens=valid_lens,
186 + block_tables=reference_indices.unsqueeze(1),
187 + seq_lens=counts,
188 max_seq_len=sparse_topk,
189 sparse_mla_top_k=sparse_topk,
181 - sparse_mla_top_k_lens=valid_lens,
190 + sparse_mla_top_k_lens=counts.clamp(min=1),
191 bmm1_scale=QK_NOPE_HEAD_DIM**-0.5,
192 bmm2_scale=1.0,
184 - ).squeeze(1)
185 -
186 - flat_cache = kv_cache.view(num_slots, KV_LORA_RANK).float()
187 - refs = []
188 - for batch_idx, valid_len in enumerate(valid_lens.tolist()):
189 - selected_kv = flat_cache[slot_tables[batch_idx, :valid_len].long()]
190 - scores = torch.einsum("hd,kd->hk", query[batch_idx, 0].float(), selected_kv)
191 - probs = torch.softmax(scores * QK_NOPE_HEAD_DIM**-0.5, dim=-1)
192 - refs.append(torch.einsum("hk,kd->hd", probs, selected_kv))
193 - ref = torch.stack(refs).to(torch.bfloat16)
194 -
195 - torch.testing.assert_close(out, ref, atol=2e-2, rtol=2e-2)
193 + return_lse=mode == "dcp",
194 + )
195 + if mode == "dcp":
196 + reference, reference_lse = reference
197 + reference_lse = reference_lse.reshape(batch_size, num_heads)
198 + reference_lse.masked_fill_(empty[:, None], float("-inf"))
199 + torch.testing.assert_close(lse, reference_lse, atol=0, rtol=0)
200 +
201 + from vllm.v1.attention.ops.dcp import CPTritonContext, correct_attn_out
202 +
203 + # The peer owns only our empty selections. Check our contribution after
204 + # the production merge, without zeroing the local attention output.
205 + peer_lse = torch.full_like(lse, float("-inf")).masked_fill(empty[:, None], 0)
206 + out, _ = correct_attn_out(
207 + out, torch.stack((lse, peer_lse)), cp_rank=0, ctx=CPTritonContext()
208 + )
209 + else:
210 + assert lse is None
211 + reference = reference.squeeze(1).masked_fill(empty[:, None, None], 0)
212 + torch.testing.assert_close(out, reference, atol=0, rtol=0)
213 +
214 + if dtype == torch.bfloat16:
215 + flat_cache = kv_cache.view(num_slots, KV_LORA_RANK).float()
216 + refs = []
217 + for token, length in enumerate(valid_lens):
218 + selected_kv = flat_cache[reference_indices[token, :length].long()]
219 + scores = torch.einsum("hd,kd->hk", query[token, 0].float(), selected_kv)
220 + probs = torch.softmax(scores * QK_NOPE_HEAD_DIM**-0.5, dim=-1)
221 + refs.append(torch.einsum("hk,kd->hd", probs, selected_kv))
222 + ref = torch.stack(refs).to(torch.bfloat16)
223 + torch.testing.assert_close(out, ref, atol=2e-2, rtol=2e-2)
224 +
225 +
226 +@requires_sm10x
227 +def test_sparse_mla_safe_lengths():
228 + from vllm.v1.attention.backends.mla.sparse_utils import (
229 + prepare_sparse_mla_safe_lengths,
230 + )
231 +
232 + device = "cuda"
233 + num_tokens = 259
234 + # Include a partial tile and expose accidental writes into adjacent storage.
235 + counts_storage = torch.full((num_tokens, 2), 17, dtype=torch.int32, device=device)
236 + counts = counts_storage[:, 0]
237 + counts[::3] = 0
238 + original_counts = counts_storage.clone()
239 + empty = counts == 0
240 + indices_storage = torch.full(
241 + (num_tokens, 256), 42, dtype=torch.int32, device=device
242 + )
243 + indices = indices_storage[:, ::2]
244 + expected_indices = indices_storage.clone()
245 + expected_indices[:, 0].masked_fill_(empty, 0)
246 + safe = prepare_sparse_mla_safe_lengths(indices, counts)
247 + torch.testing.assert_close(safe, counts.clamp(min=1), rtol=0, atol=0)
248 + torch.testing.assert_close(indices_storage, expected_indices, rtol=0, atol=0)
249 + torch.testing.assert_close(counts_storage, original_counts, rtol=0, atol=0)
250
251
252 @requires_sm90
tests/kernels/test_concat_mla_q.py
+29
@@ -148,3 +148,32 @@ def test_concat_mla_q_values_preserved(num_tokens):
148 assert torch.equal(out_bits[..., :nope_dim], ql_nope_bits)
149
150 assert torch.equal(out_bits[..., nope_dim:], q_pe_bits)
151 +
152 +
153 +@pytest.mark.parametrize("rope_dim,transposed", [(0, False), (0, True), (64, False)])
154 +@pytest.mark.parametrize("enabled", [False, True])
155 +def test_concat_mla_q_fp8_nope_and_rope(rope_dim, transposed, enabled):
156 + """FP8 query packing preserves values with empty RoPE and strided BMM output."""
157 + from vllm.config import CompilationConfig, VllmConfig, set_current_vllm_config
158 + from vllm.model_executor.layers.attention.mla_attention import _DecodeConcatQuantFP8
159 + from vllm.model_executor.layers.quantization.utils.quant_utils import GroupShape
160 +
161 + torch.manual_seed(42)
162 + shape = (8, 17, 512) if transposed else (17, 8, 512)
163 + ql = torch.randn(shape, dtype=torch.bfloat16, device="cuda") * 128
164 + if transposed:
165 + ql = ql.transpose(0, 1)
166 + qp = torch.randn(17, 8, rope_dim, dtype=torch.bfloat16, device="cuda") * 128
167 + scale = torch.tensor(0.3, device="cuda", dtype=torch.float32)
168 + joined = torch.cat((ql, qp), dim=-1)
169 + expected = (joined.float() * scale.reciprocal()).clamp(-448, 448)
170 + expected = expected.to(torch.float8_e4m3fn)
171 + custom_ops = ["none"] + (["+mla_decode_concat_quant_fp8"] if enabled else [])
172 + config = VllmConfig(
173 + compilation_config=CompilationConfig(mode=0, custom_ops=custom_ops)
174 + )
175 + with set_current_vllm_config(config):
176 + quant = _DecodeConcatQuantFP8(static=True, group_shape=GroupShape.PER_TENSOR)
177 + actual = quant(ql, qp, scale)
178 + torch.testing.assert_close(actual.float(), expected.float(), atol=0, rtol=0)
179 + assert actual.is_contiguous()
tests/v1/attention/test_indexer_dcp_localize.py
+3 -4
@@ -700,9 +700,8 @@ def test_dcp_filter_compacts_valid_slots_for_sparse_kernel(
700 @pytest.mark.skipif(not current_platform.is_cuda(), reason="This test requires CUDA")
701 @pytest.mark.parametrize("interleave", [1, 2])
702 @pytest.mark.parametrize("dcp_rank", [0, 1])
703 -# 384 is not a power of two, so it exercises the multi-tile atomic allocator
704 -# rather than the single-tile path 1024 takes.
705 -@pytest.mark.parametrize("num_topk", [1024, 384])
703 +# Include padded single-tile compaction and the multi-tile atomic fallback.
704 +@pytest.mark.parametrize("num_topk", [1024, 2176, 4224])
705 def test_dcp_filter_compaction_matches_reference(
706 interleave: int, dcp_rank: int, num_topk: int
707 ):
@@ -729,7 +728,7 @@ def test_dcp_filter_compaction_matches_reference(
728 for r in range(num_rows):
729 # Ids are distinct, so a row holds at most `seq` valid entries.
730 hi = min(num_topk * 3 // 4, seq)
732 - n_valid = int(torch.randint(num_topk // 4, hi, (1,)).item())
731 + n_valid = int(torch.randint(hi // 3, hi, (1,)).item())
732 perm = torch.randperm(seq, device=device)[:n_valid].to(torch.int32)
733 token_indices[r, :n_valid] = perm
734
tests/v1/attention/test_sparse_mla_backends.py
+18 -7
@@ -1663,10 +1663,12 @@ def test_split_indexer_prefill_chunks_single_request_overflow():
1663 assert out == expected
1664
1665
1666 -# 384 is not a power of two, so it counts via the tiled atomic accumulation
1667 -# rather than the single-tile path 128 takes.
1668 -@pytest.mark.parametrize("num_topk_tokens", [128, 384])
1669 -def test_triton_convert_returns_valid_counts(num_topk_tokens: int):
1666 +# Power-of-two, GLM's padded tile, and atomic fallback, with reused buffers.
1667 +@pytest.mark.parametrize(
1668 + "num_topk_tokens,reuse_buffers",
1669 + [(128, False), (2176, False), (2176, True), (4224, False), (4224, True)],
1670 +)
1671 +def test_triton_convert_returns_valid_counts(num_topk_tokens: int, reuse_buffers: bool):
1672 """Test that return_valid_counts correctly counts non-negative indices."""
1673 device = torch.device(DEVICE_TYPE)
1674 num_tokens = 8
@@ -1679,8 +1681,7 @@ def test_triton_convert_returns_valid_counts(num_topk_tokens: int):
1681 num_requests * max_blocks_per_req, dtype=torch.int32, device=device
1682 ).view(num_requests, max_blocks_per_req)
1683
1682 - # Create token indices with varying numbers of valid entries: half the row,
1683 - # a quarter of it, the whole row, then a single valid entry -- twice over.
1684 + # Include partly valid, fully valid, and empty selections for each request.
1685 token_indices = torch.full(
1686 (num_tokens, num_topk_tokens), -1, dtype=torch.int32, device=device
1687 )
@@ -1688,7 +1689,7 @@ def test_triton_convert_returns_valid_counts(num_topk_tokens: int):
1689 num_topk_tokens // 2,
1690 num_topk_tokens // 4,
1691 num_topk_tokens,
1691 - 1,
1692 + 0,
1693 ] * 2
1694 expected_valid = []
1695 for i in range(num_tokens):
@@ -1702,6 +1703,15 @@ def test_triton_convert_returns_valid_counts(num_topk_tokens: int):
1703 expected_valid, dtype=torch.int32, device=device
1704 )
1705
1706 + buffers = {}
1707 + if reuse_buffers:
1708 + buffers = dict(
1709 + out=torch.full_like(token_indices, -99),
1710 + valid_counts_out=torch.full(
1711 + (num_tokens,), 99, dtype=torch.int32, device=device
1712 + ),
1713 + )
1714 +
1715 # Test with return_valid_counts=True
1716 result, valid_counts = triton_convert_req_index_to_global_index(
1717 req_id,
@@ -1710,6 +1720,7 @@ def test_triton_convert_returns_valid_counts(num_topk_tokens: int):
1720 BLOCK_SIZE=block_size,
1721 NUM_TOPK_TOKENS=num_topk_tokens,
1722 return_valid_counts=True,
1723 + **buffers,
1724 )
1725
1726 torch.testing.assert_close(valid_counts, expected_valid_tensor, rtol=0, atol=0)
vllm/model_executor/layers/attention/mla_attention.py
+4 -1
@@ -1553,7 +1553,10 @@ class _DecodeConcatQuantFP8(QuantFP8):
1553 scale: torch.Tensor,
1554 scale_ub: torch.Tensor | None = None,
1555 ) -> torch.Tensor:
1556 - decode_q0 = torch.cat((decode_ql_nope, decode_q_pe), dim=-1)
1556 + if decode_q_pe.shape[-1] == 0 and decode_ql_nope.is_contiguous():
1557 + decode_q0 = decode_ql_nope
1558 + else:
1559 + decode_q0 = torch.cat((decode_ql_nope, decode_q_pe), dim=-1)
1560 decode_q_flat = decode_q0.reshape(decode_q0.shape[0], -1)
1561 decode_q, _ = quant_fn(self, decode_q_flat, scale, scale_ub)
1562 return decode_q.view(decode_q0.shape)
vllm/v1/attention/backends/mla/flashinfer_mla_sparse.py
+19 -15
@@ -30,6 +30,7 @@ from vllm.v1.attention.backend import (
30 from vllm.v1.attention.backends.mla.index_group import HiSparseMLAIndexGroup
31 from vllm.v1.attention.backends.mla.sparse_utils import (
32 flat_kv_row_view,
33 + prepare_sparse_mla_safe_lengths,
34 triton_convert_req_index_to_global_index,
35 triton_filter_and_convert_dcp_index,
36 )
@@ -697,18 +698,16 @@ class FlashInferMLASparseImpl(SparseMLACommonImpl[FlashInferMLASparseMetadata]):
698 sparse_topk_capacity = topk_indices.shape[1]
699
700 extra_kwargs: dict[str, torch.Tensor] = {}
700 - empty_rows: torch.Tensor | None = None
701 + needs_empty_query_guard = self.need_to_return_lse_for_decode or isinstance(
702 + self.index_group, HiSparseMLAIndexGroup
703 + )
704 if self.is_nope_mla:
702 - # The native no-rope kernel takes the active top-k length per query
703 - # token (``seq_lens`` here is already the compacted per-token valid
704 - # count, int32) and rejects zero-length rows. Point empty rows at a
705 - # single valid dummy slot with length 1 and zero their output after
706 - # the launch. ``triton_convert_req_index_to_global_index`` packs the
707 - # valid indices into a contiguous prefix, which is what the kernel
708 - # requires of the page table.
709 - empty_rows = seq_lens == 0
710 - topk_indices[:, 0] = topk_indices[:, 0].masked_fill(empty_rows, 0)
711 - extra_kwargs["sparse_mla_top_k_lens"] = seq_lens.clamp(min=1)
705 + # Resident TP queries have nonempty selections. Preserve empty-query
706 + # handling for DCP's local selections and host-backed HiSparse.
707 + topk_lens = seq_lens
708 + if needs_empty_query_guard:
709 + topk_lens = prepare_sparse_mla_safe_lengths(topk_indices, seq_lens)
710 + extra_kwargs["sparse_mla_top_k_lens"] = topk_lens
711
712 kernel_out = trtllm_batch_decode_with_kv_cache_mla(
713 query=query,
@@ -737,12 +736,17 @@ class FlashInferMLASparseImpl(SparseMLACommonImpl[FlashInferMLASparseMetadata]):
736 out = o.view(-1, o.shape[-2], o.shape[-1])
737 if lse is not None:
738 lse = self._normalize_lse(lse, out.shape[0], out.shape[1])
740 - if empty_rows is None and lse is not None:
739 + if self.is_nope_mla and needs_empty_query_guard:
740 + empty_queries = seq_lens == 0
741 + if lse is not None:
742 + # DCP combine already suppresses outputs with zero LSE weight.
743 + lse.masked_fill_(empty_queries[:, None], float("-inf"))
744 + else:
745 + out.masked_fill_(empty_queries[:, None, None], 0.0)
746 + elif lse is not None:
747 empty_rows = (topk_indices == -1).all(dim=-1)
742 - if empty_rows is not None:
748 out.masked_fill_(empty_rows.view(-1, 1, 1), 0.0)
744 - if lse is not None:
745 - lse.masked_fill_(empty_rows.view(-1, 1), float("-inf"))
749 + lse.masked_fill_(empty_rows.view(-1, 1), float("-inf"))
750 return out, lse
751
752 @staticmethod
vllm/v1/attention/backends/mla/sparse_utils.py
+72 -18
@@ -86,7 +86,7 @@ class ConvertReqIndexToGlobalIndexKernel(
86
87 @staticmethod
88 @triton.jit(do_not_specialize=["max_num_blocks_per_req", "workspace_rank_stride"])
89 - def kernel(
89 + def sparse_mla_index_remap_kernel(
90 req_id_ptr, # int32 [num_tokens]
91 block_table_ptr, # int32 [num_requests, max_num_blocks_per_req]
92 token_indices_ptr, # int32 [num_tokens, NUM_TOPK_TOKENS]
@@ -100,15 +100,16 @@ class ConvertReqIndexToGlobalIndexKernel(
100 BLOCK_SIZE: tl.constexpr,
101 BLOCK_STRIDE_ROWS: tl.constexpr,
102 BLOCK_N: tl.constexpr, # tile width along columns
103 + NUM_TOPK_TOKENS: tl.constexpr,
104 HAS_PREFILL: tl.constexpr,
105 COUNT_VALID: tl.constexpr, # whether to count valid indices
105 - # BLOCK_N == NUM_TOPK_TOKENS: one program owns the row, so the valid count
106 + # BLOCK_N >= NUM_TOPK_TOKENS: one program owns the query, so its count
107 # is an in-register reduction and needs no atomic.
108 SINGLE_TILE: tl.constexpr,
109 # When set, scatter valid slots to a contiguous prefix [0, valid_count) using
110 # valid_count_ptr as an atomic slot allocator (DCP filtering leaves interior
111 # -1 gaps; the trtllm-gen sparse kernel reads the first valid_count entries).
111 - # Requires COUNT_VALID and an out buffer pre-filled with -1. Order within the
112 + # Requires COUNT_VALID; multi-tile output must be pre-filled with -1. The
113 # prefix is unspecified (only the selected set matters).
114 COMPACT_TO_FRONT: tl.constexpr,
115 # DCP de-interleave: with DCP_SIZE == 1 these are an exact no-op
@@ -136,7 +137,7 @@ class ConvertReqIndexToGlobalIndexKernel(
137
138 # Load token indices for this tile
139 ti_ptr = token_indices_ptr + token_id * ti_stride0 + indice_id * ti_stride1
139 - tok = tl.load(ti_ptr) # int32
140 + tok = tl.load(ti_ptr, mask=indice_id < NUM_TOPK_TOKENS, other=-1)
141
142 # Only token == -1 should propagate as -1
143 is_invalid_tok = tok < 0
@@ -197,10 +198,19 @@ class ConvertReqIndexToGlobalIndexKernel(
198 dest = base + local_offset
199 out_ptr_dest = out_ptr + token_id * out_stride0 + dest * out_stride1
200 tl.store(out_ptr_dest, out_val, mask=is_valid == 1)
201 + if SINGLE_TILE:
202 + # This program owns both the compacted prefix and its padding.
203 + # The stores are disjoint, so no separate initialization is needed.
204 + tl.store(
205 + out_ptr + token_id * out_stride0 + indice_id * out_stride1,
206 + -1,
207 + mask=(indice_id >= tile_valid_count)
208 + & (indice_id < NUM_TOPK_TOKENS),
209 + )
210 else:
211 # Store results in place (input column == output column).
212 out_ptr_ij = out_ptr + token_id * out_stride0 + indice_id * out_stride1
203 - tl.store(out_ptr_ij, out_val)
213 + tl.store(out_ptr_ij, out_val, mask=indice_id < NUM_TOPK_TOKENS)
214
215 # Accumulate the tile's valid count into the row total; a single tile's
216 # reduction *is* the total.
@@ -211,6 +221,9 @@ class ConvertReqIndexToGlobalIndexKernel(
221 else:
222 tl.atomic_add(valid_count_ptr + token_id, tile_valid_count)
223
224 + # Keep the warmup wrapper interface while exposing a descriptive CUDA symbol.
225 + kernel = sparse_mla_index_remap_kernel
226 +
227 def dispatch( # type: ignore[override]
228 self,
229 *,
@@ -421,18 +434,19 @@ def _remap_tiling(
434 Counting the valid slots per row is the only reason the column tiles have to
435 talk to each other, so when counting give one program the whole row: the
436 count becomes an in-register reduction plus a plain store, needing neither
424 - atomics nor a zero-initialized counter. The row is one ``tl.arange``, so this
425 - needs a power-of-two width; other top-k sizes stay tiled and atomic.
437 + atomics nor a zero-initialized counter. Pad modest non-power-of-two widths
438 + (including GLM's 2176 entries) to one tile; larger widths stay tiled and atomic.
439
440 Returns:
441 (single_tile, block_n, tiles_per_row, num_warps)
442
443 """
431 - single_tile = (
432 - count_valid and triton.next_power_of_2(NUM_TOPK_TOKENS) == NUM_TOPK_TOKENS
444 + padded_width = triton.next_power_of_2(NUM_TOPK_TOKENS)
445 + single_tile = count_valid and (
446 + padded_width == NUM_TOPK_TOKENS or padded_width <= 4096
447 )
448 if single_tile:
435 - return True, NUM_TOPK_TOKENS, 1, 8
449 + return True, padded_width, 1, 8
450 return False, BLOCK_N, NUM_TOPK_TOKENS // BLOCK_N, 4
451
452
@@ -504,14 +518,11 @@ def triton_convert_req_index_to_global_index(
518 req_id_c = req_id.contiguous()
519 block_table_c = block_table.contiguous()
520 token_indices_c = token_indices.contiguous()
507 - # When return_valid_counts, the kernel scatters valid entries to a
508 - # contiguous prefix [0, valid_count) and leaves the tail unwritten, so
509 - # pre-fill -1 there. flash_mla_sparse_fwd then bounds attention to
510 - # [:topk_length] == exactly the valid set (no dropped tokens).
521 + # Only multi-tile compaction needs separate padding initialization.
522 if out is None:
523 out = (
524 torch.full_like(token_indices_c, -1)
514 - if return_valid_counts
525 + if return_valid_counts and not single_tile
526 else torch.empty_like(token_indices_c)
527 )
528 else:
@@ -519,7 +530,7 @@ def triton_convert_req_index_to_global_index(
530 assert out.device == token_indices_c.device
531 assert out.shape == token_indices_c.shape
532 assert out.is_contiguous()
522 - if return_valid_counts:
533 + if return_valid_counts and not single_tile:
534 out.fill_(-1)
535
536 valid_counts: torch.Tensor | None = None
@@ -636,13 +647,13 @@ def triton_filter_and_convert_dcp_index(
647 token_indices_c = token_indices.contiguous()
648
649 # The compaction uses the valid-count buffer as a slot allocator, so it
639 - # requires counting. Pre-fill out with -1 so the unwritten tail stays -1.
650 + # requires counting. Only the multi-tile path needs pre-filled padding.
651 count_valid = return_valid_counts or compact_valid_to_front
652
653 # The compaction builds on the counting, so it shares the tiling.
654 single_tile, _, _, _ = _remap_tiling(NUM_TOPK_TOKENS, BLOCK_N, count_valid)
655
645 - if compact_valid_to_front:
656 + if compact_valid_to_front and not single_tile:
657 out = torch.full_like(token_indices_c, -1)
658 else:
659 out = torch.empty_like(token_indices_c)
@@ -685,3 +696,46 @@ def triton_filter_and_convert_dcp_index(
696
697
698 _CONVERT_REQ_INDEX_TO_GLOBAL_INDEX_KERNEL = ConvertReqIndexToGlobalIndexKernel()
699 +
700 +
701 +@triton.jit(do_not_specialize=["num_tokens"])
702 +def sparse_mla_prepare_safe_lengths_kernel(
703 + indices_ptr,
704 + counts_ptr,
705 + safe_lengths_ptr,
706 + num_tokens,
707 + index_stride0: tl.constexpr,
708 + count_stride: tl.constexpr,
709 + BLOCK: tl.constexpr,
710 +):
711 + token = tl.program_id(0) * BLOCK + tl.arange(0, BLOCK)
712 + count = tl.load(counts_ptr + token * count_stride, token < num_tokens, other=1)
713 + tl.store(safe_lengths_ptr + token, tl.maximum(count, 1), token < num_tokens)
714 + # TRTLLM's NoPE sparse kernel requires at least one valid KV-cache entry.
715 + tl.store(
716 + indices_ptr + token * index_stride0, 0, (token < num_tokens) & (count == 0)
717 + )
718 +
719 +
720 +def prepare_sparse_mla_safe_lengths(
721 + physical_indices: torch.Tensor, valid_counts: torch.Tensor
722 +) -> torch.Tensor:
723 + """Install dummy slots for empty queries and return nonzero kernel lengths.
724 +
725 + Preserve the raw counts so empty outputs and LSE can be neutralized later.
726 + """
727 + num_tokens = valid_counts.numel()
728 + safe_lengths = torch.empty(
729 + (num_tokens,), dtype=valid_counts.dtype, device=valid_counts.device
730 + )
731 + if num_tokens:
732 + sparse_mla_prepare_safe_lengths_kernel[(triton.cdiv(num_tokens, 256),)](
733 + physical_indices,
734 + valid_counts,
735 + safe_lengths,
736 + num_tokens,
737 + physical_indices.stride(0),
738 + valid_counts.stride(0),
739 + BLOCK=256,
740 + )
741 + return safe_lengths