[ROCm][Perf] Optimize MiniMax-M3 decode indexer and top-k (#54682)

Signed-off-by: fai <fangzhouai@gmail.com> Co-authored-by: Andreas Karatzas <akaratza@amd.com> Co-authored-by: OpenAI Codex <codex@openai.com>

Fangzhou Ai committed Sep 3, 2026 at 16:20 UTC cee0f92c02112ace7120da45896d27e0fe95ea1e
6 files changed +2010 -327
tests/kernels/attention/test_minimax_m3.py
+840
@@ -21,6 +21,7 @@ from vllm.models.minimax_m3.common.ops.sparse_attn import (
21 )
22 from vllm.models.minimax_m3.common.sparse_attention import MiniMaxM3SparseTritonImpl
23 from vllm.platforms import current_platform
24 +from vllm.utils.torch_utils import set_random_seed
25 from vllm.v1.attention.backends.utils import record_kv_cache_layout
26 from vllm.v1.kv_cache_interface import (
27 FullAttentionSpec,
@@ -92,6 +93,382 @@ SM_SCALE = HEAD_DIM**-0.5
93 TOPK = 16
94
95
96 +@pytest.mark.parametrize(
97 + (
98 + "num_reqs",
99 + "head_dim",
100 + "query_dtype",
101 + "cache_dtype",
102 + "is_gfx950",
103 + "expected",
104 + ),
105 + [
106 + (19, 128, torch.bfloat16, torch.bfloat16, True, (16, False)),
107 + (20, 128, torch.bfloat16, torch.bfloat16, True, (32, True)),
108 + (32, 128, torch.bfloat16, torch.bfloat16, True, (32, True)),
109 + (33, 128, torch.bfloat16, torch.bfloat16, True, (16, True)),
110 + (64, 128, torch.bfloat16, torch.bfloat16, True, (16, True)),
111 + (65, 128, torch.bfloat16, torch.bfloat16, True, (4, False)),
112 + (20, 128, torch.bfloat16, torch.bfloat16, False, (16, False)),
113 + (20, 64, torch.bfloat16, torch.bfloat16, True, (16, False)),
114 + (20, 128, torch.float16, torch.bfloat16, True, (16, False)),
115 + (20, 128, torch.bfloat16, torch.float16, True, (16, False)),
116 + ],
117 +)
118 +def test_amd_decode_score_split_launch_policy(
119 + num_reqs: int,
120 + head_dim: int,
121 + query_dtype: torch.dtype,
122 + cache_dtype: torch.dtype,
123 + is_gfx950: bool,
124 + expected: tuple[int, bool],
125 +):
126 + from vllm.models.minimax_m3.amd.ops.index_topk import (
127 + _decode_score_split_launch_policy,
128 + )
129 +
130 + assert (
131 + _decode_score_split_launch_policy(
132 + num_reqs,
133 + head_dim,
134 + query_dtype,
135 + cache_dtype,
136 + is_gfx950=is_gfx950,
137 + )
138 + == expected
139 + )
140 +
141 +
142 +@pytest.mark.parametrize("num_reqs", range(1, 12))
143 +def test_amd_balanced_decode_score_mapping_exact_coverage(num_reqs: int):
144 + from vllm.models.minimax_m3.amd.ops.index_topk import (
145 + _decode_score_program_budget,
146 + )
147 +
148 + budget = _decode_score_program_budget(
149 + num_reqs,
150 + 128,
151 + torch.bfloat16,
152 + torch.bfloat16,
153 + is_gfx950=True,
154 + )
155 + assert budget is not None
156 + patterns = (
157 + (0,) * num_reqs,
158 + tuple((request * 7919 + 17) % 65535 for request in range(num_reqs)),
159 + (65534,) * num_reqs,
160 + )
161 + for request_blocks in patterns:
162 + width = max(1, (sum(request_blocks) + budget - 1) // budget)
163 + grid = budget + len(request_blocks) - 1
164 + intervals = []
165 + for request, blocks in enumerate(request_blocks):
166 + programs = (blocks + width - 1) // width
167 + for local_program in range(programs):
168 + begin = local_program * width
169 + intervals.append((request, begin, min(begin + width, blocks)))
170 +
171 + assert len(intervals) <= grid
172 + by_request: list[list[tuple[int, int]]] = [[] for _ in request_blocks]
173 + for request, begin, end in intervals:
174 + assert 0 <= request < len(request_blocks)
175 + assert 0 <= begin < end <= request_blocks[request]
176 + by_request[request].append((begin, end))
177 + for blocks, request_intervals in zip(
178 + request_blocks,
179 + by_request,
180 + strict=True,
181 + ):
182 + cursor = 0
183 + for begin, end in request_intervals:
184 + assert begin == cursor
185 + cursor = end
186 + assert cursor == blocks
187 +
188 +
189 +@pytest.mark.skipif(
190 + not current_platform.is_rocm(),
191 + reason="MiniMax-M3 balanced decode score is ROCm-only",
192 +)
193 +@pytest.mark.parametrize(
194 + (
195 + "num_reqs",
196 + "max_block",
197 + "num_idx_heads",
198 + "decode_query_len",
199 + "max_decode_query_len",
200 + "init_blocks",
201 + "local_blocks",
202 + ),
203 + [
204 + (1, 37, 1, 1, 1, 1, 0),
205 + (8, 37, 1, 1, 4, 2, 1),
206 + (9, 37, 1, 4, 4, 0, 2),
207 + (11, 37, 1, 3, 5, 1, 3),
208 + (1, 37, 2, 1, 1, 2, 0),
209 + (8, 37, 2, 1, 4, 0, 1),
210 + (9, 37, 2, 4, 4, 1, 2),
211 + (11, 37, 2, 3, 5, 2, 3),
212 + (1, 37, 4, 1, 1, 0, 0),
213 + (8, 37, 4, 1, 4, 1, 1),
214 + (9, 37, 4, 4, 4, 2, 2),
215 + (11, 37, 4, 3, 5, 0, 3),
216 + (20, 65, 1, 4, 4, 1, 3),
217 + (36, 65, 1, 4, 4, 2, 2),
218 + (64, 65, 1, 4, 4, 0, 1),
219 + ],
220 +)
221 +def test_amd_optimized_decode_score_bitwise_and_graph_replay(
222 + num_reqs: int,
223 + max_block: int,
224 + num_idx_heads: int,
225 + decode_query_len: int,
226 + max_decode_query_len: int,
227 + init_blocks: int,
228 + local_blocks: int,
229 +):
230 + from vllm.models.minimax_m3.amd.ops.index_topk import (
231 + _decode_index_score_balanced_kernel,
232 + _decode_index_score_kernel,
233 + _decode_score_program_budget,
234 + _decode_score_split_launch_policy,
235 + )
236 +
237 + set_random_seed(0)
238 + head_dim = 128
239 + block_size_q = 1 << (max_decode_query_len - 1).bit_length()
240 + score_stride = (max_block + 15) // 16 * 16
241 + num_pages = num_reqs * max_block
242 + block_table = torch.arange(
243 + num_pages,
244 + device="cuda",
245 + dtype=torch.int32,
246 + ).reshape(num_reqs, max_block)
247 + idx_q = torch.randn(
248 + (num_reqs * decode_query_len, num_idx_heads, head_dim),
249 + device="cuda",
250 + dtype=torch.bfloat16,
251 + )
252 + index_kv_cache = torch.randn(
253 + (num_pages, BLOCK_SIZE, head_dim),
254 + device="cuda",
255 + dtype=torch.bfloat16,
256 + )
257 + seq_lens = torch.empty(num_reqs, device="cuda", dtype=torch.int32)
258 + deployed_score = torch.empty(
259 + (num_idx_heads, num_reqs * decode_query_len, score_stride),
260 + device="cuda",
261 + dtype=torch.float32,
262 + )
263 + optimized_score = torch.empty_like(deployed_score)
264 + budget = _decode_score_program_budget(
265 + num_reqs,
266 + head_dim,
267 + idx_q.dtype,
268 + index_kv_cache.dtype,
269 + is_gfx950=True,
270 + )
271 + optimized_chunks = None
272 + if budget is None:
273 + optimized_chunks, use_high_batch_config = _decode_score_split_launch_policy(
274 + num_reqs,
275 + head_dim,
276 + idx_q.dtype,
277 + index_kv_cache.dtype,
278 + is_gfx950=True,
279 + )
280 + assert use_high_batch_config
281 + target = max(1, min(256, 512 // num_reqs))
282 + deployed_chunks = 1 << (target.bit_length() - 1)
283 +
284 + def launch_deployed() -> None:
285 + _decode_index_score_kernel[(num_reqs, deployed_chunks)](
286 + idx_q,
287 + index_kv_cache,
288 + deployed_score,
289 + block_table,
290 + seq_lens,
291 + num_idx_heads,
292 + head_dim,
293 + init_blocks,
294 + local_blocks,
295 + decode_query_len,
296 + idx_q.stride(0),
297 + idx_q.stride(1),
298 + idx_q.stride(2),
299 + index_kv_cache.stride(0),
300 + index_kv_cache.stride(1),
301 + index_kv_cache.stride(2),
302 + deployed_score.stride(0),
303 + deployed_score.stride(1),
304 + deployed_score.stride(2),
305 + block_table.stride(0),
306 + BLOCK_SIZE_K=BLOCK_SIZE,
307 + BLOCK_SIZE_Q=block_size_q,
308 + num_kv_chunks=deployed_chunks,
309 + USE_PDL=False,
310 + )
311 +
312 + def launch_optimized() -> None:
313 + if budget is not None:
314 + _decode_index_score_balanced_kernel[(budget + num_reqs - 1,)](
315 + idx_q,
316 + index_kv_cache,
317 + optimized_score,
318 + block_table,
319 + seq_lens,
320 + num_idx_heads,
321 + head_dim,
322 + init_blocks,
323 + local_blocks,
324 + num_reqs,
325 + budget,
326 + decode_query_len,
327 + idx_q.stride(0),
328 + idx_q.stride(1),
329 + idx_q.stride(2),
330 + index_kv_cache.stride(0),
331 + index_kv_cache.stride(1),
332 + index_kv_cache.stride(2),
333 + optimized_score.stride(0),
334 + optimized_score.stride(1),
335 + optimized_score.stride(2),
336 + block_table.stride(0),
337 + BLOCK_SIZE_K=BLOCK_SIZE,
338 + BLOCK_SIZE_Q=block_size_q,
339 + num_warps=2,
340 + num_stages=1,
341 + )
342 + else:
343 + assert optimized_chunks is not None
344 + _decode_index_score_kernel[(num_reqs, optimized_chunks)](
345 + idx_q,
346 + index_kv_cache,
347 + optimized_score,
348 + block_table,
349 + seq_lens,
350 + num_idx_heads,
351 + head_dim,
352 + init_blocks,
353 + local_blocks,
354 + decode_query_len,
355 + idx_q.stride(0),
356 + idx_q.stride(1),
357 + idx_q.stride(2),
358 + index_kv_cache.stride(0),
359 + index_kv_cache.stride(1),
360 + index_kv_cache.stride(2),
361 + optimized_score.stride(0),
362 + optimized_score.stride(1),
363 + optimized_score.stride(2),
364 + block_table.stride(0),
365 + BLOCK_SIZE_K=BLOCK_SIZE,
366 + BLOCK_SIZE_Q=block_size_q,
367 + num_kv_chunks=optimized_chunks,
368 + USE_PDL=False,
369 + num_warps=2,
370 + num_stages=1,
371 + )
372 +
373 + def score_masks(
374 + expected_seq_lens: torch.Tensor,
375 + ) -> tuple[torch.Tensor, torch.Tensor]:
376 + query_offsets = torch.arange(decode_query_len, device="cuda")
377 + kv_lens = torch.clamp(
378 + expected_seq_lens[:, None] - decode_query_len + query_offsets + 1,
379 + min=0,
380 + )
381 + query_blocks = (kv_lens + BLOCK_SIZE - 1) // BLOCK_SIZE
382 + block_ids = torch.arange(score_stride, device="cuda")
383 + topk_mask = block_ids[None, :] < query_blocks.reshape(-1, 1)
384 + request_blocks = query_blocks.max(dim=1).values
385 + write_mask = (
386 + block_ids[None, :]
387 + < request_blocks.repeat_interleave(decode_query_len)[:, None]
388 + )
389 + return write_mask, topk_mask
390 +
391 + def packed_keys(score: torch.Tensor, live: torch.Tensor) -> torch.Tensor:
392 + normalized = torch.where(torch.isnan(score), -1e30, score)
393 + normalized = torch.where(normalized == 0.0, 0.0, normalized)
394 + bits = normalized.contiguous().view(torch.int32).to(torch.int64)
395 + bits &= 0xFFFFFFFF
396 + ordered = bits ^ torch.where(
397 + bits >> 31 != 0,
398 + 0xFFFFFFFF,
399 + 0x80000000,
400 + )
401 + index = torch.arange(score_stride, device="cuda") + 1
402 + keys = (1 << 48) | (ordered << 16) | (0xFFFF - index)
403 + return torch.where(live, keys, 0)
404 +
405 + def assert_exact(expected_seq_lens: torch.Tensor) -> None:
406 + write_mask, topk_mask = score_masks(expected_seq_lens)
407 + write_mask = write_mask.unsqueeze(0).expand_as(deployed_score)
408 + topk_mask = topk_mask.unsqueeze(0)
409 + deployed = deployed_score
410 + optimized = optimized_score
411 + assert torch.equal(
412 + deployed[write_mask].contiguous().view(torch.int32),
413 + optimized[write_mask].contiguous().view(torch.int32),
414 + )
415 + assert torch.isnan(deployed[~write_mask]).all()
416 + assert torch.isnan(optimized[~write_mask]).all()
417 + deployed_keys = packed_keys(deployed, topk_mask)
418 + optimized_keys = packed_keys(optimized, topk_mask)
419 + assert torch.equal(deployed_keys, optimized_keys)
420 + deployed_topk = torch.topk(deployed_keys, 16, dim=-1, sorted=True)
421 + optimized_topk = torch.topk(optimized_keys, 16, dim=-1, sorted=True)
422 + assert torch.equal(deployed_topk.values, optimized_topk.values)
423 + assert torch.equal(deployed_topk.indices, optimized_topk.indices)
424 +
425 + uniform = torch.full(
426 + (num_reqs,),
427 + max_block * BLOCK_SIZE,
428 + device="cuda",
429 + dtype=torch.int32,
430 + )
431 + one_active = torch.zeros(num_reqs, device="cuda", dtype=torch.int32)
432 + one_active[0] = max(1, max_block // 2) * BLOCK_SIZE
433 + patterns = [uniform, one_active]
434 + non_aligned = torch.tensor(
435 + [(max_block - 1) * BLOCK_SIZE + request % 3 + 1 for request in range(num_reqs)],
436 + device="cuda",
437 + dtype=torch.int32,
438 + )
439 + patterns.append(non_aligned)
440 +
441 + seq_lens.copy_(uniform)
442 + deployed_score.fill_(float("nan"))
443 + optimized_score.fill_(float("nan"))
444 + launch_deployed()
445 + launch_optimized()
446 + assert_exact(uniform)
447 +
448 + graph = torch.cuda.CUDAGraph()
449 + with torch.cuda.graph(graph):
450 + launch_optimized()
451 + addresses = (
452 + seq_lens.data_ptr(),
453 + optimized_score.data_ptr(),
454 + block_table.data_ptr(),
455 + index_kv_cache.data_ptr(),
456 + )
457 + for pattern in patterns:
458 + seq_lens.copy_(pattern)
459 + deployed_score.fill_(float("nan"))
460 + optimized_score.fill_(float("nan"))
461 + launch_deployed()
462 + graph.replay()
463 + assert_exact(pattern)
464 + assert addresses == (
465 + seq_lens.data_ptr(),
466 + optimized_score.data_ptr(),
467 + block_table.data_ptr(),
468 + index_kv_cache.data_ptr(),
469 + )
470 +
471 +
472 @pytest.mark.parametrize(
473 ("kv_cache_dtype", "expected_dtype"),
474 [
@@ -638,6 +1015,338 @@ def test_decode_index_topk_correctness(
1015 _assert_topk_indices_equal_unordered(actual, expected)
1016
1017
1018 +@pytest.mark.parametrize(
1019 + (
1020 + "max_block",
1021 + "total_q",
1022 + "num_idx_heads",
1023 + "topk",
1024 + "is_gfx950",
1025 + "expected",
1026 + ),
1027 + [
1028 + (0, 24, 1, 16, True, (2, False, False)),
1029 + (1, 1, 1, 16, True, (16, True, True)),
1030 + (512, 4, 1, 16, True, (16, True, True)),
1031 + (513, 24, 4, 16, True, (16, True, True)),
1032 + (2760, 5, 2, 16, True, (16, True, True)),
1033 + (8192, 64, 4, 16, True, (16, True, True)),
1034 + (8193, 4, 1, 16, True, (16, False, False)),
1035 + (8193, 16, 1, 16, True, (4, False, False)),
1036 + (65534, 64, 1, 16, True, (1, False, False)),
1037 + (513, 16, 1, 13, True, (4, False, False)),
1038 + (513, 16, 4, 16, False, (1, False, False)),
1039 + ],
1040 +)
1041 +def test_amd_decode_topk_launch_policy(
1042 + max_block: int,
1043 + total_q: int,
1044 + num_idx_heads: int,
1045 + topk: int,
1046 + is_gfx950: bool,
1047 + expected: tuple[int, bool, bool],
1048 +):
1049 + """All supported head and query layouts use adaptive selection."""
1050 + from vllm.models.minimax_m3.amd.ops.index_topk import (
1051 + _decode_topk_launch_policy,
1052 + )
1053 +
1054 + assert (
1055 + _decode_topk_launch_policy(
1056 + max_block,
1057 + total_q,
1058 + num_idx_heads,
1059 + topk,
1060 + is_gfx950=is_gfx950,
1061 + )
1062 + == expected
1063 + )
1064 +
1065 +
1066 +@pytest.mark.skipif(
1067 + not current_platform.is_rocm(),
1068 + reason="MiniMax-M3 fused decode top-k is ROCm-only",
1069 +)
1070 +@pytest.mark.parametrize(
1071 + (
1072 + "num_topk_chunks",
1073 + "single_tile_guaranteed",
1074 + "adaptive_final_merge",
1075 + "max_blocks",
1076 + "topk",
1077 + "num_idx_heads",
1078 + ),
1079 + [
1080 + (1, False, False, 65534, 16, 1),
1081 + (2, False, False, 8193, 16, 1),
1082 + (4, False, False, 8193, 16, 1),
1083 + (8, False, False, 8193, 16, 1),
1084 + (16, False, False, 8193, 16, 1),
1085 + (16, False, False, 65534, 16, 1),
1086 + (2, True, False, 513, 13, 1),
1087 + (4, False, True, 8192, 16, 2),
1088 + (16, True, True, 8192, 16, 4),
1089 + ],
1090 +)
1091 +def test_amd_decode_fused_topk_total_order_and_replay(
1092 + num_topk_chunks: int,
1093 + single_tile_guaranteed: bool,
1094 + adaptive_final_merge: bool,
1095 + max_blocks: int,
1096 + topk: int,
1097 + num_idx_heads: int,
1098 +):
1099 + """Atomic multi-chunk selection is ordered and resets graph state."""
1100 + from vllm.models.minimax_m3.amd.ops.index_topk import (
1101 + SPARSE_BLOCK_SIZE,
1102 + _decode_topk_fused_kernel,
1103 + )
1104 +
1105 + set_random_seed(0)
1106 + block_size_t = 1 << (topk - 1).bit_length()
1107 + long_seq_lens_list = [
1108 + 0,
1109 + 1,
1110 + 15 * SPARSE_BLOCK_SIZE,
1111 + 16 * SPARSE_BLOCK_SIZE,
1112 + 17 * SPARSE_BLOCK_SIZE,
1113 + 255 * SPARSE_BLOCK_SIZE,
1114 + 256 * SPARSE_BLOCK_SIZE,
1115 + 257 * SPARSE_BLOCK_SIZE,
1116 + 511 * SPARSE_BLOCK_SIZE,
1117 + 512 * SPARSE_BLOCK_SIZE,
1118 + 513 * SPARSE_BLOCK_SIZE,
1119 + max_blocks * SPARSE_BLOCK_SIZE,
1120 + ]
1121 + short_seq_lens_list = [0, 1, 127, 128, 129, 0, 1, 128, 129, 0, 1, 129]
1122 + boundary_limit = 0
1123 + if num_topk_chunks == 16 and max_blocks >= 8192:
1124 + boundary_limit = 15
1125 + elif num_topk_chunks == 4 and adaptive_final_merge:
1126 + boundary_limit = 4
1127 + if boundary_limit:
1128 + boundary_blocks = [
1129 + (active_chunks - 1) * 512 + 1
1130 + for active_chunks in range(3, boundary_limit + 1)
1131 + ]
1132 + assert [(num_blocks + 511) // 512 for num_blocks in boundary_blocks] == list(
1133 + range(3, boundary_limit + 1)
1134 + )
1135 + long_seq_lens_list.extend(
1136 + num_blocks * SPARSE_BLOCK_SIZE for num_blocks in boundary_blocks
1137 + )
1138 + short_seq_lens_list.extend(
1139 + num_blocks * SPARSE_BLOCK_SIZE for num_blocks in reversed(boundary_blocks)
1140 + )
1141 + batch = len(long_seq_lens_list)
1142 + score = torch.randn((num_idx_heads, batch, max_blocks), device="cuda")
1143 + score[:, :, 0] = float("nan")
1144 + score[:, :, 1] = float("inf")
1145 + score[:, :, 2] = float("-inf")
1146 + score[:, :, 3] = 0.0
1147 + score[:, :, 4] = -0.0
1148 + score[:, :, 5] = 7.0
1149 + score[:, :, 6] = -1e30
1150 + score[:, :, 256] = 7.0
1151 + score[:, :, 512] = 7.0
1152 +
1153 + seq_lens = torch.empty(batch, device="cuda", dtype=torch.int32)
1154 + partial = torch.full(
1155 + (num_topk_chunks, num_idx_heads, batch, block_size_t),
1156 + -1,
1157 + device="cuda",
1158 + dtype=torch.int64,
1159 + )
1160 + counter = torch.zeros((num_idx_heads, batch), device="cuda", dtype=torch.int32)
1161 + actual = torch.full(
1162 + (num_idx_heads, batch, topk), -2, device="cuda", dtype=torch.int32
1163 + )
1164 + dummy_block_table = torch.zeros(
1165 + (batch, max_blocks), device="cuda", dtype=torch.int32
1166 + )
1167 + addresses = (partial.data_ptr(), counter.data_ptr(), actual.data_ptr())
1168 + long_seq_lens = torch.tensor(long_seq_lens_list, device="cuda", dtype=torch.int32)
1169 + short_seq_lens = torch.tensor(short_seq_lens_list, device="cuda", dtype=torch.int32)
1170 +
1171 + def launch() -> None:
1172 + _decode_topk_fused_kernel[(batch, num_idx_heads, num_topk_chunks)](
1173 + score,
1174 + partial,
1175 + counter,
1176 + actual,
1177 + seq_lens,
1178 + dummy_block_table,
1179 + actual,
1180 + seq_lens,
1181 + 1,
1182 + score.stride(0),
1183 + score.stride(1),
1184 + score.stride(2),
1185 + partial.stride(0),
1186 + partial.stride(1),
1187 + partial.stride(2),
1188 + partial.stride(3),
1189 + counter.stride(0),
1190 + counter.stride(1),
1191 + actual.stride(0),
1192 + actual.stride(1),
1193 + actual.stride(2),
1194 + dummy_block_table.stride(0),
1195 + actual.stride(1),
1196 + topk=topk,
1197 + block_size=SPARSE_BLOCK_SIZE,
1198 + pages_per_sparse_block=8,
1199 + block_page_stride=8,
1200 + NUM_TOPK_CHUNKS=num_topk_chunks,
1201 + BLOCK_SIZE_K=512,
1202 + BLOCK_SIZE_T=block_size_t,
1203 + EMIT_SPARSE_TABLE=False,
1204 + SINGLE_TILE_GUARANTEED=single_tile_guaranteed,
1205 + ADAPTIVE_FINAL_MERGE=adaptive_final_merge,
1206 + num_warps=4 if adaptive_final_merge else 8,
1207 + num_stages=2,
1208 + )
1209 +
1210 + score_cpu = score.cpu()
1211 +
1212 + def reference(seq_lens_list: list[int]) -> torch.Tensor:
1213 + expected = torch.full((num_idx_heads, batch, topk), -1, dtype=torch.int32)
1214 + for head in range(num_idx_heads):
1215 + for row, seq_len in enumerate(seq_lens_list):
1216 + num_blocks = (seq_len + SPARSE_BLOCK_SIZE - 1) // SPARSE_BLOCK_SIZE
1217 + values = score_cpu[head, row, :num_blocks].tolist()
1218 + values = [-1e30 if value != value else value for value in values]
1219 + selected = sorted(
1220 + range(num_blocks),
1221 + key=lambda index: (-values[index], index),
1222 + )[:topk]
1223 + expected[head, row, : len(selected)] = torch.tensor(selected)
1224 + return expected
1225 +
1226 + seq_lens.copy_(long_seq_lens)
1227 + launch()
1228 + torch.accelerator.synchronize()
1229 + assert torch.equal(actual.cpu(), reference(long_seq_lens_list))
1230 + assert torch.count_nonzero(counter).item() == 0
1231 +
1232 + seq_lens.copy_(short_seq_lens)
1233 + launch()
1234 + torch.accelerator.synchronize()
1235 + assert torch.equal(actual.cpu(), reference(short_seq_lens_list))
1236 + assert torch.count_nonzero(counter).item() == 0
1237 +
1238 + replay_count = 8 if max_blocks == 65534 else 128
1239 + for replay in range(replay_count):
1240 + seq_lens.copy_(long_seq_lens if replay % 2 == 0 else short_seq_lens)
1241 + launch()
1242 + seq_lens.copy_(long_seq_lens)
1243 + launch()
1244 + torch.accelerator.synchronize()
1245 + assert torch.equal(actual.cpu(), reference(long_seq_lens_list))
1246 + assert torch.count_nonzero(counter).item() == 0
1247 + assert addresses == (partial.data_ptr(), counter.data_ptr(), actual.data_ptr())
1248 +
1249 + if (num_topk_chunks, single_tile_guaranteed, adaptive_final_merge, max_blocks) in (
1250 + (4, False, False, 8193),
1251 + (16, False, False, 8193),
1252 + (4, False, True, 8192),
1253 + (16, True, True, 8192),
1254 + ):
1255 + graph = torch.cuda.CUDAGraph()
1256 + torch.accelerator.synchronize()
1257 + with torch.cuda.graph(graph):
1258 + launch()
1259 + seq_lens.copy_(short_seq_lens)
1260 + graph.replay()
1261 + torch.accelerator.synchronize()
1262 + assert torch.equal(actual.cpu(), reference(short_seq_lens_list))
1263 + assert torch.count_nonzero(counter).item() == 0
1264 + seq_lens.copy_(long_seq_lens)
1265 + graph.replay()
1266 + torch.accelerator.synchronize()
1267 + assert torch.equal(actual.cpu(), reference(long_seq_lens_list))
1268 + assert torch.count_nonzero(counter).item() == 0
1269 + assert addresses == (partial.data_ptr(), counter.data_ptr(), actual.data_ptr())
1270 +
1271 +
1272 +@pytest.mark.skipif(
1273 + not current_platform.is_rocm(),
1274 + reason="MiniMax-M3 fused decode top-k is ROCm-only",
1275 +)
1276 +@pytest.mark.parametrize(
1277 + ("num_idx_heads", "decode_query_len", "max_decode_query_len"),
1278 + [(1, 1, 1), (2, 1, 4), (4, 4, 4), (4, 3, 8)],
1279 +)
1280 +def test_amd_decode_index_topk_end_to_end(
1281 + num_idx_heads: int,
1282 + decode_query_len: int,
1283 + max_decode_query_len: int,
1284 +):
1285 + """ROCm BF16 score-to-selector parity crosses the atomic boundary."""
1286 + from vllm.models.minimax_m3.amd.ops.index_topk import (
1287 + minimax_m3_index_decode as amd_minimax_m3_index_decode,
1288 + )
1289 +
1290 + torch.manual_seed(0)
1291 + topk = 16
1292 + head_dim = 16
1293 + seq_lens = torch.tensor([65537], device="cuda", dtype=torch.int32)
1294 + q_lens = torch.tensor([decode_query_len], device="cuda", dtype=torch.int32)
1295 + prefix_lens = seq_lens - q_lens
1296 + max_seq_len = int(seq_lens.max())
1297 + max_blocks = (max_seq_len + BLOCK_SIZE - 1) // BLOCK_SIZE
1298 + block_table = torch.randperm(
1299 + max_blocks, device="cuda", dtype=torch.int32
1300 + ).unsqueeze(0)
1301 + idx_q = torch.randn(
1302 + decode_query_len,
1303 + num_idx_heads,
1304 + head_dim,
1305 + device="cuda",
1306 + dtype=torch.bfloat16,
1307 + )
1308 + index_kv_cache = torch.randn(
1309 + max_blocks,
1310 + BLOCK_SIZE,
1311 + head_dim,
1312 + device="cuda",
1313 + dtype=torch.bfloat16,
1314 + )
1315 + completion_counter = torch.zeros(
1316 + (num_idx_heads, decode_query_len), device="cuda", dtype=torch.int32
1317 + )
1318 +
1319 + actual = amd_minimax_m3_index_decode(
1320 + idx_q,
1321 + index_kv_cache,
1322 + block_table,
1323 + seq_lens,
1324 + max_seq_len=max_seq_len,
1325 + topk=topk,
1326 + init_blocks=0,
1327 + local_blocks=1,
1328 + num_kv_heads=num_idx_heads,
1329 + decode_query_len=decode_query_len,
1330 + max_decode_query_len=max_decode_query_len,
1331 + completion_counter=completion_counter,
1332 + )
1333 + expected = _reference_index_topk(
1334 + idx_q,
1335 + index_kv_cache,
1336 + block_table,
1337 + q_lens,
1338 + seq_lens,
1339 + prefix_lens,
1340 + topk,
1341 + 0,
1342 + 1,
1343 + )
1344 + torch.accelerator.synchronize()
1345 +
1346 + _assert_topk_indices_equal_unordered(actual, expected)
1347 + assert torch.count_nonzero(completion_counter).item() == 0
1348 +
1349 +
1350 @pytest.mark.skipif(
1351 not current_platform.is_device_capability_family(100),
1352 reason="fp8 e4m3 indexer cache is the SM100 (MSA) path.",
@@ -1250,6 +1959,137 @@ def test_aiter_decode_sparse_block_table_supports_spec_decode():
1959 assert torch.equal(sparse_bt, expected_bt)
1960
1961
1962 +@pytest.mark.skipif(
1963 + not current_platform.is_rocm(),
1964 + reason="MiniMax-M3 fused decode top-k is ROCm-only",
1965 +)
1966 +@pytest.mark.parametrize("block_page_stride", [8, 16])
1967 +@pytest.mark.parametrize(
1968 + "seq_lens_list",
1969 + [
1970 + [4097],
1971 + [65537],
1972 + [1, 129, 4097, 0],
1973 + [127, 128, 129, 256, 4097, 0],
1974 + [1, 127, 128, 129, 255, 256, 257, 4097, 0],
1975 + ],
1976 +)
1977 +def test_amd_decode_fused_topk_emits_sparse_table(
1978 + block_page_stride: int, seq_lens_list: list[int]
1979 +):
1980 + """The fused selector must match top-k followed by table construction."""
1981 + from vllm.models.minimax_m3.amd.ops.index_topk import (
1982 + minimax_m3_index_decode as amd_minimax_m3_index_decode,
1983 + )
1984 + from vllm.models.minimax_m3.amd.ops.sparse_pa import (
1985 + PAGES_PER_SPARSE_BLOCK,
1986 + minimax_m3_build_sparse_block_table_decode,
1987 + )
1988 +
1989 + torch.manual_seed(0)
1990 + decode_query_len = 4
1991 + seq_lens = torch.tensor(seq_lens_list, device="cuda", dtype=torch.int32)
1992 + batch = seq_lens.numel()
1993 + total_q = batch * decode_query_len
1994 + max_seq_len = int(seq_lens.max())
1995 + max_blocks = (max_seq_len + BLOCK_SIZE - 1) // BLOCK_SIZE
1996 +
1997 + index_block_table = torch.zeros(
1998 + (batch, max_blocks), device="cuda", dtype=torch.int32
1999 + )
2000 + for req_id, seq_len in enumerate(seq_lens.tolist()):
2001 + num_blocks = (seq_len + BLOCK_SIZE - 1) // BLOCK_SIZE
2002 + pages = torch.arange(max_blocks, device="cuda", dtype=torch.int32)
2003 + index_block_table[req_id, :num_blocks] = torch.roll(pages, req_id)[:num_blocks]
2004 +
2005 + # The index cache and main-attention cache have independent physical page
2006 + # mappings in production. Disjoint values make using the wrong table fail.
2007 + attention_block_table = torch.full_like(index_block_table, -31337)
2008 + for req_id, seq_len in enumerate(seq_lens.tolist()):
2009 + num_blocks = (seq_len + BLOCK_SIZE - 1) // BLOCK_SIZE
2010 + first_page = 10_000 + req_id * max_blocks
2011 + attention_block_table[req_id, :num_blocks] = torch.arange(
2012 + first_page + num_blocks - 1,
2013 + first_page - 1,
2014 + -1,
2015 + device="cuda",
2016 + dtype=torch.int32,
2017 + )
2018 + assert not torch.equal(index_block_table, attention_block_table)
2019 +
2020 + idx_q = torch.randn(total_q, 1, HEAD_DIM, device="cuda", dtype=torch.bfloat16)
2021 + index_kv_cache = torch.randn(
2022 + max_blocks,
2023 + BLOCK_SIZE,
2024 + HEAD_DIM,
2025 + device="cuda",
2026 + dtype=torch.bfloat16,
2027 + )
2028 + decode_kwargs = {
2029 + "max_seq_len": max_seq_len,
2030 + "topk": TOPK,
2031 + "init_blocks": 0,
2032 + "local_blocks": 1,
2033 + "num_kv_heads": 1,
2034 + "decode_query_len": decode_query_len,
2035 + "max_decode_query_len": decode_query_len,
2036 + }
2037 + completion_counter = torch.zeros((1, total_q), device="cuda", dtype=torch.int32)
2038 +
2039 + expected_topk = amd_minimax_m3_index_decode(
2040 + idx_q,
2041 + index_kv_cache,
2042 + index_block_table,
2043 + seq_lens,
2044 + completion_counter=completion_counter,
2045 + **decode_kwargs,
2046 + )
2047 + expected_sparse_bt, expected_sparse_ctx = (
2048 + minimax_m3_build_sparse_block_table_decode(
2049 + expected_topk,
2050 + attention_block_table,
2051 + seq_lens,
2052 + decode_query_len,
2053 + block_page_stride,
2054 + )
2055 + )
2056 +
2057 + topk_out = torch.full_like(expected_topk, -27182)
2058 + sparse_bt_out = torch.full(
2059 + (total_q, TOPK * PAGES_PER_SPARSE_BLOCK),
2060 + -27182,
2061 + device="cuda",
2062 + dtype=torch.int32,
2063 + )
2064 + sparse_ctx_out = torch.full((total_q,), -27182, device="cuda", dtype=torch.int32)
2065 + actual_topk = amd_minimax_m3_index_decode(
2066 + idx_q,
2067 + index_kv_cache,
2068 + index_block_table,
2069 + seq_lens,
2070 + out=topk_out,
2071 + attention_block_table=attention_block_table,
2072 + sparse_block_table_out=sparse_bt_out,
2073 + sparse_context_lens_out=sparse_ctx_out,
2074 + block_page_stride=block_page_stride,
2075 + completion_counter=completion_counter,
2076 + **decode_kwargs,
2077 + )
2078 + torch.accelerator.synchronize()
2079 +
2080 + assert actual_topk.data_ptr() == topk_out.data_ptr()
2081 + assert torch.equal(actual_topk, expected_topk)
2082 + assert torch.equal(sparse_bt_out, expected_sparse_bt)
2083 + assert torch.equal(sparse_ctx_out, expected_sparse_ctx)
2084 + assert torch.count_nonzero(completion_counter).item() == 0
2085 +
2086 + if seq_lens_list[-1] == 0:
2087 + padded_rows = slice(total_q - decode_query_len, total_q)
2088 + assert torch.all(actual_topk[:, padded_rows] == -1)
2089 + assert torch.count_nonzero(sparse_bt_out[padded_rows]) == 0
2090 + assert torch.count_nonzero(sparse_ctx_out[padded_rows]) == 0
2091 +
2092 +
2093 @pytest.mark.parametrize("kv_layout", ["NHD", "HND"], indirect=True)
2094 @pytest.mark.parametrize(
2095 "seq_lens_list",
vllm/models/minimax_m3/amd/model.py
+46 -1
@@ -92,6 +92,13 @@ from vllm.models.minimax_m3.amd.ops import (
92 gemma_rmsnorm,
93 swiglu_oai_split,
94 )
95 +from vllm.models.minimax_m3.amd.ops.sparse_pa import (
96 + minimax_m3_alloc_sparse_block_table,
97 + minimax_m3_sparse_block_page_stride,
98 +)
99 +from vllm.models.minimax_m3.amd.sparse_attention_msa import (
100 + MiniMaxM3SparseAiterPAImpl,
101 +)
102 from vllm.models.minimax_m3.common.indexer import MiniMaxM3Indexer
103 from vllm.models.minimax_m3.common.mm_preprocess import (
104 MiniMaxM3VLDummyInputsBuilder,
@@ -701,6 +708,7 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
708 self.kv_cache_k = torch.tensor([])
709 self.kv_cache_v = torch.tensor([])
710 self._aiter_sparse_pa_cache_data_ptr = 0
711 + 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(
@@ -796,6 +804,9 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
804 self.head_dim,
805 x,
806 )[v_page_offset:]
807 + self._aiter_sparse_pa_block_page_stride = minimax_m3_sparse_block_page_stride(
808 + self.kv_cache_k, self.kv_cache_v
809 + )
810 self._aiter_sparse_pa_cache_data_ptr = self.kv_cache.data_ptr()
811
812 def get_aiter_sparse_pa_kv_cache(self) -> tuple[torch.Tensor, torch.Tensor]:
@@ -1020,9 +1031,43 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
1031 # top-k into the shared ``topk_indices_buffer``; the attend reads it back.
1032 # When skip_index_topk is set (ATOM index_topk_freq), reuse the selection
1033 # the preceding compute layer wrote into the shared buffer this forward.
1034 + decode_sparse_table: tuple[torch.Tensor, torch.Tensor] | None = None
1035 if not self.skip_index_topk:
1036 assert index_query is not None
1025 - self.indexer(index_query)
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 + )
1058 + else:
1059 + self.indexer(index_query)
1060 + else:
1061 + self.indexer(index_query)
1062 + if self.use_aiter_sparse_pa:
1063 + assert isinstance(self.impl, MiniMaxM3SparseAiterPAImpl)
1064 + return self.impl.forward(
1065 + self,
1066 + query,
1067 + self.kv_cache,
1068 + output,
1069 + decode_sparse_table=decode_sparse_table,
1070 + )
1071 return self.impl.forward(self, query, self.kv_cache, output)
1072
1073
vllm/models/minimax_m3/amd/ops/index_topk.py
+986 -306
@@ -7,21 +7,119 @@ then the top-k blocks (plus forced init/local blocks) are selected per query
7 token. Adapted to vLLM's paged KV cache: the KV page size is forced to equal the
8 sparse block size (128), so one sparse block maps to exactly one page.
9
10 -Index-K cache layout (vLLM): ``(num_blocks, 128, idx_head_dim)`` (single head).
10 +Index-K cache layout (vLLM): ``(num_blocks, 128, idx_head_dim)`` (one shared
11 +key vector per token).
12
13 Only the paths MiniMax M3 uses are implemented: score_type="max", index value
13 -disabled (score-only indexer), single shared index head. The selected block ids
14 -feed the block-sparse attention kernels in ``sparse_attn``.
14 +disabled (score-only indexer), and shared index keys. Each local index-query
15 +head selects its own block ids for the block-sparse attention kernels in
16 +``sparse_attn``.
17 """
18
19 import torch
20
21 +from vllm.models.minimax_m3.amd.ops.sparse_pa import (
22 + PAGES_PER_SPARSE_BLOCK,
23 + _write_sparse_block_table_row_from_values,
24 +)
25 from vllm.platforms import current_platform
26 from vllm.triton_utils import tl, triton
27 from vllm.utils.math_utils import round_up
28
29 # One sparse block == one KV page.
30 SPARSE_BLOCK_SIZE = 128
31 +DECODE_SCORE_BALANCED_PROGRAM_BUDGET = 1024
32 +DECODE_SCORE_HIGH_BATCH_PROGRAM_BUDGET = 768
33 +MAX_DECODE_SCORE_BALANCED_REQUESTS = 11
34 +DECODE_SCORE_DEFAULT_TARGET_GRID = 512
35 +DECODE_SCORE_GFX950_HIGH_BATCH_TARGET_GRID = 1024
36 +MIN_DECODE_SCORE_GFX950_HIGH_BATCH_REQUESTS = 20
37 +MAX_DECODE_SCORE_GFX950_HIGH_BATCH_REQUESTS = 64
38 +MAX_DECODE_SCORE_KV_CHUNKS = 256
39 +DECODE_TOPK_BLOCKS_PER_CHUNK = 512
40 +MAX_DECODE_TOPK_FAST_CHUNKS = 16
41 +DECODE_TOPK_TARGET_GRID = 64
42 +
43 +
44 +def _decode_score_program_budget(
45 + num_reqs: int,
46 + head_dim: int,
47 + query_dtype: torch.dtype,
48 + cache_dtype: torch.dtype,
49 + *,
50 + is_gfx950: bool,
51 +) -> int | None:
52 + if not (
53 + is_gfx950
54 + and head_dim == 128
55 + and query_dtype == torch.bfloat16
56 + and cache_dtype == torch.bfloat16
57 + ):
58 + return None
59 + if 1 <= num_reqs <= 8:
60 + return DECODE_SCORE_BALANCED_PROGRAM_BUDGET
61 + if 9 <= num_reqs <= MAX_DECODE_SCORE_BALANCED_REQUESTS:
62 + return DECODE_SCORE_HIGH_BATCH_PROGRAM_BUDGET
63 + return None
64 +
65 +
66 +def _decode_score_split_launch_policy(
67 + num_reqs: int,
68 + head_dim: int,
69 + query_dtype: torch.dtype,
70 + cache_dtype: torch.dtype,
71 + *,
72 + is_gfx950: bool,
73 +) -> tuple[int, bool]:
74 + """Choose the generic split-K launch and high-batch specialization."""
75 + use_high_batch_config = (
76 + is_gfx950
77 + and MIN_DECODE_SCORE_GFX950_HIGH_BATCH_REQUESTS
78 + <= num_reqs
79 + <= MAX_DECODE_SCORE_GFX950_HIGH_BATCH_REQUESTS
80 + and head_dim == 128
81 + and query_dtype == torch.bfloat16
82 + and cache_dtype == torch.bfloat16
83 + )
84 + target_grid = (
85 + DECODE_SCORE_GFX950_HIGH_BATCH_TARGET_GRID
86 + if use_high_batch_config
87 + else DECODE_SCORE_DEFAULT_TARGET_GRID
88 + )
89 + target = max(
90 + 1,
91 + min(
92 + MAX_DECODE_SCORE_KV_CHUNKS,
93 + target_grid // max(1, num_reqs),
94 + ),
95 + )
96 + return 1 << (target.bit_length() - 1), use_high_batch_config
97 +
98 +
99 +def _decode_topk_launch_policy(
100 + max_block: int,
101 + total_q: int,
102 + num_idx_heads: int,
103 + topk: int,
104 + *,
105 + is_gfx950: bool,
106 +) -> tuple[int, bool, bool]:
107 + """Choose the graph grid and bounded-context selector specialization."""
108 + if (
109 + is_gfx950
110 + and topk == 16
111 + and 0 < max_block <= MAX_DECODE_TOPK_FAST_CHUNKS * DECODE_TOPK_BLOCKS_PER_CHUNK
112 + ):
113 + return MAX_DECODE_TOPK_FAST_CHUNKS, True, True
114 +
115 + target = max(
116 + 1,
117 + min(
118 + MAX_DECODE_TOPK_FAST_CHUNKS,
119 + DECODE_TOPK_TARGET_GRID // max(1, total_q * num_idx_heads),
120 + ),
121 + )
122 + return 1 << (target.bit_length() - 1), False, False
123
124
125 # ---------------------------------------------------------------------------
@@ -397,255 +495,670 @@ def _decode_index_score_kernel(
495 )
496
497
400 -# ---------------------------------------------------------------------------
401 -# Decode top-k (split-K): per-chunk partial top-k + merge. Forced init/local
402 -# blocks are already encoded in the scores.
403 -# ---------------------------------------------------------------------------
404 -@triton.heuristics({"BLOCK_SIZE_T": lambda args: triton.next_power_of_2(args["topk"])})
405 -@triton.autotune(
406 - configs=[
407 - triton.Config({"BLOCK_SIZE_K": 256}, num_warps=8, num_stages=2),
408 - triton.Config({"BLOCK_SIZE_K": 256}, num_warps=4, num_stages=2),
409 - triton.Config({"BLOCK_SIZE_K": 128}, num_warps=4, num_stages=2),
410 - triton.Config({"BLOCK_SIZE_K": 128}, num_warps=4, num_stages=3),
411 - triton.Config({"BLOCK_SIZE_K": 64}, num_warps=2, num_stages=2),
412 - ],
413 - key=["topk"],
414 -)
415 -@triton.jit(do_not_specialize=["chunk_blocks", "decode_query_len"])
416 -def _topk_index_partial_kernel(
417 - s_ptr, # score: [num_idx_heads, total_q, max_block]
418 - ts_partial_ptr, # partial scores out: [NUM_TOPK_CHUNKS, num_idx_heads, total_q, T]
419 - ti_partial_ptr, # partial idx out (1-indexed global, 0=invalid): same shape
420 - seq_lens, # [num_reqs]
421 - block_size: tl.constexpr, # sparse block size (128)
422 - topk: tl.constexpr,
423 - chunk_blocks, # how many score-blocks each chunk owns
498 +@triton.jit
499 +def _ceil_div_nonnegative(value, divisor):
500 + return value // divisor + tl.where(value % divisor != 0, 1, 0)
501 +
502 +
503 +@triton.jit
504 +def _decode_index_score_mapped_range(
505 + q_ptr,
506 + ik_cache_ptr,
507 + score_ptr,
508 + block_table_ptr,
509 + pid_r,
510 + seq_len,
511 + chunk_start_block,
512 + blocks_per_program,
513 + num_idx_heads: tl.constexpr,
514 + head_dim: tl.constexpr,
515 + init_blocks,
516 + local_blocks,
517 decode_query_len,
518 + stride_q_n,
519 + stride_q_h,
520 + stride_q_d,
521 + stride_ik_blk,
522 + stride_ik_pos,
523 + stride_ik_d,
524 stride_s_h,
426 - stride_s_b,
525 + stride_s_n,
526 stride_s_k,
428 - stride_ts_c,
429 - stride_ts_h,
430 - stride_ts_b,
431 - stride_ts_t,
432 - stride_ti_c,
433 - stride_ti_h,
434 - stride_ti_b,
435 - stride_ti_t,
527 + stride_bt_b,
528 BLOCK_SIZE_K: tl.constexpr,
437 - BLOCK_SIZE_T: tl.constexpr,
438 - USE_PDL: tl.constexpr,
529 + BLOCK_SIZE_Q: tl.constexpr,
530 ):
440 - tl.static_assert(topk < BLOCK_SIZE_K)
441 - pid_b = tl.program_id(0) # flattened query-token id
442 - pid_h = tl.program_id(1)
443 - pid_chunk = tl.program_id(2)
444 - req_id = pid_b // decode_query_len
445 - q_offset = pid_b - req_id * decode_query_len
446 -
447 - if USE_PDL:
448 - tl.extra.cuda.gdc_wait()
531 + BLOCK_SIZE_HQ: tl.constexpr = num_idx_heads * BLOCK_SIZE_Q
532 + hq_offsets = tl.arange(0, BLOCK_SIZE_HQ)
533 + h_offsets = hq_offsets // BLOCK_SIZE_Q
534 + q_offsets = hq_offsets % BLOCK_SIZE_Q
535 + q_mask = q_offsets < decode_query_len
536 + q_ids = pid_r * decode_query_len + q_offsets
537
450 - seq_len = tl.load(seq_lens + req_id)
451 - query_pos = seq_len - decode_query_len + q_offset
452 - # Full-CG padding uses zero-length request rows. Clamp to an empty
453 - # attention range instead of letting padded rows produce negative lengths.
538 + query_pos = seq_len - decode_query_len + q_offsets
539 kv_len = tl.maximum(query_pos + 1, 0)
455 - num_blocks = (kv_len + block_size - 1) // block_size
456 -
457 - # Slice this chunk owns within [0, num_blocks).
458 - chunk_start = pid_chunk * chunk_blocks
459 - chunk_end = tl.minimum(chunk_start + chunk_blocks, num_blocks)
460 - chunk_actual = tl.maximum(chunk_end - chunk_start, 0)
461 -
462 - off_k = tl.arange(0, BLOCK_SIZE_K)
463 - off_t = tl.arange(0, BLOCK_SIZE_T)
464 -
465 - s_ptrs = (
466 - s_ptr
467 - + pid_b * stride_s_b
468 - + pid_h * stride_s_h
469 - + (chunk_start + off_k) * stride_s_k
540 + num_blocks_q = (kv_len + BLOCK_SIZE_K - 1) // BLOCK_SIZE_K
541 + kv_len_max = tl.max(tl.where(q_mask, kv_len, 0), axis=0)
542 + num_blocks = (kv_len_max + BLOCK_SIZE_K - 1) // BLOCK_SIZE_K
543 + chunk_end_block = tl.minimum(
544 + chunk_start_block + blocks_per_program,
545 + num_blocks,
546 )
547
472 - topk_score = tl.full((BLOCK_SIZE_K,), -1e30, dtype=tl.float32)
473 - topk_idx = tl.full((BLOCK_SIZE_K,), 0, dtype=tl.int32)
474 - left_half_mask = tl.arange(0, BLOCK_SIZE_K) < BLOCK_SIZE_K // 2
475 -
476 - # Streaming top-K within this chunk. tl.range(0, 0) is a no-op so empty
477 - # chunks (chunk_actual == 0) skip the body and store sentinel -1e30 / 0.
478 - for i in tl.range(0, chunk_actual, BLOCK_SIZE_K):
479 - mask = off_k < chunk_actual - i
480 - score = tl.load(s_ptrs, mask=mask, other=-1e30).to(tl.float32)
481 - score = tl.where(score != score, -1e30, score)
482 - s_ptrs = s_ptrs + stride_s_k * BLOCK_SIZE_K
483 - topk_score, last_topk_score = score, topk_score
484 - topk_idx, last_topk_idx = (
485 - tl.where(mask, chunk_start + i + off_k + 1, 0), # 1-indexed global
486 - topk_idx,
548 + if chunk_start_block < chunk_end_block:
549 + off_k = tl.arange(0, BLOCK_SIZE_K)
550 + off_d = tl.arange(0, head_dim)
551 + bt_row = block_table_ptr + pid_r * stride_bt_b
552 + local_start = tl.maximum(0, num_blocks_q - local_blocks)
553 + q = tl.load(
554 + q_ptr
555 + + q_ids[None, :] * stride_q_n
556 + + h_offsets[None, :] * stride_q_h
557 + + off_d[:, None] * stride_q_d,
558 + mask=q_mask[None, :],
559 + other=0.0,
560 )
488 - n_dims: tl.constexpr = tl.standard._log2(BLOCK_SIZE_K)
489 - for j in tl.static_range(1, n_dims):
490 - topk_score, topk_idx = _bitonic_merge(
491 - topk_score, topk_idx.to(tl.int32), j, 2, n_dims
492 - )
493 - if i != 0:
494 - topk_score, topk_idx = _bitonic_merge(
495 - topk_score, topk_idx.to(tl.int32), n_dims, False, n_dims
561 + for blk in tl.range(chunk_start_block, chunk_end_block):
562 + page = tl.load(bt_row + blk).to(tl.int64)
563 + pos = blk * BLOCK_SIZE_K + off_k
564 + pos_mask = pos[:, None] < kv_len[None, :]
565 + k = tl.load(
566 + ik_cache_ptr
567 + + page * stride_ik_blk
568 + + off_k[:, None] * stride_ik_pos
569 + + off_d * stride_ik_d,
570 )
497 - topk_score_new = last_topk_score * left_half_mask + topk_score * (
498 - 1 - left_half_mask
499 - )
500 - topk_idx_new = last_topk_idx * left_half_mask + topk_idx * (
501 - 1 - left_half_mask
571 + if BLOCK_SIZE_HQ == 1:
572 + q_vec = tl.sum(q, axis=1).to(tl.float32)
573 + kq = tl.sum(
574 + k.to(tl.float32) * q_vec[None, :],
575 + axis=1,
576 + )[:, None]
577 + else:
578 + kq = tl.dot(k, q, out_dtype=tl.float32)
579 + kq = tl.where(
580 + pos_mask & q_mask[None, :],
581 + kq,
582 + float("-inf"),
583 )
503 - topk_score, topk_idx = _bitonic_merge(
504 - topk_score_new, topk_idx_new.to(tl.int32), n_dims, True, n_dims
584 + score = tl.max(kq, axis=0)
585 + is_visible_block = blk < num_blocks_q
586 + is_init = (blk < init_blocks) & is_visible_block
587 + is_local = (blk >= local_start) & is_visible_block
588 + score = tl.where(
589 + is_local,
590 + 1e29,
591 + tl.where(is_init, 1e30, score),
592 )
506 - else:
507 - topk_score, topk_idx = _bitonic_merge(
508 - topk_score, topk_idx.to(tl.int32), n_dims, True, n_dims
593 + tl.store(
594 + score_ptr
595 + + h_offsets * stride_s_h
596 + + q_ids * stride_s_n
597 + + blk * stride_s_k,
598 + score,
599 + mask=q_mask,
600 )
601
511 - if USE_PDL:
512 - tl.extra.cuda.gdc_launch_dependents()
602
514 - # Extract first BLOCK_SIZE_T entries (top-K of this chunk after the sort).
515 - topk_mask_extract = tl.arange(0, BLOCK_SIZE_K // BLOCK_SIZE_T) == 0
516 - final_score = tl.sum(
517 - topk_mask_extract[:, None]
518 - * tl.reshape(topk_score, [BLOCK_SIZE_K // BLOCK_SIZE_T, BLOCK_SIZE_T]),
519 - axis=0,
520 - )
521 - final_idx = tl.sum(
522 - topk_mask_extract[:, None]
523 - * tl.reshape(topk_idx, [BLOCK_SIZE_K // BLOCK_SIZE_T, BLOCK_SIZE_T]),
524 - axis=0,
603 +@triton.jit(do_not_specialize=["score_program_budget", "decode_query_len"])
604 +def _decode_index_score_balanced_kernel(
605 + q_ptr,
606 + ik_cache_ptr,
607 + score_ptr,
608 + block_table_ptr,
609 + seq_lens,
610 + num_idx_heads: tl.constexpr,
611 + head_dim: tl.constexpr,
612 + init_blocks,
613 + local_blocks,
614 + NUM_REQUESTS: tl.constexpr,
615 + score_program_budget,
616 + decode_query_len,
617 + stride_q_n,
618 + stride_q_h,
619 + stride_q_d,
620 + stride_ik_blk,
621 + stride_ik_pos,
622 + stride_ik_d,
623 + stride_s_h,
624 + stride_s_n,
625 + stride_s_k,
626 + stride_bt_b,
627 + BLOCK_SIZE_K: tl.constexpr,
628 + BLOCK_SIZE_Q: tl.constexpr,
629 +):
630 + pid = tl.program_id(0)
631 + total_blocks = tl.zeros((), dtype=tl.int32)
632 + for req in tl.static_range(0, NUM_REQUESTS):
633 + req_seq_len = tl.maximum(tl.load(seq_lens + req), 0)
634 + total_blocks += _ceil_div_nonnegative(
635 + req_seq_len,
636 + BLOCK_SIZE_K,
637 + )
638 + blocks_per_program = tl.maximum(
639 + 1,
640 + _ceil_div_nonnegative(
641 + total_blocks,
642 + score_program_budget,
643 + ),
644 )
645
527 - # Always write all BLOCK_SIZE_T slots — invalid slots carry -1e30 / 0
528 - # sentinels and lose to real scores in the merge stage.
529 - ts_ptrs = (
530 - ts_partial_ptr
531 - + pid_chunk * stride_ts_c
532 - + pid_b * stride_ts_b
533 - + pid_h * stride_ts_h
534 - + off_t * stride_ts_t
646 + program_offset = tl.zeros((), dtype=tl.int32)
647 + pid_r = tl.zeros((), dtype=tl.int32)
648 + chunk_start_block = tl.zeros((), dtype=tl.int32)
649 + selected_seq_len = tl.zeros((), dtype=tl.int32)
650 + owns_work = tl.zeros((), dtype=tl.int32)
651 + for req in tl.static_range(0, NUM_REQUESTS):
652 + req_seq_len = tl.maximum(tl.load(seq_lens + req), 0)
653 + req_num_blocks = _ceil_div_nonnegative(
654 + req_seq_len,
655 + BLOCK_SIZE_K,
656 + )
657 + req_programs = _ceil_div_nonnegative(
658 + req_num_blocks,
659 + blocks_per_program,
660 + )
661 + owns_request = (pid >= program_offset) & (pid < program_offset + req_programs)
662 + pid_r = tl.where(owns_request, req, pid_r)
663 + local_program = tl.where(
664 + owns_request,
665 + pid - program_offset,
666 + 0,
667 + )
668 + chunk_start_block = tl.where(
669 + owns_request,
670 + local_program * blocks_per_program,
671 + chunk_start_block,
672 + )
673 + selected_seq_len = tl.where(
674 + owns_request,
675 + req_seq_len,
676 + selected_seq_len,
677 + )
678 + owns_work += owns_request.to(tl.int32)
679 + program_offset += req_programs
680 + if owns_work == 0:
681 + return
682 +
683 + _decode_index_score_mapped_range(
684 + q_ptr,
685 + ik_cache_ptr,
686 + score_ptr,
687 + block_table_ptr,
688 + pid_r,
689 + selected_seq_len,
690 + chunk_start_block,
691 + blocks_per_program,
692 + num_idx_heads,
693 + head_dim,
694 + init_blocks,
695 + local_blocks,
696 + decode_query_len,
697 + stride_q_n,
698 + stride_q_h,
699 + stride_q_d,
700 + stride_ik_blk,
701 + stride_ik_pos,
702 + stride_ik_d,
703 + stride_s_h,
704 + stride_s_n,
705 + stride_s_k,
706 + stride_bt_b,
707 + BLOCK_SIZE_K,
708 + BLOCK_SIZE_Q,
709 )
536 - ti_ptrs = (
537 - ti_partial_ptr
538 - + pid_chunk * stride_ti_c
539 - + pid_b * stride_ti_b
540 - + pid_h * stride_ti_h
541 - + off_t * stride_ti_t
710 +
711 +
712 +# ---------------------------------------------------------------------------
713 +# Decode top-k. Each CTA keeps a packed score/id top-k. Short rows emit
714 +# directly; long rows synchronize their live CTAs and the last arrival merges
715 +# the partial lists and optionally builds the sparse page table.
716 +# ---------------------------------------------------------------------------
717 +@triton.jit
718 +def _decode_topk_key(score, index, valid):
719 + score = tl.where(score != score, -1e30, score)
720 + score = tl.where(score == 0.0, 0.0, score)
721 + bits = score.to(tl.uint32, bitcast=True)
722 + ordered = bits ^ tl.where(bits >> 31 != 0, 0xFFFFFFFF, 0x80000000)
723 + tie = (0xFFFF - index).to(tl.int64)
724 + key = (1 << 48) | (ordered.to(tl.int64) << 16) | tie
725 + return tl.where(valid, key, 0)
726 +
727 +
728 +@triton.jit
729 +def _store_decode_topk(
730 + winners,
731 + topk_ptr,
732 + attention_block_table_ptr,
733 + sparse_bt_ptr,
734 + sparse_ctx_ptr,
735 + pid_b,
736 + pid_h,
737 + req_id,
738 + query_pos,
739 + num_blocks,
740 + topk: tl.constexpr,
741 + block_size: tl.constexpr,
742 + pages_per_sparse_block: tl.constexpr,
743 + block_page_stride: tl.constexpr,
744 + stride_topk_h,
745 + stride_topk_b,
746 + stride_topk_t,
747 + stride_attention_bt_b,
748 + stride_sparse_bt_b,
749 + BLOCK_SIZE_T: tl.constexpr,
750 + EMIT_SPARSE_TABLE: tl.constexpr,
751 +):
752 + off_t = tl.arange(0, BLOCK_SIZE_T)
753 + topk_idx = (0xFFFF - (winners & 0xFFFF)).to(tl.int32) - 1
754 + topk_idx = tl.where(off_t < tl.minimum(topk, num_blocks), topk_idx, -1)
755 + tl.store(
756 + topk_ptr
757 + + pid_h * stride_topk_h
758 + + pid_b * stride_topk_b
759 + + off_t * stride_topk_t,
760 + topk_idx,
761 + mask=off_t < topk,
762 )
543 - tl.store(ts_ptrs, final_score)
544 - tl.store(ti_ptrs, final_idx)
763 + if EMIT_SPARSE_TABLE:
764 + _write_sparse_block_table_row_from_values(
765 + topk_idx,
766 + attention_block_table_ptr + req_id * stride_attention_bt_b,
767 + sparse_bt_ptr + pid_b * stride_sparse_bt_b,
768 + sparse_ctx_ptr + pid_b,
769 + query_pos,
770 + topk,
771 + block_size,
772 + pages_per_sparse_block,
773 + block_page_stride,
774 + BLOCK_SIZE_T,
775 + )
776
777
547 -@triton.heuristics(
548 - {
549 - "BLOCK_SIZE_T": lambda args: triton.next_power_of_2(args["topk"]),
550 - "BLOCK_SIZE_K": lambda args: triton.next_power_of_2(
551 - args["num_topk_chunks"] * triton.next_power_of_2(args["topk"])
552 - ),
553 - }
554 -)
555 -@triton.jit(do_not_specialize=["num_topk_chunks", "decode_query_len"])
556 -def _topk_index_merge_kernel(
557 - ts_partial_ptr, # partial scores: [NUM_TOPK_CHUNKS, num_idx_heads, total_q, T]
558 - ti_partial_ptr, # partial idx (1-indexed global, 0=invalid): same shape
559 - ti_final_ptr, # final idx (0-indexed, -1=invalid): [num_idx_heads, total_q, topk]
560 - seq_lens, # [num_reqs]
561 - block_size: tl.constexpr, # sparse block size (128)
778 +@triton.jit
779 +def _merge_store_decode_topk(
780 + partial_ptr,
781 + counter,
782 + topk_ptr,
783 + attention_block_table_ptr,
784 + sparse_bt_ptr,
785 + sparse_ctx_ptr,
786 + active_chunks,
787 + pid_b,
788 + pid_h,
789 + req_id,
790 + query_pos,
791 + num_blocks,
792 + stride_partial_c,
793 + stride_partial_h,
794 + stride_partial_b,
795 + stride_partial_t,
796 + stride_topk_h,
797 + stride_topk_b,
798 + stride_topk_t,
799 + stride_attention_bt_b,
800 + stride_sparse_bt_b,
801 topk: tl.constexpr,
802 + block_size: tl.constexpr,
803 + pages_per_sparse_block: tl.constexpr,
804 + block_page_stride: tl.constexpr,
805 + BLOCK_SIZE_T: tl.constexpr,
806 + MERGE_CHUNKS: tl.constexpr,
807 + EMIT_SPARSE_TABLE: tl.constexpr,
808 +):
809 + candidate_width: tl.constexpr = MERGE_CHUNKS * BLOCK_SIZE_T
810 + off_candidate = tl.arange(0, candidate_width)
811 + candidate_chunk = off_candidate // BLOCK_SIZE_T
812 + candidate_slot = off_candidate % BLOCK_SIZE_T
813 + candidates = tl.load(
814 + partial_ptr
815 + + candidate_chunk * stride_partial_c
816 + + pid_h * stride_partial_h
817 + + pid_b * stride_partial_b
818 + + candidate_slot * stride_partial_t,
819 + mask=candidate_chunk < active_chunks,
820 + other=0,
821 + volatile=True,
822 + )
823 + winners = tl.topk(candidates, BLOCK_SIZE_T)
824 + _store_decode_topk(
825 + winners,
826 + topk_ptr,
827 + attention_block_table_ptr,
828 + sparse_bt_ptr,
829 + sparse_ctx_ptr,
830 + pid_b,
831 + pid_h,
832 + req_id,
833 + query_pos,
834 + num_blocks,
835 + topk,
836 + block_size,
837 + pages_per_sparse_block,
838 + block_page_stride,
839 + stride_topk_h,
840 + stride_topk_b,
841 + stride_topk_t,
842 + stride_attention_bt_b,
843 + stride_sparse_bt_b,
844 + BLOCK_SIZE_T,
845 + EMIT_SPARSE_TABLE,
846 + )
847 + tl.atomic_xchg(counter, 0, sem="acq_rel", scope="gpu")
848 +
849 +
850 +@triton.jit(do_not_specialize=["decode_query_len"])
851 +def _decode_topk_fused_kernel(
852 + score_ptr, # [num_idx_heads, total_q, max_block]
853 + partial_ptr, # [NUM_TOPK_CHUNKS, num_idx_heads, total_q, BLOCK_SIZE_T]
854 + counter_ptr, # [num_idx_heads, total_q]
855 + topk_ptr, # [num_idx_heads, total_q, topk]
856 + seq_lens_ptr, # [num_reqs]
857 + attention_block_table_ptr, # [num_reqs, max_blocks]
858 + sparse_bt_ptr, # [total_q, topk * PAGES_PER_SPARSE_BLOCK]
859 + sparse_ctx_ptr, # [total_q]
860 decode_query_len,
564 - stride_ts_c,
565 - stride_ts_h,
566 - stride_ts_b,
567 - stride_ts_t,
568 - stride_ti_c,
569 - stride_ti_h,
570 - stride_ti_b,
571 - stride_ti_t,
572 - stride_tif_h,
573 - stride_tif_b,
574 - stride_tif_t,
575 - num_topk_chunks,
861 + stride_s_h,
862 + stride_s_b,
863 + stride_s_k,
864 + stride_partial_c,
865 + stride_partial_h,
866 + stride_partial_b,
867 + stride_partial_t,
868 + stride_counter_h,
869 + stride_counter_b,
870 + stride_topk_h,
871 + stride_topk_b,
872 + stride_topk_t,
873 + stride_attention_bt_b,
874 + stride_sparse_bt_b,
875 + topk: tl.constexpr,
876 + block_size: tl.constexpr,
877 + pages_per_sparse_block: tl.constexpr,
878 + block_page_stride: tl.constexpr,
879 + NUM_TOPK_CHUNKS: tl.constexpr,
880 BLOCK_SIZE_K: tl.constexpr,
881 BLOCK_SIZE_T: tl.constexpr,
578 - USE_PDL: tl.constexpr,
882 + EMIT_SPARSE_TABLE: tl.constexpr,
883 + SINGLE_TILE_GUARANTEED: tl.constexpr,
884 + ADAPTIVE_FINAL_MERGE: tl.constexpr,
885 ):
580 - pid_b = tl.program_id(0) # flattened query-token id
886 + tl.static_assert(topk <= BLOCK_SIZE_T)
887 + tl.static_assert(BLOCK_SIZE_T <= BLOCK_SIZE_K)
888 + pid_b = tl.program_id(0)
889 pid_h = tl.program_id(1)
890 + pid_chunk = tl.program_id(2)
891 req_id = pid_b // decode_query_len
892 q_offset = pid_b - req_id * decode_query_len
893
585 - if USE_PDL:
586 - tl.extra.cuda.gdc_wait()
587 - tl.extra.cuda.gdc_launch_dependents()
588 -
589 - seq_len = tl.load(seq_lens + req_id)
894 + seq_len = tl.load(seq_lens_ptr + req_id)
895 query_pos = seq_len - decode_query_len + q_offset
591 - # Full-CG padding uses zero-length request rows. Clamp to an empty
592 - # attention range instead of letting padded rows produce negative lengths.
896 kv_len = tl.maximum(query_pos + 1, 0)
897 num_blocks = (kv_len + block_size - 1) // block_size
898
596 - # Load NUM_TOPK_CHUNKS * BLOCK_SIZE_T candidates, padded to BLOCK_SIZE_K.
597 - # Candidate at flat position p comes from chunk = p // BLOCK_SIZE_T,
598 - # in_chunk = p % BLOCK_SIZE_T.
599 - off = tl.arange(0, BLOCK_SIZE_K)
600 - chunk_idx = off // BLOCK_SIZE_T
601 - in_chunk_idx = off % BLOCK_SIZE_T
602 - valid = chunk_idx < num_topk_chunks
603 -
604 - score_offset = (
605 - chunk_idx * stride_ts_c
606 - + pid_h * stride_ts_h
607 - + pid_b * stride_ts_b
608 - + in_chunk_idx * stride_ts_t
609 - )
610 - idx_offset = (
611 - chunk_idx * stride_ti_c
612 - + pid_h * stride_ti_h
613 - + pid_b * stride_ti_b
614 - + in_chunk_idx * stride_ti_t
615 - )
899 + desired_chunks = (num_blocks + BLOCK_SIZE_K - 1) // BLOCK_SIZE_K
900 + active_chunks = tl.maximum(1, tl.minimum(NUM_TOPK_CHUNKS, desired_chunks))
901 + if pid_chunk >= active_chunks:
902 + return
903
617 - score = tl.load(ts_partial_ptr + score_offset, mask=valid, other=-1e30).to(
618 - tl.float32
619 - )
620 - score = tl.where(score != score, -1e30, score)
621 - idx = tl.load(ti_partial_ptr + idx_offset, mask=valid, other=0).to(tl.int32)
622 -
623 - # Full bitonic descending sort of BLOCK_SIZE_K items.
624 - n_dims: tl.constexpr = tl.standard._log2(BLOCK_SIZE_K)
625 - for j in tl.static_range(1, n_dims):
626 - score, idx = _bitonic_merge(score, idx.to(tl.int32), j, 2, n_dims)
627 - score, idx = _bitonic_merge(score, idx.to(tl.int32), n_dims, True, n_dims)
628 -
629 - # Extract first BLOCK_SIZE_T positions — these are the global top-K.
630 - extract_mask = tl.arange(0, BLOCK_SIZE_K // BLOCK_SIZE_T) == 0
631 - topk_idx_final = tl.sum(
632 - extract_mask[:, None]
633 - * tl.reshape(idx - 1, [BLOCK_SIZE_K // BLOCK_SIZE_T, BLOCK_SIZE_T]),
634 - axis=0,
635 - )
904 + blocks_per_chunk = num_blocks // active_chunks
905 + extra_chunks = num_blocks - blocks_per_chunk * active_chunks
906 + chunk_start = pid_chunk * blocks_per_chunk + tl.minimum(pid_chunk, extra_chunks)
907 + chunk_blocks = blocks_per_chunk + (pid_chunk < extra_chunks)
908
909 + off_k = tl.arange(0, BLOCK_SIZE_K)
910 off_t = tl.arange(0, BLOCK_SIZE_T)
638 - tif_ptrs = (
639 - ti_final_ptr
640 - + pid_h * stride_tif_h
641 - + pid_b * stride_tif_b
642 - + off_t * stride_tif_t
643 - )
644 - store_mask = off_t < topk
645 - topk_idx_final = tl.where(off_t < tl.minimum(topk, num_blocks), topk_idx_final, -1)
911 + if SINGLE_TILE_GUARANTEED:
912 + valid = off_k < chunk_blocks
913 + score = tl.load(
914 + score_ptr
915 + + pid_h * stride_s_h
916 + + pid_b * stride_s_b
917 + + (chunk_start + off_k) * stride_s_k,
918 + mask=valid,
919 + other=-1e30,
920 + ).to(tl.float32)
921 + index = (chunk_start + off_k + 1).to(tl.int32)
922 + local_topk = tl.topk(
923 + _decode_topk_key(score, index, valid),
924 + BLOCK_SIZE_T,
925 + )
926 + else:
927 + score_ptrs = (
928 + score_ptr
929 + + pid_h * stride_s_h
930 + + pid_b * stride_s_b
931 + + (chunk_start + off_k) * stride_s_k
932 + )
933 + valid = off_k < chunk_blocks
934 + score = tl.load(score_ptrs, mask=valid, other=-1e30).to(tl.float32)
935 + index = (chunk_start + off_k + 1).to(tl.int32)
936 + local_topk = tl.topk(
937 + _decode_topk_key(score, index, valid),
938 + BLOCK_SIZE_T,
939 + )
940 + score_ptrs += BLOCK_SIZE_K * stride_s_k
941 + for offset in tl.range(BLOCK_SIZE_K, chunk_blocks, BLOCK_SIZE_K):
942 + valid = off_k < chunk_blocks - offset
943 + score = tl.load(score_ptrs, mask=valid, other=-1e30).to(tl.float32)
944 + index = (chunk_start + offset + off_k + 1).to(tl.int32)
945 + tile_topk = tl.topk(
946 + _decode_topk_key(score, index, valid),
947 + BLOCK_SIZE_T,
948 + )
949 + local_topk = tl.topk(
950 + tl.cat(local_topk, tile_topk, can_reorder=True),
951 + BLOCK_SIZE_T,
952 + )
953 + score_ptrs += BLOCK_SIZE_K * stride_s_k
954 +
955 + if active_chunks == 1:
956 + _store_decode_topk(
957 + local_topk,
958 + topk_ptr,
959 + attention_block_table_ptr,
960 + sparse_bt_ptr,
961 + sparse_ctx_ptr,
962 + pid_b,
963 + pid_h,
964 + req_id,
965 + query_pos,
966 + num_blocks,
967 + topk,
968 + block_size,
969 + pages_per_sparse_block,
970 + block_page_stride,
971 + stride_topk_h,
972 + stride_topk_b,
973 + stride_topk_t,
974 + stride_attention_bt_b,
975 + stride_sparse_bt_b,
976 + BLOCK_SIZE_T,
977 + EMIT_SPARSE_TABLE,
978 + )
979 + return
980 +
981 tl.store(
647 - tif_ptrs, topk_idx_final.to(ti_final_ptr.dtype.element_ty), mask=store_mask
982 + partial_ptr
983 + + pid_chunk * stride_partial_c
984 + + pid_h * stride_partial_h
985 + + pid_b * stride_partial_b
986 + + off_t * stride_partial_t,
987 + local_topk,
988 + )
989 + counter = counter_ptr + pid_h * stride_counter_h + pid_b * stride_counter_b
990 + arrival = tl.atomic_add(counter, 1, sem="acq_rel", scope="gpu")
991 + if arrival != active_chunks - 1:
992 + return
993 +
994 + if ADAPTIVE_FINAL_MERGE:
995 + if active_chunks <= 2:
996 + _merge_store_decode_topk(
997 + partial_ptr,
998 + counter,
999 + topk_ptr,
1000 + attention_block_table_ptr,
1001 + sparse_bt_ptr,
1002 + sparse_ctx_ptr,
1003 + active_chunks,
1004 + pid_b,
1005 + pid_h,
1006 + req_id,
1007 + query_pos,
1008 + num_blocks,
1009 + stride_partial_c,
1010 + stride_partial_h,
1011 + stride_partial_b,
1012 + stride_partial_t,
1013 + stride_topk_h,
1014 + stride_topk_b,
1015 + stride_topk_t,
1016 + stride_attention_bt_b,
1017 + stride_sparse_bt_b,
1018 + topk,
1019 + block_size,
1020 + pages_per_sparse_block,
1021 + block_page_stride,
1022 + BLOCK_SIZE_T,
1023 + 2,
1024 + EMIT_SPARSE_TABLE,
1025 + )
1026 + return
1027 + if active_chunks <= 4:
1028 + _merge_store_decode_topk(
1029 + partial_ptr,
1030 + counter,
1031 + topk_ptr,
1032 + attention_block_table_ptr,
1033 + sparse_bt_ptr,
1034 + sparse_ctx_ptr,
1035 + active_chunks,
1036 + pid_b,
1037 + pid_h,
1038 + req_id,
1039 + query_pos,
1040 + num_blocks,
1041 + stride_partial_c,
1042 + stride_partial_h,
1043 + stride_partial_b,
1044 + stride_partial_t,
1045 + stride_topk_h,
1046 + stride_topk_b,
1047 + stride_topk_t,
1048 + stride_attention_bt_b,
1049 + stride_sparse_bt_b,
1050 + topk,
1051 + block_size,
1052 + pages_per_sparse_block,
1053 + block_page_stride,
1054 + BLOCK_SIZE_T,
1055 + 4,
1056 + EMIT_SPARSE_TABLE,
1057 + )
1058 + return
1059 + if active_chunks <= 8:
1060 + _merge_store_decode_topk(
1061 + partial_ptr,
1062 + counter,
1063 + topk_ptr,
1064 + attention_block_table_ptr,
1065 + sparse_bt_ptr,
1066 + sparse_ctx_ptr,
1067 + active_chunks,
1068 + pid_b,
1069 + pid_h,
1070 + req_id,
1071 + query_pos,
1072 + num_blocks,
1073 + stride_partial_c,
1074 + stride_partial_h,
1075 + stride_partial_b,
1076 + stride_partial_t,
1077 + stride_topk_h,
1078 + stride_topk_b,
1079 + stride_topk_t,
1080 + stride_attention_bt_b,
1081 + stride_sparse_bt_b,
1082 + topk,
1083 + block_size,
1084 + pages_per_sparse_block,
1085 + block_page_stride,
1086 + BLOCK_SIZE_T,
1087 + 8,
1088 + EMIT_SPARSE_TABLE,
1089 + )
1090 + return
1091 + _merge_store_decode_topk(
1092 + partial_ptr,
1093 + counter,
1094 + topk_ptr,
1095 + attention_block_table_ptr,
1096 + sparse_bt_ptr,
1097 + sparse_ctx_ptr,
1098 + active_chunks,
1099 + pid_b,
1100 + pid_h,
1101 + req_id,
1102 + query_pos,
1103 + num_blocks,
1104 + stride_partial_c,
1105 + stride_partial_h,
1106 + stride_partial_b,
1107 + stride_partial_t,
1108 + stride_topk_h,
1109 + stride_topk_b,
1110 + stride_topk_t,
1111 + stride_attention_bt_b,
1112 + stride_sparse_bt_b,
1113 + topk,
1114 + block_size,
1115 + pages_per_sparse_block,
1116 + block_page_stride,
1117 + BLOCK_SIZE_T,
1118 + 16,
1119 + EMIT_SPARSE_TABLE,
1120 + )
1121 + return
1122 +
1123 + candidate_width: tl.constexpr = NUM_TOPK_CHUNKS * BLOCK_SIZE_T
1124 + off_candidate = tl.arange(0, candidate_width)
1125 + candidate_chunk = off_candidate // BLOCK_SIZE_T
1126 + candidate_slot = off_candidate % BLOCK_SIZE_T
1127 + candidates = tl.load(
1128 + partial_ptr
1129 + + candidate_chunk * stride_partial_c
1130 + + pid_h * stride_partial_h
1131 + + pid_b * stride_partial_b
1132 + + candidate_slot * stride_partial_t,
1133 + mask=candidate_chunk < active_chunks,
1134 + other=0,
1135 + volatile=True,
1136 )
1137 + winners = tl.topk(candidates, BLOCK_SIZE_T)
1138 + _store_decode_topk(
1139 + winners,
1140 + topk_ptr,
1141 + attention_block_table_ptr,
1142 + sparse_bt_ptr,
1143 + sparse_ctx_ptr,
1144 + pid_b,
1145 + pid_h,
1146 + req_id,
1147 + query_pos,
1148 + num_blocks,
1149 + topk,
1150 + block_size,
1151 + pages_per_sparse_block,
1152 + block_page_stride,
1153 + stride_topk_h,
1154 + stride_topk_b,
1155 + stride_topk_t,
1156 + stride_attention_bt_b,
1157 + stride_sparse_bt_b,
1158 + BLOCK_SIZE_T,
1159 + EMIT_SPARSE_TABLE,
1160 + )
1161 + tl.atomic_xchg(counter, 0, sem="acq_rel", scope="gpu")
1162
1163
1164 # ---------------------------------------------------------------------------
@@ -777,21 +1290,118 @@ def minimax_m3_index_decode(
1290 decode_query_len: int,
1291 max_decode_query_len: int,
1292 out: torch.Tensor | None = None,
1293 + *,
1294 + attention_block_table: torch.Tensor | None = None,
1295 + sparse_block_table_out: torch.Tensor | None = None,
1296 + sparse_context_lens_out: torch.Tensor | None = None,
1297 + block_page_stride: int | None = None,
1298 + completion_counter: torch.Tensor | None = None,
1299 ) -> torch.Tensor:
781 - """Decode index block-score + top-k, both split-K (cudagraph-safe).
1300 + """Decode index block-score followed by fused adaptive top-k selection.
1301
1302 Returns topk_idx [num_kv_heads, total_q, topk] (0-indexed block ids, -1 pad).
1303 When ``out`` ([num_kv_heads, >=total_q, topk]) is given, writes into
1304 ``out[:, :total_q, :]`` (stable address for cudagraph) instead of allocating.
1305 + The optional sparse-table arguments fuse current-layer table construction
1306 + into the selector. They must be provided together. ``completion_counter``
1307 + provides stable per-query synchronization storage for CUDA graphs. It must
1308 + be zero before its first launch and must not be shared by overlapping
1309 + selector invocations; every completed launch resets its active entries.
1310 """
1311 total_q, num_idx_heads, head_dim = idx_q.shape
1312 assert num_idx_heads == num_kv_heads, (
1313 "M3 expects num_idx_heads == num_kv_heads (no topk index reduce)"
1314 )
791 - assert decode_query_len <= max_decode_query_len
1315 + assert 0 < decode_query_len <= max_decode_query_len
1316 assert total_q == seq_lens.shape[0] * decode_query_len
1317 batch = total_q
1318 + emit_sparse_table = attention_block_table is not None
1319 + if emit_sparse_table and (
1320 + sparse_block_table_out is None
1321 + or sparse_context_lens_out is None
1322 + or block_page_stride is None
1323 + ):
1324 + raise ValueError(
1325 + "MiniMax-M3 fused decode sparse-table arguments must be provided together"
1326 + )
1327 + if not emit_sparse_table and (
1328 + sparse_block_table_out is not None
1329 + or sparse_context_lens_out is not None
1330 + or block_page_stride is not None
1331 + ):
1332 + raise ValueError(
1333 + "MiniMax-M3 fused decode sparse-table arguments must be provided together"
1334 + )
1335 + if emit_sparse_table:
1336 + assert attention_block_table is not None
1337 + assert sparse_block_table_out is not None
1338 + assert sparse_context_lens_out is not None
1339 + assert block_page_stride is not None
1340 + if num_idx_heads != 1:
1341 + raise ValueError(
1342 + "MiniMax-M3 fused decode sparse-table construction requires "
1343 + f"one index head, got {num_idx_heads}"
1344 + )
1345 + if block_page_stride not in (
1346 + PAGES_PER_SPARSE_BLOCK,
1347 + 2 * PAGES_PER_SPARSE_BLOCK,
1348 + ):
1349 + raise ValueError(
1350 + "MiniMax-M3 fused decode sparse-table page stride must be "
1351 + f"{PAGES_PER_SPARSE_BLOCK} or {2 * PAGES_PER_SPARSE_BLOCK}, "
1352 + f"got {block_page_stride}"
1353 + )
1354 + expected_sparse_shape = (total_q, topk * PAGES_PER_SPARSE_BLOCK)
1355 + if sparse_block_table_out.shape != expected_sparse_shape:
1356 + raise ValueError(
1357 + "MiniMax-M3 fused decode sparse block table has shape "
1358 + f"{tuple(sparse_block_table_out.shape)}, expected "
1359 + f"{expected_sparse_shape}"
1360 + )
1361 + if sparse_context_lens_out.shape != (total_q,):
1362 + raise ValueError(
1363 + "MiniMax-M3 fused decode sparse context lengths have shape "
1364 + f"{tuple(sparse_context_lens_out.shape)}, expected {(total_q,)}"
1365 + )
1366 + if (
1367 + attention_block_table.dim() != 2
1368 + or attention_block_table.shape[0] != seq_lens.shape[0]
1369 + ):
1370 + raise ValueError(
1371 + "MiniMax-M3 fused decode attention block table must be "
1372 + "[num_requests, max_blocks]"
1373 + )
1374 + int32_tensors = (
1375 + attention_block_table,
1376 + sparse_block_table_out,
1377 + sparse_context_lens_out,
1378 + )
1379 + if any(tensor.dtype != torch.int32 for tensor in int32_tensors):
1380 + raise ValueError("MiniMax-M3 fused decode sparse tables require int32")
1381 + if any(tensor.device != idx_q.device for tensor in int32_tensors):
1382 + raise ValueError(
1383 + "MiniMax-M3 fused decode sparse tables must share the query device"
1384 + )
1385 + if (
1386 + attention_block_table.stride(1) != 1
1387 + or sparse_block_table_out.stride(1) != 1
1388 + or sparse_context_lens_out.stride(0) != 1
1389 + ):
1390 + raise ValueError(
1391 + "MiniMax-M3 fused decode sparse tables require contiguous rows"
1392 + )
1393 max_block = triton.cdiv(max_seq_len, SPARSE_BLOCK_SIZE)
1394 + if max_block >= 0xFFFF:
1395 + raise ValueError(
1396 + "MiniMax-M3 decode top-k supports fewer than 65535 sparse blocks"
1397 + )
1398 + if emit_sparse_table:
1399 + assert attention_block_table is not None
1400 + if attention_block_table.shape[1] < max_block:
1401 + raise ValueError(
1402 + "MiniMax-M3 fused decode attention block table is shorter "
1403 + f"than the required {max_block} sparse blocks"
1404 + )
1405 use_pdl = current_platform.is_arch_support_pdl()
1406 # `launch_pdl` is a Triton runtime kwarg only some backends accept (CUDA
1407 # SM9+); this ROCm Triton rejects it even when False ("Keyword argument
@@ -800,9 +1410,13 @@ def minimax_m3_index_decode(
1410 pdl_kwargs: dict[str, bool | int] = {}
1411 if use_pdl:
1412 pdl_kwargs.update({"launch_pdl": True})
803 - # TP=1 spec decode scores a wide 4-head x 4-position query tile per K block;
804 - # reduce stages to ease memory/register pressure. Keep no-spec and TP=4
805 - # single-head codegen unchanged.
1413 + is_gfx950 = False
1414 + if current_platform.is_rocm():
1415 + from vllm.platforms.rocm import on_gfx950
1416 +
1417 + is_gfx950 = on_gfx950()
1418 + # Multi-head spec decode scores a wider head-position tile per K block;
1419 + # reduce stages to ease memory/register pressure on the fallback path.
1420 score_kwargs = pdl_kwargs.copy()
1421 if num_idx_heads > 1 and max_decode_query_len > 1:
1422 score_kwargs.update({"num_warps": 4, "num_stages": 2})
@@ -814,47 +1428,90 @@ def minimax_m3_index_decode(
1428 dtype=torch.float32,
1429 device=idx_q.device,
1430 )
817 - # split-K over seq blocks; chunk count depends only on shape constants so
818 - # the grid is fixed within a cuda graph.
819 - TARGET_GRID = 512
820 - MAX_NUM_KV_CHUNKS = 256
1431 # Use the configured max decode length to avoid Triton recompiles when
1432 # switching between qlen=1 and spec-decode verification batches.
1433 BLOCK_SIZE_Q = triton.next_power_of_2(max_decode_query_len)
824 - score_ctas_per_chunk = seq_lens.shape[0]
825 - target = max(
826 - 1,
827 - min(MAX_NUM_KV_CHUNKS, TARGET_GRID // max(1, score_ctas_per_chunk)),
828 - )
829 - num_kv_chunks = 1 << (target.bit_length() - 1)
830 - grid_score = (seq_lens.shape[0], num_kv_chunks)
831 - _decode_index_score_kernel[grid_score](
832 - idx_q,
833 - index_kv_cache,
834 - score,
835 - block_table,
836 - seq_lens,
837 - num_idx_heads,
1434 + num_reqs = seq_lens.shape[0]
1435 + score_program_budget = _decode_score_program_budget(
1436 + num_reqs,
1437 head_dim,
839 - init_blocks,
840 - local_blocks,
841 - decode_query_len,
842 - idx_q.stride(0),
843 - idx_q.stride(1),
844 - idx_q.stride(2),
845 - index_kv_cache.stride(0),
846 - index_kv_cache.stride(1),
847 - index_kv_cache.stride(2),
848 - score.stride(0),
849 - score.stride(1),
850 - score.stride(2),
851 - block_table.stride(0),
852 - BLOCK_SIZE_K=SPARSE_BLOCK_SIZE,
853 - BLOCK_SIZE_Q=BLOCK_SIZE_Q,
854 - num_kv_chunks=num_kv_chunks,
855 - USE_PDL=use_pdl,
856 - **score_kwargs,
1438 + idx_q.dtype,
1439 + index_kv_cache.dtype,
1440 + is_gfx950=is_gfx950,
1441 )
1442 + grid_score: tuple[int, ...]
1443 + if score_program_budget is not None:
1444 + grid_score = (score_program_budget + num_reqs - 1,)
1445 + _decode_index_score_balanced_kernel[grid_score](
1446 + idx_q,
1447 + index_kv_cache,
1448 + score,
1449 + block_table,
1450 + seq_lens,
1451 + num_idx_heads,
1452 + head_dim,
1453 + init_blocks,
1454 + local_blocks,
1455 + num_reqs,
1456 + score_program_budget,
1457 + decode_query_len,
1458 + idx_q.stride(0),
1459 + idx_q.stride(1),
1460 + idx_q.stride(2),
1461 + index_kv_cache.stride(0),
1462 + index_kv_cache.stride(1),
1463 + index_kv_cache.stride(2),
1464 + score.stride(0),
1465 + score.stride(1),
1466 + score.stride(2),
1467 + block_table.stride(0),
1468 + BLOCK_SIZE_K=SPARSE_BLOCK_SIZE,
1469 + BLOCK_SIZE_Q=BLOCK_SIZE_Q,
1470 + num_warps=2,
1471 + num_stages=1,
1472 + )
1473 + else:
1474 + # Increase independent work for the measured high-batch gfx950 decode
1475 + # range while preserving the deployed split for every other shape.
1476 + num_kv_chunks, use_high_batch_config = _decode_score_split_launch_policy(
1477 + num_reqs,
1478 + head_dim,
1479 + idx_q.dtype,
1480 + index_kv_cache.dtype,
1481 + is_gfx950=is_gfx950,
1482 + )
1483 + if use_high_batch_config and not (
1484 + num_idx_heads > 1 and max_decode_query_len > 1
1485 + ):
1486 + score_kwargs.update({"num_warps": 2, "num_stages": 1})
1487 + grid_score = (num_reqs, num_kv_chunks)
1488 + _decode_index_score_kernel[grid_score](
1489 + idx_q,
1490 + index_kv_cache,
1491 + score,
1492 + block_table,
1493 + seq_lens,
1494 + num_idx_heads,
1495 + head_dim,
1496 + init_blocks,
1497 + local_blocks,
1498 + decode_query_len,
1499 + idx_q.stride(0),
1500 + idx_q.stride(1),
1501 + idx_q.stride(2),
1502 + index_kv_cache.stride(0),
1503 + index_kv_cache.stride(1),
1504 + index_kv_cache.stride(2),
1505 + score.stride(0),
1506 + score.stride(1),
1507 + score.stride(2),
1508 + block_table.stride(0),
1509 + BLOCK_SIZE_K=SPARSE_BLOCK_SIZE,
1510 + BLOCK_SIZE_Q=BLOCK_SIZE_Q,
1511 + num_kv_chunks=num_kv_chunks,
1512 + USE_PDL=use_pdl,
1513 + **score_kwargs,
1514 + )
1515
1516 if out is not None:
1517 topk_idx = out[:, :total_q, :]
@@ -864,76 +1521,99 @@ def minimax_m3_index_decode(
1521 dtype=torch.int32,
1522 device=idx_q.device,
1523 )
867 - # Chunk count is shape-constant (cudagraph-safe), capped so the merge sorts
868 - # pow2(num_topk_chunks * pow2(topk)) candidates.
869 - TOPK_TARGET_GRID = 64
870 - MAX_NUM_TOPK_CHUNKS = 16
871 - topk_target = max(
872 - 1, min(MAX_NUM_TOPK_CHUNKS, TOPK_TARGET_GRID // max(1, batch * num_idx_heads))
873 - )
874 - num_topk_chunks = 1 << (topk_target.bit_length() - 1)
875 - block_size_t = triton.next_power_of_2(topk)
876 - chunk_blocks = (max_block + num_topk_chunks - 1) // num_topk_chunks
877 - topk_score_partial = torch.empty(
1524 + # The launch grid remains shape-constant for CUDA graphs. Each query uses
1525 + # only the number of chunks needed for its live context.
1526 + (
1527 num_topk_chunks,
879 - num_idx_heads,
1528 + single_tile_guaranteed,
1529 + adaptive_final_merge,
1530 + ) = _decode_topk_launch_policy(
1531 + max_block,
1532 batch,
881 - block_size_t,
882 - dtype=torch.float32,
883 - device=idx_q.device,
1533 + num_idx_heads,
1534 + topk,
1535 + is_gfx950=is_gfx950,
1536 )
885 - topk_idx_partial = torch.empty(
1537 + block_size_t = triton.next_power_of_2(topk)
1538 + topk_partial = torch.empty(
1539 num_topk_chunks,
1540 num_idx_heads,
1541 batch,
1542 block_size_t,
890 - dtype=torch.int32,
1543 + dtype=torch.int64,
1544 device=idx_q.device,
1545 )
893 - _topk_index_partial_kernel[(batch, num_idx_heads, num_topk_chunks)](
1546 + if completion_counter is None:
1547 + active_counter = torch.zeros(
1548 + (num_idx_heads, batch),
1549 + dtype=torch.int32,
1550 + device=idx_q.device,
1551 + )
1552 + else:
1553 + if (
1554 + completion_counter.dim() != 2
1555 + or completion_counter.shape[0] != num_idx_heads
1556 + or completion_counter.shape[1] < batch
1557 + or completion_counter.dtype != torch.int32
1558 + or completion_counter.device != idx_q.device
1559 + or not completion_counter.is_contiguous()
1560 + ):
1561 + raise ValueError(
1562 + "MiniMax-M3 completion counter must be contiguous int32 "
1563 + "[num_idx_heads, >=total_q] on the query device"
1564 + )
1565 + active_counter = completion_counter[:, :batch]
1566 +
1567 + selector_attention_block_table = block_table
1568 + selector_sparse_block_table = topk_idx
1569 + selector_sparse_context_lens = seq_lens
1570 + selector_block_page_stride = PAGES_PER_SPARSE_BLOCK
1571 + selector_sparse_block_stride = topk_idx.stride(1)
1572 + if emit_sparse_table:
1573 + assert attention_block_table is not None
1574 + assert sparse_block_table_out is not None
1575 + assert sparse_context_lens_out is not None
1576 + assert block_page_stride is not None
1577 + selector_attention_block_table = attention_block_table
1578 + selector_sparse_block_table = sparse_block_table_out
1579 + selector_sparse_context_lens = sparse_context_lens_out
1580 + selector_block_page_stride = block_page_stride
1581 + selector_sparse_block_stride = sparse_block_table_out.stride(0)
1582 + _decode_topk_fused_kernel[(batch, num_idx_heads, num_topk_chunks)](
1583 score,
895 - topk_score_partial,
896 - topk_idx_partial,
1584 + topk_partial,
1585 + active_counter,
1586 + topk_idx,
1587 seq_lens,
898 - SPARSE_BLOCK_SIZE,
899 - topk,
900 - chunk_blocks,
1588 + selector_attention_block_table,
1589 + selector_sparse_block_table,
1590 + selector_sparse_context_lens,
1591 decode_query_len,
1592 score.stride(0),
1593 score.stride(1),
1594 score.stride(2),
905 - topk_score_partial.stride(0),
906 - topk_score_partial.stride(1),
907 - topk_score_partial.stride(2),
908 - topk_score_partial.stride(3),
909 - topk_idx_partial.stride(0),
910 - topk_idx_partial.stride(1),
911 - topk_idx_partial.stride(2),
912 - topk_idx_partial.stride(3),
913 - USE_PDL=use_pdl,
914 - **pdl_kwargs,
915 - )
916 - _topk_index_merge_kernel[(batch, num_idx_heads)](
917 - topk_score_partial,
918 - topk_idx_partial,
919 - topk_idx,
920 - seq_lens,
921 - SPARSE_BLOCK_SIZE,
922 - topk,
923 - decode_query_len,
924 - topk_score_partial.stride(0),
925 - topk_score_partial.stride(1),
926 - topk_score_partial.stride(2),
927 - topk_score_partial.stride(3),
928 - topk_idx_partial.stride(0),
929 - topk_idx_partial.stride(1),
930 - topk_idx_partial.stride(2),
931 - topk_idx_partial.stride(3),
1595 + topk_partial.stride(0),
1596 + topk_partial.stride(1),
1597 + topk_partial.stride(2),
1598 + topk_partial.stride(3),
1599 + active_counter.stride(0),
1600 + active_counter.stride(1),
1601 topk_idx.stride(0),
1602 topk_idx.stride(1),
1603 topk_idx.stride(2),
935 - num_topk_chunks=num_topk_chunks,
936 - USE_PDL=use_pdl,
937 - **pdl_kwargs,
1604 + selector_attention_block_table.stride(0),
1605 + selector_sparse_block_stride,
1606 + topk=topk,
1607 + block_size=SPARSE_BLOCK_SIZE,
1608 + pages_per_sparse_block=PAGES_PER_SPARSE_BLOCK,
1609 + block_page_stride=selector_block_page_stride,
1610 + NUM_TOPK_CHUNKS=num_topk_chunks,
1611 + BLOCK_SIZE_K=512,
1612 + BLOCK_SIZE_T=block_size_t,
1613 + EMIT_SPARSE_TABLE=emit_sparse_table,
1614 + SINGLE_TILE_GUARANTEED=single_tile_guaranteed,
1615 + ADAPTIVE_FINAL_MERGE=adaptive_final_merge,
1616 + num_warps=4 if adaptive_final_merge else 8,
1617 + num_stages=2,
1618 )
1619 return topk_idx
vllm/models/minimax_m3/amd/ops/sparse_pa.py
+60 -19
@@ -32,14 +32,13 @@ def _is_fp8_kv_cache_tensor(kv_cache: torch.Tensor) -> bool:
32
33
34 @triton.jit
35 -def _write_sparse_block_table_row(
36 - topk_row, # [topk] int32, selected logical block ids for this query
35 +def _write_sparse_block_table_row_from_values(
36 + blk, # [BLOCK_SIZE_T] int32 selected logical block ids for this query
37 bt_row, # [max_blocks] int32, this request's logical page table
38 sbt_row, # [topk * PAGES_PER_BLOCK] int32, physical 16-page table
39 ctx_ptr, # int32, this query's attended context length
40 abs_pos, # absolute position of this query token, may be negative
41 max_topk,
42 - stride_topk_k,
42 SPARSE_BLOCK_SIZE_C: tl.constexpr,
43 PAGES_PER_BLOCK: tl.constexpr,
44 BLOCK_PAGE_STRIDE: tl.constexpr,
@@ -56,8 +55,7 @@ def _write_sparse_block_table_row(
55 self_blk = abs_pos // SPARSE_BLOCK_SIZE_C
56
57 off_t = tl.arange(0, BLOCK_SIZE_T)
59 - blk = tl.load(topk_row + off_t * stride_topk_k, mask=off_t < max_topk, other=-1)
60 - valid = (causal_len > 0) & (blk >= 0) & (blk <= self_blk)
58 + valid = (off_t < max_topk) & (causal_len > 0) & (blk >= 0) & (blk <= self_blk)
59 is_tail = valid & (blk == self_blk)
60 is_full = valid & (blk < self_blk)
61
@@ -89,6 +87,36 @@ def _write_sparse_block_table_row(
87 tl.store(ctx_ptr, ctx)
88
89
90 +@triton.jit
91 +def _write_sparse_block_table_row(
92 + topk_row, # [topk] int32, selected logical block ids for this query
93 + bt_row, # [max_blocks] int32, this request's logical page table
94 + sbt_row, # [topk * PAGES_PER_BLOCK] int32, physical 16-page table
95 + ctx_ptr, # int32, this query's attended context length
96 + abs_pos, # absolute position of this query token, may be negative
97 + max_topk,
98 + stride_topk_k,
99 + SPARSE_BLOCK_SIZE_C: tl.constexpr,
100 + PAGES_PER_BLOCK: tl.constexpr,
101 + BLOCK_PAGE_STRIDE: tl.constexpr,
102 + BLOCK_SIZE_T: tl.constexpr,
103 +):
104 + off_t = tl.arange(0, BLOCK_SIZE_T)
105 + blk = tl.load(topk_row + off_t * stride_topk_k, mask=off_t < max_topk, other=-1)
106 + _write_sparse_block_table_row_from_values(
107 + blk,
108 + bt_row,
109 + sbt_row,
110 + ctx_ptr,
111 + abs_pos,
112 + max_topk,
113 + SPARSE_BLOCK_SIZE_C,
114 + PAGES_PER_BLOCK,
115 + BLOCK_PAGE_STRIDE,
116 + BLOCK_SIZE_T,
117 + )
118 +
119 +
120 @triton.jit
121 def _build_sparse_block_table_kernel(
122 topk_ptr, # [1, batch * DECODE_QUERY_LEN, topk] int32, logical block ids
@@ -169,7 +197,7 @@ def _build_sparse_block_table_prefill_kernel(
197 )
198
199
172 -def _alloc_sparse_block_table(
200 +def minimax_m3_alloc_sparse_block_table(
201 topk_idx: torch.Tensor,
202 ) -> tuple[torch.Tensor, torch.Tensor]:
203 assert topk_idx.shape[0] == 1, "AITER sparse PA requires num_kv_heads == 1"
@@ -190,7 +218,7 @@ def minimax_m3_build_sparse_block_table_prefill(
218 block_page_stride: int = PAGES_PER_SPARSE_BLOCK,
219 ) -> tuple[torch.Tensor, torch.Tensor]:
220 """Build one page-16 sparse block table row per prefill query token."""
193 - sparse_bt, sparse_ctx = _alloc_sparse_block_table(topk_idx)
221 + sparse_bt, sparse_ctx = minimax_m3_alloc_sparse_block_table(topk_idx)
222 topk = topk_idx.shape[-1]
223 _build_sparse_block_table_prefill_kernel[(topk_idx.shape[1],)](
224 topk_idx,
@@ -227,7 +255,7 @@ def minimax_m3_build_sparse_block_table_decode(
255 "MiniMax-M3 decode top-k rows must equal batch * decode_query_len: "
256 f"{total_q} != {expected_q}"
257 )
230 - sparse_bt, sparse_ctx = _alloc_sparse_block_table(topk_idx)
258 + sparse_bt, sparse_ctx = minimax_m3_alloc_sparse_block_table(topk_idx)
259 topk = topk_idx.shape[-1]
260 _build_sparse_block_table_kernel[(total_q,)](
261 topk_idx,
@@ -328,7 +356,9 @@ def _sides_are_packed(k_cache: torch.Tensor, v_cache: torch.Tensor) -> bool:
356 return v_cache.shape[0] != k_cache.shape[0]
357
358
331 -def _block_page_stride(k_cache: torch.Tensor, v_cache: torch.Tensor) -> int:
359 +def minimax_m3_sparse_block_page_stride(
360 + k_cache: torch.Tensor, v_cache: torch.Tensor
361 +) -> int:
362 """How many page ids one sparse block spans.
363
364 Each side owns ``PAGES_PER_SPARSE_BLOCK`` pages. When the sides are dense
@@ -384,20 +414,31 @@ def minimax_m3_sparse_attn_decode_aiter(
414 k_scale: torch.Tensor | None = None,
415 v_scale: torch.Tensor | None = None,
416 decode_query_len: int = 1,
417 + sparse_block_table: torch.Tensor | None = None,
418 + sparse_context_lens: torch.Tensor | None = None,
419 ) -> None:
388 - sparse_bt, sparse_ctx = minimax_m3_build_sparse_block_table_decode(
389 - topk_idx,
390 - block_table,
391 - seq_lens,
392 - decode_query_len,
393 - _block_page_stride(k_cache, v_cache),
394 - )
420 + if (sparse_block_table is None) != (sparse_context_lens is None):
421 + raise ValueError(
422 + "MiniMax-M3 prepared sparse block table and context lengths "
423 + "must be provided together"
424 + )
425 + if sparse_block_table is None:
426 + sparse_block_table, sparse_context_lens = (
427 + minimax_m3_build_sparse_block_table_decode(
428 + topk_idx,
429 + block_table,
430 + seq_lens,
431 + decode_query_len,
432 + minimax_m3_sparse_block_page_stride(k_cache, v_cache),
433 + )
434 + )
435 + assert sparse_context_lens is not None
436 _run_gluon_decode(
437 q,
438 k_cache,
439 v_cache,
399 - sparse_bt,
400 - sparse_ctx,
440 + sparse_block_table,
441 + sparse_context_lens,
442 num_kv_heads,
443 sm_scale,
444 output,
@@ -426,7 +467,7 @@ def minimax_m3_sparse_attn_prefill_aiter(
467 block_table,
468 query_req_id,
469 query_abs_pos,
429 - _block_page_stride(k_cache, v_cache),
470 + minimax_m3_sparse_block_page_stride(k_cache, v_cache),
471 )
472 _run_gluon_decode(
473 q,
vllm/models/minimax_m3/amd/sparse_attention_msa.py
+7
@@ -25,6 +25,7 @@ class MiniMaxM3SparseAiterPAImpl(MiniMaxM3SparseImpl):
25 output: torch.Tensor,
26 *,
27 query_fp8: torch.Tensor | None = None,
28 + decode_sparse_table: tuple[torch.Tensor, torch.Tensor] | None = None,
29 ) -> torch.Tensor:
30 from vllm.models.minimax_m3.amd.ops.sparse_pa import (
31 minimax_m3_sparse_attn_decode_aiter,
@@ -70,6 +71,12 @@ class MiniMaxM3SparseAiterPAImpl(MiniMaxM3SparseImpl):
71 k_scale=k_scale,
72 v_scale=v_scale,
73 decode_query_len=d.decode_query_len,
74 + sparse_block_table=(
75 + decode_sparse_table[0] if decode_sparse_table is not None else None
76 + ),
77 + sparse_context_lens=(
78 + decode_sparse_table[1] if decode_sparse_table is not None else None
79 + ),
80 )
81
82 if main_md.num_prefills > 0:
vllm/models/minimax_m3/common/indexer.py
+71 -1
@@ -369,6 +369,18 @@ class MiniMaxM3IndexerImpl(nn.Module):
369 # Shared, stable-address top-k output buffer (set by the model for the
370 # cudagraph-safe MSA impl); None -> impl allocates fresh (eager).
371 self.topk_indices_buffer = topk_indices_buffer
372 + topk_completion_counter = None
373 + if current_platform.is_rocm() and topk_indices_buffer is not None:
374 + topk_completion_counter = torch.zeros(
375 + topk_indices_buffer.shape[:2],
376 + dtype=torch.int32,
377 + device=topk_indices_buffer.device,
378 + )
379 + self.register_buffer(
380 + "topk_completion_counter",
381 + topk_completion_counter,
382 + persistent=False,
383 + )
384 # Owns the side cache (registers itself in the static forward context).
385 self.index_cache = MiniMaxM3IndexerCache(
386 head_dim=index_head_dim,
@@ -392,6 +404,11 @@ class MiniMaxM3IndexerTritonImpl(MiniMaxM3IndexerImpl):
404 def forward(
405 self,
406 index_query: torch.Tensor,
407 + *,
408 + attention_block_table: torch.Tensor | None = None,
409 + sparse_block_table_out: torch.Tensor | None = None,
410 + sparse_context_lens_out: torch.Tensor | None = None,
411 + block_page_stride: int | None = None,
412 ) -> tuple[torch.Tensor | None, torch.Tensor | None]:
413 attn_metadata = get_forward_context().attn_metadata
414 if not isinstance(attn_metadata, dict):
@@ -417,6 +434,19 @@ class MiniMaxM3IndexerTritonImpl(MiniMaxM3IndexerImpl):
434 if index_md.num_decodes > 0:
435 d = index_md.decode
436 assert d is not None
437 + fused_sparse_kwargs = {}
438 + if attention_block_table is not None:
439 + fused_sparse_kwargs = {
440 + "attention_block_table": attention_block_table,
441 + "sparse_block_table_out": sparse_block_table_out,
442 + "sparse_context_lens_out": sparse_context_lens_out,
443 + "block_page_stride": block_page_stride,
444 + }
445 + decode_backend_kwargs = {}
446 + if current_platform.is_rocm():
447 + decode_backend_kwargs["completion_counter"] = (
448 + self.topk_completion_counter
449 + )
450 decode_topk = minimax_m3_index_decode(
451 iq[:nd],
452 kv,
@@ -430,6 +460,8 @@ class MiniMaxM3IndexerTritonImpl(MiniMaxM3IndexerImpl):
460 d.decode_query_len,
461 d.max_decode_query_len,
462 out=buf_htk,
463 + **fused_sparse_kwargs,
464 + **decode_backend_kwargs,
465 )
466 if index_md.num_prefills > 0:
467 p = index_md.prefill
@@ -567,5 +599,43 @@ class MiniMaxM3Indexer(nn.Module):
599 def forward(
600 self,
601 index_query: torch.Tensor,
602 + *,
603 + attention_block_table: torch.Tensor | None = None,
604 + sparse_block_table_out: torch.Tensor | None = None,
605 + sparse_context_lens_out: torch.Tensor | None = None,
606 + block_page_stride: int | None = None,
607 ) -> tuple[torch.Tensor | None, torch.Tensor | None]:
571 - return self.impl(index_query)
608 + if attention_block_table is None and all(
609 + arg is None
610 + for arg in (
611 + sparse_block_table_out,
612 + sparse_context_lens_out,
613 + block_page_stride,
614 + )
615 + ):
616 + return self.impl(index_query)
617 + if attention_block_table is None or any(
618 + arg is None
619 + for arg in (
620 + sparse_block_table_out,
621 + sparse_context_lens_out,
622 + block_page_stride,
623 + )
624 + ):
625 + raise ValueError(
626 + "MiniMax-M3 fused decode sparse-table arguments must be "
627 + "provided together"
628 + )
629 + if not current_platform.is_rocm() or not isinstance(
630 + self.impl, MiniMaxM3IndexerTritonImpl
631 + ):
632 + raise ValueError(
633 + "MiniMax-M3 fused decode sparse-table construction is ROCm-only"
634 + )
635 + return self.impl(
636 + index_query,
637 + attention_block_table=attention_block_table,
638 + sparse_block_table_out=sparse_block_table_out,
639 + sparse_context_lens_out=sparse_context_lens_out,
640 + block_page_stride=block_page_stride,
641 + )