[ROCm][MiniMax-M3][Spec Decode] Support speculative decode with AITER sparse PA (#47984)

Signed-off-by: Tan Pin Siang <tanpinsiang@gmail.com> Co-authored-by: vllmellm <vllm.ellm@embeddedllm.com> Co-authored-by: Hongxia Yang <hongxia.yang@amd.com> Co-authored-by: Jun Kang Chow <junkangchow@gmail.com> Co-authored-by: TJian <tunjian.tan@embeddedllm.com>

Tan Pin Siang committed Jul 14, 2026 at 12:12 UTC 95aab66e9585e0f7889f2adc5ef6067a4a0e0f25
3 files changed +182 -15
tests/kernels/attention/test_minimax_m3.py
+140
@@ -1010,6 +1010,146 @@ def _build_decode_inputs(
1010 return q, block_table, seq_lens, topk_idx, num_pages
1011
1012
1013 +@pytest.mark.skipif(
1014 + not current_platform.is_rocm(),
1015 + reason="MiniMax-M3 AITER sparse PA is ROCm-only",
1016 +)
1017 +def test_aiter_sparse_block_table_handles_padded_decode_rows():
1018 + """Zero-length padded decode rows must produce empty sparse attention."""
1019 + from vllm.models.minimax_m3.amd.ops.sparse_pa import (
1020 + PAGES_PER_SPARSE_BLOCK,
1021 + minimax_m3_build_sparse_block_table,
1022 + )
1023 +
1024 + topk_idx = torch.tensor(
1025 + [[[1, 0, -1], [0, 1, -1]]],
1026 + device="cuda",
1027 + dtype=torch.int32,
1028 + )
1029 + block_table = torch.tensor(
1030 + [[5, 7], [11, 13]],
1031 + device="cuda",
1032 + dtype=torch.int32,
1033 + )
1034 + seq_lens = torch.tensor([129, 0], device="cuda", dtype=torch.int32)
1035 +
1036 + sparse_bt, sparse_ctx = minimax_m3_build_sparse_block_table(
1037 + topk_idx, block_table, seq_lens
1038 + )
1039 + torch.accelerator.synchronize()
1040 +
1041 + expected_bt = torch.zeros_like(sparse_bt)
1042 + expected_ctx = torch.tensor([129, 0], device="cuda", dtype=torch.int32)
1043 +
1044 + for slot, block_id in enumerate((0, 1)):
1045 + base_phys = int(block_table[0, block_id]) * PAGES_PER_SPARSE_BLOCK
1046 + start = slot * PAGES_PER_SPARSE_BLOCK
1047 + expected_bt[0, start : start + PAGES_PER_SPARSE_BLOCK] = torch.arange(
1048 + base_phys,
1049 + base_phys + PAGES_PER_SPARSE_BLOCK,
1050 + device="cuda",
1051 + dtype=torch.int32,
1052 + )
1053 +
1054 + assert torch.equal(sparse_ctx, expected_ctx)
1055 + assert torch.equal(sparse_bt, expected_bt)
1056 +
1057 +
1058 +@pytest.mark.skipif(
1059 + not current_platform.is_rocm(),
1060 + reason="MiniMax-M3 AITER sparse PA is ROCm-only",
1061 +)
1062 +def test_aiter_decode_sparse_block_table_supports_spec_decode():
1063 + """AITER sparse PA needs one compact page-16 table per speculative query.
1064 +
1065 + The decode indexer flattens speculative rows as
1066 + ``req_id * decode_query_len + local_q``. This verifies that the ROCm AITER
1067 + block-table adapter uses the same mapping before handing rows to Gluon.
1068 + """
1069 + from vllm.models.minimax_m3.amd.ops.sparse_pa import (
1070 + PAGES_PER_SPARSE_BLOCK,
1071 + minimax_m3_build_sparse_block_table_decode,
1072 + )
1073 +
1074 + decode_query_len = 4
1075 + seq_lens = torch.tensor([260, 132, 0], device="cuda", dtype=torch.int32)
1076 + block_table = torch.tensor(
1077 + [
1078 + [5, 7, 11],
1079 + [13, 17, 19],
1080 + [0, 0, 0],
1081 + ],
1082 + device="cuda",
1083 + dtype=torch.int32,
1084 + )
1085 + topk_idx = torch.tensor(
1086 + [
1087 + [
1088 + [2, 0, 3, -1],
1089 + [0, 2, 3, -1],
1090 + [2, 1, 0, -1],
1091 + [1, 2, 0, -1],
1092 + [1, 0, 2, -1],
1093 + [0, 1, 2, -1],
1094 + [1, -1, 0, -1],
1095 + [0, 2, 1, -1],
1096 + [0, 1, -1, -1],
1097 + [0, 1, -1, -1],
1098 + [0, 1, -1, -1],
1099 + [0, 1, -1, -1],
1100 + ]
1101 + ],
1102 + device="cuda",
1103 + dtype=torch.int32,
1104 + )
1105 +
1106 + sparse_bt, sparse_ctx = minimax_m3_build_sparse_block_table_decode(
1107 + topk_idx, block_table, seq_lens, decode_query_len
1108 + )
1109 + torch.accelerator.synchronize()
1110 +
1111 + expected_bt = torch.zeros_like(sparse_bt)
1112 + expected_ctx = torch.empty_like(sparse_ctx)
1113 + topk_cpu = topk_idx.cpu()[0].tolist()
1114 + block_table_cpu = block_table.cpu()
1115 + seq_lens_cpu = seq_lens.cpu().tolist()
1116 +
1117 + for row, selected_blocks in enumerate(topk_cpu):
1118 + req_id = row // decode_query_len
1119 + local_q = row - req_id * decode_query_len
1120 + query_pos = seq_lens_cpu[req_id] - decode_query_len + local_q
1121 + causal_len = max(query_pos + 1, 0)
1122 + if causal_len == 0:
1123 + expected_ctx[row] = 0
1124 + continue
1125 + self_block = query_pos // BLOCK_SIZE
1126 +
1127 + valid = [blk for blk in selected_blocks if 0 <= blk <= self_block]
1128 + full_blocks = [blk for blk in valid if blk < self_block]
1129 + tail_blocks = [blk for blk in valid if blk == self_block]
1130 + ordered_blocks = full_blocks + tail_blocks
1131 +
1132 + for slot, block_id in enumerate(ordered_blocks):
1133 + base_phys = int(block_table_cpu[req_id, block_id]) * PAGES_PER_SPARSE_BLOCK
1134 + start = slot * PAGES_PER_SPARSE_BLOCK
1135 + expected_bt[row, start : start + PAGES_PER_SPARSE_BLOCK] = torch.arange(
1136 + base_phys,
1137 + base_phys + PAGES_PER_SPARSE_BLOCK,
1138 + device="cuda",
1139 + dtype=torch.int32,
1140 + )
1141 +
1142 + if tail_blocks:
1143 + expected_ctx[row] = (
1144 + len(full_blocks) * BLOCK_SIZE + causal_len - self_block * BLOCK_SIZE
1145 + )
1146 + else:
1147 + expected_ctx[row] = min(len(valid) * BLOCK_SIZE, causal_len)
1148 +
1149 + assert torch.equal(sparse_ctx, expected_ctx)
1150 + assert torch.equal(sparse_bt, expected_bt)
1151 +
1152 +
1153 @pytest.mark.parametrize("kv_layout", ["NHD", "HND"], indirect=True)
1154 @pytest.mark.parametrize(
1155 "seq_lens_list",
vllm/models/minimax_m3/amd/ops/sparse_pa.py
+41 -10
@@ -49,7 +49,8 @@ def _build_sparse_block_table_kernel(
49 ):
50 pid_b = tl.program_id(0)
51 seq_len = tl.load(seq_lens_ptr + pid_b)
52 - last_blk = (seq_len - 1) // SPARSE_BLOCK_SIZE_C
52 + has_tokens = seq_len > 0
53 + last_blk = tl.maximum((seq_len - 1) // SPARSE_BLOCK_SIZE_C, 0)
54
55 topk_row = topk_ptr + pid_b * stride_topk_n
56 bt_row = block_table_ptr + pid_b * stride_bt_b
@@ -57,7 +58,7 @@ def _build_sparse_block_table_kernel(
58
59 off_t = tl.arange(0, BLOCK_SIZE_T)
60 blk = tl.load(topk_row + off_t * stride_topk_k, mask=off_t < max_topk, other=-1)
60 - valid = blk >= 0
61 + valid = has_tokens & (blk >= 0)
62 is_tail = valid & (blk == last_blk)
63 is_full = valid & (blk != last_blk)
64
@@ -82,7 +83,7 @@ def _build_sparse_block_table_kernel(
83 mask=(off_w >= n_used) & (off_w < row_width),
84 )
85
85 - tail_tokens = seq_len - last_blk * SPARSE_BLOCK_SIZE_C
86 + tail_tokens = tl.where(has_tokens, seq_len - last_blk * SPARSE_BLOCK_SIZE_C, 0)
87 has_tail = tl.sum(is_tail.to(tl.int32), axis=0) > 0
88 ctx = n_full * SPARSE_BLOCK_SIZE_C + tl.where(has_tail, tail_tokens, 0)
89 ctx = tl.where(has_tail, ctx, tl.minimum(n_valid * SPARSE_BLOCK_SIZE_C, seq_len))
@@ -140,7 +141,8 @@ def _build_sparse_block_table_prefill_kernel(
141 pid_n = tl.program_id(0)
142 req_id = tl.load(req_id_ptr + pid_n)
143 abs_pos = tl.load(abs_pos_ptr + pid_n)
143 - causal_len = abs_pos + 1
144 + # Padded speculative rows can have negative positions; clamp to empty range.
145 + causal_len = tl.maximum(abs_pos + 1, 0)
146 self_blk = abs_pos // SPARSE_BLOCK_SIZE_C
147
148 topk_row = topk_ptr + pid_n * stride_topk_n
@@ -149,7 +151,7 @@ def _build_sparse_block_table_prefill_kernel(
151
152 off_t = tl.arange(0, BLOCK_SIZE_T)
153 blk = tl.load(topk_row + off_t * stride_topk_k, mask=off_t < max_topk, other=-1)
152 - valid = (blk >= 0) & (blk <= self_blk)
154 + valid = (causal_len > 0) & (blk >= 0) & (blk <= self_blk)
155 is_tail = valid & (blk == self_blk)
156 is_full = valid & (blk < self_blk)
157
@@ -214,6 +216,34 @@ def minimax_m3_build_sparse_block_table_prefill(
216 return sparse_bt, sparse_ctx
217
218
219 +@torch.no_grad()
220 +def minimax_m3_build_sparse_block_table_decode(
221 + topk_idx: torch.Tensor, # [1, batch * decode_query_len, topk]
222 + block_table: torch.Tensor, # [batch, max_blocks]
223 + seq_lens: torch.Tensor, # [batch]
224 + decode_query_len: int,
225 +) -> tuple[torch.Tensor, torch.Tensor]:
226 + """Build one page-16 sparse block table row per decode query token."""
227 + if decode_query_len == 1:
228 + return minimax_m3_build_sparse_block_table(topk_idx, block_table, seq_lens)
229 +
230 + total_q = topk_idx.shape[1]
231 + expected_q = block_table.shape[0] * decode_query_len
232 + assert total_q == expected_q, (
233 + "MiniMax-M3 decode top-k rows must equal batch * decode_query_len: "
234 + f"{total_q} != {expected_q}"
235 + )
236 + pos = torch.arange(total_q, dtype=torch.int32, device=topk_idx.device)
237 + query_req_id = torch.div(pos, decode_query_len, rounding_mode="floor")
238 + q_offset = pos - query_req_id * decode_query_len
239 + query_abs_pos = (seq_lens[query_req_id] - decode_query_len + q_offset).to(
240 + torch.int32
241 + )
242 + return minimax_m3_build_sparse_block_table_prefill(
243 + topk_idx, block_table, query_req_id, query_abs_pos
244 + )
245 +
246 +
247 @triton.jit
248 def _insert_index_cache_kernel(
249 index_k_ptr, # [num_tokens, head_dim]
@@ -317,20 +347,21 @@ def _gluon_scale_arg(
347
348 @torch.no_grad()
349 def minimax_m3_sparse_attn_decode_aiter(
320 - q: torch.Tensor, # [batch, num_heads, head_dim]
350 + q: torch.Tensor, # [batch * decode_query_len, num_heads, head_dim]
351 k_cache: torch.Tensor, # [phys16, num_kv_heads, head_dim // x, 16, x]
352 v_cache: torch.Tensor, # [phys16, num_kv_heads, 16 // x, head_dim, x]
323 - topk_idx: torch.Tensor, # [1, batch, topk]
353 + topk_idx: torch.Tensor, # [1, batch * decode_query_len, topk]
354 block_table: torch.Tensor, # [batch, max_blocks]
355 seq_lens: torch.Tensor, # [batch]
356 num_kv_heads: int,
357 sm_scale: float,
328 - output: torch.Tensor, # [batch, num_heads, head_dim]
358 + output: torch.Tensor, # [batch * decode_query_len, num_heads, head_dim]
359 k_scale: torch.Tensor | None = None,
360 v_scale: torch.Tensor | None = None,
361 + decode_query_len: int = 1,
362 ) -> None:
332 - sparse_bt, sparse_ctx = minimax_m3_build_sparse_block_table(
333 - topk_idx, block_table, seq_lens
363 + sparse_bt, sparse_ctx = minimax_m3_build_sparse_block_table_decode(
364 + topk_idx, block_table, seq_lens, decode_query_len
365 )
366 _run_gluon_decode(
367 q,
vllm/models/minimax_m3/amd/sparse_attention_msa.py
+1 -5
@@ -55,11 +55,6 @@ class MiniMaxM3SparseAiterPAImpl(MiniMaxM3SparseImpl):
55 if main_md.num_decodes > 0:
56 d = main_md.decode
57 assert d is not None
58 - if d.decode_query_len != 1:
59 - raise NotImplementedError(
60 - "MiniMax-M3 AITER sparse PA does not support speculative "
61 - f"decode_query_len={d.decode_query_len}"
62 - )
58 minimax_m3_sparse_attn_decode_aiter(
59 q[:nd],
60 k_cache,
@@ -72,6 +67,7 @@ class MiniMaxM3SparseAiterPAImpl(MiniMaxM3SparseImpl):
67 out[:nd],
68 k_scale=k_scale,
69 v_scale=v_scale,
70 + decode_query_len=d.decode_query_len,
71 )
72
73 if main_md.num_prefills > 0: