[Bugfix][MLA] Fix BLHNC addressing for FlashInfer sparse MLA (#54465)

Signed-off-by: Lucas Wilkinson <lwilkins@redhat.com>

Lucas Wilkinson committed Aug 30, 2026 at 21:13 UTC 570735520942b6428ae98f1eb442c6d88adae55e
4 files changed +25 -4
tests/v1/attention/test_indexer_dcp_localize.py
+10 -2
@@ -642,7 +642,10 @@ def test_sparse_prefill_dcp_metadata_localizes_causal_bounds():
642
643
644 @pytest.mark.skipif(not current_platform.is_cuda(), reason="This test requires CUDA")
645 -def test_dcp_filter_compacts_valid_slots_for_sparse_kernel():
645 +@pytest.mark.parametrize("block_stride_rows", [None, 12])
646 +def test_dcp_filter_compacts_valid_slots_for_sparse_kernel(
647 + block_stride_rows: int | None,
648 +):
649 block_size = 4
650 num_topk = 128
651 dcp_size = 2
@@ -658,6 +661,7 @@ def test_dcp_filter_compacts_valid_slots_for_sparse_kernel():
661 dcp_size=dcp_size,
662 dcp_rank=0,
663 BLOCK_SIZE=block_size,
664 + BLOCK_STRIDE_ROWS=block_stride_rows,
665 NUM_TOPK_TOKENS=num_topk,
666 return_valid_counts=True,
667 )
@@ -668,7 +672,11 @@ def test_dcp_filter_compacts_valid_slots_for_sparse_kernel():
672 assert (out[0, valid:] == -1).all()
673 # In-kernel compaction packs valid slots to the front; prefix order is
674 # unspecified, so compare as a set.
671 - assert set(out[0, :valid].cpu().tolist()) == {40, 41, 42, 43}
675 + block_stride_rows = block_stride_rows or block_size
676 + expected_base = 10 * block_stride_rows
677 + assert set(out[0, :valid].cpu().tolist()) == set(
678 + range(expected_base, expected_base + block_size)
679 + )
680
681
682 @pytest.mark.skipif(not current_platform.is_cuda(), reason="This test requires CUDA")
vllm/models/deepseek_v32/common/kernels.py
+5 -1
@@ -140,6 +140,7 @@ def _fused_norm_rope_kernel(
140 mla_cache_ptr,
141 mla_cache_block_stride,
142 mla_cache_entry_stride,
143 + MLA_CACHE_BLOCK_SIZE: tl.constexpr,
144 MLA_CACHE_FP8: tl.constexpr,
145 mla_cache_scale_ptr,
146 # fp8_ds_mla cache views (block-scaled fp8 NoPE + unquantized bf16 RoPE).
@@ -242,7 +243,7 @@ def _fused_norm_rope_kernel(
243 return
244
245 slot_idx = tl.load(slot_mapping_ptr + tok_idx)
245 - mla_block_size = mla_cache_block_stride // mla_cache_entry_stride
246 + mla_block_size = MLA_CACHE_BLOCK_SIZE
247 mla_block_idx = slot_idx // mla_block_size
248 mla_block_off = slot_idx % mla_block_size
249
@@ -479,6 +480,7 @@ def fused_norm_rope(
480 mla_ds_scale_view = torch.empty(0, dtype=torch.float32, device=device)
481 mla_ds_rope_view = torch.empty(0, dtype=torch.bfloat16, device=device)
482 if mla_kv_cache is not None:
483 + mla_block_size = mla_kv_cache.shape[1]
484 if mla_cache_ds_mla:
485 # 656-byte custom layout addressed in bytes; mla_cache_ptr is the
486 # 1-byte fp8 view, so block/entry strides are byte offsets and the
@@ -503,6 +505,7 @@ def fused_norm_rope(
505 mla_kv_cache = torch.empty(0, dtype=torch.bfloat16, device=device)
506 mla_block_stride = 0
507 mla_entry_stride = 0
508 + mla_block_size = 1
509 mla_k_scale = _dummy((1,), torch.float32, device)
510
511 if q_c_out is None:
@@ -570,6 +573,7 @@ def fused_norm_rope(
573 mla_kv_cache,
574 mla_block_stride,
575 mla_entry_stride,
576 + mla_block_size,
577 mla_cache_fp8,
578 mla_k_scale,
579 mla_ds_scale_view,
vllm/v1/attention/backends/mla/flashinfer_mla_sparse.py
+7
@@ -28,6 +28,7 @@ from vllm.v1.attention.backend import (
28 MultipleOf,
29 )
30 from vllm.v1.attention.backends.mla.sparse_utils import (
31 + flat_kv_row_view,
32 triton_convert_req_index_to_global_index,
33 triton_filter_and_convert_dcp_index,
34 )
@@ -377,6 +378,10 @@ class FlashInferMLASparseImpl(SparseMLACommonImpl[FlashInferMLASparseMetadata]):
378 assert self.topk_indices_buffer is not None
379 topk_indices = self.topk_indices_buffer[:num_actual_toks]
380
381 + _, block_stride_rows = flat_kv_row_view(
382 + kv_c_and_k_pe_cache, attn_metadata.block_size
383 + )
384 +
385 if self.dcp_world_size > 1:
386 topk_indices_physical, seq_lens = triton_filter_and_convert_dcp_index(
387 attn_metadata.req_id_per_token[:num_actual_toks],
@@ -386,6 +391,7 @@ class FlashInferMLASparseImpl(SparseMLACommonImpl[FlashInferMLASparseMetadata]):
391 dcp_rank=self.dcp_rank,
392 cp_kv_cache_interleave_size=(attn_metadata.cp_kv_cache_interleave_size),
393 BLOCK_SIZE=attn_metadata.block_size,
394 + BLOCK_STRIDE_ROWS=block_stride_rows,
395 NUM_TOPK_TOKENS=topk_indices.shape[1],
396 return_valid_counts=True,
397 )
@@ -395,6 +401,7 @@ class FlashInferMLASparseImpl(SparseMLACommonImpl[FlashInferMLASparseMetadata]):
401 attn_metadata.block_table,
402 topk_indices,
403 BLOCK_SIZE=attn_metadata.block_size,
404 + BLOCK_STRIDE_ROWS=block_stride_rows,
405 NUM_TOPK_TOKENS=topk_indices.shape[1],
406 return_valid_counts=True,
407 )
vllm/v1/attention/backends/mla/sparse_utils.py
+3 -1
@@ -307,6 +307,7 @@ def triton_filter_and_convert_dcp_index(
307 dcp_rank: int,
308 cp_kv_cache_interleave_size: int = 1,
309 BLOCK_SIZE: int = 64,
310 + BLOCK_STRIDE_ROWS: int | None = None,
311 NUM_TOPK_TOKENS: int = 2048,
312 BLOCK_N: int = 128,
313 return_valid_counts: bool = False,
@@ -343,6 +344,7 @@ def triton_filter_and_convert_dcp_index(
344 block_table,
345 token_indices,
346 BLOCK_SIZE=BLOCK_SIZE,
347 + BLOCK_STRIDE_ROWS=BLOCK_STRIDE_ROWS,
348 NUM_TOPK_TOKENS=NUM_TOPK_TOKENS,
349 BLOCK_N=BLOCK_N,
350 return_valid_counts=return_valid_counts,
@@ -390,7 +392,7 @@ def triton_filter_and_convert_dcp_index(
392 None,
393 max_num_blocks_per_req,
394 BLOCK_SIZE,
393 - BLOCK_SIZE, # dense caches on the DCP path
395 + BLOCK_STRIDE_ROWS if BLOCK_STRIDE_ROWS is not None else BLOCK_SIZE,
396 block_n,
397 False, # HAS_PREFILL
398 count_valid,