[Minimax-M3] Using tok_sparse_select from MSA instead of triton kernels (#47502)
Signed-off-by: Yongye Zhu <zyy1102000@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Yongye Zhu committed
Jul 7, 2026 at 21:01 UTC
902158949803be74ad6941ee4bd4b08741297fa0
9 files changed
+233
-72
cmake/external_projects/fmha_sm100.cmake
+1
-1
@@ -17,7 +17,7 @@ else()
17
FetchContent_Declare(
18
fmha_sm100
19
GIT_REPOSITORY https://github.com/vllm-project/MSA.git
20
- GIT_TAG fee783153f3efe57e3e933c5cb7e267a7cebcfb5
20
+ GIT_TAG 2e63ec37a0fc29bc20f39cd1a52e0f5affc33a73
21
GIT_PROGRESS TRUE
22
CONFIGURE_COMMAND ""
23
BUILD_COMMAND ""
tests/kernels/attention/test_minimax_m3.py
+4
-4
@@ -150,7 +150,7 @@ def _reference_index_topk(
150
num_blocks = (seq_len + BLOCK_SIZE - 1) // BLOCK_SIZE
151
pages = block_table[req_id, :num_blocks]
152
k = index_kv_cache[pages].reshape(num_blocks * BLOCK_SIZE, -1)
153
- score = torch.einsum("qhd,kd->hqk", q.float(), k.float()) * sm_scale
153
+ score = sm_scale * torch.einsum("qhd,kd->hqk", q.float(), k.float())
154
155
q_pos = prefix_len + torch.arange(q_len, device=idx_q.device)
156
k_pos = torch.arange(k.shape[0], device=idx_q.device)
@@ -621,11 +621,12 @@ def test_decode_index_topk_fp8(num_idx_heads: int):
621
init_blocks=init_blocks,
622
local_blocks=local_blocks,
623
num_kv_heads=num_idx_heads,
624
- sm_scale=head_dim**-0.5,
624
decode_query_len=decode_query_len,
625
+ max_decode_query_len=decode_query_len,
626
)
627
# Reference from the DEQUANTIZED fp8 values (the kernel computes the fp8 QK
628
- # in fp32, so it must match an fp32 matmul of the same e4m3 values).
628
+ # in fp32 with no scaling, so it must match an unscaled fp32 matmul of the
629
+ # same e4m3 values).
630
expected = _reference_index_topk(
631
idx_q.float(),
632
index_kv_cache.float(),
@@ -636,7 +637,6 @@ def test_decode_index_topk_fp8(num_idx_heads: int):
637
topk,
638
init_blocks,
639
local_blocks,
639
- head_dim**-0.5,
640
)
641
_assert_topk_indices_equal_unordered(actual, expected)
642
vllm/models/minimax_m3/common/indexer.py
+7
@@ -256,6 +256,13 @@ class MiniMaxM3IndexerMetadataBuilder(
256
dtype=torch.int32,
257
device=device,
258
)
259
+ # Stable per-token causal page-count buffer for decode cudagraph replays
260
+ # (consumed by the MSA top-k path's sparse_topk_select num_valid_pages).
261
+ self.num_valid_pages_buffer = torch.empty(
262
+ vllm_config.scheduler_config.max_num_batched_tokens,
263
+ dtype=torch.int32,
264
+ device=device,
265
+ )
266
267
268
class MiniMaxM3IndexerTritonMetadataBuilder(MiniMaxM3IndexerMetadataBuilder):
vllm/models/minimax_m3/common/ops/__init__.py
+2
@@ -4,6 +4,7 @@
4
5
from .index_topk import (
6
minimax_m3_index_decode,
7
+ minimax_m3_index_decode_score,
8
minimax_m3_index_score,
9
minimax_m3_index_topk,
10
)
@@ -11,6 +12,7 @@ from .sparse_attn import minimax_m3_sparse_attn, minimax_m3_sparse_attn_decode
12
13
__all__ = [
14
"minimax_m3_index_decode",
15
+ "minimax_m3_index_decode_score",
16
"minimax_m3_index_score",
17
"minimax_m3_index_topk",
18
"minimax_m3_sparse_attn",
vllm/models/minimax_m3/common/ops/index_topk.py
+67
-15
@@ -757,25 +757,26 @@ def minimax_m3_index_topk(
757
758
759
@torch.no_grad()
760
-def minimax_m3_index_decode(
760
+def minimax_m3_index_decode_score(
761
idx_q: torch.Tensor, # [total_q, num_idx_heads, head_dim]
762
index_kv_cache: torch.Tensor, # [num_blocks, 128, head_dim]
763
block_table: torch.Tensor, # [num_reqs, max_blocks]
764
seq_lens: torch.Tensor, # [num_reqs] int32
765
max_seq_len: int,
766
- topk: int,
766
init_blocks: int,
767
local_blocks: int,
768
num_kv_heads: int,
769
decode_query_len: int,
770
max_decode_query_len: int,
772
- out: torch.Tensor | None = None,
771
+ score_out: torch.Tensor | None = None,
772
) -> torch.Tensor:
774
- """Decode index block-score + top-k, both split-K (cudagraph-safe).
773
+ """Decode index block-score (split-K, cudagraph-safe); no top-k.
774
776
- Returns topk_idx [num_kv_heads, total_q, topk] (0-indexed block ids, -1 pad).
777
- When ``out`` ([num_kv_heads, >=total_q, topk]) is given, writes into
778
- ``out[:, :total_q, :]`` (stable address for cudagraph) instead of allocating.
775
+ Returns score [num_kv_heads, total_q, >=max_block] (fp32; init/local blocks
776
+ forced to 1e30/1e29). When ``score_out`` is given the scores are written into
777
+ it (read/written by strides, so a transposed view of a unified buffer is
778
+ accepted) instead of a fresh tensor -- used to share a unified score buffer
779
+ with the prefill side and run a single top-k over both.
780
"""
781
total_q, num_idx_heads, head_dim = idx_q.shape
782
assert num_idx_heads == num_kv_heads, (
@@ -783,7 +784,6 @@ def minimax_m3_index_decode(
784
)
785
assert decode_query_len <= max_decode_query_len
786
assert total_q == seq_lens.shape[0] * decode_query_len
786
- batch = total_q
787
max_block = triton.cdiv(max_seq_len, SPARSE_BLOCK_SIZE)
788
use_pdl = current_platform.is_arch_support_pdl()
789
# `launch_pdl` is a Triton runtime kwarg only some backends accept (CUDA
@@ -800,13 +800,16 @@ def minimax_m3_index_decode(
800
if num_idx_heads > 1 and max_decode_query_len > 1:
801
score_kwargs.update({"num_warps": 4, "num_stages": 2})
802
803
- # Keep score strides 16-divisible to avoid Triton recompiles.
804
- score_block_stride = round_up(max_block, 16)
805
- score = torch.empty(
806
- (num_idx_heads, total_q, score_block_stride),
807
- dtype=torch.float32,
808
- device=idx_q.device,
809
- )
803
+ if score_out is not None:
804
+ score = score_out
805
+ else:
806
+ # Keep score strides 16-divisible to avoid Triton recompiles.
807
+ score_block_stride = round_up(max_block, 16)
808
+ score = torch.empty(
809
+ (num_idx_heads, total_q, score_block_stride),
810
+ dtype=torch.float32,
811
+ device=idx_q.device,
812
+ )
813
# split-K over seq blocks; chunk count depends only on shape constants so
814
# the grid is fixed within a cuda graph.
815
TARGET_GRID = 512
@@ -848,6 +851,55 @@ def minimax_m3_index_decode(
851
USE_PDL=use_pdl,
852
**score_kwargs,
853
)
854
+ return score
855
+
856
+
857
+@torch.no_grad()
858
+def minimax_m3_index_decode(
859
+ idx_q: torch.Tensor, # [total_q, num_idx_heads, head_dim]
860
+ index_kv_cache: torch.Tensor, # [num_blocks, 128, head_dim]
861
+ block_table: torch.Tensor, # [num_reqs, max_blocks]
862
+ seq_lens: torch.Tensor, # [num_reqs] int32
863
+ max_seq_len: int,
864
+ topk: int,
865
+ init_blocks: int,
866
+ local_blocks: int,
867
+ num_kv_heads: int,
868
+ decode_query_len: int,
869
+ max_decode_query_len: int,
870
+ out: torch.Tensor | None = None,
871
+ score_out: torch.Tensor | None = None,
872
+) -> torch.Tensor:
873
+ """Decode index block-score + top-k, both split-K (cudagraph-safe).
874
+
875
+ Returns topk_idx [num_kv_heads, total_q, topk] (0-indexed block ids, -1 pad).
876
+ When ``out`` ([num_kv_heads, >=total_q, topk]) is given, writes into
877
+ ``out[:, :total_q, :]`` (stable address for cudagraph) instead of allocating.
878
+ When ``score_out`` ([num_kv_heads, total_q, >=max_block]) is given, the block
879
+ scores are written into it (read back by the top-k) instead of a fresh
880
+ tensor -- used to share a unified score buffer with the prefill side. Reads
881
+ via strides, so a transposed view of a block-major buffer is accepted.
882
+ """
883
+ total_q, num_idx_heads, _ = idx_q.shape
884
+ batch = total_q
885
+ max_block = triton.cdiv(max_seq_len, SPARSE_BLOCK_SIZE)
886
+ use_pdl = current_platform.is_arch_support_pdl()
887
+ pdl_kwargs: dict[str, bool | int] = {}
888
+ if use_pdl:
889
+ pdl_kwargs.update({"launch_pdl": True})
890
+ score = minimax_m3_index_decode_score(
891
+ idx_q,
892
+ index_kv_cache,
893
+ block_table,
894
+ seq_lens,
895
+ max_seq_len,
896
+ init_blocks,
897
+ local_blocks,
898
+ num_kv_heads,
899
+ decode_query_len,
900
+ max_decode_query_len,
901
+ score_out=score_out,
902
+ )
903
904
if out is not None:
905
topk_idx = out[:, :total_q, :]
vllm/models/minimax_m3/common/ops/sparse_attn.py
+7
-11
@@ -43,7 +43,6 @@ _FP8_DTYPES = (
43
{
44
"BLOCK_SIZE_D": lambda args: triton.next_power_of_2(args["head_dim"]),
45
"BLOCK_SIZE_H": lambda args: triton.next_power_of_2(args["gqa_group_size"]),
46
- "BLOCK_SIZE_T": lambda args: triton.next_power_of_2(args["max_topk"]),
46
"BLOCK_SIZE_QH": lambda args: args["BLOCK_SIZE_Q"]
47
* triton.next_power_of_2(args["gqa_group_size"]),
48
}
@@ -84,7 +83,6 @@ def _gqa_sparse_fwd_kernel(
83
BLOCK_SIZE_K: tl.constexpr, # == SPARSE_BLOCK_SIZE (128)
84
BLOCK_SIZE_D: tl.constexpr,
85
BLOCK_SIZE_H: tl.constexpr,
87
- BLOCK_SIZE_T: tl.constexpr,
86
BLOCK_SIZE_QH: tl.constexpr,
87
USE_FP8: tl.constexpr, # fp8 KV cache: dequantize K/V to q.dtype on load
88
):
@@ -109,9 +107,10 @@ def _gqa_sparse_fwd_kernel(
107
for j in range(real_q_loop):
108
pid_q_j = pid_q * num_q_loop + j
109
t_ptr_j = t_ptr + (q_block_start + pid_q_j) * stride_tn + pid_kh * stride_th
112
- off_t = tl.arange(0, BLOCK_SIZE_T)
113
- topk_idx = tl.load(t_ptr_j + off_t * stride_tk, mask=off_t < max_topk, other=-1)
114
- real_topk = tl.sum((topk_idx >= 0).to(tl.int32), axis=0)
110
+ # Valid block count from seq position (no sentinel): block_size_q == 1.
111
+ q_abs = prefix_len + pid_q_j * BLOCK_SIZE_Q
112
+ valid_blocks = (q_abs + BLOCK_SIZE_K) // BLOCK_SIZE_K
113
+ real_topk = tl.minimum(max_topk, valid_blocks)
114
q_ptrs = tl.make_block_ptr(
115
base=q_ptr + q_start * stride_qn + pid_h * stride_qh,
116
shape=(q_len, gqa_group_size, head_dim),
@@ -202,7 +201,6 @@ def _gqa_sparse_fwd_kernel(
201
16, triton.next_power_of_2(args["gqa_group_size"])
202
),
203
"BLOCK_SIZE_D": lambda args: triton.next_power_of_2(args["head_dim"]),
205
- "BLOCK_SIZE_T": lambda args: triton.next_power_of_2(args["max_topk"]),
204
}
205
)
206
@triton.jit(do_not_specialize=["decode_query_len"])
@@ -243,7 +241,6 @@ def _gqa_sparse_decode_kernel(
241
NUM_TOPK_CHUNKS: tl.constexpr,
242
BLOCK_SIZE_H: tl.constexpr,
243
BLOCK_SIZE_D: tl.constexpr,
246
- BLOCK_SIZE_T: tl.constexpr,
244
USE_FP8: tl.constexpr, # fp8 KV cache: dequantize K/V to q.dtype on load
245
USE_PDL: tl.constexpr,
246
):
@@ -268,11 +265,10 @@ def _gqa_sparse_decode_kernel(
265
# attention range instead of letting padded rows produce negative lengths.
266
kv_len = tl.maximum(query_pos + 1, 0)
267
271
- # number of valid (non-padded) selected blocks for this query token
272
- off_t = tl.arange(0, BLOCK_SIZE_T)
268
+ # Valid block count from seq_len (no sentinel): min(topk, cdiv(kv_len, blk)).
269
idx_base = t_ptr + pid_kh * stride_th + pid_b * stride_tn
274
- topk_idx = tl.load(idx_base + off_t * stride_tk, mask=off_t < max_topk, other=-1)
275
- real_topk = tl.sum((topk_idx >= 0).to(tl.int32), axis=0)
270
+ num_blocks = (kv_len + BLOCK_SIZE_K - 1) // BLOCK_SIZE_K
271
+ real_topk = tl.minimum(max_topk, num_blocks)
272
chunk_end_topk = tl.minimum(chunk_end_compiletime, real_topk)
273
274
off_n = tl.arange(0, BLOCK_SIZE_K)
vllm/models/minimax_m3/nvidia/indexer_msa.py
+135
-35
@@ -2,16 +2,21 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
"""MSA (SM100/Blackwell) indexer impl for MiniMax M3.
4
5
-Prefill scores with ``fmha_sm100``'s score-only (``OnlyScore``) path then selects
6
-top-k blocks with the Triton ``minimax_m3_index_topk`` kernel -- fmha is much
7
-faster than Triton for the wide prefill score (benchmarked ~3-5x).
8
-
9
-Decode uses the Triton fused ``minimax_m3_index_decode`` (the same kernel the
10
-Triton indexer impl uses): for q_len==1 it is a purpose-built vector x matrix
11
-score (no wasted tensor-core tiles) with a 256-way split-K and a fused split-K
12
-top-k, which beats fmha's OnlyScore (wasted MMA on a single query, 64-split cap)
13
-by ~1.1-3.7x. It is cudagraph-safe by construction (shape-constant split grids)
14
-and writes the shared ``topk_indices_buffer`` via ``out=``.
5
+Both sides write block scores into one unified token-major buffer
6
+``[total_q, H, max_k_tiles]``, then a single ``fmha_sm100.sparse_topk_select``
7
+selects the top-k blocks for the whole batch (decode ``[:nd]`` + prefill
8
+``[nd:]``) into the shared ``topk_indices_buffer``. It bounds each row by its
9
+causal page count and force-includes the init/local blocks, so the unwritten
10
+tail of the buffer is pre-filled with ``-inf``.
11
+
12
+Prefill scores with ``fmha_sm100``'s score-only (``OnlyScore``) path (much faster
13
+than Triton for the wide prefill score, benchmarked ~3-5x), writing its
14
+``max_score`` straight into the buffer's prefill region (stride-aware, no copy).
15
+
16
+Decode scores with the Triton split-K ``minimax_m3_index_decode_score`` (a
17
+purpose-built vector x matrix score, no wasted tensor-core tiles, 256-way
18
+split-K, cudagraph-safe by shape-constant grids), writing into the decode
19
+region. Its tuning heuristics are kept; only the top-k is shared with prefill.
20
21
``fmha_sm100`` imports are function-local so this module is import-safe on
22
AMD / non-SM100.
@@ -22,6 +27,7 @@ from typing import ClassVar
27
28
import torch
29
30
+from vllm.config import VllmConfig
31
from vllm.forward_context import get_forward_context
32
from vllm.models.minimax_m3.common.indexer import (
33
MiniMaxM3IndexerBackend,
@@ -31,8 +37,7 @@ from vllm.models.minimax_m3.common.indexer import (
37
MiniMaxM3IndexerMetadataBuilder,
38
)
39
from vllm.models.minimax_m3.common.ops.index_topk import (
34
- minimax_m3_index_decode,
35
- minimax_m3_index_topk,
40
+ minimax_m3_index_decode_score,
41
)
42
from vllm.v1.attention.backend import (
43
AttentionBackend,
@@ -40,10 +45,22 @@ from vllm.v1.attention.backend import (
45
CommonAttentionMetadata,
46
)
47
from vllm.v1.attention.backends.utils import split_decodes_and_prefills
48
+from vllm.v1.kv_cache_interface import AttentionSpec
49
50
# Page size == sparse block size == index-K block; fmha tile id == M3 block id.
51
PAGE_SIZE = 128
52
53
+# Fill for unwritten score tiles: -inf so they never win the top-k (score kernels
54
+# only write causally-valid blocks).
55
+_SCORE_SENTINEL = float("-inf")
56
+
57
+# Tile (KV-block) dim of the unified score buffer, hardcoded as a cudagraph
58
+# capture-time constant so the decode score kernel's buffer shape is frozen
59
+# across replays. 8192 tiles == 1M tokens of context; -inf padding +
60
+# num_valid_pages bound each row to its causal range, so shorter replays reuse
61
+# the same buffer safely.
62
+MAX_K_TILES = 8192
63
+
64
65
class MiniMaxM3IndexerMSABackend(MiniMaxM3IndexerBackend):
66
"""Indexer side-cache backend selecting the MSA builder."""
@@ -71,6 +88,25 @@ class MiniMaxM3IndexerMSAMetadata(MiniMaxM3IndexerMetadata):
88
(the base ``prefill`` field is unused on this path)."""
89
90
prefill_msa: MiniMaxM3IndexerMSAPrefillMetadata | None = None
91
+ # Per-forward view (``[:num_tokens]``) of the builder's persistent unified
92
+ # score buffer ``[total_q, H, MAX_K_TILES]``, shared by decode and prefill
93
+ # and reused across all layers. Pre-filled with the -inf sentinel in
94
+ # ``build()``; each layer overwrites its valid tiles before its own top-k.
95
+ unified_scores: torch.Tensor | None = None
96
+ # Tile (KV-block) dim of the unified score buffer (== ``MAX_K_TILES``).
97
+ # Forced as the fmha plan's ``max_k_tiles`` so prefill writes its max_score
98
+ # straight into the shared buffer.
99
+ max_k_tiles: int = 0
100
+ # Batch-wide inputs for the single top-k over the unified buffer (decode +
101
+ # prefill in one call). ``cu_seqlens_q`` is the per-request query-start
102
+ # offsets (== query_start_loc, a stable view for cudagraph); ``prefix_lens``
103
+ # is the per-request context length (== context_lens).
104
+ topk_cu_seqlens_q: torch.Tensor | None = None
105
+ topk_prefix_lens: torch.Tensor | None = None
106
+ topk_max_query_len: int = 0
107
+ # Per-token causal page count cdiv(seq_pos+1, PAGE_SIZE), [total_q] int32:
108
+ # drives sparse_topk_select force_end_blocks + -1 out-of-range clamp.
109
+ topk_num_valid_pages: torch.Tensor | None = None
110
111
112
class MiniMaxM3IndexerMSAMetadataBuilder(MiniMaxM3IndexerMetadataBuilder):
@@ -79,6 +115,28 @@ class MiniMaxM3IndexerMSAMetadataBuilder(MiniMaxM3IndexerMetadataBuilder):
115
116
_cudagraph_support: ClassVar[AttentionCGSupport] = AttentionCGSupport.UNIFORM_BATCH
117
118
+ def __init__(
119
+ self,
120
+ kv_cache_spec: AttentionSpec,
121
+ layer_names: list[str],
122
+ vllm_config: VllmConfig,
123
+ device: torch.device,
124
+ ) -> None:
125
+ super().__init__(kv_cache_spec, layer_names, vllm_config, device)
126
+ # Persistent unified score buffer [T, H, MAX_K_TILES] shared by all
127
+ # indexer layers and reused across forwards. Stable address (required
128
+ # for the captured decode path) + fixed tile dim so the decode score
129
+ # kernel's shape is frozen at capture. Filled with -inf per forward in
130
+ # build(); the valid/padding partition is a per-forward constant, so
131
+ # every layer overwrites the same valid tiles before its own top-k.
132
+ self.unified_scores_buffer = torch.empty(
133
+ vllm_config.scheduler_config.max_num_batched_tokens,
134
+ self.num_index_heads,
135
+ MAX_K_TILES,
136
+ dtype=torch.float32,
137
+ device=device,
138
+ )
139
+
140
def build(
141
self,
142
common_prefix_len: int,
@@ -107,6 +165,19 @@ class MiniMaxM3IndexerMSAMetadataBuilder(MiniMaxM3IndexerMetadataBuilder):
165
common_attn_metadata.compute_num_computed_tokens(), non_blocking=True
166
)
167
168
+ # Per-token causal page count for the top-k, into the stable cg buffer.
169
+ positions = common_attn_metadata.positions
170
+ assert positions is not None
171
+ num_valid_pages = self.num_valid_pages_buffer[:num_tokens]
172
+ num_valid_pages.copy_(positions[:num_tokens] // PAGE_SIZE + 1)
173
+
174
+ # Unified score buffer: a per-forward view of the persistent buffer,
175
+ # reset to the -inf sentinel once here and shared by every layer (the
176
+ # tile dim is the capture-time constant MAX_K_TILES).
177
+ max_k_tiles = MAX_K_TILES
178
+ unified_scores = self.unified_scores_buffer[:num_tokens]
179
+ unified_scores.fill_(_SCORE_SENTINEL)
180
+
181
decode: MiniMaxM3IndexerDecodeMetadata | None = None
182
if num_decodes > 0:
183
qsl_cpu = common_attn_metadata.query_start_loc_cpu
@@ -148,6 +219,11 @@ class MiniMaxM3IndexerMSAMetadataBuilder(MiniMaxM3IndexerMetadataBuilder):
219
causal=True,
220
num_kv_splits=1,
221
)
222
+ # Force the plan's tile dim to the unified buffer's so prefill writes
223
+ # its max_score straight into unified[:, :, nd:] (the stride-aware
224
+ # binding shape-matches the tile dim exactly). max_k_tiles >= the
225
+ # plan's natural value, so the extra tiles are simply never written.
226
+ plan["max_k_tiles"] = max_k_tiles
227
cols = torch.arange(block_table.shape[1], device=block_table.device)
228
valid = cols[None, :] < nvp[lo:hi].to(block_table.device)[:, None]
229
prefill = MiniMaxM3IndexerMSAPrefillMetadata(
@@ -171,6 +247,12 @@ class MiniMaxM3IndexerMSAMetadataBuilder(MiniMaxM3IndexerMetadataBuilder):
247
num_prefill_tokens=num_prefill_tokens,
248
decode=decode,
249
prefill_msa=prefill,
250
+ unified_scores=unified_scores,
251
+ max_k_tiles=max_k_tiles,
252
+ topk_cu_seqlens_q=query_start_loc[: num_reqs + 1],
253
+ topk_prefix_lens=context_lens,
254
+ topk_max_query_len=common_attn_metadata.max_query_len,
255
+ topk_num_valid_pages=num_valid_pages,
256
)
257
258
@@ -195,36 +277,49 @@ class MiniMaxM3IndexerMSAImpl(MiniMaxM3IndexerImpl):
277
-1, self.num_index_heads, self.index_head_dim
278
)
279
kv = self.index_cache.kv_cache
198
- # Both sides write into the single shared persistent topk_indices_buffer:
199
- # decode at [:, :nd], prefill at [:, nd:] (each kernel writes [:, :total_q]).
280
+ # Shared persistent top-k output buffer; the unified top-k below writes
281
+ # the selected block ids into buf[:num_tokens].
282
buf = self.topk_indices_buffer
201
-
202
- decode_topk: torch.Tensor | None = None
283
+ assert buf is not None
284
+
285
+ # Unified token-major score buffer [total_q, H, MAX_K_TILES]: the tile
286
+ # dim is innermost/contiguous, so both fmha writes (native [T,H,K]) and
287
+ # the block-iterating top-k reads hit contiguous tiles. Each side gets a
288
+ # contiguous slice on dim 0: decode [:nd], prefill [nd:]; the kernels
289
+ # read/write by strides. The builder allocates it once (persistent,
290
+ # shared by all layers) and resets it to the sentinel each forward, so
291
+ # the top-k never picks an unwritten tile.
292
+ unified_scores = md.unified_scores
293
+ assert unified_scores is not None
294
+
295
+ # Decode scores -> unified[:nd] (transposed [H, nd, MK] view; the kernel
296
+ # writes by strides). Top-k is deferred to the single unified call below.
297
if md.decode is not None:
298
d = md.decode
205
- decode_topk = minimax_m3_index_decode(
299
+ minimax_m3_index_decode_score(
300
index_q[:nd],
301
kv,
302
d.block_table,
303
d.seq_lens,
304
d.max_seq_len,
211
- self.topk_blocks,
305
self.init_blocks,
306
self.local_blocks,
307
self.num_kv_heads,
308
d.decode_query_len,
309
d.max_decode_query_len,
217
- out=buf,
310
+ score_out=unified_scores[:nd].transpose(0, 1),
311
)
312
220
- prefill_topk: torch.Tensor | None = None
313
if md.prefill_msa is not None:
314
from vllm.third_party.fmha_sm100.api import _fmha_sm100
315
316
p = md.prefill_msa
317
# Index-K cache (num_blocks, 128, D) -> paged MQA (num_blocks,1,128,D).
318
k_pages = kv.view(kv.shape[0], 1, PAGE_SIZE, self.index_head_dim)
227
- _, max_score = _fmha_sm100(
319
+ # fmha writes its max_score natively as [nnz_p, H, max_k_tiles] into
320
+ # the prefill region (stride-aware; plan max_k_tiles forced to
321
+ # md.max_k_tiles so the shape matches exactly -> no copy).
322
+ _fmha_sm100(
323
index_q[nd:],
324
k_pages,
325
k_pages, # V placeholder; not read in OnlyScore
@@ -233,19 +328,24 @@ class MiniMaxM3IndexerMSAImpl(MiniMaxM3IndexerImpl):
328
output_o=False,
329
output_maxscore=True,
330
sm_scale=self.scale,
331
+ max_score=unified_scores[nd:],
332
)
237
- # Triton top-k wants [num_index_heads, num_tokens, max_block]; the
238
- # transpose is a strided view (the kernel reads via strides).
239
- out = buf[:, nd:, :] if buf is not None else None
240
- prefill_topk = minimax_m3_index_topk(
241
- max_score.transpose(1, 2),
242
- p.cu_seqlens_q,
243
- p.prefix_lens,
244
- p.max_query_len,
245
- self.topk_blocks,
246
- self.init_blocks,
247
- self.local_blocks,
248
- out=out,
249
- )
333
251
- return decode_topk, prefill_topk
334
+ # Single top-k over the unified buffer via fmha_sm100 sparse_topk_select
335
+ # (THK, no transpose) into ``buf``. num_valid_pages drives force_end_blocks
336
+ # (always keep each token's local block; fmha OnlyScore won't) + the -1
337
+ # out-of-range clamp.
338
+ from vllm.third_party.fmha_sm100.api import sparse_topk_select
339
+
340
+ sparse_topk_select(
341
+ unified_scores,
342
+ self.topk_blocks,
343
+ num_valid_pages=md.topk_num_valid_pages,
344
+ force_begin_blocks=self.init_blocks,
345
+ force_end_blocks=self.local_blocks,
346
+ output=buf[:num_tokens],
347
+ max_score_layout="THK",
348
+ )
349
+
350
+ # The attend reads ``buf`` directly; this return is vestigial.
351
+ return None, None
vllm/models/minimax_m3/nvidia/model.py
+4
-3
@@ -769,8 +769,9 @@ class MiniMaxM3Model(nn.Module, EagleModelMixin):
769
770
# Reserved top-k indices buffer shared by all sparse-attention indexer
771
# layers (mirrors DeepseekV4); kept at a stable address so the indexer's
772
- # top-k output survives cudagraph capture/replay. Shape matches the
773
- # per-head index top-k output [num_index_heads, total_q, topk].
772
+ # top-k output survives cudagraph capture/replay. Token-major
773
+ # [total_q, num_index_heads, topk] so the indexer writes its native
774
+ # [token, head, topk] top-k; the attend transposes to [H, tokens, topk].
775
sparse_cfg = getattr(config, "sparse_attention_config", None)
776
if sparse_cfg is not None:
777
tp_size = get_tensor_model_parallel_world_size()
@@ -780,8 +781,8 @@ class MiniMaxM3Model(nn.Module, EagleModelMixin):
781
max_num_batched_tokens = vllm_config.scheduler_config.max_num_batched_tokens
782
padded_num_tokens = (max_num_batched_tokens + 3) // 4 * 4
783
self.topk_indices_buffer = torch.empty(
783
- num_index_heads,
784
padded_num_tokens,
785
+ num_index_heads,
786
sparse_cfg["sparse_topk_blocks"],
787
dtype=torch.int32,
788
)
vllm/models/minimax_m3/nvidia/sparse_attention_msa.py
+6
-3
@@ -39,7 +39,8 @@ class MiniMaxM3SparseMSAImpl(MiniMaxM3SparseImpl):
39
40
nd = main_md.num_decode_tokens
41
num_tokens = main_md.num_actual_tokens
42
- # Indexer top-k from the shared buffer: decode [:, :nd], prefill [:, nd:].
42
+ # Indexer top-k from the shared token-major buffer [total_q, H, MK]; the
43
+ # kernels want [H, tokens, MK], so slice tokens on dim 0 then transpose.
44
topk = layer.topk_indices_buffer # type: ignore[attr-defined]
45
assert topk is not None
46
hd = self.head_size
@@ -56,7 +57,7 @@ class MiniMaxM3SparseMSAImpl(MiniMaxM3SparseImpl):
57
minimax_m3_sparse_attn_decode(
58
q[:nd],
59
kv_cache,
59
- topk[:, :nd, :],
60
+ topk[:nd].transpose(0, 1),
61
d.block_table,
62
d.seq_lens,
63
self.num_kv_heads,
@@ -74,7 +75,9 @@ class MiniMaxM3SparseMSAImpl(MiniMaxM3SparseImpl):
75
76
p = main_md.prefill
77
assert p is not None
77
- prefill_topk = topk[:, nd:num_tokens, :]
78
+ # [H, prefill, MK] transposed view; build_k2q_csr consumes the
79
+ # strided view directly (topK stays innermost-contiguous).
80
+ prefill_topk = topk[nd:num_tokens].transpose(0, 1)
81
qp = q[nd:]
82
k_cache = kv_cache[:, 0].transpose(1, 2)
83
v_cache = kv_cache[:, 1].transpose(1, 2)