[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>
This commit is contained in:
ykamiset
2026-09-09 15:40:33 -07:00
committed by GitHub
parent 6983a0883d
commit 83252ea899
6 changed files with 1077 additions and 58 deletions
@@ -1694,6 +1694,58 @@ def test_aiter_sparse_pa_rejects_multiple_kv_heads(monkeypatch):
with pytest.raises(ValueError, match="num_kv_heads == 1"):
sparse_attn_mod.minimax_m3_use_aiter_sparse_pa(2)
# An indexer that emits the attend's page table addresses every head, so
# the pairing the check guards against does not arise.
assert sparse_attn_mod.minimax_m3_use_aiter_sparse_pa(
2, emits_sparse_block_table=True
)
# The AITER path carries its own backend: its builder is what rebases the
# block table the indexer's top-k resolves through, so selecting the impl
# without it would leave that field unset.
backend, impl_cls = sparse_attn_mod.select_main_backend_and_impl_cls(
topk_blocks=TOPK,
kv_cache_dtype="fp8_e4m3",
num_kv_heads=2,
emits_sparse_block_table=True,
)
assert impl_cls.__name__ == "MiniMaxM3SparseAiterPAImpl"
assert backend.__name__ == "MiniMaxM3SparseAiterPABackend"
assert backend.get_builder_cls().__name__ == "MiniMaxM3SparseAiterPAMetadataBuilder"
def test_aiter_indexer_requires_the_aiter_attend(monkeypatch):
"""The top-k emits the attend's page table, so it needs that attend.
Selected without it, the indexer would reach its top-k with no table to
resolve through; falling back instead surfaces the fp8 index cache as the
configuration error it is.
"""
import vllm.models.minimax_m3.amd.indexer_aiter as indexer_aiter_mod
monkeypatch.setattr(indexer_aiter_mod.current_platform, "is_rocm", lambda: True)
kwargs = dict(
topk_blocks=TOPK,
sparse_block_size=BLOCK_SIZE,
num_index_heads=1,
index_head_dim=HEAD_DIM,
indexer_kv_dtype="fp8_e4m3",
max_model_len=8192,
)
monkeypatch.setattr(
indexer_aiter_mod, "_minimax_m3_aiter_sparse_pa_requested", lambda: False
)
reason = indexer_aiter_mod.aiter_indexer_unsupported_reason(**kwargs)
assert reason is not None and "AITER sparse PA attend" in reason
# With the attend asked for, the gate moves on to the kernel-contract
# checks rather than stopping here.
monkeypatch.setattr(
indexer_aiter_mod, "_minimax_m3_aiter_sparse_pa_requested", lambda: True
)
reason = indexer_aiter_mod.aiter_indexer_unsupported_reason(**kwargs)
assert reason is None or "AITER sparse PA attend" not in reason
def test_indexer_cache_squeezes_to_contiguous_3d():
"""The indexer side cache is standardized 4D with H=1: under both layouts
+646
View File
@@ -0,0 +1,646 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""AITER (ROCm) indexer impl for MiniMax M3.
Scores index blocks and selects the top-k with AITER's fp8 MFMA kernels on both
sides of the batch: ``pa_sparse_block_score_decode`` for the uniform-query-length
decode rows and ``pa_sparse_block_score_prefill`` for the ragged prefill rows,
then ``pa_sparse_block_topk`` for each. The two scoring passes share one tile
body in AITER, so they agree block for block, and both encode the forced
init/local blocks as the same sentinel scores the Triton indexer uses.
The top-k also emits the attend's page table. The winners are already in its
workgroup's LDS, so resolving them through the block table there costs one wave
and replaces the separate Triton pass in ``ops.sparse_pa``; the rows it writes
are one per (token, kv head) with the page ids folded head-minor, which is the
layout ``pa_decode_gluon`` reads after it flattens the cache.
The score kernels are built on ``v_mfma_f32_16x16x32_fp8_fp8``, so this impl
requires an fp8 (e4m3) index cache and an fp8 index query -- the fused
QK-norm/RoPE kernel emits both directly when the index cache is e4m3. See
``aiter_indexer_unsupported_reason`` for the full set of limits;
``select_aiter_indexer_impl_cls`` refuses to pick this impl unless they all
hold, and the model falls back to the platform-neutral ``MiniMaxM3Indexer``.
"""
import math
from dataclasses import dataclass
from functools import cache
from typing import ClassVar
import torch
from torch import nn
from vllm.config import VllmConfig, get_current_vllm_config
from vllm.config.attention import IndexerKVDType
from vllm.forward_context import get_forward_context
from vllm.logger import init_logger
from vllm.models.minimax_m3.amd.ops.sparse_pa import ASM_PAGE_SIZE
from vllm.models.minimax_m3.common.indexer import (
MiniMaxM3IndexerBackend,
MiniMaxM3IndexerCache,
MiniMaxM3IndexerDecodeMetadata,
MiniMaxM3IndexerImpl,
MiniMaxM3IndexerMetadata,
MiniMaxM3IndexerMetadataBuilder,
MiniMaxM3IndexerPrefillMetadata,
)
# The single source of truth for whether the AITER attend was asked for; this
# indexer is only usable alongside it.
from vllm.models.minimax_m3.common.sparse_attention import (
_minimax_m3_aiter_sparse_pa_requested,
)
from vllm.platforms import current_platform
from vllm.v1.attention.backend import AttentionBackend, CommonAttentionMetadata
from vllm.v1.attention.backends.utils import split_decodes_and_prefills
from vllm.v1.kv_cache_interface import AttentionSpec
logger = init_logger(__name__)
# Compiled AITER MiniMax-M3 score/top-k contract
MSA_TOPK_BLOCKS = 16
MSA_SCORE_TYPE = "max"
MSA_SPARSE_BLOCK_SIZE = 128
MSA_INDEX_HEAD_DIM = 128
# Wave width the top-k is written against: it gives one lane per output slot and
# reads the score row in wave-wide strips.
WAVE_SIZE = 64
# Per-lane register slots the top-k can hold, which is what caps the context: a
# row may span at most SLOTS_MAX * WAVE_SIZE blocks.
SLOTS_MAX = 128
MAX_SUPPORTED_BLOCKS = SLOTS_MAX * WAVE_SIZE
# MFMA columns the score pass has, one per (query token, index head) pair.
MFMA_COLS = 16
# The fp8 MFMA the score kernels are built on exists on these targets only.
SUPPORTED_ARCHS = ("gfx950",)
def _pow2_ceil(n: int) -> int:
return 1 << (n - 1).bit_length() if n >= 1 else 1
def score_block_width(max_seq_len: int, block_size: int) -> int:
"""Block-axis width the top-k requires of the score buffer.
Lanes read whole wave-wide strips with no tail guard and each holds a
power-of-two count of them, so the axis is padded past the block count.
"""
max_blk = math.ceil(max(max_seq_len, 1) / block_size)
return _pow2_ceil(math.ceil(max_blk / WAVE_SIZE)) * WAVE_SIZE
def aiter_indexer_max_decode_query_len(vllm_config: VllmConfig) -> int:
"""Longest query a decode row can carry, which spec decode is what sets.
Mirrors ``_init_reorder_batch_threshold(1, supports_spec_as_decode=True)``,
since that is what the builder splits the batch on and therefore what the
decode kernel will actually be handed.
"""
spec = vllm_config.speculative_config
if spec is None or spec.num_speculative_tokens is None:
return 1
return 1 + (2 if spec.parallel_drafting else 1) * spec.num_speculative_tokens
@cache
def aiter_msa_kernels_unavailable_reason() -> str | None:
"""Return why the AITER MSA score/top-k ops cannot be imported, or None.
They are a recent addition, so an AITER that predates them imports fine
while these three names do not exist, and the failure would otherwise
surface as an ImportError from the middle of a forward. ``compile_ops``
binds them lazily, so this costs the module import only -- the kernel build
itself still happens on the first call.
"""
try:
from aiter.ops.msa_attention import ( # noqa: F401
pa_sparse_block_score_decode,
pa_sparse_block_score_prefill,
pa_sparse_block_topk,
)
except ImportError as exc:
return f"AITER cannot supply the MSA score/top-k kernels ({exc})"
return None
def aiter_indexer_unsupported_reason(
*,
topk_blocks: int,
sparse_block_size: int,
num_index_heads: int,
index_head_dim: int,
indexer_kv_dtype: IndexerKVDType,
max_model_len: int,
max_decode_query_len: int = 1,
score_type: str = "max",
) -> str | None:
"""Return why this config cannot use the AITER indexer, or None if it can.
Checks platform (ROCm/gfx950), the AITER sparse PA attend, index-cache
dtype, the compiled score/top-k contract, MFMA column limits, max context
in blocks, and whether AITER exposes the MSA kernels.
``select_aiter_indexer_impl_cls`` logs the string and falls back when it is
not None.
"""
if not current_platform.is_rocm():
return (
"needs ROCm for the AITER fp8 MFMA score/top-k, "
f"got platform={current_platform.device_type!r}"
)
if not _minimax_m3_aiter_sparse_pa_requested():
# The top-k emits the attend's page table in page-16 numbering, which
# only addresses the interleaved cache the AITER attend reads. Paired
# with any other attend there is nowhere valid to write it, so this
# indexer is not usable on its own.
return (
"needs the AITER sparse PA attend, whose page table its top-k "
"emits (rocm_aiter_ops + shuffle KV cache layout)"
)
if indexer_kv_dtype not in ("fp8", "fp8_e4m3"):
# The score kernels are fp8 MFMA; there is no bf16 instantiation.
return (
f"needs an fp8 e4m3 index cache, got indexer_kv_dtype={indexer_kv_dtype!r}"
)
from vllm.platforms.rocm import on_gfx950
if not on_gfx950():
return f"needs {' or '.join(SUPPORTED_ARCHS)} for the fp8 MFMA"
if score_type != MSA_SCORE_TYPE:
return f"needs score_type={MSA_SCORE_TYPE!r}, got score_type={score_type!r}"
if topk_blocks != MSA_TOPK_BLOCKS:
return f"needs topk_blocks={MSA_TOPK_BLOCKS}, got topk_blocks={topk_blocks}"
if sparse_block_size != MSA_SPARSE_BLOCK_SIZE:
return (
f"needs sparse_block_size={MSA_SPARSE_BLOCK_SIZE}, "
f"got sparse_block_size={sparse_block_size}"
)
if index_head_dim != MSA_INDEX_HEAD_DIM:
return (
f"needs index_head_dim={MSA_INDEX_HEAD_DIM}, "
f"got index_head_dim={index_head_dim}"
)
if num_index_heads > MFMA_COLS:
return f"num_index_heads={num_index_heads} exceeds the {MFMA_COLS} MFMA columns"
# A decode row's whole query shares one MFMA tile, one column per (token,
# head) pair, so spec decode trades columns against the head count.
if num_index_heads * max_decode_query_len > MFMA_COLS:
return (
f"num_index_heads={num_index_heads} x max_decode_query_len="
f"{max_decode_query_len} exceeds the {MFMA_COLS} MFMA columns"
)
max_blocks = math.ceil(max_model_len / sparse_block_size)
if max_blocks > MAX_SUPPORTED_BLOCKS:
return (
f"max_model_len={max_model_len} needs {max_blocks} blocks per row, "
f"more than the top-k's {MAX_SUPPORTED_BLOCKS}"
)
return aiter_msa_kernels_unavailable_reason()
class MiniMaxM3IndexerAiterBackend(MiniMaxM3IndexerBackend):
"""Indexer side-cache backend selecting the AITER builder."""
@staticmethod
def get_builder_cls() -> type["MiniMaxM3IndexerAiterMetadataBuilder"]:
return MiniMaxM3IndexerAiterMetadataBuilder
@dataclass
class MiniMaxM3IndexerAiterMetadata(MiniMaxM3IndexerMetadata):
"""Adds the per-row shape the ragged top-k needs.
The uniform decode rows are recovered inside the kernel from ``seq_lens`` and
the shared query length, which also clamps cudagraph padding rows to nothing.
Prefill rows have no such shape, so it is materialized here once per forward
and shared by every layer -- which is also where the emitted page table's
tail block comes from, since a block count alone does not say how many tokens
the last block holds.
"""
# [num_prefill_tokens] int32, cdiv(position + 1, sparse_block_size) per prefill row.
prefill_num_valid_pages: torch.Tensor | None = None
# [num_prefill_tokens] int32, the request each prefill row belongs to.
prefill_row_req_id: torch.Tensor | None = None
# [num_prefill_tokens] int32, causal token count (position + 1) per row.
prefill_kv_lens: torch.Tensor | None = None
class MiniMaxM3IndexerAiterMetadataBuilder(MiniMaxM3IndexerMetadataBuilder):
"""The Triton indexer's metadata plus the prefill rows' causal shape."""
def __init__(
self,
kv_cache_spec: AttentionSpec,
layer_names: list[str],
vllm_config: VllmConfig,
device: torch.device,
) -> None:
super().__init__(kv_cache_spec, layer_names, vllm_config, device)
hf_config = vllm_config.model_config.hf_config
text_config = getattr(hf_config, "text_config", hf_config)
self.sparse_block_size = int(
text_config.sparse_attention_config["sparse_block_size"]
)
max_tokens = vllm_config.scheduler_config.max_num_batched_tokens
# Companions to the base's num_valid_pages_buffer, for the two vectors
# only the emitted table needs.
self.row_req_id_buffer = torch.empty(
max_tokens, dtype=torch.int32, device=device
)
self.kv_lens_buffer = torch.empty(max_tokens, dtype=torch.int32, device=device)
def build(
self,
common_prefix_len: int,
common_attn_metadata: CommonAttentionMetadata,
fast_build: bool = False,
) -> MiniMaxM3IndexerAiterMetadata:
num_reqs = common_attn_metadata.num_reqs
num_tokens = common_attn_metadata.num_actual_tokens
query_start_loc = common_attn_metadata.query_start_loc
seq_lens = common_attn_metadata.seq_lens
block_table = common_attn_metadata.block_table_tensor
num_decodes, num_prefills, num_decode_tokens, num_prefill_tokens = (
split_decodes_and_prefills(
common_attn_metadata,
decode_threshold=self.reorder_batch_threshold,
require_uniform=True,
)
)
assert num_decodes + num_prefills == num_reqs
assert num_decode_tokens + num_prefill_tokens == num_tokens
# Decode-first batch: context lengths into the stable cudagraph buffer.
context_lens = self.context_len_buffer[:num_reqs]
context_lens.copy_(
common_attn_metadata.compute_num_computed_tokens(), non_blocking=True
)
prefill_metadata: MiniMaxM3IndexerPrefillMetadata | None = None
prefill_num_valid_pages: torch.Tensor | None = None
prefill_row_req_id: torch.Tensor | None = None
prefill_kv_lens: torch.Tensor | None = None
if num_prefills > 0:
cu_seqlens_q = (query_start_loc[num_decodes:] - num_decode_tokens).to(
torch.int32
)
prefill_metadata = MiniMaxM3IndexerPrefillMetadata(
cu_seqlens_q=cu_seqlens_q,
seq_lens=seq_lens[num_decodes:],
context_lens=context_lens[num_decodes:],
block_table=block_table[num_decodes:],
max_query_len=common_attn_metadata.max_query_len,
max_seq_len=common_attn_metadata.max_seq_len,
)
# A prefill row sees its own position, so its causal length and block
# count both follow from that alone; the request it belongs to comes
# from the query offsets. Prefill batches are never captured, so the
# stable buffers are only being reused here, not required.
positions = common_attn_metadata.positions
assert positions is not None
row_positions = positions[num_decode_tokens:num_tokens]
prefill_num_valid_pages = self.num_valid_pages_buffer[
num_decode_tokens:num_tokens
]
prefill_num_valid_pages.copy_(
row_positions // self.sparse_block_size + 1, non_blocking=True
)
prefill_kv_lens = self.kv_lens_buffer[num_decode_tokens:num_tokens]
prefill_kv_lens.copy_(row_positions + 1, non_blocking=True)
prefill_row_req_id = self.row_req_id_buffer[num_decode_tokens:num_tokens]
prefill_row_req_id.copy_(
torch.searchsorted(
cu_seqlens_q[1:].contiguous(),
torch.arange(
num_prefill_tokens,
dtype=torch.int32,
device=cu_seqlens_q.device,
),
right=True,
),
non_blocking=True,
)
decode_metadata: MiniMaxM3IndexerDecodeMetadata | None = None
if num_decodes > 0:
qsl_cpu = common_attn_metadata.query_start_loc_cpu
query_lens_cpu = qsl_cpu[1 : num_decodes + 1] - qsl_cpu[:num_decodes]
decode_query_len = int(query_lens_cpu[0].item())
assert decode_query_len > 0
assert torch.all(
(query_lens_cpu == decode_query_len) | (query_lens_cpu == 0)
)
assert num_decode_tokens == num_decodes * decode_query_len
decode_metadata = MiniMaxM3IndexerDecodeMetadata(
seq_lens=seq_lens[:num_decodes],
block_table=block_table[:num_decodes],
max_seq_len=common_attn_metadata.max_seq_len,
decode_query_len=decode_query_len,
max_decode_query_len=self.max_decode_query_len,
)
return MiniMaxM3IndexerAiterMetadata(
seq_lens=seq_lens,
max_seq_len=common_attn_metadata.max_seq_len,
slot_mapping=common_attn_metadata.slot_mapping,
num_actual_tokens=num_tokens,
num_decodes=num_decodes,
num_decode_tokens=num_decode_tokens,
num_prefills=num_prefills,
num_prefill_tokens=num_prefill_tokens,
prefill=prefill_metadata,
decode=decode_metadata,
prefill_num_valid_pages=prefill_num_valid_pages,
prefill_row_req_id=prefill_row_req_id,
prefill_kv_lens=prefill_kv_lens,
)
class MiniMaxM3IndexerAiterImpl(MiniMaxM3IndexerImpl):
"""AITER fp8 score + top-k for both prefill and decode."""
indexer_backend_cls: ClassVar[type[AttentionBackend]] = MiniMaxM3IndexerAiterBackend
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
# Both passes are v_mfma_f32_16x16x32_fp8_fp8 with no bf16 instantiation,
# so an index cache of any other dtype would be read as e4m3 bytes.
# The selector will not get here, but nothing else may either.
if self.indexer_kv_dtype not in ("fp8", "fp8_e4m3"):
raise ValueError(
"The AITER indexer requires an fp8 e4m3 index cache, got "
f"indexer_kv_dtype={self.indexer_kv_dtype!r}"
)
# Shared, stable-address page table + per-row context bound the top-k
# emits for the attend. Owned by the model so one allocation serves
# every layer; left None when it reserved none, in which case the
# attend rebuilds the table itself.
self.sparse_bt_buffer: torch.Tensor | None = None
self.sparse_ctx_buffer: torch.Tensor | None = None
@property
def pages_per_block(self) -> int:
"""Physical pages one selected block expands into for the attend."""
return self.block_size // ASM_PAGE_SIZE
def _table_rows(self, lo: int, hi: int) -> tuple[torch.Tensor, torch.Tensor]:
"""The page table and context rows covering score rows ``[lo, hi)``.
One table row per (token, kv head), head minor, which is the order
``pa_decode_gluon`` reads once it flattens the cache. Slices of the
shared buffers rather than copies, so the attend sees the writes; a
model that reserved no buffers gets throwaway ones, and the attend
rebuilds the table itself in that case.
"""
kv_heads = self.num_kv_heads
if self.sparse_bt_buffer is None or self.sparse_ctx_buffer is None:
rows = (hi - lo) * kv_heads
device = self.index_cache.kv_cache.device
return (
torch.empty(
(rows, self.topk_blocks * self.pages_per_block),
dtype=torch.int32,
device=device,
),
torch.empty(rows, dtype=torch.int32, device=device),
)
return (
self.sparse_bt_buffer[lo * kv_heads : hi * kv_heads],
self.sparse_ctx_buffer[lo * kv_heads : hi * kv_heads],
)
def _new_score(self, rows: int, max_seq_len: int) -> torch.Tensor:
"""Score buffer for ``rows`` query rows.
Left uninitialized on purpose: the score pass writes every block up to
the longest row it covers, and the top-k reads only the blocks its own
row can see, so nothing downstream observes the padded tail. Filling it
would cost a write over the whole [H, rows, width] extent, which at long
context is the largest tensor in the indexer.
"""
return torch.empty(
(
self.num_index_heads,
rows,
score_block_width(max_seq_len, self.block_size),
),
dtype=torch.float32,
device=self.index_cache.kv_cache.device,
)
def forward(
self,
index_query: torch.Tensor,
*,
decode_page16_block_table: torch.Tensor | None = None,
prefill_page16_block_table: torch.Tensor | None = None,
) -> tuple[torch.Tensor | None, torch.Tensor | None]:
from aiter.ops.msa_attention import (
pa_sparse_block_score_decode,
pa_sparse_block_score_prefill,
pa_sparse_block_topk,
)
attn_metadata = get_forward_context().attn_metadata
if not isinstance(attn_metadata, dict):
return None, None # profiling run; caches unbound
md = attn_metadata[self.index_cache.prefix]
assert isinstance(md, MiniMaxM3IndexerAiterMetadata)
# The emitted page table addresses the main cache, whose blocks are a
# different group's than the index cache's, so the top-k resolves the
# selection through the attend's block table -- only the score pass reads
# the index cache and takes the indexer's. It has to be the page-16
# rebase of that table, which the attend's own metadata builder does
# once per step; the indexer's metadata cannot reach another group's
# blocks, so the layer reads it off the attend and hands it in.
num_tokens = md.num_actual_tokens
nd = md.num_decode_tokens
iq = index_query[:num_tokens].view(
-1, self.num_index_heads, self.index_head_dim
)
kv = self.index_cache.kv_cache
# Both sides write into the single shared persistent buffer (decode at
# [:, :nd], prefill at [:, nd:]) and return views into it. The top-k
# takes the head/row strides, so these slices need no copy back.
buf = self.topk_indices_buffer
if buf is None:
buf = torch.empty(
(self.num_index_heads, num_tokens, self.topk_blocks),
dtype=torch.int32,
device=iq.device,
)
decode_topk: torch.Tensor | None = None
prefill_topk: torch.Tensor | None = None
if md.num_decodes > 0:
d = md.decode
assert d is not None
assert decode_page16_block_table is not None, (
"the AITER indexer's top-k emits the attend's page table and "
"needs the page-16 rebase of the attend's decode block table"
)
score = self._new_score(nd, d.max_seq_len)
pa_sparse_block_score_decode(
iq[:nd],
kv,
score,
d.block_table,
d.seq_lens,
init_blocks=self.init_blocks,
local_blocks=self.local_blocks,
query_len=d.decode_query_len,
max_seq_len=d.max_seq_len,
)
decode_topk = buf[:, :nd, :]
sparse_bt, sparse_ctx = self._table_rows(0, nd)
# A decode row's causal length is seq_len - query_len + token + 1,
# which the kernel derives itself, so the emitted table covers
# speculative rows without any extra per-row shape.
pa_sparse_block_topk(
score,
decode_topk,
decode_page16_block_table,
d.seq_lens,
sparse_bt,
sparse_ctx,
max_seq_len=d.max_seq_len,
block_size=self.block_size,
query_len=d.decode_query_len,
num_kv_heads=self.num_kv_heads,
pages_per_block=self.pages_per_block,
)
if md.num_prefills > 0:
p = md.prefill
assert p is not None
assert prefill_page16_block_table is not None, (
"the AITER indexer's top-k emits the attend's page table and "
"needs the page-16 rebase of the attend's prefill block table"
)
assert md.prefill_num_valid_pages is not None
assert md.prefill_row_req_id is not None
assert md.prefill_kv_lens is not None
score = self._new_score(num_tokens - nd, p.max_seq_len)
pa_sparse_block_score_prefill(
iq[nd:],
kv,
score,
p.block_table,
p.cu_seqlens_q,
p.seq_lens,
init_blocks=self.init_blocks,
local_blocks=self.local_blocks,
max_query_len=p.max_query_len,
max_seq_len=p.max_seq_len,
)
prefill_topk = buf[:, nd:num_tokens, :]
sparse_bt, sparse_ctx = self._table_rows(nd, num_tokens)
# Ragged rows carry their request and causal length explicitly: the
# block count alone cannot place the tail block the table ends on.
pa_sparse_block_topk(
score,
prefill_topk,
prefill_page16_block_table,
p.seq_lens,
sparse_bt,
sparse_ctx,
max_seq_len=p.max_seq_len,
block_size=self.block_size,
num_valid_pages=md.prefill_num_valid_pages,
row_req_id=md.prefill_row_req_id,
kv_lens=md.prefill_kv_lens,
num_kv_heads=self.num_kv_heads,
pages_per_block=self.pages_per_block,
)
return decode_topk, prefill_topk
def select_aiter_indexer_impl_cls(
*,
topk_blocks: int,
sparse_block_size: int,
num_index_heads: int,
index_head_dim: int,
indexer_kv_dtype: IndexerKVDType,
score_type: str = "max",
) -> type[MiniMaxM3IndexerAiterImpl] | None:
"""The AITER indexer impl if this config can use it, else None.
``None`` sends the caller to the platform-neutral ``MiniMaxM3Indexer``,
which on ROCm means the Triton indexer -- and a bf16-only one, so an fp8
index cache that lands here has nowhere to go.
"""
reason = aiter_indexer_unsupported_reason(
topk_blocks=topk_blocks,
sparse_block_size=sparse_block_size,
num_index_heads=num_index_heads,
index_head_dim=index_head_dim,
indexer_kv_dtype=indexer_kv_dtype,
max_model_len=get_current_vllm_config().model_config.max_model_len,
max_decode_query_len=aiter_indexer_max_decode_query_len(
get_current_vllm_config()
),
score_type=score_type,
)
if reason is not None:
logger.info_once("MiniMax M3 indexer: AITER unavailable (%s)", reason)
return None
logger.info_once(
"MiniMax M3 indexer: selected AITER (fp8 MFMA score + top-k) "
"[topk_blocks=%d, indexer_kv_dtype=%s]",
topk_blocks,
indexer_kv_dtype,
)
return MiniMaxM3IndexerAiterImpl
class MiniMaxM3AiterIndexer(nn.Module):
"""``MiniMaxM3Indexer``'s surface over the AITER impl.
The platform-neutral wrapper picks its impl through ``common``'s selector
and forwards a Triton-only set of fused-table arguments, neither of which
can reach this impl without editing ``common``. This holds the same three
members the attention layer uses -- ``impl``, ``index_cache``,
``num_index_heads`` -- and forwards the one argument AITER needs instead.
"""
def __init__(
self,
*,
impl_cls: type[MiniMaxM3IndexerAiterImpl],
sparse_bt_buffer: torch.Tensor | None = None,
sparse_ctx_buffer: torch.Tensor | None = None,
**impl_kwargs,
) -> None:
super().__init__()
self.impl = impl_cls(**impl_kwargs)
# Assigned rather than passed: the impl's base ``__init__`` lives in
# common and takes no table buffers.
self.impl.sparse_bt_buffer = sparse_bt_buffer
self.impl.sparse_ctx_buffer = sparse_ctx_buffer
@property
def index_cache(self) -> MiniMaxM3IndexerCache:
return self.impl.index_cache
@property
def num_index_heads(self) -> int:
return self.impl.num_index_heads
def forward(
self,
index_query: torch.Tensor,
*,
decode_page16_block_table: torch.Tensor | None = None,
prefill_page16_block_table: torch.Tensor | None = None,
) -> tuple[torch.Tensor | None, torch.Tensor | None]:
return self.impl(
index_query,
decode_page16_block_table=decode_page16_block_table,
prefill_page16_block_table=prefill_page16_block_table,
)
+158 -36
View File
@@ -87,17 +87,24 @@ from vllm.model_executor.models.utils import (
maybe_prefix,
)
from vllm.model_executor.models.vision import run_dp_sharded_mrope_vision_model
from vllm.models.minimax_m3.amd.indexer_aiter import (
MiniMaxM3AiterIndexer,
select_aiter_indexer_impl_cls,
)
from vllm.models.minimax_m3.amd.ops import (
gemma_fused_add_rmsnorm,
gemma_rmsnorm,
swiglu_oai_split,
)
from vllm.models.minimax_m3.amd.ops.sparse_pa import (
ASM_PAGE_SIZE,
minimax_m3_alloc_sparse_block_table,
minimax_m3_sparse_block_page_stride,
)
from vllm.models.minimax_m3.amd.sparse_attention_msa import (
MiniMaxM3SparseAiterPADecodeMetadata,
MiniMaxM3SparseAiterPAImpl,
MiniMaxM3SparseAiterPAPrefillMetadata,
)
from vllm.models.minimax_m3.common.indexer import MiniMaxM3Indexer
from vllm.models.minimax_m3.common.mm_preprocess import (
@@ -111,7 +118,7 @@ from vllm.models.minimax_m3.common.sparse_attention import (
MiniMaxM3SparseMetadata,
minimax_m3_rebase_slots_to_page16,
minimax_m3_use_aiter_sparse_pa,
select_main_impl_cls,
select_main_backend_and_impl_cls,
)
from vllm.models.minimax_m3.common.vision_tower import MiniMaxVLVisionModel
from vllm.multimodal import MULTIMODAL_REGISTRY
@@ -596,6 +603,7 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
prefix: str = "",
cache_config: CacheConfig | None = None,
topk_indices_buffer: torch.Tensor | None = None,
sparse_table_buffers: tuple[torch.Tensor, torch.Tensor] | None = None,
) -> None:
super().__init__()
self.hidden_size = config.hidden_size
@@ -684,18 +692,45 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
# attributes so FP8 KV reads can honor vLLM's per-layer descale contract.
set_default_quant_scales(self, register_buffer=True)
# Indexer side-cache dtype, mirroring --kv-cache-dtype for the main cache
# (--attention-config '{"indexer_kv_dtype": ...}'). fp8 e4m3 is what the
# AITER indexer needs; bf16 keeps the Triton indexer.
self.indexer_kv_dtype = vllm_config.attention_config.resolve_indexer_kv_dtype(
"bf16"
)
# Shared top-k buffer: the indexer writes the selected blocks into it and
# the attend impl reads them back (no Python value crosses the break).
self.topk_indices_buffer = topk_indices_buffer
self.attn_backend = MiniMaxM3SparseBackend
# Indexer and main attention are separate impls. On ROCm the SM100 gate
# is always False, so both pick Triton and the index cache stays bf16.
# impl is AttentionImplBase (broader than AttentionLayerBase's annotation).
self.impl: MiniMaxM3SparseImpl = select_main_impl_cls( # type: ignore[assignment]
# Indexer and main attention are separate impls. The AITER indexer is
# ROCm-only, so it is selected here rather than inside the neutral
# MiniMaxM3Indexer, which knows nothing about it and would pick Triton.
indexer_impl_cls = select_aiter_indexer_impl_cls(
topk_blocks=sparse_cfg["sparse_topk_blocks"],
sparse_block_size=sparse_cfg["sparse_block_size"],
num_index_heads=self.num_idx_heads,
index_head_dim=self.idx_head_dim,
indexer_kv_dtype=self.indexer_kv_dtype,
score_type=sparse_cfg.get("sparse_score_type", "max"),
)
# The attend gate below needs this: an emitted table is what lets it
# serve more than one KV head per rank. Buffers to write the table into
# only arrive when the model already resolved the attend to the AITER
# path the table addresses, so that is not rechecked here.
self.indexer_emits_table = (
indexer_impl_cls is not None and sparse_table_buffers is not None
)
# The backend names the metadata builder, which for the AITER path is
# the one that rebases the block table the indexer's top-k resolves
# through, so both have to come from the same selection.
self.attn_backend, main_impl_cls = select_main_backend_and_impl_cls(
topk_blocks=sparse_cfg["sparse_topk_blocks"],
kv_cache_dtype=self.kv_cache_dtype,
num_kv_heads=self.num_kv_heads,
)(
emits_sparse_block_table=self.indexer_emits_table,
)
# impl is AttentionImplBase (broader than AttentionLayerBase's annotation).
self.impl: MiniMaxM3SparseImpl = main_impl_cls( # type: ignore[assignment]
self.num_heads,
self.head_dim,
self.scaling,
@@ -704,14 +739,14 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
topk_blocks=sparse_cfg["sparse_topk_blocks"],
sparse_block_size=sparse_cfg["sparse_block_size"],
)
self.use_aiter_sparse_pa = minimax_m3_use_aiter_sparse_pa(self.num_kv_heads)
self.use_aiter_sparse_pa = minimax_m3_use_aiter_sparse_pa(
self.num_kv_heads, emits_sparse_block_table=self.indexer_emits_table
)
self.kv_cache_k = torch.tensor([])
self.kv_cache_v = torch.tensor([])
self._aiter_sparse_pa_cache_data_ptr = 0
self._aiter_sparse_pa_block_page_stride = 0
# Self-contained nn.Module: owns its side cache, selects its impl in init
# (Triton on ROCm, where the SM100 gate is always False).
self.indexer = MiniMaxM3Indexer(
indexer_kwargs = dict(
num_kv_heads=self.num_kv_heads,
scale=self.scaling,
topk_blocks=sparse_cfg["sparse_topk_blocks"],
@@ -723,8 +758,24 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
local_blocks=sparse_cfg.get("sparse_local_block", 0),
score_type=sparse_cfg.get("sparse_score_type", "max"),
cache_config=cache_config,
indexer_kv_dtype=self.indexer_kv_dtype,
topk_indices_buffer=topk_indices_buffer,
)
self.sparse_bt_buffer: torch.Tensor | None = None
self.sparse_ctx_buffer: torch.Tensor | None = None
if indexer_impl_cls is None:
# Self-contained nn.Module: owns its side cache, selects its impl.
self.indexer = MiniMaxM3Indexer(**indexer_kwargs)
else:
if self.indexer_emits_table:
assert sparse_table_buffers is not None
self.sparse_bt_buffer, self.sparse_ctx_buffer = sparse_table_buffers
self.indexer = MiniMaxM3AiterIndexer(
impl_cls=indexer_impl_cls,
sparse_bt_buffer=self.sparse_bt_buffer,
sparse_ctx_buffer=self.sparse_ctx_buffer,
**indexer_kwargs,
)
# Register the main K/V cache so the KV-cache manager allocates it.
compilation_config = vllm_config.compilation_config
@@ -952,7 +1003,13 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
)
else:
index_slot_mapping = fwd_slot_mapping[self.indexer.index_cache.prefix]
index_q = qkv.new_empty((num_tokens, self.index_q_size))
# index_q matches the index-K cache dtype (e4m3 for the AITER fp8
# score path); the fused kernel emits fp8 directly when this buffer
# is e4m3.
index_q = qkv.new_empty(
(num_tokens, self.index_q_size),
dtype=self.indexer.index_cache.dtype,
)
if self.use_aiter_sparse_pa:
ops.fused_minimax_m3_qknorm_rope_kv_insert(
qkv,
@@ -1034,31 +1091,56 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
decode_sparse_table: tuple[torch.Tensor, torch.Tensor] | None = None
if not self.skip_index_topk:
assert index_query is not None
attn_metadata = get_forward_context().attn_metadata
if self.use_aiter_sparse_pa and isinstance(attn_metadata, dict):
main_md = attn_metadata[self.layer_name]
assert isinstance(main_md, MiniMaxM3SparseMetadata)
if main_md.num_decodes > 0:
d = main_md.decode
assert d is not None
topk = self.topk_indices_buffer
assert topk is not None
decode_sparse_table = minimax_m3_alloc_sparse_block_table(
topk[:, : main_md.num_decode_tokens, :]
)
block_page_stride = self._aiter_sparse_pa_block_page_stride
assert block_page_stride > 0
self.indexer(
index_query,
attention_block_table=d.block_table,
sparse_block_table_out=decode_sparse_table[0],
sparse_context_lens_out=decode_sparse_table[1],
block_page_stride=block_page_stride,
)
# The AITER indexer emits the attend table into its persistent
# buffers, resolving its selection through the page-16 rebase of the
# attend's block table. The Triton indexer can instead fuse decode
# top-k with main's per-forward sparse-table allocation.
if self.indexer_emits_table:
attn_metadata = get_forward_context().attn_metadata
decode_page16 = prefill_page16 = None
if isinstance(attn_metadata, dict):
main_md = attn_metadata[self.layer_name]
assert isinstance(main_md, MiniMaxM3SparseMetadata)
# Rebased once per step by the AITER attend's builder, which
# this path selected along with the impl.
d, p = main_md.decode, main_md.prefill
if d is not None:
assert isinstance(d, MiniMaxM3SparseAiterPADecodeMetadata)
decode_page16 = d.page16_block_table
if p is not None:
assert isinstance(p, MiniMaxM3SparseAiterPAPrefillMetadata)
prefill_page16 = p.page16_block_table
self.indexer(
index_query,
decode_page16_block_table=decode_page16,
prefill_page16_block_table=prefill_page16,
)
else:
attn_metadata = get_forward_context().attn_metadata
if self.use_aiter_sparse_pa and isinstance(attn_metadata, dict):
main_md = attn_metadata[self.layer_name]
assert isinstance(main_md, MiniMaxM3SparseMetadata)
if main_md.num_decodes > 0:
d = main_md.decode
assert d is not None
topk = self.topk_indices_buffer
assert topk is not None
decode_sparse_table = minimax_m3_alloc_sparse_block_table(
topk[:, : main_md.num_decode_tokens, :]
)
block_page_stride = self._aiter_sparse_pa_block_page_stride
assert block_page_stride > 0
self.indexer(
index_query,
attention_block_table=d.block_table,
sparse_block_table_out=decode_sparse_table[0],
sparse_context_lens_out=decode_sparse_table[1],
block_page_stride=block_page_stride,
)
else:
self.indexer(index_query)
else:
self.indexer(index_query)
else:
self.indexer(index_query)
if self.use_aiter_sparse_pa:
assert isinstance(self.impl, MiniMaxM3SparseAiterPAImpl)
return self.impl.forward(
@@ -1081,6 +1163,7 @@ class MiniMaxM3DecoderLayer(nn.Module):
force_sparse_attn: bool = False,
force_moe: bool = False,
topk_indices_buffer: torch.Tensor | None = None,
sparse_table_buffers: tuple[torch.Tensor, torch.Tensor] | None = None,
) -> None:
super().__init__()
self.hidden_size = config.hidden_size
@@ -1101,6 +1184,7 @@ class MiniMaxM3DecoderLayer(nn.Module):
prefix=f"{prefix}.self_attn",
cache_config=cache_config,
topk_indices_buffer=topk_indices_buffer,
sparse_table_buffers=sparse_table_buffers,
)
else:
self.self_attn = MiniMaxM3Attention(
@@ -1189,15 +1273,52 @@ class MiniMaxM3Model(nn.Module, EagleModelMixin):
# layers (mirrors DeepseekV4); the indexer writes its per-head decode/
# prefill block selection into it, the attend reads it back.
sparse_cfg = getattr(config, "sparse_attention_config", None)
self.sparse_table_buffers: tuple[torch.Tensor, torch.Tensor] | None = None
if sparse_cfg is not None:
tp_size = get_tensor_model_parallel_world_size()
num_index_heads = max(1, sparse_cfg["sparse_num_index_heads"] // tp_size)
max_tokens = vllm_config.scheduler_config.max_num_batched_tokens
self.topk_indices_buffer = torch.empty(
num_index_heads,
vllm_config.scheduler_config.max_num_batched_tokens,
max_tokens,
sparse_cfg["sparse_topk_blocks"],
dtype=torch.int32,
)
num_kv_heads = max(1, config.num_key_value_heads // tp_size)
indexer_kv_dtype = vllm_config.attention_config.resolve_indexer_kv_dtype(
"bf16"
)
# Probed with the head count the layer will pass (it derives
# num_idx_heads from num_kv_heads, not from sparse_num_index_heads),
# so this cannot land on the other side of the MFMA column limit
# from the selection the layer makes.
emits_table = (
select_aiter_indexer_impl_cls(
topk_blocks=sparse_cfg["sparse_topk_blocks"],
sparse_block_size=sparse_cfg["sparse_block_size"],
num_index_heads=num_kv_heads,
index_head_dim=sparse_cfg["sparse_index_dim"],
indexer_kv_dtype=indexer_kv_dtype,
score_type=sparse_cfg.get("sparse_score_type", "max"),
)
is not None
)
if emits_table and minimax_m3_use_aiter_sparse_pa(
num_kv_heads, emits_sparse_block_table=True
):
# One row per (token, kv head): the table addresses the attend's
# cache, not the index cache.
rows = max_tokens * num_kv_heads
self.sparse_table_buffers = (
torch.empty(
rows,
sparse_cfg["sparse_topk_blocks"]
* (sparse_cfg["sparse_block_size"] // ASM_PAGE_SIZE),
dtype=torch.int32,
),
torch.empty(rows, dtype=torch.int32),
)
else:
self.topk_indices_buffer = None
@@ -1209,6 +1330,7 @@ class MiniMaxM3Model(nn.Module, EagleModelMixin):
cache_config=cache_config,
quant_config=quant_config,
topk_indices_buffer=self.topk_indices_buffer,
sparse_table_buffers=self.sparse_table_buffers,
),
prefix=f"{prefix}.layers",
)
+45 -8
View File
@@ -312,7 +312,9 @@ def _insert_index_cache_kernel(
)
mask = (slot >= 0) & (offs_d < HEAD_DIM)
value = tl.load(src, mask=offs_d < HEAD_DIM, other=0.0)
tl.store(dst, value, mask=mask)
# The side cache carries its own dtype (bf16, or e4m3 for the fp8 indexer),
# so convert rather than leaning on the store's implicit cast.
tl.store(dst, value.to(dst.dtype.element_ty), mask=mask)
@torch.no_grad()
@@ -368,6 +370,32 @@ def minimax_m3_sparse_block_page_stride(
return PAGES_PER_SPARSE_BLOCK * (2 if _sides_are_packed(k_cache, v_cache) else 1)
# K/V sides sharing one block, which is how many sides' pages a block spans.
# The attend publishes two head slots and the indexer's side cache one, and
# specs that mix HNC shapes narrow the resolved layout to a block-compact one
# (LHBNC is not), so this path always lands on an interleaved block.
PAGE16_SIDES_PER_BLOCK = 2
def minimax_m3_rebase_block_table_to_page16(
block_table: torch.Tensor,
out: torch.Tensor | None = None,
) -> torch.Tensor:
"""Rebase a logical page table onto AITER's page-16 page numbering.
The page ids AITER's top-k emits for the attend are
``block_table[blk] * pages_per_block + j``, and ``pages_per_block`` is fixed
at AITER build time to one side's pages. An interleaved block spans both
sides, so the only way to reach its pages through that kernel is to hand it
a table already scaled to the wider stride. The caller does this once per
step, since every sparse layer resolves its selection through the same
table.
"""
if out is None:
out = torch.empty_like(block_table)
return torch.mul(block_table, PAGE16_SIDES_PER_BLOCK, out=out)
def _gluon_scale_arg(
scale: torch.Tensor | None,
*,
@@ -461,14 +489,23 @@ def minimax_m3_sparse_attn_prefill_aiter(
output: torch.Tensor,
k_scale: torch.Tensor | None = None,
v_scale: torch.Tensor | None = None,
sparse_bt: torch.Tensor | None = None,
sparse_ctx: torch.Tensor | None = None,
) -> None:
sparse_bt, sparse_ctx = minimax_m3_build_sparse_block_table_prefill(
topk_idx,
block_table,
query_req_id,
query_abs_pos,
minimax_m3_sparse_block_page_stride(k_cache, v_cache),
)
if (sparse_bt is None) != (sparse_ctx is None):
raise ValueError(
"MiniMax-M3 prepared sparse block table and context lengths "
"must be provided together"
)
if sparse_bt is None:
sparse_bt, sparse_ctx = minimax_m3_build_sparse_block_table_prefill(
topk_idx,
block_table,
query_req_id,
query_abs_pos,
minimax_m3_sparse_block_page_stride(k_cache, v_cache),
)
assert sparse_ctx is not None
_run_gluon_decode(
q,
k_cache,
@@ -2,16 +2,118 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""MSA AITER block-sparse attend for MiniMax M3."""
from dataclasses import dataclass
import torch
from vllm.config import VllmConfig
from vllm.forward_context import get_forward_context
from vllm.models.minimax_m3.common.sparse_attention import (
MiniMaxM3SparseBackend,
MiniMaxM3SparseDecodeMetadata,
MiniMaxM3SparseImpl,
MiniMaxM3SparseMetadata,
MiniMaxM3SparseMetadataBuilder,
MiniMaxM3SparsePrefillMetadata,
)
from vllm.utils.math_utils import cdiv
from vllm.v1.attention.backend import (
AttentionLayer,
)
from vllm.v1.attention.backends.utils import CommonAttentionMetadata
from vllm.v1.kv_cache_interface import AttentionSpec
class MiniMaxM3SparseAiterPABackend(MiniMaxM3SparseBackend):
"""MiniMax M3 backend carrying the AITER page-16 block-table rebase."""
@staticmethod
def get_builder_cls() -> type["MiniMaxM3SparseAiterPAMetadataBuilder"]:
return MiniMaxM3SparseAiterPAMetadataBuilder
@dataclass
class MiniMaxM3SparseAiterPAPrefillMetadata(MiniMaxM3SparsePrefillMetadata):
# ``block_table`` rebased onto AITER's page-16 numbering; see
# ``MiniMaxM3SparseAiterPAMetadataBuilder.build``.
page16_block_table: torch.Tensor | None = None
@dataclass
class MiniMaxM3SparseAiterPADecodeMetadata(MiniMaxM3SparseDecodeMetadata):
page16_block_table: torch.Tensor | None = None
class MiniMaxM3SparseAiterPAMetadataBuilder(MiniMaxM3SparseMetadataBuilder):
"""Adds the page-16 rebase of the block table the indexer's top-k needs.
The AITER indexer's top-k emits the attend's page table, expanding a
selected block into a compile-time number of pages -- one side's worth,
while an interleaved block holds both -- so it has to resolve the selection
through a table pre-scaled to the wider stride. Every sparse layer selects
through the same table, so it is rebased here once per step rather than
once per layer, alongside the slot mapping the base rebases for the writer.
"""
def __init__(
self,
kv_cache_spec: AttentionSpec,
layer_names: list[str],
vllm_config: VllmConfig,
device: torch.device,
) -> None:
super().__init__(kv_cache_spec, layer_names, vllm_config, device)
# Stable address for the same reason as the base's own buffers: a
# captured decode replay writes through the address it captured.
self.page16_block_table_buffer = torch.empty(
(
vllm_config.scheduler_config.max_num_seqs,
cdiv(vllm_config.model_config.max_model_len, kv_cache_spec.block_size),
),
dtype=torch.int32,
device=device,
)
def build(
self,
common_prefix_len: int,
common_attn_metadata: CommonAttentionMetadata,
fast_build: bool = False,
) -> MiniMaxM3SparseMetadata:
from vllm.models.minimax_m3.amd.ops.sparse_pa import (
minimax_m3_rebase_block_table_to_page16,
)
metadata = super().build(common_prefix_len, common_attn_metadata, fast_build)
block_table = common_attn_metadata.block_table_tensor
num_reqs, cols = block_table.shape
assert (
num_reqs <= self.page16_block_table_buffer.shape[0]
and cols <= self.page16_block_table_buffer.shape[1]
), (
f"block table {(num_reqs, cols)} exceeds the page-16 rebase buffer "
f"{tuple(self.page16_block_table_buffer.shape)}"
)
page16 = minimax_m3_rebase_block_table_to_page16(
block_table,
out=self.page16_block_table_buffer[:num_reqs, :cols],
)
# Decode-first batch, so each side takes the rows its own block table
# was sliced from. Reconstructed from the base's fields rather than
# listed out, so a field added there does not silently drop here.
nd = metadata.num_decodes
if metadata.decode is not None:
metadata.decode = MiniMaxM3SparseAiterPADecodeMetadata(
**vars(metadata.decode),
page16_block_table=page16[:nd],
)
if metadata.prefill is not None:
metadata.prefill = MiniMaxM3SparseAiterPAPrefillMetadata(
**vars(metadata.prefill),
page16_block_table=page16[nd:],
)
return metadata
class MiniMaxM3SparseAiterPAImpl(MiniMaxM3SparseImpl):
@@ -28,8 +130,11 @@ class MiniMaxM3SparseAiterPAImpl(MiniMaxM3SparseImpl):
decode_sparse_table: tuple[torch.Tensor, torch.Tensor] | None = None,
) -> torch.Tensor:
from vllm.models.minimax_m3.amd.ops.sparse_pa import (
PAGE16_SIDES_PER_BLOCK,
PAGES_PER_SPARSE_BLOCK,
minimax_m3_sparse_attn_decode_aiter,
minimax_m3_sparse_attn_prefill_aiter,
minimax_m3_sparse_block_page_stride,
)
attn_metadata = get_forward_context().attn_metadata
@@ -42,10 +147,16 @@ class MiniMaxM3SparseAiterPAImpl(MiniMaxM3SparseImpl):
num_tokens = main_md.num_actual_tokens
topk = layer.topk_indices_buffer # type: ignore[attr-defined]
assert topk is not None
if self.num_kv_heads != 1:
# Set only when the layer's indexer emitted the page table alongside its
# selection; otherwise these stay None and the table is built below.
sparse_bt_buf = getattr(layer, "sparse_bt_buffer", None)
sparse_ctx_buf = getattr(layer, "sparse_ctx_buffer", None)
kvh = self.num_kv_heads
if kvh != 1 and sparse_bt_buf is None:
raise NotImplementedError(
"MiniMax-M3 AITER sparse PA currently requires per-rank "
f"num_kv_heads == 1, got {self.num_kv_heads}"
"MiniMax-M3 AITER sparse PA needs the page table the indexer's "
f"top-k emits to serve per-rank num_kv_heads == {kvh}; the "
"Triton builders it falls back to address one head's cache."
)
hd = self.head_size
@@ -55,9 +166,31 @@ class MiniMaxM3SparseAiterPAImpl(MiniMaxM3SparseImpl):
k_scale = getattr(layer, "_k_scale", None) if self.use_fp8_kv else None
v_scale = getattr(layer, "_v_scale", None) if self.use_fp8_kv else None
if sparse_bt_buf is not None:
# An emitted table was scaled by the metadata builder, which reads
# the packing off the layout rather than off these tensors. The two
# only disagree if the resolved layout gives each side its own
# plane, and a silent disagreement reads unrelated pages.
stride = minimax_m3_sparse_block_page_stride(k_cache, v_cache)
expected = PAGES_PER_SPARSE_BLOCK * PAGE16_SIDES_PER_BLOCK
if stride != expected:
raise RuntimeError(
"MiniMax-M3 AITER sparse PA: the indexer emitted a page "
f"table for a block spanning {expected} pages, but this "
f"KV cache lays a block out over {stride}. The page-16 "
"rebase assumes both K/V sides share a block."
)
if main_md.num_decodes > 0:
d = main_md.decode
assert d is not None
prepared_decode_table = decode_sparse_table
if sparse_bt_buf is not None:
assert sparse_ctx_buf is not None
prepared_decode_table = (
sparse_bt_buf[: nd * kvh],
sparse_ctx_buf[: nd * kvh],
)
minimax_m3_sparse_attn_decode_aiter(
q[:nd],
k_cache,
@@ -72,10 +205,14 @@ class MiniMaxM3SparseAiterPAImpl(MiniMaxM3SparseImpl):
v_scale=v_scale,
decode_query_len=d.decode_query_len,
sparse_block_table=(
decode_sparse_table[0] if decode_sparse_table is not None else None
prepared_decode_table[0]
if prepared_decode_table is not None
else None
),
sparse_context_lens=(
decode_sparse_table[1] if decode_sparse_table is not None else None
prepared_decode_table[1]
if prepared_decode_table is not None
else None
),
)
@@ -96,5 +233,15 @@ class MiniMaxM3SparseAiterPAImpl(MiniMaxM3SparseImpl):
out[nd:],
k_scale=k_scale,
v_scale=v_scale,
sparse_bt=(
None
if sparse_bt_buf is None
else sparse_bt_buf[nd * kvh : num_tokens * kvh]
),
sparse_ctx=(
None
if sparse_ctx_buf is None
else sparse_ctx_buf[nd * kvh : num_tokens * kvh]
),
)
return output
@@ -109,13 +109,22 @@ def minimax_m3_query_token_positions(
return query_req_id, query_abs_pos
def minimax_m3_use_aiter_sparse_pa(num_kv_heads: int) -> bool:
"""Whether to use the ROCm AITER page-16 sparse PA prototype."""
def minimax_m3_use_aiter_sparse_pa(
num_kv_heads: int, *, emits_sparse_block_table: bool = False
) -> bool:
"""Whether to use the ROCm AITER page-16 sparse PA prototype.
More than one KV head per rank is only served when the layer's indexer
emits the attend's page table itself, since the Triton builders this path
otherwise falls back to address one head's cache. Whether an indexer does
that is a ROCm-side fact, so its caller passes it in.
"""
requested = _minimax_m3_aiter_sparse_pa_requested()
if requested and num_kv_heads != 1:
if requested and num_kv_heads != 1 and not emits_sparse_block_table:
raise ValueError(
"MiniMax M3 AITER sparse paged attention requires "
f"num_kv_heads == 1 per tensor-parallel rank, got {num_kv_heads}."
f"num_kv_heads == 1 per tensor-parallel rank, got {num_kv_heads}, "
"unless the indexer emits the attend's page table."
)
return requested
@@ -252,9 +261,9 @@ class MiniMaxM3SparseMetadataBuilder(AttentionMetadataBuilder[MiniMaxM3SparseMet
# Every sparse layer shares one slot mapping, so the AITER page-16
# rebase is done here once per step instead of once per layer. Stable
# buffer for the same reason as the context lengths above.
self.use_aiter_sparse_pa = minimax_m3_use_aiter_sparse_pa(
kv_cache_spec.num_kv_heads
)
# The request itself, not the gate: the buffer is the same whatever the
# head count, and a builder cannot see which indexer its layers picked.
self.use_aiter_sparse_pa = _minimax_m3_aiter_sparse_pa_requested()
self.page16_slot_mapping_buffer: torch.Tensor | None = None
if self.use_aiter_sparse_pa:
self.page16_slot_mapping_buffer = torch.empty(
@@ -514,6 +523,7 @@ def select_main_backend_and_impl_cls(
topk_blocks: int,
kv_cache_dtype: str,
num_kv_heads: int,
emits_sparse_block_table: bool = False,
) -> tuple[type[MiniMaxM3SparseBackend], type[MiniMaxM3SparseImpl]]:
"""Pick the main attention backend and implementation.
@@ -523,7 +533,9 @@ def select_main_backend_and_impl_cls(
back to Triton. The MSA modules are imported lazily to avoid import errors
on unsupported platforms.
"""
use_aiter_sparse_pa = minimax_m3_use_aiter_sparse_pa(num_kv_heads)
use_aiter_sparse_pa = minimax_m3_use_aiter_sparse_pa(
num_kv_heads, emits_sparse_block_table=emits_sparse_block_table
)
use_msa = (
current_platform.is_cuda()
and current_platform.is_device_capability_family(100)
@@ -541,10 +553,11 @@ def select_main_backend_and_impl_cls(
)
if use_aiter_sparse_pa:
from vllm.models.minimax_m3.amd.sparse_attention_msa import (
MiniMaxM3SparseAiterPABackend,
MiniMaxM3SparseAiterPAImpl,
)
return MiniMaxM3SparseBackend, MiniMaxM3SparseAiterPAImpl
return MiniMaxM3SparseAiterPABackend, MiniMaxM3SparseAiterPAImpl
if use_msa:
from vllm.models.minimax_m3.nvidia.sparse_attention_msa import (
MiniMaxM3SparseMSABackend,
@@ -560,10 +573,12 @@ def select_main_impl_cls(
topk_blocks: int,
kv_cache_dtype: str,
num_kv_heads: int,
emits_sparse_block_table: bool = False,
) -> type[MiniMaxM3SparseImpl]:
"""Backward-compatible implementation-only selector."""
return select_main_backend_and_impl_cls(
topk_blocks=topk_blocks,
kv_cache_dtype=kv_cache_dtype,
num_kv_heads=num_kv_heads,
emits_sparse_block_table=emits_sparse_block_table,
)[1]