[Performance][ROCm] Integrate aiter indexer scoring and top-k kernels into MiniMax-M3 sparse attention path (#52664)

Signed-off-by: Yamini Preethi Kamisetty <YaminiPreethi.Kamisetty@amd.com>

ykamiset committed Sep 9, 2026 at 17:40 UTC 83252ea899c6538eaa0c1fb31f28a92c661bbffc
6 files changed +1077 -58
tests/kernels/attention/test_minimax_m3.py
+52
@@ -1694,6 +1694,58 @@ def test_aiter_sparse_pa_rejects_multiple_kv_heads(monkeypatch):
1694 with pytest.raises(ValueError, match="num_kv_heads == 1"):
1695 sparse_attn_mod.minimax_m3_use_aiter_sparse_pa(2)
1696
1697 + # An indexer that emits the attend's page table addresses every head, so
1698 + # the pairing the check guards against does not arise.
1699 + assert sparse_attn_mod.minimax_m3_use_aiter_sparse_pa(
1700 + 2, emits_sparse_block_table=True
1701 + )
1702 + # The AITER path carries its own backend: its builder is what rebases the
1703 + # block table the indexer's top-k resolves through, so selecting the impl
1704 + # without it would leave that field unset.
1705 + backend, impl_cls = sparse_attn_mod.select_main_backend_and_impl_cls(
1706 + topk_blocks=TOPK,
1707 + kv_cache_dtype="fp8_e4m3",
1708 + num_kv_heads=2,
1709 + emits_sparse_block_table=True,
1710 + )
1711 + assert impl_cls.__name__ == "MiniMaxM3SparseAiterPAImpl"
1712 + assert backend.__name__ == "MiniMaxM3SparseAiterPABackend"
1713 + assert backend.get_builder_cls().__name__ == "MiniMaxM3SparseAiterPAMetadataBuilder"
1714 +
1715 +
1716 +def test_aiter_indexer_requires_the_aiter_attend(monkeypatch):
1717 + """The top-k emits the attend's page table, so it needs that attend.
1718 +
1719 + Selected without it, the indexer would reach its top-k with no table to
1720 + resolve through; falling back instead surfaces the fp8 index cache as the
1721 + configuration error it is.
1722 + """
1723 + import vllm.models.minimax_m3.amd.indexer_aiter as indexer_aiter_mod
1724 +
1725 + monkeypatch.setattr(indexer_aiter_mod.current_platform, "is_rocm", lambda: True)
1726 + kwargs = dict(
1727 + topk_blocks=TOPK,
1728 + sparse_block_size=BLOCK_SIZE,
1729 + num_index_heads=1,
1730 + index_head_dim=HEAD_DIM,
1731 + indexer_kv_dtype="fp8_e4m3",
1732 + max_model_len=8192,
1733 + )
1734 +
1735 + monkeypatch.setattr(
1736 + indexer_aiter_mod, "_minimax_m3_aiter_sparse_pa_requested", lambda: False
1737 + )
1738 + reason = indexer_aiter_mod.aiter_indexer_unsupported_reason(**kwargs)
1739 + assert reason is not None and "AITER sparse PA attend" in reason
1740 +
1741 + # With the attend asked for, the gate moves on to the kernel-contract
1742 + # checks rather than stopping here.
1743 + monkeypatch.setattr(
1744 + indexer_aiter_mod, "_minimax_m3_aiter_sparse_pa_requested", lambda: True
1745 + )
1746 + reason = indexer_aiter_mod.aiter_indexer_unsupported_reason(**kwargs)
1747 + assert reason is None or "AITER sparse PA attend" not in reason
1748 +
1749
1750 def test_indexer_cache_squeezes_to_contiguous_3d():
1751 """The indexer side cache is standardized 4D with H=1: under both layouts
vllm/models/minimax_m3/amd/indexer_aiter.py new
+646
@@ -0,0 +1,646 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""AITER (ROCm) indexer impl for MiniMax M3.
4 +
5 +Scores index blocks and selects the top-k with AITER's fp8 MFMA kernels on both
6 +sides of the batch: ``pa_sparse_block_score_decode`` for the uniform-query-length
7 +decode rows and ``pa_sparse_block_score_prefill`` for the ragged prefill rows,
8 +then ``pa_sparse_block_topk`` for each. The two scoring passes share one tile
9 +body in AITER, so they agree block for block, and both encode the forced
10 +init/local blocks as the same sentinel scores the Triton indexer uses.
11 +
12 +The top-k also emits the attend's page table. The winners are already in its
13 +workgroup's LDS, so resolving them through the block table there costs one wave
14 +and replaces the separate Triton pass in ``ops.sparse_pa``; the rows it writes
15 +are one per (token, kv head) with the page ids folded head-minor, which is the
16 +layout ``pa_decode_gluon`` reads after it flattens the cache.
17 +
18 +The score kernels are built on ``v_mfma_f32_16x16x32_fp8_fp8``, so this impl
19 +requires an fp8 (e4m3) index cache and an fp8 index query -- the fused
20 +QK-norm/RoPE kernel emits both directly when the index cache is e4m3. See
21 +``aiter_indexer_unsupported_reason`` for the full set of limits;
22 +``select_aiter_indexer_impl_cls`` refuses to pick this impl unless they all
23 +hold, and the model falls back to the platform-neutral ``MiniMaxM3Indexer``.
24 +"""
25 +
26 +import math
27 +from dataclasses import dataclass
28 +from functools import cache
29 +from typing import ClassVar
30 +
31 +import torch
32 +from torch import nn
33 +
34 +from vllm.config import VllmConfig, get_current_vllm_config
35 +from vllm.config.attention import IndexerKVDType
36 +from vllm.forward_context import get_forward_context
37 +from vllm.logger import init_logger
38 +from vllm.models.minimax_m3.amd.ops.sparse_pa import ASM_PAGE_SIZE
39 +from vllm.models.minimax_m3.common.indexer import (
40 + MiniMaxM3IndexerBackend,
41 + MiniMaxM3IndexerCache,
42 + MiniMaxM3IndexerDecodeMetadata,
43 + MiniMaxM3IndexerImpl,
44 + MiniMaxM3IndexerMetadata,
45 + MiniMaxM3IndexerMetadataBuilder,
46 + MiniMaxM3IndexerPrefillMetadata,
47 +)
48 +
49 +# The single source of truth for whether the AITER attend was asked for; this
50 +# indexer is only usable alongside it.
51 +from vllm.models.minimax_m3.common.sparse_attention import (
52 + _minimax_m3_aiter_sparse_pa_requested,
53 +)
54 +from vllm.platforms import current_platform
55 +from vllm.v1.attention.backend import AttentionBackend, CommonAttentionMetadata
56 +from vllm.v1.attention.backends.utils import split_decodes_and_prefills
57 +from vllm.v1.kv_cache_interface import AttentionSpec
58 +
59 +logger = init_logger(__name__)
60 +
61 +# Compiled AITER MiniMax-M3 score/top-k contract
62 +MSA_TOPK_BLOCKS = 16
63 +MSA_SCORE_TYPE = "max"
64 +MSA_SPARSE_BLOCK_SIZE = 128
65 +MSA_INDEX_HEAD_DIM = 128
66 +# Wave width the top-k is written against: it gives one lane per output slot and
67 +# reads the score row in wave-wide strips.
68 +WAVE_SIZE = 64
69 +# Per-lane register slots the top-k can hold, which is what caps the context: a
70 +# row may span at most SLOTS_MAX * WAVE_SIZE blocks.
71 +SLOTS_MAX = 128
72 +MAX_SUPPORTED_BLOCKS = SLOTS_MAX * WAVE_SIZE
73 +# MFMA columns the score pass has, one per (query token, index head) pair.
74 +MFMA_COLS = 16
75 +# The fp8 MFMA the score kernels are built on exists on these targets only.
76 +SUPPORTED_ARCHS = ("gfx950",)
77 +
78 +
79 +def _pow2_ceil(n: int) -> int:
80 + return 1 << (n - 1).bit_length() if n >= 1 else 1
81 +
82 +
83 +def score_block_width(max_seq_len: int, block_size: int) -> int:
84 + """Block-axis width the top-k requires of the score buffer.
85 +
86 + Lanes read whole wave-wide strips with no tail guard and each holds a
87 + power-of-two count of them, so the axis is padded past the block count.
88 + """
89 + max_blk = math.ceil(max(max_seq_len, 1) / block_size)
90 + return _pow2_ceil(math.ceil(max_blk / WAVE_SIZE)) * WAVE_SIZE
91 +
92 +
93 +def aiter_indexer_max_decode_query_len(vllm_config: VllmConfig) -> int:
94 + """Longest query a decode row can carry, which spec decode is what sets.
95 +
96 + Mirrors ``_init_reorder_batch_threshold(1, supports_spec_as_decode=True)``,
97 + since that is what the builder splits the batch on and therefore what the
98 + decode kernel will actually be handed.
99 + """
100 + spec = vllm_config.speculative_config
101 + if spec is None or spec.num_speculative_tokens is None:
102 + return 1
103 + return 1 + (2 if spec.parallel_drafting else 1) * spec.num_speculative_tokens
104 +
105 +
106 +@cache
107 +def aiter_msa_kernels_unavailable_reason() -> str | None:
108 + """Return why the AITER MSA score/top-k ops cannot be imported, or None.
109 +
110 + They are a recent addition, so an AITER that predates them imports fine
111 + while these three names do not exist, and the failure would otherwise
112 + surface as an ImportError from the middle of a forward. ``compile_ops``
113 + binds them lazily, so this costs the module import only -- the kernel build
114 + itself still happens on the first call.
115 + """
116 + try:
117 + from aiter.ops.msa_attention import ( # noqa: F401
118 + pa_sparse_block_score_decode,
119 + pa_sparse_block_score_prefill,
120 + pa_sparse_block_topk,
121 + )
122 + except ImportError as exc:
123 + return f"AITER cannot supply the MSA score/top-k kernels ({exc})"
124 + return None
125 +
126 +
127 +def aiter_indexer_unsupported_reason(
128 + *,
129 + topk_blocks: int,
130 + sparse_block_size: int,
131 + num_index_heads: int,
132 + index_head_dim: int,
133 + indexer_kv_dtype: IndexerKVDType,
134 + max_model_len: int,
135 + max_decode_query_len: int = 1,
136 + score_type: str = "max",
137 +) -> str | None:
138 + """Return why this config cannot use the AITER indexer, or None if it can.
139 +
140 + Checks platform (ROCm/gfx950), the AITER sparse PA attend, index-cache
141 + dtype, the compiled score/top-k contract, MFMA column limits, max context
142 + in blocks, and whether AITER exposes the MSA kernels.
143 + ``select_aiter_indexer_impl_cls`` logs the string and falls back when it is
144 + not None.
145 + """
146 + if not current_platform.is_rocm():
147 + return (
148 + "needs ROCm for the AITER fp8 MFMA score/top-k, "
149 + f"got platform={current_platform.device_type!r}"
150 + )
151 + if not _minimax_m3_aiter_sparse_pa_requested():
152 + # The top-k emits the attend's page table in page-16 numbering, which
153 + # only addresses the interleaved cache the AITER attend reads. Paired
154 + # with any other attend there is nowhere valid to write it, so this
155 + # indexer is not usable on its own.
156 + return (
157 + "needs the AITER sparse PA attend, whose page table its top-k "
158 + "emits (rocm_aiter_ops + shuffle KV cache layout)"
159 + )
160 + if indexer_kv_dtype not in ("fp8", "fp8_e4m3"):
161 + # The score kernels are fp8 MFMA; there is no bf16 instantiation.
162 + return (
163 + f"needs an fp8 e4m3 index cache, got indexer_kv_dtype={indexer_kv_dtype!r}"
164 + )
165 + from vllm.platforms.rocm import on_gfx950
166 +
167 + if not on_gfx950():
168 + return f"needs {' or '.join(SUPPORTED_ARCHS)} for the fp8 MFMA"
169 + if score_type != MSA_SCORE_TYPE:
170 + return f"needs score_type={MSA_SCORE_TYPE!r}, got score_type={score_type!r}"
171 + if topk_blocks != MSA_TOPK_BLOCKS:
172 + return f"needs topk_blocks={MSA_TOPK_BLOCKS}, got topk_blocks={topk_blocks}"
173 + if sparse_block_size != MSA_SPARSE_BLOCK_SIZE:
174 + return (
175 + f"needs sparse_block_size={MSA_SPARSE_BLOCK_SIZE}, "
176 + f"got sparse_block_size={sparse_block_size}"
177 + )
178 + if index_head_dim != MSA_INDEX_HEAD_DIM:
179 + return (
180 + f"needs index_head_dim={MSA_INDEX_HEAD_DIM}, "
181 + f"got index_head_dim={index_head_dim}"
182 + )
183 + if num_index_heads > MFMA_COLS:
184 + return f"num_index_heads={num_index_heads} exceeds the {MFMA_COLS} MFMA columns"
185 + # A decode row's whole query shares one MFMA tile, one column per (token,
186 + # head) pair, so spec decode trades columns against the head count.
187 + if num_index_heads * max_decode_query_len > MFMA_COLS:
188 + return (
189 + f"num_index_heads={num_index_heads} x max_decode_query_len="
190 + f"{max_decode_query_len} exceeds the {MFMA_COLS} MFMA columns"
191 + )
192 + max_blocks = math.ceil(max_model_len / sparse_block_size)
193 + if max_blocks > MAX_SUPPORTED_BLOCKS:
194 + return (
195 + f"max_model_len={max_model_len} needs {max_blocks} blocks per row, "
196 + f"more than the top-k's {MAX_SUPPORTED_BLOCKS}"
197 + )
198 + return aiter_msa_kernels_unavailable_reason()
199 +
200 +
201 +class MiniMaxM3IndexerAiterBackend(MiniMaxM3IndexerBackend):
202 + """Indexer side-cache backend selecting the AITER builder."""
203 +
204 + @staticmethod
205 + def get_builder_cls() -> type["MiniMaxM3IndexerAiterMetadataBuilder"]:
206 + return MiniMaxM3IndexerAiterMetadataBuilder
207 +
208 +
209 +@dataclass
210 +class MiniMaxM3IndexerAiterMetadata(MiniMaxM3IndexerMetadata):
211 + """Adds the per-row shape the ragged top-k needs.
212 +
213 + The uniform decode rows are recovered inside the kernel from ``seq_lens`` and
214 + the shared query length, which also clamps cudagraph padding rows to nothing.
215 + Prefill rows have no such shape, so it is materialized here once per forward
216 + and shared by every layer -- which is also where the emitted page table's
217 + tail block comes from, since a block count alone does not say how many tokens
218 + the last block holds.
219 + """
220 +
221 + # [num_prefill_tokens] int32, cdiv(position + 1, sparse_block_size) per prefill row.
222 + prefill_num_valid_pages: torch.Tensor | None = None
223 + # [num_prefill_tokens] int32, the request each prefill row belongs to.
224 + prefill_row_req_id: torch.Tensor | None = None
225 + # [num_prefill_tokens] int32, causal token count (position + 1) per row.
226 + prefill_kv_lens: torch.Tensor | None = None
227 +
228 +
229 +class MiniMaxM3IndexerAiterMetadataBuilder(MiniMaxM3IndexerMetadataBuilder):
230 + """The Triton indexer's metadata plus the prefill rows' causal shape."""
231 +
232 + def __init__(
233 + self,
234 + kv_cache_spec: AttentionSpec,
235 + layer_names: list[str],
236 + vllm_config: VllmConfig,
237 + device: torch.device,
238 + ) -> None:
239 + super().__init__(kv_cache_spec, layer_names, vllm_config, device)
240 + hf_config = vllm_config.model_config.hf_config
241 + text_config = getattr(hf_config, "text_config", hf_config)
242 + self.sparse_block_size = int(
243 + text_config.sparse_attention_config["sparse_block_size"]
244 + )
245 + max_tokens = vllm_config.scheduler_config.max_num_batched_tokens
246 + # Companions to the base's num_valid_pages_buffer, for the two vectors
247 + # only the emitted table needs.
248 + self.row_req_id_buffer = torch.empty(
249 + max_tokens, dtype=torch.int32, device=device
250 + )
251 + self.kv_lens_buffer = torch.empty(max_tokens, dtype=torch.int32, device=device)
252 +
253 + def build(
254 + self,
255 + common_prefix_len: int,
256 + common_attn_metadata: CommonAttentionMetadata,
257 + fast_build: bool = False,
258 + ) -> MiniMaxM3IndexerAiterMetadata:
259 + num_reqs = common_attn_metadata.num_reqs
260 + num_tokens = common_attn_metadata.num_actual_tokens
261 + query_start_loc = common_attn_metadata.query_start_loc
262 + seq_lens = common_attn_metadata.seq_lens
263 + block_table = common_attn_metadata.block_table_tensor
264 +
265 + num_decodes, num_prefills, num_decode_tokens, num_prefill_tokens = (
266 + split_decodes_and_prefills(
267 + common_attn_metadata,
268 + decode_threshold=self.reorder_batch_threshold,
269 + require_uniform=True,
270 + )
271 + )
272 + assert num_decodes + num_prefills == num_reqs
273 + assert num_decode_tokens + num_prefill_tokens == num_tokens
274 +
275 + # Decode-first batch: context lengths into the stable cudagraph buffer.
276 + context_lens = self.context_len_buffer[:num_reqs]
277 + context_lens.copy_(
278 + common_attn_metadata.compute_num_computed_tokens(), non_blocking=True
279 + )
280 +
281 + prefill_metadata: MiniMaxM3IndexerPrefillMetadata | None = None
282 + prefill_num_valid_pages: torch.Tensor | None = None
283 + prefill_row_req_id: torch.Tensor | None = None
284 + prefill_kv_lens: torch.Tensor | None = None
285 + if num_prefills > 0:
286 + cu_seqlens_q = (query_start_loc[num_decodes:] - num_decode_tokens).to(
287 + torch.int32
288 + )
289 + prefill_metadata = MiniMaxM3IndexerPrefillMetadata(
290 + cu_seqlens_q=cu_seqlens_q,
291 + seq_lens=seq_lens[num_decodes:],
292 + context_lens=context_lens[num_decodes:],
293 + block_table=block_table[num_decodes:],
294 + max_query_len=common_attn_metadata.max_query_len,
295 + max_seq_len=common_attn_metadata.max_seq_len,
296 + )
297 + # A prefill row sees its own position, so its causal length and block
298 + # count both follow from that alone; the request it belongs to comes
299 + # from the query offsets. Prefill batches are never captured, so the
300 + # stable buffers are only being reused here, not required.
301 + positions = common_attn_metadata.positions
302 + assert positions is not None
303 + row_positions = positions[num_decode_tokens:num_tokens]
304 + prefill_num_valid_pages = self.num_valid_pages_buffer[
305 + num_decode_tokens:num_tokens
306 + ]
307 + prefill_num_valid_pages.copy_(
308 + row_positions // self.sparse_block_size + 1, non_blocking=True
309 + )
310 + prefill_kv_lens = self.kv_lens_buffer[num_decode_tokens:num_tokens]
311 + prefill_kv_lens.copy_(row_positions + 1, non_blocking=True)
312 + prefill_row_req_id = self.row_req_id_buffer[num_decode_tokens:num_tokens]
313 + prefill_row_req_id.copy_(
314 + torch.searchsorted(
315 + cu_seqlens_q[1:].contiguous(),
316 + torch.arange(
317 + num_prefill_tokens,
318 + dtype=torch.int32,
319 + device=cu_seqlens_q.device,
320 + ),
321 + right=True,
322 + ),
323 + non_blocking=True,
324 + )
325 +
326 + decode_metadata: MiniMaxM3IndexerDecodeMetadata | None = None
327 + if num_decodes > 0:
328 + qsl_cpu = common_attn_metadata.query_start_loc_cpu
329 + query_lens_cpu = qsl_cpu[1 : num_decodes + 1] - qsl_cpu[:num_decodes]
330 + decode_query_len = int(query_lens_cpu[0].item())
331 + assert decode_query_len > 0
332 + assert torch.all(
333 + (query_lens_cpu == decode_query_len) | (query_lens_cpu == 0)
334 + )
335 + assert num_decode_tokens == num_decodes * decode_query_len
336 + decode_metadata = MiniMaxM3IndexerDecodeMetadata(
337 + seq_lens=seq_lens[:num_decodes],
338 + block_table=block_table[:num_decodes],
339 + max_seq_len=common_attn_metadata.max_seq_len,
340 + decode_query_len=decode_query_len,
341 + max_decode_query_len=self.max_decode_query_len,
342 + )
343 +
344 + return MiniMaxM3IndexerAiterMetadata(
345 + seq_lens=seq_lens,
346 + max_seq_len=common_attn_metadata.max_seq_len,
347 + slot_mapping=common_attn_metadata.slot_mapping,
348 + num_actual_tokens=num_tokens,
349 + num_decodes=num_decodes,
350 + num_decode_tokens=num_decode_tokens,
351 + num_prefills=num_prefills,
352 + num_prefill_tokens=num_prefill_tokens,
353 + prefill=prefill_metadata,
354 + decode=decode_metadata,
355 + prefill_num_valid_pages=prefill_num_valid_pages,
356 + prefill_row_req_id=prefill_row_req_id,
357 + prefill_kv_lens=prefill_kv_lens,
358 + )
359 +
360 +
361 +class MiniMaxM3IndexerAiterImpl(MiniMaxM3IndexerImpl):
362 + """AITER fp8 score + top-k for both prefill and decode."""
363 +
364 + indexer_backend_cls: ClassVar[type[AttentionBackend]] = MiniMaxM3IndexerAiterBackend
365 +
366 + def __init__(self, **kwargs) -> None:
367 + super().__init__(**kwargs)
368 + # Both passes are v_mfma_f32_16x16x32_fp8_fp8 with no bf16 instantiation,
369 + # so an index cache of any other dtype would be read as e4m3 bytes.
370 + # The selector will not get here, but nothing else may either.
371 + if self.indexer_kv_dtype not in ("fp8", "fp8_e4m3"):
372 + raise ValueError(
373 + "The AITER indexer requires an fp8 e4m3 index cache, got "
374 + f"indexer_kv_dtype={self.indexer_kv_dtype!r}"
375 + )
376 + # Shared, stable-address page table + per-row context bound the top-k
377 + # emits for the attend. Owned by the model so one allocation serves
378 + # every layer; left None when it reserved none, in which case the
379 + # attend rebuilds the table itself.
380 + self.sparse_bt_buffer: torch.Tensor | None = None
381 + self.sparse_ctx_buffer: torch.Tensor | None = None
382 +
383 + @property
384 + def pages_per_block(self) -> int:
385 + """Physical pages one selected block expands into for the attend."""
386 + return self.block_size // ASM_PAGE_SIZE
387 +
388 + def _table_rows(self, lo: int, hi: int) -> tuple[torch.Tensor, torch.Tensor]:
389 + """The page table and context rows covering score rows ``[lo, hi)``.
390 +
391 + One table row per (token, kv head), head minor, which is the order
392 + ``pa_decode_gluon`` reads once it flattens the cache. Slices of the
393 + shared buffers rather than copies, so the attend sees the writes; a
394 + model that reserved no buffers gets throwaway ones, and the attend
395 + rebuilds the table itself in that case.
396 + """
397 + kv_heads = self.num_kv_heads
398 + if self.sparse_bt_buffer is None or self.sparse_ctx_buffer is None:
399 + rows = (hi - lo) * kv_heads
400 + device = self.index_cache.kv_cache.device
401 + return (
402 + torch.empty(
403 + (rows, self.topk_blocks * self.pages_per_block),
404 + dtype=torch.int32,
405 + device=device,
406 + ),
407 + torch.empty(rows, dtype=torch.int32, device=device),
408 + )
409 + return (
410 + self.sparse_bt_buffer[lo * kv_heads : hi * kv_heads],
411 + self.sparse_ctx_buffer[lo * kv_heads : hi * kv_heads],
412 + )
413 +
414 + def _new_score(self, rows: int, max_seq_len: int) -> torch.Tensor:
415 + """Score buffer for ``rows`` query rows.
416 +
417 + Left uninitialized on purpose: the score pass writes every block up to
418 + the longest row it covers, and the top-k reads only the blocks its own
419 + row can see, so nothing downstream observes the padded tail. Filling it
420 + would cost a write over the whole [H, rows, width] extent, which at long
421 + context is the largest tensor in the indexer.
422 + """
423 + return torch.empty(
424 + (
425 + self.num_index_heads,
426 + rows,
427 + score_block_width(max_seq_len, self.block_size),
428 + ),
429 + dtype=torch.float32,
430 + device=self.index_cache.kv_cache.device,
431 + )
432 +
433 + def forward(
434 + self,
435 + index_query: torch.Tensor,
436 + *,
437 + decode_page16_block_table: torch.Tensor | None = None,
438 + prefill_page16_block_table: torch.Tensor | None = None,
439 + ) -> tuple[torch.Tensor | None, torch.Tensor | None]:
440 + from aiter.ops.msa_attention import (
441 + pa_sparse_block_score_decode,
442 + pa_sparse_block_score_prefill,
443 + pa_sparse_block_topk,
444 + )
445 +
446 + attn_metadata = get_forward_context().attn_metadata
447 + if not isinstance(attn_metadata, dict):
448 + return None, None # profiling run; caches unbound
449 + md = attn_metadata[self.index_cache.prefix]
450 + assert isinstance(md, MiniMaxM3IndexerAiterMetadata)
451 + # The emitted page table addresses the main cache, whose blocks are a
452 + # different group's than the index cache's, so the top-k resolves the
453 + # selection through the attend's block table -- only the score pass reads
454 + # the index cache and takes the indexer's. It has to be the page-16
455 + # rebase of that table, which the attend's own metadata builder does
456 + # once per step; the indexer's metadata cannot reach another group's
457 + # blocks, so the layer reads it off the attend and hands it in.
458 + num_tokens = md.num_actual_tokens
459 + nd = md.num_decode_tokens
460 + iq = index_query[:num_tokens].view(
461 + -1, self.num_index_heads, self.index_head_dim
462 + )
463 + kv = self.index_cache.kv_cache
464 +
465 + # Both sides write into the single shared persistent buffer (decode at
466 + # [:, :nd], prefill at [:, nd:]) and return views into it. The top-k
467 + # takes the head/row strides, so these slices need no copy back.
468 + buf = self.topk_indices_buffer
469 + if buf is None:
470 + buf = torch.empty(
471 + (self.num_index_heads, num_tokens, self.topk_blocks),
472 + dtype=torch.int32,
473 + device=iq.device,
474 + )
475 +
476 + decode_topk: torch.Tensor | None = None
477 + prefill_topk: torch.Tensor | None = None
478 +
479 + if md.num_decodes > 0:
480 + d = md.decode
481 + assert d is not None
482 + assert decode_page16_block_table is not None, (
483 + "the AITER indexer's top-k emits the attend's page table and "
484 + "needs the page-16 rebase of the attend's decode block table"
485 + )
486 + score = self._new_score(nd, d.max_seq_len)
487 + pa_sparse_block_score_decode(
488 + iq[:nd],
489 + kv,
490 + score,
491 + d.block_table,
492 + d.seq_lens,
493 + init_blocks=self.init_blocks,
494 + local_blocks=self.local_blocks,
495 + query_len=d.decode_query_len,
496 + max_seq_len=d.max_seq_len,
497 + )
498 + decode_topk = buf[:, :nd, :]
499 + sparse_bt, sparse_ctx = self._table_rows(0, nd)
500 + # A decode row's causal length is seq_len - query_len + token + 1,
501 + # which the kernel derives itself, so the emitted table covers
502 + # speculative rows without any extra per-row shape.
503 + pa_sparse_block_topk(
504 + score,
505 + decode_topk,
506 + decode_page16_block_table,
507 + d.seq_lens,
508 + sparse_bt,
509 + sparse_ctx,
510 + max_seq_len=d.max_seq_len,
511 + block_size=self.block_size,
512 + query_len=d.decode_query_len,
513 + num_kv_heads=self.num_kv_heads,
514 + pages_per_block=self.pages_per_block,
515 + )
516 +
517 + if md.num_prefills > 0:
518 + p = md.prefill
519 + assert p is not None
520 + assert prefill_page16_block_table is not None, (
521 + "the AITER indexer's top-k emits the attend's page table and "
522 + "needs the page-16 rebase of the attend's prefill block table"
523 + )
524 + assert md.prefill_num_valid_pages is not None
525 + assert md.prefill_row_req_id is not None
526 + assert md.prefill_kv_lens is not None
527 + score = self._new_score(num_tokens - nd, p.max_seq_len)
528 + pa_sparse_block_score_prefill(
529 + iq[nd:],
530 + kv,
531 + score,
532 + p.block_table,
533 + p.cu_seqlens_q,
534 + p.seq_lens,
535 + init_blocks=self.init_blocks,
536 + local_blocks=self.local_blocks,
537 + max_query_len=p.max_query_len,
538 + max_seq_len=p.max_seq_len,
539 + )
540 + prefill_topk = buf[:, nd:num_tokens, :]
541 + sparse_bt, sparse_ctx = self._table_rows(nd, num_tokens)
542 + # Ragged rows carry their request and causal length explicitly: the
543 + # block count alone cannot place the tail block the table ends on.
544 + pa_sparse_block_topk(
545 + score,
546 + prefill_topk,
547 + prefill_page16_block_table,
548 + p.seq_lens,
549 + sparse_bt,
550 + sparse_ctx,
551 + max_seq_len=p.max_seq_len,
552 + block_size=self.block_size,
553 + num_valid_pages=md.prefill_num_valid_pages,
554 + row_req_id=md.prefill_row_req_id,
555 + kv_lens=md.prefill_kv_lens,
556 + num_kv_heads=self.num_kv_heads,
557 + pages_per_block=self.pages_per_block,
558 + )
559 +
560 + return decode_topk, prefill_topk
561 +
562 +
563 +def select_aiter_indexer_impl_cls(
564 + *,
565 + topk_blocks: int,
566 + sparse_block_size: int,
567 + num_index_heads: int,
568 + index_head_dim: int,
569 + indexer_kv_dtype: IndexerKVDType,
570 + score_type: str = "max",
571 +) -> type[MiniMaxM3IndexerAiterImpl] | None:
572 + """The AITER indexer impl if this config can use it, else None.
573 +
574 + ``None`` sends the caller to the platform-neutral ``MiniMaxM3Indexer``,
575 + which on ROCm means the Triton indexer -- and a bf16-only one, so an fp8
576 + index cache that lands here has nowhere to go.
577 + """
578 + reason = aiter_indexer_unsupported_reason(
579 + topk_blocks=topk_blocks,
580 + sparse_block_size=sparse_block_size,
581 + num_index_heads=num_index_heads,
582 + index_head_dim=index_head_dim,
583 + indexer_kv_dtype=indexer_kv_dtype,
584 + max_model_len=get_current_vllm_config().model_config.max_model_len,
585 + max_decode_query_len=aiter_indexer_max_decode_query_len(
586 + get_current_vllm_config()
587 + ),
588 + score_type=score_type,
589 + )
590 + if reason is not None:
591 + logger.info_once("MiniMax M3 indexer: AITER unavailable (%s)", reason)
592 + return None
593 + logger.info_once(
594 + "MiniMax M3 indexer: selected AITER (fp8 MFMA score + top-k) "
595 + "[topk_blocks=%d, indexer_kv_dtype=%s]",
596 + topk_blocks,
597 + indexer_kv_dtype,
598 + )
599 + return MiniMaxM3IndexerAiterImpl
600 +
601 +
602 +class MiniMaxM3AiterIndexer(nn.Module):
603 + """``MiniMaxM3Indexer``'s surface over the AITER impl.
604 +
605 + The platform-neutral wrapper picks its impl through ``common``'s selector
606 + and forwards a Triton-only set of fused-table arguments, neither of which
607 + can reach this impl without editing ``common``. This holds the same three
608 + members the attention layer uses -- ``impl``, ``index_cache``,
609 + ``num_index_heads`` -- and forwards the one argument AITER needs instead.
610 + """
611 +
612 + def __init__(
613 + self,
614 + *,
615 + impl_cls: type[MiniMaxM3IndexerAiterImpl],
616 + sparse_bt_buffer: torch.Tensor | None = None,
617 + sparse_ctx_buffer: torch.Tensor | None = None,
618 + **impl_kwargs,
619 + ) -> None:
620 + super().__init__()
621 + self.impl = impl_cls(**impl_kwargs)
622 + # Assigned rather than passed: the impl's base ``__init__`` lives in
623 + # common and takes no table buffers.
624 + self.impl.sparse_bt_buffer = sparse_bt_buffer
625 + self.impl.sparse_ctx_buffer = sparse_ctx_buffer
626 +
627 + @property
628 + def index_cache(self) -> MiniMaxM3IndexerCache:
629 + return self.impl.index_cache
630 +
631 + @property
632 + def num_index_heads(self) -> int:
633 + return self.impl.num_index_heads
634 +
635 + def forward(
636 + self,
637 + index_query: torch.Tensor,
638 + *,
639 + decode_page16_block_table: torch.Tensor | None = None,
640 + prefill_page16_block_table: torch.Tensor | None = None,
641 + ) -> tuple[torch.Tensor | None, torch.Tensor | None]:
642 + return self.impl(
643 + index_query,
644 + decode_page16_block_table=decode_page16_block_table,
645 + prefill_page16_block_table=prefill_page16_block_table,
646 + )
vllm/models/minimax_m3/amd/model.py
+158 -36
@@ -87,17 +87,24 @@ from vllm.model_executor.models.utils import (
87 maybe_prefix,
88 )
89 from vllm.model_executor.models.vision import run_dp_sharded_mrope_vision_model
90 +from vllm.models.minimax_m3.amd.indexer_aiter import (
91 + MiniMaxM3AiterIndexer,
92 + select_aiter_indexer_impl_cls,
93 +)
94 from vllm.models.minimax_m3.amd.ops import (
95 gemma_fused_add_rmsnorm,
96 gemma_rmsnorm,
97 swiglu_oai_split,
98 )
99 from vllm.models.minimax_m3.amd.ops.sparse_pa import (
100 + ASM_PAGE_SIZE,
101 minimax_m3_alloc_sparse_block_table,
102 minimax_m3_sparse_block_page_stride,
103 )
104 from vllm.models.minimax_m3.amd.sparse_attention_msa import (
105 + MiniMaxM3SparseAiterPADecodeMetadata,
106 MiniMaxM3SparseAiterPAImpl,
107 + MiniMaxM3SparseAiterPAPrefillMetadata,
108 )
109 from vllm.models.minimax_m3.common.indexer import MiniMaxM3Indexer
110 from vllm.models.minimax_m3.common.mm_preprocess import (
@@ -111,7 +118,7 @@ from vllm.models.minimax_m3.common.sparse_attention import (
118 MiniMaxM3SparseMetadata,
119 minimax_m3_rebase_slots_to_page16,
120 minimax_m3_use_aiter_sparse_pa,
114 - select_main_impl_cls,
121 + select_main_backend_and_impl_cls,
122 )
123 from vllm.models.minimax_m3.common.vision_tower import MiniMaxVLVisionModel
124 from vllm.multimodal import MULTIMODAL_REGISTRY
@@ -596,6 +603,7 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
603 prefix: str = "",
604 cache_config: CacheConfig | None = None,
605 topk_indices_buffer: torch.Tensor | None = None,
606 + sparse_table_buffers: tuple[torch.Tensor, torch.Tensor] | None = None,
607 ) -> None:
608 super().__init__()
609 self.hidden_size = config.hidden_size
@@ -684,18 +692,45 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
692 # attributes so FP8 KV reads can honor vLLM's per-layer descale contract.
693 set_default_quant_scales(self, register_buffer=True)
694
695 + # Indexer side-cache dtype, mirroring --kv-cache-dtype for the main cache
696 + # (--attention-config '{"indexer_kv_dtype": ...}'). fp8 e4m3 is what the
697 + # AITER indexer needs; bf16 keeps the Triton indexer.
698 + self.indexer_kv_dtype = vllm_config.attention_config.resolve_indexer_kv_dtype(
699 + "bf16"
700 + )
701 +
702 # Shared top-k buffer: the indexer writes the selected blocks into it and
703 # the attend impl reads them back (no Python value crosses the break).
704 self.topk_indices_buffer = topk_indices_buffer
690 - self.attn_backend = MiniMaxM3SparseBackend
691 - # Indexer and main attention are separate impls. On ROCm the SM100 gate
692 - # is always False, so both pick Triton and the index cache stays bf16.
693 - # impl is AttentionImplBase (broader than AttentionLayerBase's annotation).
694 - self.impl: MiniMaxM3SparseImpl = select_main_impl_cls( # type: ignore[assignment]
705 + # Indexer and main attention are separate impls. The AITER indexer is
706 + # ROCm-only, so it is selected here rather than inside the neutral
707 + # MiniMaxM3Indexer, which knows nothing about it and would pick Triton.
708 + indexer_impl_cls = select_aiter_indexer_impl_cls(
709 + topk_blocks=sparse_cfg["sparse_topk_blocks"],
710 + sparse_block_size=sparse_cfg["sparse_block_size"],
711 + num_index_heads=self.num_idx_heads,
712 + index_head_dim=self.idx_head_dim,
713 + indexer_kv_dtype=self.indexer_kv_dtype,
714 + score_type=sparse_cfg.get("sparse_score_type", "max"),
715 + )
716 + # The attend gate below needs this: an emitted table is what lets it
717 + # serve more than one KV head per rank. Buffers to write the table into
718 + # only arrive when the model already resolved the attend to the AITER
719 + # path the table addresses, so that is not rechecked here.
720 + self.indexer_emits_table = (
721 + indexer_impl_cls is not None and sparse_table_buffers is not None
722 + )
723 + # The backend names the metadata builder, which for the AITER path is
724 + # the one that rebases the block table the indexer's top-k resolves
725 + # through, so both have to come from the same selection.
726 + self.attn_backend, main_impl_cls = select_main_backend_and_impl_cls(
727 topk_blocks=sparse_cfg["sparse_topk_blocks"],
728 kv_cache_dtype=self.kv_cache_dtype,
729 num_kv_heads=self.num_kv_heads,
698 - )(
730 + emits_sparse_block_table=self.indexer_emits_table,
731 + )
732 + # impl is AttentionImplBase (broader than AttentionLayerBase's annotation).
733 + self.impl: MiniMaxM3SparseImpl = main_impl_cls( # type: ignore[assignment]
734 self.num_heads,
735 self.head_dim,
736 self.scaling,
@@ -704,14 +739,14 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
739 topk_blocks=sparse_cfg["sparse_topk_blocks"],
740 sparse_block_size=sparse_cfg["sparse_block_size"],
741 )
707 - self.use_aiter_sparse_pa = minimax_m3_use_aiter_sparse_pa(self.num_kv_heads)
742 + self.use_aiter_sparse_pa = minimax_m3_use_aiter_sparse_pa(
743 + self.num_kv_heads, emits_sparse_block_table=self.indexer_emits_table
744 + )
745 self.kv_cache_k = torch.tensor([])
746 self.kv_cache_v = torch.tensor([])
747 self._aiter_sparse_pa_cache_data_ptr = 0
748 self._aiter_sparse_pa_block_page_stride = 0
712 - # Self-contained nn.Module: owns its side cache, selects its impl in init
713 - # (Triton on ROCm, where the SM100 gate is always False).
714 - self.indexer = MiniMaxM3Indexer(
749 + indexer_kwargs = dict(
750 num_kv_heads=self.num_kv_heads,
751 scale=self.scaling,
752 topk_blocks=sparse_cfg["sparse_topk_blocks"],
@@ -723,8 +758,24 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
758 local_blocks=sparse_cfg.get("sparse_local_block", 0),
759 score_type=sparse_cfg.get("sparse_score_type", "max"),
760 cache_config=cache_config,
761 + indexer_kv_dtype=self.indexer_kv_dtype,
762 topk_indices_buffer=topk_indices_buffer,
763 )
764 + self.sparse_bt_buffer: torch.Tensor | None = None
765 + self.sparse_ctx_buffer: torch.Tensor | None = None
766 + if indexer_impl_cls is None:
767 + # Self-contained nn.Module: owns its side cache, selects its impl.
768 + self.indexer = MiniMaxM3Indexer(**indexer_kwargs)
769 + else:
770 + if self.indexer_emits_table:
771 + assert sparse_table_buffers is not None
772 + self.sparse_bt_buffer, self.sparse_ctx_buffer = sparse_table_buffers
773 + self.indexer = MiniMaxM3AiterIndexer(
774 + impl_cls=indexer_impl_cls,
775 + sparse_bt_buffer=self.sparse_bt_buffer,
776 + sparse_ctx_buffer=self.sparse_ctx_buffer,
777 + **indexer_kwargs,
778 + )
779
780 # Register the main K/V cache so the KV-cache manager allocates it.
781 compilation_config = vllm_config.compilation_config
@@ -952,7 +1003,13 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
1003 )
1004 else:
1005 index_slot_mapping = fwd_slot_mapping[self.indexer.index_cache.prefix]
955 - index_q = qkv.new_empty((num_tokens, self.index_q_size))
1006 + # index_q matches the index-K cache dtype (e4m3 for the AITER fp8
1007 + # score path); the fused kernel emits fp8 directly when this buffer
1008 + # is e4m3.
1009 + index_q = qkv.new_empty(
1010 + (num_tokens, self.index_q_size),
1011 + dtype=self.indexer.index_cache.dtype,
1012 + )
1013 if self.use_aiter_sparse_pa:
1014 ops.fused_minimax_m3_qknorm_rope_kv_insert(
1015 qkv,
@@ -1034,31 +1091,56 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
1091 decode_sparse_table: tuple[torch.Tensor, torch.Tensor] | None = None
1092 if not self.skip_index_topk:
1093 assert index_query is not None
1037 - attn_metadata = get_forward_context().attn_metadata
1038 - if self.use_aiter_sparse_pa and isinstance(attn_metadata, dict):
1039 - main_md = attn_metadata[self.layer_name]
1040 - assert isinstance(main_md, MiniMaxM3SparseMetadata)
1041 - if main_md.num_decodes > 0:
1042 - d = main_md.decode
1043 - assert d is not None
1044 - topk = self.topk_indices_buffer
1045 - assert topk is not None
1046 - decode_sparse_table = minimax_m3_alloc_sparse_block_table(
1047 - topk[:, : main_md.num_decode_tokens, :]
1048 - )
1049 - block_page_stride = self._aiter_sparse_pa_block_page_stride
1050 - assert block_page_stride > 0
1051 - self.indexer(
1052 - index_query,
1053 - attention_block_table=d.block_table,
1054 - sparse_block_table_out=decode_sparse_table[0],
1055 - sparse_context_lens_out=decode_sparse_table[1],
1056 - block_page_stride=block_page_stride,
1057 - )
1094 + # The AITER indexer emits the attend table into its persistent
1095 + # buffers, resolving its selection through the page-16 rebase of the
1096 + # attend's block table. The Triton indexer can instead fuse decode
1097 + # top-k with main's per-forward sparse-table allocation.
1098 + if self.indexer_emits_table:
1099 + attn_metadata = get_forward_context().attn_metadata
1100 + decode_page16 = prefill_page16 = None
1101 + if isinstance(attn_metadata, dict):
1102 + main_md = attn_metadata[self.layer_name]
1103 + assert isinstance(main_md, MiniMaxM3SparseMetadata)
1104 + # Rebased once per step by the AITER attend's builder, which
1105 + # this path selected along with the impl.
1106 + d, p = main_md.decode, main_md.prefill
1107 + if d is not None:
1108 + assert isinstance(d, MiniMaxM3SparseAiterPADecodeMetadata)
1109 + decode_page16 = d.page16_block_table
1110 + if p is not None:
1111 + assert isinstance(p, MiniMaxM3SparseAiterPAPrefillMetadata)
1112 + prefill_page16 = p.page16_block_table
1113 + self.indexer(
1114 + index_query,
1115 + decode_page16_block_table=decode_page16,
1116 + prefill_page16_block_table=prefill_page16,
1117 + )
1118 + else:
1119 + attn_metadata = get_forward_context().attn_metadata
1120 + if self.use_aiter_sparse_pa and isinstance(attn_metadata, dict):
1121 + main_md = attn_metadata[self.layer_name]
1122 + assert isinstance(main_md, MiniMaxM3SparseMetadata)
1123 + if main_md.num_decodes > 0:
1124 + d = main_md.decode
1125 + assert d is not None
1126 + topk = self.topk_indices_buffer
1127 + assert topk is not None
1128 + decode_sparse_table = minimax_m3_alloc_sparse_block_table(
1129 + topk[:, : main_md.num_decode_tokens, :]
1130 + )
1131 + block_page_stride = self._aiter_sparse_pa_block_page_stride
1132 + assert block_page_stride > 0
1133 + self.indexer(
1134 + index_query,
1135 + attention_block_table=d.block_table,
1136 + sparse_block_table_out=decode_sparse_table[0],
1137 + sparse_context_lens_out=decode_sparse_table[1],
1138 + block_page_stride=block_page_stride,
1139 + )
1140 + else:
1141 + self.indexer(index_query)
1142 else:
1143 self.indexer(index_query)
1060 - else:
1061 - self.indexer(index_query)
1144 if self.use_aiter_sparse_pa:
1145 assert isinstance(self.impl, MiniMaxM3SparseAiterPAImpl)
1146 return self.impl.forward(
@@ -1081,6 +1163,7 @@ class MiniMaxM3DecoderLayer(nn.Module):
1163 force_sparse_attn: bool = False,
1164 force_moe: bool = False,
1165 topk_indices_buffer: torch.Tensor | None = None,
1166 + sparse_table_buffers: tuple[torch.Tensor, torch.Tensor] | None = None,
1167 ) -> None:
1168 super().__init__()
1169 self.hidden_size = config.hidden_size
@@ -1101,6 +1184,7 @@ class MiniMaxM3DecoderLayer(nn.Module):
1184 prefix=f"{prefix}.self_attn",
1185 cache_config=cache_config,
1186 topk_indices_buffer=topk_indices_buffer,
1187 + sparse_table_buffers=sparse_table_buffers,
1188 )
1189 else:
1190 self.self_attn = MiniMaxM3Attention(
@@ -1189,15 +1273,52 @@ class MiniMaxM3Model(nn.Module, EagleModelMixin):
1273 # layers (mirrors DeepseekV4); the indexer writes its per-head decode/
1274 # prefill block selection into it, the attend reads it back.
1275 sparse_cfg = getattr(config, "sparse_attention_config", None)
1276 + self.sparse_table_buffers: tuple[torch.Tensor, torch.Tensor] | None = None
1277 if sparse_cfg is not None:
1278 tp_size = get_tensor_model_parallel_world_size()
1279 num_index_heads = max(1, sparse_cfg["sparse_num_index_heads"] // tp_size)
1280 + max_tokens = vllm_config.scheduler_config.max_num_batched_tokens
1281 self.topk_indices_buffer = torch.empty(
1282 num_index_heads,
1197 - vllm_config.scheduler_config.max_num_batched_tokens,
1283 + max_tokens,
1284 sparse_cfg["sparse_topk_blocks"],
1285 dtype=torch.int32,
1286 )
1287 +
1288 + num_kv_heads = max(1, config.num_key_value_heads // tp_size)
1289 + indexer_kv_dtype = vllm_config.attention_config.resolve_indexer_kv_dtype(
1290 + "bf16"
1291 + )
1292 + # Probed with the head count the layer will pass (it derives
1293 + # num_idx_heads from num_kv_heads, not from sparse_num_index_heads),
1294 + # so this cannot land on the other side of the MFMA column limit
1295 + # from the selection the layer makes.
1296 + emits_table = (
1297 + select_aiter_indexer_impl_cls(
1298 + topk_blocks=sparse_cfg["sparse_topk_blocks"],
1299 + sparse_block_size=sparse_cfg["sparse_block_size"],
1300 + num_index_heads=num_kv_heads,
1301 + index_head_dim=sparse_cfg["sparse_index_dim"],
1302 + indexer_kv_dtype=indexer_kv_dtype,
1303 + score_type=sparse_cfg.get("sparse_score_type", "max"),
1304 + )
1305 + is not None
1306 + )
1307 + if emits_table and minimax_m3_use_aiter_sparse_pa(
1308 + num_kv_heads, emits_sparse_block_table=True
1309 + ):
1310 + # One row per (token, kv head): the table addresses the attend's
1311 + # cache, not the index cache.
1312 + rows = max_tokens * num_kv_heads
1313 + self.sparse_table_buffers = (
1314 + torch.empty(
1315 + rows,
1316 + sparse_cfg["sparse_topk_blocks"]
1317 + * (sparse_cfg["sparse_block_size"] // ASM_PAGE_SIZE),
1318 + dtype=torch.int32,
1319 + ),
1320 + torch.empty(rows, dtype=torch.int32),
1321 + )
1322 else:
1323 self.topk_indices_buffer = None
1324
@@ -1209,6 +1330,7 @@ class MiniMaxM3Model(nn.Module, EagleModelMixin):
1330 cache_config=cache_config,
1331 quant_config=quant_config,
1332 topk_indices_buffer=self.topk_indices_buffer,
1333 + sparse_table_buffers=self.sparse_table_buffers,
1334 ),
1335 prefix=f"{prefix}.layers",
1336 )
vllm/models/minimax_m3/amd/ops/sparse_pa.py
+45 -8
@@ -312,7 +312,9 @@ def _insert_index_cache_kernel(
312 )
313 mask = (slot >= 0) & (offs_d < HEAD_DIM)
314 value = tl.load(src, mask=offs_d < HEAD_DIM, other=0.0)
315 - tl.store(dst, value, mask=mask)
315 + # The side cache carries its own dtype (bf16, or e4m3 for the fp8 indexer),
316 + # so convert rather than leaning on the store's implicit cast.
317 + tl.store(dst, value.to(dst.dtype.element_ty), mask=mask)
318
319
320 @torch.no_grad()
@@ -368,6 +370,32 @@ def minimax_m3_sparse_block_page_stride(
370 return PAGES_PER_SPARSE_BLOCK * (2 if _sides_are_packed(k_cache, v_cache) else 1)
371
372
373 +# K/V sides sharing one block, which is how many sides' pages a block spans.
374 +# The attend publishes two head slots and the indexer's side cache one, and
375 +# specs that mix HNC shapes narrow the resolved layout to a block-compact one
376 +# (LHBNC is not), so this path always lands on an interleaved block.
377 +PAGE16_SIDES_PER_BLOCK = 2
378 +
379 +
380 +def minimax_m3_rebase_block_table_to_page16(
381 + block_table: torch.Tensor,
382 + out: torch.Tensor | None = None,
383 +) -> torch.Tensor:
384 + """Rebase a logical page table onto AITER's page-16 page numbering.
385 +
386 + The page ids AITER's top-k emits for the attend are
387 + ``block_table[blk] * pages_per_block + j``, and ``pages_per_block`` is fixed
388 + at AITER build time to one side's pages. An interleaved block spans both
389 + sides, so the only way to reach its pages through that kernel is to hand it
390 + a table already scaled to the wider stride. The caller does this once per
391 + step, since every sparse layer resolves its selection through the same
392 + table.
393 + """
394 + if out is None:
395 + out = torch.empty_like(block_table)
396 + return torch.mul(block_table, PAGE16_SIDES_PER_BLOCK, out=out)
397 +
398 +
399 def _gluon_scale_arg(
400 scale: torch.Tensor | None,
401 *,
@@ -461,14 +489,23 @@ def minimax_m3_sparse_attn_prefill_aiter(
489 output: torch.Tensor,
490 k_scale: torch.Tensor | None = None,
491 v_scale: torch.Tensor | None = None,
492 + sparse_bt: torch.Tensor | None = None,
493 + sparse_ctx: torch.Tensor | None = None,
494 ) -> None:
465 - sparse_bt, sparse_ctx = minimax_m3_build_sparse_block_table_prefill(
466 - topk_idx,
467 - block_table,
468 - query_req_id,
469 - query_abs_pos,
470 - minimax_m3_sparse_block_page_stride(k_cache, v_cache),
471 - )
495 + if (sparse_bt is None) != (sparse_ctx is None):
496 + raise ValueError(
497 + "MiniMax-M3 prepared sparse block table and context lengths "
498 + "must be provided together"
499 + )
500 + if sparse_bt is None:
501 + sparse_bt, sparse_ctx = minimax_m3_build_sparse_block_table_prefill(
502 + topk_idx,
503 + block_table,
504 + query_req_id,
505 + query_abs_pos,
506 + minimax_m3_sparse_block_page_stride(k_cache, v_cache),
507 + )
508 + assert sparse_ctx is not None
509 _run_gluon_decode(
510 q,
511 k_cache,
vllm/models/minimax_m3/amd/sparse_attention_msa.py
+152 -5
@@ -2,16 +2,118 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """MSA AITER block-sparse attend for MiniMax M3."""
4
5 +from dataclasses import dataclass
6 +
7 import torch
8
9 +from vllm.config import VllmConfig
10 from vllm.forward_context import get_forward_context
11 from vllm.models.minimax_m3.common.sparse_attention import (
12 + MiniMaxM3SparseBackend,
13 + MiniMaxM3SparseDecodeMetadata,
14 MiniMaxM3SparseImpl,
15 MiniMaxM3SparseMetadata,
16 + MiniMaxM3SparseMetadataBuilder,
17 + MiniMaxM3SparsePrefillMetadata,
18 )
19 +from vllm.utils.math_utils import cdiv
20 from vllm.v1.attention.backend import (
21 AttentionLayer,
22 )
23 +from vllm.v1.attention.backends.utils import CommonAttentionMetadata
24 +from vllm.v1.kv_cache_interface import AttentionSpec
25 +
26 +
27 +class MiniMaxM3SparseAiterPABackend(MiniMaxM3SparseBackend):
28 + """MiniMax M3 backend carrying the AITER page-16 block-table rebase."""
29 +
30 + @staticmethod
31 + def get_builder_cls() -> type["MiniMaxM3SparseAiterPAMetadataBuilder"]:
32 + return MiniMaxM3SparseAiterPAMetadataBuilder
33 +
34 +
35 +@dataclass
36 +class MiniMaxM3SparseAiterPAPrefillMetadata(MiniMaxM3SparsePrefillMetadata):
37 + # ``block_table`` rebased onto AITER's page-16 numbering; see
38 + # ``MiniMaxM3SparseAiterPAMetadataBuilder.build``.
39 + page16_block_table: torch.Tensor | None = None
40 +
41 +
42 +@dataclass
43 +class MiniMaxM3SparseAiterPADecodeMetadata(MiniMaxM3SparseDecodeMetadata):
44 + page16_block_table: torch.Tensor | None = None
45 +
46 +
47 +class MiniMaxM3SparseAiterPAMetadataBuilder(MiniMaxM3SparseMetadataBuilder):
48 + """Adds the page-16 rebase of the block table the indexer's top-k needs.
49 +
50 + The AITER indexer's top-k emits the attend's page table, expanding a
51 + selected block into a compile-time number of pages -- one side's worth,
52 + while an interleaved block holds both -- so it has to resolve the selection
53 + through a table pre-scaled to the wider stride. Every sparse layer selects
54 + through the same table, so it is rebased here once per step rather than
55 + once per layer, alongside the slot mapping the base rebases for the writer.
56 + """
57 +
58 + def __init__(
59 + self,
60 + kv_cache_spec: AttentionSpec,
61 + layer_names: list[str],
62 + vllm_config: VllmConfig,
63 + device: torch.device,
64 + ) -> None:
65 + super().__init__(kv_cache_spec, layer_names, vllm_config, device)
66 + # Stable address for the same reason as the base's own buffers: a
67 + # captured decode replay writes through the address it captured.
68 + self.page16_block_table_buffer = torch.empty(
69 + (
70 + vllm_config.scheduler_config.max_num_seqs,
71 + cdiv(vllm_config.model_config.max_model_len, kv_cache_spec.block_size),
72 + ),
73 + dtype=torch.int32,
74 + device=device,
75 + )
76 +
77 + def build(
78 + self,
79 + common_prefix_len: int,
80 + common_attn_metadata: CommonAttentionMetadata,
81 + fast_build: bool = False,
82 + ) -> MiniMaxM3SparseMetadata:
83 + from vllm.models.minimax_m3.amd.ops.sparse_pa import (
84 + minimax_m3_rebase_block_table_to_page16,
85 + )
86 +
87 + metadata = super().build(common_prefix_len, common_attn_metadata, fast_build)
88 + block_table = common_attn_metadata.block_table_tensor
89 + num_reqs, cols = block_table.shape
90 + assert (
91 + num_reqs <= self.page16_block_table_buffer.shape[0]
92 + and cols <= self.page16_block_table_buffer.shape[1]
93 + ), (
94 + f"block table {(num_reqs, cols)} exceeds the page-16 rebase buffer "
95 + f"{tuple(self.page16_block_table_buffer.shape)}"
96 + )
97 + page16 = minimax_m3_rebase_block_table_to_page16(
98 + block_table,
99 + out=self.page16_block_table_buffer[:num_reqs, :cols],
100 + )
101 +
102 + # Decode-first batch, so each side takes the rows its own block table
103 + # was sliced from. Reconstructed from the base's fields rather than
104 + # listed out, so a field added there does not silently drop here.
105 + nd = metadata.num_decodes
106 + if metadata.decode is not None:
107 + metadata.decode = MiniMaxM3SparseAiterPADecodeMetadata(
108 + **vars(metadata.decode),
109 + page16_block_table=page16[:nd],
110 + )
111 + if metadata.prefill is not None:
112 + metadata.prefill = MiniMaxM3SparseAiterPAPrefillMetadata(
113 + **vars(metadata.prefill),
114 + page16_block_table=page16[nd:],
115 + )
116 + return metadata
117
118
119 class MiniMaxM3SparseAiterPAImpl(MiniMaxM3SparseImpl):
@@ -28,8 +130,11 @@ class MiniMaxM3SparseAiterPAImpl(MiniMaxM3SparseImpl):
130 decode_sparse_table: tuple[torch.Tensor, torch.Tensor] | None = None,
131 ) -> torch.Tensor:
132 from vllm.models.minimax_m3.amd.ops.sparse_pa import (
133 + PAGE16_SIDES_PER_BLOCK,
134 + PAGES_PER_SPARSE_BLOCK,
135 minimax_m3_sparse_attn_decode_aiter,
136 minimax_m3_sparse_attn_prefill_aiter,
137 + minimax_m3_sparse_block_page_stride,
138 )
139
140 attn_metadata = get_forward_context().attn_metadata
@@ -42,10 +147,16 @@ class MiniMaxM3SparseAiterPAImpl(MiniMaxM3SparseImpl):
147 num_tokens = main_md.num_actual_tokens
148 topk = layer.topk_indices_buffer # type: ignore[attr-defined]
149 assert topk is not None
45 - if self.num_kv_heads != 1:
150 + # Set only when the layer's indexer emitted the page table alongside its
151 + # selection; otherwise these stay None and the table is built below.
152 + sparse_bt_buf = getattr(layer, "sparse_bt_buffer", None)
153 + sparse_ctx_buf = getattr(layer, "sparse_ctx_buffer", None)
154 + kvh = self.num_kv_heads
155 + if kvh != 1 and sparse_bt_buf is None:
156 raise NotImplementedError(
47 - "MiniMax-M3 AITER sparse PA currently requires per-rank "
48 - f"num_kv_heads == 1, got {self.num_kv_heads}"
157 + "MiniMax-M3 AITER sparse PA needs the page table the indexer's "
158 + f"top-k emits to serve per-rank num_kv_heads == {kvh}; the "
159 + "Triton builders it falls back to address one head's cache."
160 )
161
162 hd = self.head_size
@@ -55,9 +166,31 @@ class MiniMaxM3SparseAiterPAImpl(MiniMaxM3SparseImpl):
166 k_scale = getattr(layer, "_k_scale", None) if self.use_fp8_kv else None
167 v_scale = getattr(layer, "_v_scale", None) if self.use_fp8_kv else None
168
169 + if sparse_bt_buf is not None:
170 + # An emitted table was scaled by the metadata builder, which reads
171 + # the packing off the layout rather than off these tensors. The two
172 + # only disagree if the resolved layout gives each side its own
173 + # plane, and a silent disagreement reads unrelated pages.
174 + stride = minimax_m3_sparse_block_page_stride(k_cache, v_cache)
175 + expected = PAGES_PER_SPARSE_BLOCK * PAGE16_SIDES_PER_BLOCK
176 + if stride != expected:
177 + raise RuntimeError(
178 + "MiniMax-M3 AITER sparse PA: the indexer emitted a page "
179 + f"table for a block spanning {expected} pages, but this "
180 + f"KV cache lays a block out over {stride}. The page-16 "
181 + "rebase assumes both K/V sides share a block."
182 + )
183 +
184 if main_md.num_decodes > 0:
185 d = main_md.decode
186 assert d is not None
187 + prepared_decode_table = decode_sparse_table
188 + if sparse_bt_buf is not None:
189 + assert sparse_ctx_buf is not None
190 + prepared_decode_table = (
191 + sparse_bt_buf[: nd * kvh],
192 + sparse_ctx_buf[: nd * kvh],
193 + )
194 minimax_m3_sparse_attn_decode_aiter(
195 q[:nd],
196 k_cache,
@@ -72,10 +205,14 @@ class MiniMaxM3SparseAiterPAImpl(MiniMaxM3SparseImpl):
205 v_scale=v_scale,
206 decode_query_len=d.decode_query_len,
207 sparse_block_table=(
75 - decode_sparse_table[0] if decode_sparse_table is not None else None
208 + prepared_decode_table[0]
209 + if prepared_decode_table is not None
210 + else None
211 ),
212 sparse_context_lens=(
78 - decode_sparse_table[1] if decode_sparse_table is not None else None
213 + prepared_decode_table[1]
214 + if prepared_decode_table is not None
215 + else None
216 ),
217 )
218
@@ -96,5 +233,15 @@ class MiniMaxM3SparseAiterPAImpl(MiniMaxM3SparseImpl):
233 out[nd:],
234 k_scale=k_scale,
235 v_scale=v_scale,
236 + sparse_bt=(
237 + None
238 + if sparse_bt_buf is None
239 + else sparse_bt_buf[nd * kvh : num_tokens * kvh]
240 + ),
241 + sparse_ctx=(
242 + None
243 + if sparse_ctx_buf is None
244 + else sparse_ctx_buf[nd * kvh : num_tokens * kvh]
245 + ),
246 )
247 return output
vllm/models/minimax_m3/common/sparse_attention.py
+24 -9
@@ -109,13 +109,22 @@ def minimax_m3_query_token_positions(
109 return query_req_id, query_abs_pos
110
111
112 -def minimax_m3_use_aiter_sparse_pa(num_kv_heads: int) -> bool:
113 - """Whether to use the ROCm AITER page-16 sparse PA prototype."""
112 +def minimax_m3_use_aiter_sparse_pa(
113 + num_kv_heads: int, *, emits_sparse_block_table: bool = False
114 +) -> bool:
115 + """Whether to use the ROCm AITER page-16 sparse PA prototype.
116 +
117 + More than one KV head per rank is only served when the layer's indexer
118 + emits the attend's page table itself, since the Triton builders this path
119 + otherwise falls back to address one head's cache. Whether an indexer does
120 + that is a ROCm-side fact, so its caller passes it in.
121 + """
122 requested = _minimax_m3_aiter_sparse_pa_requested()
115 - if requested and num_kv_heads != 1:
123 + if requested and num_kv_heads != 1 and not emits_sparse_block_table:
124 raise ValueError(
125 "MiniMax M3 AITER sparse paged attention requires "
118 - f"num_kv_heads == 1 per tensor-parallel rank, got {num_kv_heads}."
126 + f"num_kv_heads == 1 per tensor-parallel rank, got {num_kv_heads}, "
127 + "unless the indexer emits the attend's page table."
128 )
129 return requested
130
@@ -252,9 +261,9 @@ class MiniMaxM3SparseMetadataBuilder(AttentionMetadataBuilder[MiniMaxM3SparseMet
261 # Every sparse layer shares one slot mapping, so the AITER page-16
262 # rebase is done here once per step instead of once per layer. Stable
263 # buffer for the same reason as the context lengths above.
255 - self.use_aiter_sparse_pa = minimax_m3_use_aiter_sparse_pa(
256 - kv_cache_spec.num_kv_heads
257 - )
264 + # The request itself, not the gate: the buffer is the same whatever the
265 + # head count, and a builder cannot see which indexer its layers picked.
266 + self.use_aiter_sparse_pa = _minimax_m3_aiter_sparse_pa_requested()
267 self.page16_slot_mapping_buffer: torch.Tensor | None = None
268 if self.use_aiter_sparse_pa:
269 self.page16_slot_mapping_buffer = torch.empty(
@@ -514,6 +523,7 @@ def select_main_backend_and_impl_cls(
523 topk_blocks: int,
524 kv_cache_dtype: str,
525 num_kv_heads: int,
526 + emits_sparse_block_table: bool = False,
527 ) -> tuple[type[MiniMaxM3SparseBackend], type[MiniMaxM3SparseImpl]]:
528 """Pick the main attention backend and implementation.
529
@@ -523,7 +533,9 @@ def select_main_backend_and_impl_cls(
533 back to Triton. The MSA modules are imported lazily to avoid import errors
534 on unsupported platforms.
535 """
526 - use_aiter_sparse_pa = minimax_m3_use_aiter_sparse_pa(num_kv_heads)
536 + use_aiter_sparse_pa = minimax_m3_use_aiter_sparse_pa(
537 + num_kv_heads, emits_sparse_block_table=emits_sparse_block_table
538 + )
539 use_msa = (
540 current_platform.is_cuda()
541 and current_platform.is_device_capability_family(100)
@@ -541,10 +553,11 @@ def select_main_backend_and_impl_cls(
553 )
554 if use_aiter_sparse_pa:
555 from vllm.models.minimax_m3.amd.sparse_attention_msa import (
556 + MiniMaxM3SparseAiterPABackend,
557 MiniMaxM3SparseAiterPAImpl,
558 )
559
547 - return MiniMaxM3SparseBackend, MiniMaxM3SparseAiterPAImpl
560 + return MiniMaxM3SparseAiterPABackend, MiniMaxM3SparseAiterPAImpl
561 if use_msa:
562 from vllm.models.minimax_m3.nvidia.sparse_attention_msa import (
563 MiniMaxM3SparseMSABackend,
@@ -560,10 +573,12 @@ def select_main_impl_cls(
573 topk_blocks: int,
574 kv_cache_dtype: str,
575 num_kv_heads: int,
576 + emits_sparse_block_table: bool = False,
577 ) -> type[MiniMaxM3SparseImpl]:
578 """Backward-compatible implementation-only selector."""
579 return select_main_backend_and_impl_cls(
580 topk_blocks=topk_blocks,
581 kv_cache_dtype=kv_cache_dtype,
582 num_kv_heads=num_kv_heads,
583 + emits_sparse_block_table=emits_sparse_block_table,
584 )[1]