[ROCm][ [Perf] sparse attention optimization on minimax-m3 (#46546)
Signed-off-by: Hongxia Yang <hongxia.yang@amd.com> Signed-off-by: tjtanaa <tunjian.tan@embeddedllm.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: yueliu14 <yue.liu4@amd.com> Co-authored-by: tjtanaa <tunjian.tan@embeddedllm.com>
Hongxia Yang committed
Jun 25, 2026 at 04:56 UTC
c63cd4906c2a67f18b3714786cc036c1ad97a64f
5 files changed
+1238
-36
vllm/models/minimax_m3/amd/ops/index_topk.py
new
+939
@@ -0,0 +1,939 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Triton kernels for MiniMax M3 lightning-indexer block scoring + top-k.
4
+
5
+Index queries score each 128-token block of index keys (max over the block),
6
+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).
11
+
12
+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``.
15
+"""
16
+
17
+import torch
18
+
19
+from vllm.platforms import current_platform
20
+from vllm.triton_utils import tl, triton
21
+from vllm.utils.math_utils import round_up
22
+
23
+# One sparse block == one KV page.
24
+SPARSE_BLOCK_SIZE = 128
25
+
26
+
27
+# ---------------------------------------------------------------------------
28
+# Bitonic top-k helpers (layout-agnostic).
29
+# ---------------------------------------------------------------------------
30
+@triton.jit
31
+def _compare_and_swap(x, ids, flip, i: tl.constexpr, n_dims: tl.constexpr):
32
+ n_outer: tl.constexpr = x.numel >> n_dims
33
+ shape: tl.constexpr = [n_outer * 2**i, 2, 2 ** (n_dims - i - 1)]
34
+ y = tl.reshape(x, shape)
35
+ mask = tl.arange(0, 2)[None, :, None]
36
+ left = tl.broadcast_to(tl.sum(y * (1 - mask), 1)[:, None, :], shape).to(y.dtype)
37
+ right = tl.broadcast_to(tl.sum(y * mask, 1)[:, None, :], shape).to(y.dtype)
38
+ left = tl.reshape(left, x.shape)
39
+ right = tl.reshape(right, x.shape)
40
+ y_idx = tl.reshape(ids, shape)
41
+ left_idx = tl.broadcast_to(tl.sum(y_idx * (1 - mask), 1)[:, None, :], shape)
42
+ right_idx = tl.broadcast_to(tl.sum(y_idx * mask, 1)[:, None, :], shape)
43
+ left_idx = tl.reshape(left_idx, x.shape).to(y_idx.dtype)
44
+ right_idx = tl.reshape(right_idx, x.shape).to(y_idx.dtype)
45
+ idtype = tl.core.get_int_dtype(bitwidth=x.dtype.primitive_bitwidth, signed=True)
46
+ ileft = left.to(idtype, bitcast=True)
47
+ iright = right.to(idtype, bitcast=True)
48
+ ix = x.to(idtype, bitcast=True)
49
+ cond = (left > right) != flip
50
+ ret = ix ^ tl.where(cond, ileft ^ iright, tl.zeros_like(ix))
51
+ new_ids = ids ^ tl.where(cond, left_idx ^ right_idx, tl.zeros_like(ids))
52
+ return ret.to(x.dtype, bitcast=True), new_ids
53
+
54
+
55
+@triton.jit
56
+def _bitonic_merge(
57
+ x, ids, stage: tl.constexpr, order: tl.constexpr, n_dims: tl.constexpr
58
+):
59
+ n_outer: tl.constexpr = x.numel >> n_dims
60
+ tl.static_assert(stage <= n_dims)
61
+ if order == 2:
62
+ shape: tl.constexpr = [n_outer * 2 ** (n_dims - 1 - stage), 2, 2**stage]
63
+ flip = tl.reshape(
64
+ tl.broadcast_to(tl.arange(0, 2)[None, :, None], shape), x.shape
65
+ )
66
+ else:
67
+ flip = order
68
+ for i in tl.static_range(stage):
69
+ x, ids = _compare_and_swap(x, ids, flip, i + (n_dims - stage), n_dims)
70
+ return x, ids
71
+
72
+
73
+# ---------------------------------------------------------------------------
74
+# Index block-score kernel (paged). score[h, token, block] = max over the
75
+# 128-token block of (idx_q . index_k), causal-masked. BLOCK_SIZE_K == 128 so
76
+# each K-tile is exactly one page (BLOCKS_PER_K_BLOCK == 1).
77
+# ---------------------------------------------------------------------------
78
+# since prefill metadata is sliced from mixed batch metadata, seq_lens and prefix_lens
79
+# might lose pointer alignment, which trigger Triton recompiles. we don't actually
80
+# need pointer alignment for those tensors anyway because we do scalar load.
81
+@triton.jit(do_not_specialize_on_alignment=["seq_lens", "prefix_lens"])
82
+def _index_block_score_kernel(
83
+ q_ptr, # idx_q: [total_q, num_idx_heads, head_dim]
84
+ ik_cache_ptr, # index-K cache: [num_blocks, 128, head_dim]
85
+ score_ptr, # [num_idx_heads, total_q, max_block]
86
+ block_table_ptr, # [num_reqs, max_blocks]
87
+ cu_seqlens, # [batch+1] query start offsets
88
+ seq_lens, # [batch] total K length
89
+ prefix_lens, # [batch] context length before this chunk's queries
90
+ num_idx_heads,
91
+ head_dim: tl.constexpr,
92
+ stride_q_n,
93
+ stride_q_h,
94
+ stride_q_d,
95
+ stride_ik_blk,
96
+ stride_ik_pos,
97
+ stride_ik_d,
98
+ stride_s_h,
99
+ stride_s_n,
100
+ stride_s_k,
101
+ stride_bt_b,
102
+ BLOCK_SIZE_Q: tl.constexpr,
103
+ BLOCK_SIZE_K: tl.constexpr, # == SPARSE_BLOCK_SIZE (128)
104
+):
105
+ pid_q = tl.program_id(0)
106
+ pid_bh = tl.program_id(1)
107
+ pid_b = pid_bh // num_idx_heads
108
+ pid_h = pid_bh % num_idx_heads
109
+
110
+ seq_start = tl.load(cu_seqlens + pid_b)
111
+ q_len = tl.load(cu_seqlens + pid_b + 1) - seq_start
112
+ seq_len = tl.load(seq_lens + pid_b)
113
+ prefix_len = tl.load(prefix_lens + pid_b)
114
+ if BLOCK_SIZE_Q * pid_q >= q_len:
115
+ return
116
+
117
+ q_ptrs = tl.make_block_ptr(
118
+ base=q_ptr + seq_start * stride_q_n + pid_h * stride_q_h,
119
+ shape=(q_len, head_dim),
120
+ strides=(stride_q_n, stride_q_d),
121
+ offsets=(pid_q * BLOCK_SIZE_Q, 0),
122
+ block_shape=(BLOCK_SIZE_Q, head_dim),
123
+ order=(1, 0),
124
+ )
125
+ q = tl.load(q_ptrs, boundary_check=(0,), padding_option="zero")
126
+ q_start = prefix_len + pid_q * BLOCK_SIZE_Q
127
+
128
+ off_q = tl.arange(0, BLOCK_SIZE_Q) + pid_q * BLOCK_SIZE_Q + prefix_len
129
+ off_k = tl.arange(0, BLOCK_SIZE_K)
130
+ off_d = tl.arange(0, head_dim)
131
+ # Block table row for this request.
132
+ bt_row = block_table_ptr + pid_b * stride_bt_b
133
+ # Causal window: only blocks up to the last query token's position.
134
+ hi = min(seq_len, prefix_len + (pid_q + 1) * BLOCK_SIZE_Q)
135
+ for i in tl.range(0, hi, BLOCK_SIZE_K):
136
+ blk = i // BLOCK_SIZE_K
137
+ page = tl.load(bt_row + blk).to(tl.int64)
138
+ pos = i + off_k
139
+ # index-K for this page: [BLOCK_SIZE_D, BLOCK_SIZE_K] (transposed)
140
+ # we don't need masked load for K, because KV cache ensures
141
+ # allocation is multiple of BLOCK_SIZE_K.
142
+ # for tokens beyond seqlen, they will be masked in qk later.
143
+ k = tl.load(
144
+ ik_cache_ptr
145
+ + page * stride_ik_blk
146
+ + off_k[None, :] * stride_ik_pos
147
+ + off_d[:, None] * stride_ik_d,
148
+ )
149
+ qk = tl.dot(q, k)
150
+ # apply causal mask as needed
151
+ if q_start < i + BLOCK_SIZE_K:
152
+ qk = tl.where(off_q[:, None] >= pos[None, :], qk, float("-inf"))
153
+ # one sparse block per K-tile -> max over the 128 positions
154
+ score = tl.max(qk, axis=1) # [BLOCK_SIZE_Q]
155
+ s_ptrs = (
156
+ score_ptr
157
+ + pid_h * stride_s_h
158
+ + (seq_start + pid_q * BLOCK_SIZE_Q + tl.arange(0, BLOCK_SIZE_Q))
159
+ * stride_s_n
160
+ + blk * stride_s_k
161
+ )
162
+ q_store_mask = (pid_q * BLOCK_SIZE_Q + tl.arange(0, BLOCK_SIZE_Q)) < q_len
163
+ tl.store(s_ptrs, score, mask=q_store_mask)
164
+
165
+
166
+# ---------------------------------------------------------------------------
167
+# Top-k selection over per-token block scores (layout-agnostic). block_size_q
168
+# is 1 for M3, so top-k is computed per query token.
169
+# ---------------------------------------------------------------------------
170
+# since prefill metadata is sliced from mixed batch metadata, prefix_lens
171
+# might lose pointer alignment, which trigger Triton recompiles. we don't actually
172
+# need pointer alignment for those tensors anyway because we do scalar load.
173
+@triton.heuristics({"BLOCK_SIZE_T": lambda args: triton.next_power_of_2(args["topk"])})
174
+@triton.autotune(
175
+ configs=[
176
+ triton.Config({"BLOCK_SIZE_K": 2048}, num_warps=8, num_stages=2),
177
+ triton.Config({"BLOCK_SIZE_K": 1024}, num_warps=8, num_stages=2),
178
+ triton.Config({"BLOCK_SIZE_K": 512}, num_warps=8, num_stages=2),
179
+ triton.Config({"BLOCK_SIZE_K": 256}, num_warps=8, num_stages=2),
180
+ triton.Config({"BLOCK_SIZE_K": 128}, num_warps=4, num_stages=2),
181
+ triton.Config({"BLOCK_SIZE_K": 64}, num_warps=2, num_stages=2),
182
+ ],
183
+ key=["BLOCK_SIZE_T"],
184
+)
185
+@triton.jit(do_not_specialize_on_alignment=["prefix_lens"])
186
+def _topk_index_kernel(
187
+ s_ptr, # [num_heads, total_q, max_block]
188
+ ti_ptr, # [num_heads, total_q, topk]
189
+ sample_interval: tl.constexpr, # block_size_q (1 for M3)
190
+ block_size: tl.constexpr, # sparse block size (128)
191
+ cu_seqlens,
192
+ cu_seqblocks_q,
193
+ prefix_lens,
194
+ topk,
195
+ init_blocks: tl.constexpr,
196
+ local_blocks: tl.constexpr,
197
+ stride_s_h,
198
+ stride_s_n,
199
+ stride_s_k,
200
+ stride_ti_h,
201
+ stride_ti_n,
202
+ stride_ti_t,
203
+ BLOCK_SIZE_K: tl.constexpr,
204
+ BLOCK_SIZE_T: tl.constexpr,
205
+ MASK_INIT: tl.constexpr,
206
+ MASK_LOCAL: tl.constexpr,
207
+):
208
+ tl.static_assert(BLOCK_SIZE_K > BLOCK_SIZE_T)
209
+ pid_q = tl.program_id(0)
210
+ pid_b = tl.program_id(1)
211
+ pid_h = tl.program_id(2)
212
+ seq_start = tl.load(cu_seqlens + pid_b)
213
+ block_start = tl.load(cu_seqblocks_q + pid_b)
214
+ block_num = tl.load(cu_seqblocks_q + pid_b + 1) - block_start
215
+ prefix_len = tl.load(prefix_lens + pid_b)
216
+ if pid_q >= block_num:
217
+ return
218
+ off_k = tl.arange(0, BLOCK_SIZE_K)
219
+ off_t = tl.arange(0, BLOCK_SIZE_T)
220
+ s_ptrs = (
221
+ s_ptr
222
+ + (seq_start + pid_q * sample_interval) * stride_s_n
223
+ + pid_h * stride_s_h
224
+ + off_k * stride_s_k
225
+ )
226
+ topk_score = tl.full((BLOCK_SIZE_K,), -1e30, dtype=tl.float32)
227
+ topk_idx = tl.full((BLOCK_SIZE_K,), 0, dtype=tl.int32)
228
+ left_half_mask = tl.arange(0, BLOCK_SIZE_K) < BLOCK_SIZE_K // 2
229
+ valid_blocks = (prefix_len + pid_q * sample_interval + block_size) // block_size
230
+ for i in tl.range(0, valid_blocks, BLOCK_SIZE_K):
231
+ causal_mask = i + off_k < valid_blocks
232
+ local_mask = i + off_k >= max(0, valid_blocks - local_blocks)
233
+ init_mask = i + off_k < init_blocks
234
+ score = tl.load(s_ptrs, mask=causal_mask, other=-1e30).to(tl.float32)
235
+ score = tl.where(score != score, -1e30, score)
236
+ s_ptrs = s_ptrs + stride_s_k * BLOCK_SIZE_K
237
+ if MASK_INIT:
238
+ score = tl.where(causal_mask & init_mask, score - 1e29, score)
239
+ else:
240
+ score = tl.where(causal_mask & init_mask, 1e30, score)
241
+ if MASK_LOCAL:
242
+ score = tl.where(causal_mask & local_mask, score - 1e28, score)
243
+ else:
244
+ score = tl.where(causal_mask & local_mask, 1e29, score)
245
+ topk_score, last_topk_score = score, topk_score
246
+ topk_idx, last_topk_idx = (tl.where(causal_mask, i + off_k + 1, 0), topk_idx)
247
+ n_dims: tl.constexpr = tl.standard._log2(BLOCK_SIZE_K)
248
+ for j in tl.static_range(1, n_dims):
249
+ topk_score, topk_idx = _bitonic_merge(
250
+ topk_score, topk_idx.to(tl.int32), j, 2, n_dims
251
+ )
252
+ if i != 0:
253
+ topk_score, topk_idx = _bitonic_merge(
254
+ topk_score, topk_idx.to(tl.int32), n_dims, False, n_dims
255
+ )
256
+ topk_score_new = last_topk_score * left_half_mask + topk_score * (
257
+ 1 - left_half_mask
258
+ )
259
+ topk_idx_new = last_topk_idx * left_half_mask + topk_idx * (
260
+ 1 - left_half_mask
261
+ )
262
+ topk_score, topk_idx = _bitonic_merge(
263
+ topk_score_new, topk_idx_new.to(tl.int32), n_dims, True, n_dims
264
+ )
265
+ else:
266
+ topk_score, topk_idx = _bitonic_merge(
267
+ topk_score, topk_idx.to(tl.int32), n_dims, True, n_dims
268
+ )
269
+ topk_mask = tl.arange(0, BLOCK_SIZE_K // BLOCK_SIZE_T) == 0
270
+ topk_idx = tl.sum(
271
+ topk_mask[:, None]
272
+ * tl.reshape(topk_idx - 1, [BLOCK_SIZE_K // BLOCK_SIZE_T, BLOCK_SIZE_T]),
273
+ axis=0,
274
+ )
275
+ ti_ptrs = (
276
+ ti_ptr
277
+ + (block_start + pid_q) * stride_ti_n
278
+ + pid_h * stride_ti_h
279
+ + off_t * stride_ti_t
280
+ )
281
+ store_mask = off_t < topk
282
+ valid_mask = off_t < valid_blocks
283
+ topk_idx = tl.where(store_mask & valid_mask, topk_idx, -1)
284
+ tl.store(ti_ptrs, topk_idx.to(ti_ptrs.dtype.element_ty), mask=store_mask)
285
+
286
+
287
+# ---------------------------------------------------------------------------
288
+# Decode index-score kernel (split-K over seq blocks). Decode batches are
289
+# flattened request-major, with a runtime query length used to map each query
290
+# token back to its request metadata. Chunk counts depend only on shape
291
+# constants so the grid is fixed within a cuda graph. The score scale is omitted
292
+# because decode only consumes block ordering.
293
+# ---------------------------------------------------------------------------
294
+@triton.jit(do_not_specialize=["num_kv_chunks", "decode_query_len"])
295
+def _decode_index_score_kernel(
296
+ q_ptr, # idx_q: [total_q, num_idx_heads, head_dim]
297
+ ik_cache_ptr, # index-K cache: [num_blocks, 128, head_dim]
298
+ score_ptr, # [num_idx_heads, total_q, max_block]
299
+ block_table_ptr, # [num_reqs, max_blocks]
300
+ seq_lens, # [num_reqs]
301
+ num_idx_heads: tl.constexpr,
302
+ head_dim: tl.constexpr,
303
+ init_blocks,
304
+ local_blocks,
305
+ decode_query_len,
306
+ stride_q_n,
307
+ stride_q_h,
308
+ stride_q_d,
309
+ stride_ik_blk,
310
+ stride_ik_pos,
311
+ stride_ik_d,
312
+ stride_s_h,
313
+ stride_s_n,
314
+ stride_s_k,
315
+ stride_bt_b,
316
+ BLOCK_SIZE_K: tl.constexpr, # == SPARSE_BLOCK_SIZE (128)
317
+ BLOCK_SIZE_Q: tl.constexpr,
318
+ num_kv_chunks,
319
+ USE_PDL: tl.constexpr,
320
+):
321
+ BLOCK_SIZE_HQ: tl.constexpr = num_idx_heads * BLOCK_SIZE_Q
322
+ pid_r = tl.program_id(0)
323
+ pid_c = tl.program_id(1)
324
+ hq_offsets = tl.arange(0, BLOCK_SIZE_HQ)
325
+ h_offsets = hq_offsets // BLOCK_SIZE_Q
326
+ q_offsets = hq_offsets % BLOCK_SIZE_Q
327
+ q_mask = q_offsets < decode_query_len
328
+ q_ids = pid_r * decode_query_len + q_offsets
329
+
330
+ if USE_PDL:
331
+ tl.extra.cuda.gdc_wait()
332
+ tl.extra.cuda.gdc_launch_dependents()
333
+
334
+ seq_len = tl.load(seq_lens + pid_r)
335
+ query_pos = seq_len - decode_query_len + q_offsets
336
+ # Full-CG padding uses zero-length request rows. Clamp to an empty
337
+ # attention range instead of letting padded rows produce negative lengths.
338
+ kv_len = tl.maximum(query_pos + 1, 0)
339
+ num_blocks_q = (kv_len + BLOCK_SIZE_K - 1) // BLOCK_SIZE_K
340
+ kv_len_max = tl.max(tl.where(q_mask, kv_len, 0), axis=0)
341
+ num_blocks = (kv_len_max + BLOCK_SIZE_K - 1) // BLOCK_SIZE_K
342
+
343
+ # block-aligned fixed-count split: grid independent of seq_len (cuda graph).
344
+ chunk_size_blocks = (num_blocks + num_kv_chunks - 1) // num_kv_chunks
345
+ chunk_start_block = pid_c * chunk_size_blocks
346
+ chunk_end_block = tl.minimum(chunk_start_block + chunk_size_blocks, num_blocks)
347
+ if chunk_start_block >= chunk_end_block:
348
+ return
349
+ off_k = tl.arange(0, BLOCK_SIZE_K) # positions within a 128-block
350
+ off_d = tl.arange(0, head_dim)
351
+ bt_row = block_table_ptr + pid_r * stride_bt_b
352
+ # Force-select init (1e30) and local (1e29, higher priority) blocks.
353
+ local_start = tl.maximum(0, num_blocks_q - local_blocks)
354
+ # Query vectors for all index heads in a small spec-decode block.
355
+ q = tl.load(
356
+ q_ptr
357
+ + q_ids[None, :] * stride_q_n
358
+ + h_offsets[None, :] * stride_q_h
359
+ + off_d[:, None] * stride_q_d,
360
+ mask=q_mask[None, :],
361
+ other=0.0,
362
+ ) # [D,HQ]
363
+ for blk in tl.range(chunk_start_block, chunk_end_block):
364
+ page = tl.load(bt_row + blk).to(tl.int64)
365
+ pos = blk * BLOCK_SIZE_K + off_k
366
+ pos_mask = pos[:, None] < kv_len[None, :]
367
+ # we don't need masked load for K, because KV cache ensures
368
+ # allocation is multiple of BLOCK_SIZE_K.
369
+ # for tokens beyond seqlen, they will be masked in qk later.
370
+ k = tl.load(
371
+ ik_cache_ptr
372
+ + page * stride_ik_blk
373
+ + off_k[:, None] * stride_ik_pos
374
+ + off_d * stride_ik_d,
375
+ ) # [N,D]
376
+ if BLOCK_SIZE_HQ == 1:
377
+ # Degenerate GEMV (q is [D,1]): vectorized fp32 multiply + reduce
378
+ # instead of an MFMA tile. Numerically equivalent to tl.dot.
379
+ q_vec = tl.sum(q, axis=1).to(tl.float32) # [D]
380
+ kq = tl.sum(k.to(tl.float32) * q_vec[None, :], axis=1)[:, None] # [N,1]
381
+ else:
382
+ # fp32 accumulation is required for the fp8 (e4m3) index cache: q/k
383
+ # are loaded in their stored dtype (bf16 or e4m3) and the MMA
384
+ # accumulates in fp32 so the per-block max score is exact for the
385
+ # fp8 indexer too.
386
+ kq = tl.dot(k, q, out_dtype=tl.float32) # [N,HQ]
387
+ kq = tl.where(pos_mask & q_mask[None, :], kq, float("-inf"))
388
+ score = tl.max(kq, axis=0) # [HQ]
389
+ is_visible_block = blk < num_blocks_q
390
+ is_init = (blk < init_blocks) & is_visible_block
391
+ is_local = (blk >= local_start) & is_visible_block
392
+ score = tl.where(is_local, 1e29, tl.where(is_init, 1e30, score))
393
+ tl.store(
394
+ score_ptr + h_offsets * stride_s_h + q_ids * stride_s_n + blk * stride_s_k,
395
+ score,
396
+ mask=q_mask,
397
+ )
398
+
399
+
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
424
+ decode_query_len,
425
+ stride_s_h,
426
+ stride_s_b,
427
+ 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,
436
+ BLOCK_SIZE_K: tl.constexpr,
437
+ BLOCK_SIZE_T: tl.constexpr,
438
+ USE_PDL: tl.constexpr,
439
+):
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()
449
+
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.
454
+ 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
470
+ )
471
+
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,
487
+ )
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
496
+ )
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
502
+ )
503
+ topk_score, topk_idx = _bitonic_merge(
504
+ topk_score_new, topk_idx_new.to(tl.int32), n_dims, True, n_dims
505
+ )
506
+ else:
507
+ topk_score, topk_idx = _bitonic_merge(
508
+ topk_score, topk_idx.to(tl.int32), n_dims, True, n_dims
509
+ )
510
+
511
+ if USE_PDL:
512
+ tl.extra.cuda.gdc_launch_dependents()
513
+
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,
525
+ )
526
+
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
535
+ )
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
542
+ )
543
+ tl.store(ts_ptrs, final_score)
544
+ tl.store(ti_ptrs, final_idx)
545
+
546
+
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)
562
+ topk: tl.constexpr,
563
+ 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,
576
+ BLOCK_SIZE_K: tl.constexpr,
577
+ BLOCK_SIZE_T: tl.constexpr,
578
+ USE_PDL: tl.constexpr,
579
+):
580
+ pid_b = tl.program_id(0) # flattened query-token id
581
+ pid_h = tl.program_id(1)
582
+ req_id = pid_b // decode_query_len
583
+ q_offset = pid_b - req_id * decode_query_len
584
+
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)
590
+ 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.
593
+ kv_len = tl.maximum(query_pos + 1, 0)
594
+ num_blocks = (kv_len + block_size - 1) // block_size
595
+
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
+ )
616
+
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
+ )
636
+
637
+ 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)
646
+ tl.store(
647
+ tif_ptrs, topk_idx_final.to(ti_final_ptr.dtype.element_ty), mask=store_mask
648
+ )
649
+
650
+
651
+# ---------------------------------------------------------------------------
652
+# Python wrappers
653
+# ---------------------------------------------------------------------------
654
+@torch.no_grad()
655
+def minimax_m3_index_score(
656
+ idx_q: torch.Tensor, # [total_q, num_idx_heads, head_dim]
657
+ index_kv_cache: torch.Tensor, # [num_blocks, 128, head_dim]
658
+ block_table: torch.Tensor, # [batch, max_blocks]
659
+ cu_seqlens_q: torch.Tensor, # [batch+1] int32
660
+ seq_lens: torch.Tensor, # [batch] int32
661
+ prefix_lens: torch.Tensor, # [batch] int32
662
+ max_query_len: int,
663
+ max_seq_len: int,
664
+ num_kv_heads: int,
665
+) -> torch.Tensor:
666
+ """Compute per-token index scores for each visible sparse block.
667
+
668
+ Returns score [num_kv_heads, total_q, max_block], where each score is the
669
+ max over a 128-token index-K block. M3 has num_idx_heads == num_kv_heads.
670
+ """
671
+ total_q, num_idx_heads, head_dim = idx_q.shape
672
+ assert num_idx_heads == num_kv_heads, (
673
+ "M3 expects num_idx_heads == num_kv_heads (no topk index reduce)"
674
+ )
675
+ batch = cu_seqlens_q.shape[0] - 1
676
+ max_block = triton.cdiv(max_seq_len, SPARSE_BLOCK_SIZE)
677
+
678
+ # Keep score strides 16-divisible to avoid Triton recompiles.
679
+ score_block_stride = round_up(max_block, 16)
680
+ score = torch.empty(
681
+ (num_idx_heads, total_q, score_block_stride),
682
+ dtype=torch.float32,
683
+ device=idx_q.device,
684
+ )
685
+ BLOCK_SIZE_Q = 64
686
+ grid_score = (triton.cdiv(max_query_len, BLOCK_SIZE_Q), batch * num_idx_heads)
687
+ _index_block_score_kernel[grid_score](
688
+ idx_q,
689
+ index_kv_cache,
690
+ score,
691
+ block_table,
692
+ cu_seqlens_q,
693
+ seq_lens,
694
+ prefix_lens,
695
+ num_idx_heads,
696
+ head_dim,
697
+ idx_q.stride(0),
698
+ idx_q.stride(1),
699
+ idx_q.stride(2),
700
+ index_kv_cache.stride(0),
701
+ index_kv_cache.stride(1),
702
+ index_kv_cache.stride(2),
703
+ score.stride(0),
704
+ score.stride(1),
705
+ score.stride(2),
706
+ block_table.stride(0),
707
+ BLOCK_SIZE_Q=BLOCK_SIZE_Q,
708
+ BLOCK_SIZE_K=SPARSE_BLOCK_SIZE,
709
+ )
710
+ return score
711
+
712
+
713
+@torch.no_grad()
714
+def minimax_m3_index_topk(
715
+ score: torch.Tensor, # [num_idx_heads, total_q, max_block]
716
+ cu_seqlens_q: torch.Tensor, # [batch+1] int32
717
+ prefix_lens: torch.Tensor, # [batch] int32
718
+ max_query_len: int,
719
+ topk: int,
720
+ init_blocks: int,
721
+ local_blocks: int,
722
+ out: torch.Tensor | None = None,
723
+) -> torch.Tensor:
724
+ """Select index top-k from a precomputed score tensor.
725
+
726
+ When ``out`` is provided (a ``[num_idx_heads, >=total_q, topk]`` buffer), the
727
+ result is written into ``out[:, :total_q, :]`` instead of a fresh tensor --
728
+ used to keep the top-k output at a stable address for cudagraph capture.
729
+ """
730
+ num_idx_heads = score.shape[0]
731
+ batch = cu_seqlens_q.shape[0] - 1
732
+ total_q = score.shape[1]
733
+ if out is not None:
734
+ topk_idx = out[:, :total_q, :]
735
+ else:
736
+ topk_idx = torch.empty(
737
+ (num_idx_heads, total_q, topk),
738
+ dtype=torch.int32,
739
+ device=score.device,
740
+ )
741
+ # block_size_q == 1 -> query blocks coincide with query tokens.
742
+ grid_topk = (max_query_len, batch, num_idx_heads)
743
+ _topk_index_kernel[grid_topk](
744
+ score,
745
+ topk_idx,
746
+ 1, # sample_interval (block_size_q)
747
+ SPARSE_BLOCK_SIZE,
748
+ cu_seqlens_q,
749
+ cu_seqlens_q, # cu_seqblocks_q == cu_seqlens_q when block_size_q == 1
750
+ prefix_lens,
751
+ topk,
752
+ init_blocks,
753
+ local_blocks,
754
+ score.stride(0),
755
+ score.stride(1),
756
+ score.stride(2),
757
+ topk_idx.stride(0),
758
+ topk_idx.stride(1),
759
+ topk_idx.stride(2),
760
+ MASK_INIT=False,
761
+ MASK_LOCAL=False,
762
+ )
763
+ return topk_idx
764
+
765
+
766
+@torch.no_grad()
767
+def minimax_m3_index_decode(
768
+ idx_q: torch.Tensor, # [total_q, num_idx_heads, head_dim]
769
+ index_kv_cache: torch.Tensor, # [num_blocks, 128, head_dim]
770
+ block_table: torch.Tensor, # [num_reqs, max_blocks]
771
+ seq_lens: torch.Tensor, # [num_reqs] int32
772
+ max_seq_len: int,
773
+ topk: int,
774
+ init_blocks: int,
775
+ local_blocks: int,
776
+ num_kv_heads: int,
777
+ decode_query_len: int,
778
+ max_decode_query_len: int,
779
+ out: torch.Tensor | None = None,
780
+) -> torch.Tensor:
781
+ """Decode index block-score + top-k, both split-K (cudagraph-safe).
782
+
783
+ Returns topk_idx [num_kv_heads, total_q, topk] (0-indexed block ids, -1 pad).
784
+ When ``out`` ([num_kv_heads, >=total_q, topk]) is given, writes into
785
+ ``out[:, :total_q, :]`` (stable address for cudagraph) instead of allocating.
786
+ """
787
+ total_q, num_idx_heads, head_dim = idx_q.shape
788
+ assert num_idx_heads == num_kv_heads, (
789
+ "M3 expects num_idx_heads == num_kv_heads (no topk index reduce)"
790
+ )
791
+ assert decode_query_len <= max_decode_query_len
792
+ assert total_q == seq_lens.shape[0] * decode_query_len
793
+ batch = total_q
794
+ max_block = triton.cdiv(max_seq_len, SPARSE_BLOCK_SIZE)
795
+ use_pdl = current_platform.is_arch_support_pdl()
796
+ # `launch_pdl` is a Triton runtime kwarg only some backends accept (CUDA
797
+ # SM9+); this ROCm Triton rejects it even when False ("Keyword argument
798
+ # launch_pdl was specified but unrecognised"). Only pass it when PDL is
799
+ # actually supported -- on ROCm use_pdl is always False, so it's omitted.
800
+ pdl_kwargs: dict[str, bool | int] = {}
801
+ if use_pdl:
802
+ 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.
806
+ score_kwargs = pdl_kwargs.copy()
807
+ if num_idx_heads > 1 and max_decode_query_len > 1:
808
+ score_kwargs.update({"num_warps": 4, "num_stages": 2})
809
+
810
+ # Keep score strides 16-divisible to avoid Triton recompiles.
811
+ score_block_stride = round_up(max_block, 16)
812
+ score = torch.empty(
813
+ (num_idx_heads, total_q, score_block_stride),
814
+ dtype=torch.float32,
815
+ device=idx_q.device,
816
+ )
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
821
+ # Use the configured max decode length to avoid Triton recompiles when
822
+ # switching between qlen=1 and spec-decode verification batches.
823
+ 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,
838
+ 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,
857
+ )
858
+
859
+ if out is not None:
860
+ topk_idx = out[:, :total_q, :]
861
+ else:
862
+ topk_idx = torch.empty(
863
+ (num_idx_heads, total_q, topk),
864
+ dtype=torch.int32,
865
+ device=idx_q.device,
866
+ )
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(
878
+ num_topk_chunks,
879
+ num_idx_heads,
880
+ batch,
881
+ block_size_t,
882
+ dtype=torch.float32,
883
+ device=idx_q.device,
884
+ )
885
+ topk_idx_partial = torch.empty(
886
+ num_topk_chunks,
887
+ num_idx_heads,
888
+ batch,
889
+ block_size_t,
890
+ dtype=torch.int32,
891
+ device=idx_q.device,
892
+ )
893
+ _topk_index_partial_kernel[(batch, num_idx_heads, num_topk_chunks)](
894
+ score,
895
+ topk_score_partial,
896
+ topk_idx_partial,
897
+ seq_lens,
898
+ SPARSE_BLOCK_SIZE,
899
+ topk,
900
+ chunk_blocks,
901
+ decode_query_len,
902
+ score.stride(0),
903
+ score.stride(1),
904
+ 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),
932
+ topk_idx.stride(0),
933
+ topk_idx.stride(1),
934
+ topk_idx.stride(2),
935
+ num_topk_chunks=num_topk_chunks,
936
+ USE_PDL=use_pdl,
937
+ **pdl_kwargs,
938
+ )
939
+ return topk_idx
vllm/models/minimax_m3/amd/ops/sparse_attn.py
new
+271
@@ -0,0 +1,271 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""ROCm gfx942/gfx950 block-sparse GQA prefill kernel for MiniMax-M3.
4
+
5
+Only the prefill path is specialized on CDNA: each 128-token KV block is split
6
+into SUB_K-token sub-tiles to right-size the per-block QK/PV MFMAs. Everything
7
+else -- the decode split-K kernels, the FP8 dtype set, the sparse block size --
8
+is reused unchanged from ``common.ops.sparse_attn``.
9
+"""
10
+
11
+import torch
12
+
13
+from vllm.models.minimax_m3.common.ops.sparse_attn import (
14
+ _FP8_DTYPES,
15
+ SPARSE_BLOCK_SIZE,
16
+ minimax_m3_sparse_attn_decode,
17
+)
18
+from vllm.platforms.rocm import on_gfx950, on_mi3xx
19
+from vllm.triton_utils import tl, triton
20
+
21
+__all__ = ["minimax_m3_sparse_attn", "minimax_m3_sparse_attn_decode"]
22
+
23
+
24
+# Sub-tile width for the prefill kernel's per-block QK/PV GEMMs. gfx950 -> 64,
25
+# gfx942 -> 32 (re-tune with tune_sparse_attn.py). Must divide SPARSE_BLOCK_SIZE.
26
+_SPARSE_ATTN_SUB_K = SPARSE_BLOCK_SIZE // 2 if on_gfx950() else SPARSE_BLOCK_SIZE // 4
27
+
28
+_SPARSE_ATTN_PREFILL_KWARG: dict | None = None
29
+
30
+
31
+def _sparse_attn_prefill_kwargs() -> dict:
32
+ """MFMA + pipeline launch params for the sub-tiled prefill kernel.
33
+
34
+ gfx942 and gfx950 share the same params: ``num_warps=1`` keeps one wave
35
+ resident on the small per-sub-tile GEMM, ``matrix_instr_nonkdim=16`` /
36
+ ``kpack=2`` select the MFMA_16x16 path, and ``num_stages=1`` fits LDS and is
37
+ fastest in the sweep. Only the sub-tile width (``_SPARSE_ATTN_SUB_K``)
38
+ differs by arch. Empty on other AMD archs. Cached: arch is fixed per process.
39
+ """
40
+ global _SPARSE_ATTN_PREFILL_KWARG
41
+ if _SPARSE_ATTN_PREFILL_KWARG is None:
42
+ kwarg: dict = {}
43
+ if on_mi3xx():
44
+ kwarg = {
45
+ "num_warps": 1,
46
+ "matrix_instr_nonkdim": 16,
47
+ "kpack": 2,
48
+ "num_stages": 1,
49
+ }
50
+ _SPARSE_ATTN_PREFILL_KWARG = kwarg
51
+ return _SPARSE_ATTN_PREFILL_KWARG
52
+
53
+
54
+# ---------------------------------------------------------------------------
55
+# GQA block-sparse attention (paged). Main heads attend only to the selected
56
+# blocks. BLOCK_SIZE_K == 128 so each selected block is one page.
57
+# ---------------------------------------------------------------------------
58
+# since prefill metadata is sliced from mixed batch metadata, seq_lens and prefix_lens
59
+# might lose pointer alignment, which trigger Triton recompiles. we don't actually
60
+# need pointer alignment for those tensors anyway because we do scalar load.
61
+@triton.heuristics(
62
+ {
63
+ "BLOCK_SIZE_D": lambda args: triton.next_power_of_2(args["head_dim"]),
64
+ "BLOCK_SIZE_H": lambda args: triton.next_power_of_2(args["gqa_group_size"]),
65
+ "BLOCK_SIZE_T": lambda args: triton.next_power_of_2(args["max_topk"]),
66
+ "BLOCK_SIZE_QH": lambda args: args["BLOCK_SIZE_Q"]
67
+ * triton.next_power_of_2(args["gqa_group_size"]),
68
+ }
69
+)
70
+@triton.jit(do_not_specialize_on_alignment=["seq_lens", "prefix_lens"])
71
+def _gqa_sparse_fwd_kernel(
72
+ q_ptr, # [total_q, num_heads, head_dim]
73
+ kv_cache_ptr, # main cache: [num_blocks, 2, 128, num_kv_heads, head_dim]
74
+ t_ptr, # topk_idx: [num_kv_heads, total_q, topk]
75
+ o_ptr, # [total_q, num_heads, head_dim]
76
+ block_table_ptr, # [num_reqs, max_blocks]
77
+ cu_seqlens_q,
78
+ cu_seqblocks_q,
79
+ seq_lens,
80
+ prefix_lens,
81
+ num_kv_heads,
82
+ gqa_group_size,
83
+ head_dim,
84
+ max_topk,
85
+ num_q_loop,
86
+ sm_scale,
87
+ stride_qn,
88
+ stride_qh,
89
+ stride_qd,
90
+ stride_kv_blk,
91
+ stride_kv_kv,
92
+ stride_kv_pos,
93
+ stride_kv_h,
94
+ stride_kv_d,
95
+ stride_th,
96
+ stride_tn,
97
+ stride_tk,
98
+ stride_on,
99
+ stride_oh,
100
+ stride_od,
101
+ stride_bt_b,
102
+ BLOCK_SIZE_Q: tl.constexpr,
103
+ BLOCK_SIZE_K: tl.constexpr, # == SPARSE_BLOCK_SIZE (128)
104
+ BLOCK_SIZE_D: tl.constexpr,
105
+ BLOCK_SIZE_H: tl.constexpr,
106
+ BLOCK_SIZE_T: tl.constexpr,
107
+ BLOCK_SIZE_QH: tl.constexpr,
108
+ USE_FP8: tl.constexpr, # fp8 KV cache: dequantize K/V to q.dtype on load
109
+ SUB_K: tl.constexpr, # CDNA only: KV sub-tile width (see _IS_MI3XX)
110
+):
111
+ sm_scale_log2e = sm_scale * 1.4426950409
112
+ pid_q = tl.program_id(0)
113
+ pid_kh = tl.program_id(1)
114
+ pid_b = tl.program_id(2)
115
+ pid_h = pid_kh * gqa_group_size
116
+ q_start = tl.load(cu_seqlens_q + pid_b)
117
+ q_len = tl.load(cu_seqlens_q + pid_b + 1) - q_start
118
+ q_block_start = tl.load(cu_seqblocks_q + pid_b)
119
+ q_block_len = tl.load(cu_seqblocks_q + pid_b + 1) - q_block_start
120
+ seq_len = tl.load(seq_lens + pid_b)
121
+ prefix_len = tl.load(prefix_lens + pid_b)
122
+ if pid_q * num_q_loop >= q_block_len:
123
+ return
124
+ real_q_loop = min(num_q_loop, q_block_len - pid_q * num_q_loop)
125
+ bt_row = block_table_ptr + pid_b * stride_bt_b
126
+ off_d = tl.arange(0, BLOCK_SIZE_D)
127
+ d_mask = off_d < head_dim
128
+ for j in range(real_q_loop):
129
+ pid_q_j = pid_q * num_q_loop + j
130
+ t_ptr_j = t_ptr + (q_block_start + pid_q_j) * stride_tn + pid_kh * stride_th
131
+ off_t = tl.arange(0, BLOCK_SIZE_T)
132
+ topk_idx = tl.load(t_ptr_j + off_t * stride_tk, mask=off_t < max_topk, other=-1)
133
+ real_topk = tl.sum((topk_idx >= 0).to(tl.int32), axis=0)
134
+ q_ptrs = tl.make_block_ptr(
135
+ base=q_ptr + q_start * stride_qn + pid_h * stride_qh,
136
+ shape=(q_len, gqa_group_size, head_dim),
137
+ strides=(stride_qn, stride_qh, stride_qd),
138
+ offsets=(pid_q_j * BLOCK_SIZE_Q, 0, 0),
139
+ block_shape=(BLOCK_SIZE_Q, BLOCK_SIZE_H, BLOCK_SIZE_D),
140
+ order=(2, 1, 0),
141
+ )
142
+ q = tl.load(q_ptrs, boundary_check=(0, 1, 2), padding_option="zero")
143
+ m_i = tl.full((BLOCK_SIZE_QH,), float("-inf"), dtype=tl.float32)
144
+ lse_i = tl.full((BLOCK_SIZE_QH,), float("-inf"), dtype=tl.float32)
145
+ acc_o = tl.zeros((BLOCK_SIZE_QH, BLOCK_SIZE_D), dtype=tl.float32)
146
+ q = tl.reshape(q, BLOCK_SIZE_QH, BLOCK_SIZE_D)
147
+
148
+ # CDNA: process each 128-token KV block in SUB_K-token sub-tiles so
149
+ # each QK/PV MFMA is right-sized. Numerically equivalent to the dense
150
+ # path below (flash-softmax reassociation).
151
+ NUM_SUB: tl.constexpr = BLOCK_SIZE_K // SUB_K
152
+ for _ in tl.range(real_topk):
153
+ blk = tl.load(t_ptr_j).to(tl.int32)
154
+ t_ptr_j = t_ptr_j + stride_tk
155
+ c = blk * BLOCK_SIZE_K
156
+ page = tl.load(bt_row + blk).to(tl.int64)
157
+ kv_base = kv_cache_ptr + page * stride_kv_blk + pid_kh * stride_kv_h
158
+ for sub_i in range(NUM_SUB):
159
+ off_sub = tl.arange(0, SUB_K) + sub_i * SUB_K
160
+ pos_sub = c + off_sub
161
+ pos_mask_sub = pos_sub < seq_len
162
+ k_sub = tl.load(
163
+ kv_base
164
+ + 0 * stride_kv_kv
165
+ + off_sub[None, :] * stride_kv_pos
166
+ + off_d[:, None] * stride_kv_d,
167
+ mask=d_mask[:, None] & pos_mask_sub[None, :],
168
+ other=0.0,
169
+ )
170
+ if USE_FP8:
171
+ k_sub = k_sub.to(q.dtype)
172
+ off_q_sub = (
173
+ tl.arange(0, BLOCK_SIZE_Q)[:, None]
174
+ + pid_q_j * BLOCK_SIZE_Q
175
+ + prefix_len
176
+ - off_sub[None, :]
177
+ )
178
+ qk_sub = tl.zeros((BLOCK_SIZE_Q, BLOCK_SIZE_H, SUB_K), dtype=tl.float32)
179
+ # causal: q_abs_pos - k_off >= block_start (c)
180
+ qk_sub += tl.where(off_q_sub[:, None, :] >= c, 0, float("-inf"))
181
+ qk_sub = tl.reshape(qk_sub, BLOCK_SIZE_QH, SUB_K)
182
+ qk_sub += tl.dot(q, k_sub) * sm_scale_log2e
183
+ qk_sub += tl.where(pos_mask_sub[None, :], 0, float("-inf"))
184
+ m_ij = tl.maximum(m_i, tl.max(qk_sub, axis=1))
185
+ p_sub = tl.exp2(qk_sub - m_ij[:, None])
186
+ l_ij = tl.sum(p_sub, axis=1)
187
+ acc_o = acc_o * tl.exp2(m_i - m_ij)[:, None]
188
+ v_sub = tl.load(
189
+ kv_base
190
+ + 1 * stride_kv_kv
191
+ + off_sub[:, None] * stride_kv_pos
192
+ + off_d[None, :] * stride_kv_d,
193
+ mask=pos_mask_sub[:, None] & d_mask[None, :],
194
+ other=0.0,
195
+ )
196
+ if USE_FP8:
197
+ v_sub = v_sub.to(q.dtype)
198
+ acc_o += tl.dot(p_sub.to(v_sub.dtype), v_sub)
199
+ m_i = m_ij
200
+ lse_i = m_ij + tl.log2(tl.exp2(lse_i - m_ij) + l_ij)
201
+ acc_o = acc_o * tl.exp2(m_i - lse_i)[:, None]
202
+ acc_o = tl.reshape(acc_o, BLOCK_SIZE_Q, BLOCK_SIZE_H, BLOCK_SIZE_D)
203
+ o_ptrs = tl.make_block_ptr(
204
+ base=o_ptr + q_start * stride_on + pid_h * stride_oh,
205
+ shape=(q_len, gqa_group_size, head_dim),
206
+ strides=(stride_on, stride_oh, stride_od),
207
+ offsets=(pid_q_j * BLOCK_SIZE_Q, 0, 0),
208
+ block_shape=(BLOCK_SIZE_Q, BLOCK_SIZE_H, BLOCK_SIZE_D),
209
+ order=(2, 1, 0),
210
+ )
211
+ tl.store(o_ptrs, acc_o.to(o_ptr.dtype.element_ty), boundary_check=(0, 1, 2))
212
+
213
+
214
+@torch.no_grad()
215
+def minimax_m3_sparse_attn(
216
+ q: torch.Tensor, # [total_q, num_heads, head_dim]
217
+ kv_cache: torch.Tensor, # [num_blocks, 2, 128, num_kv_heads, head_dim]
218
+ topk_idx: torch.Tensor, # [num_kv_heads, total_q, topk]
219
+ block_table: torch.Tensor, # [batch, max_blocks]
220
+ cu_seqlens_q: torch.Tensor, # [batch+1] int32
221
+ seq_lens: torch.Tensor, # [batch] int32
222
+ prefix_lens: torch.Tensor, # [batch] int32
223
+ max_query_len: int,
224
+ num_kv_heads: int,
225
+ sm_scale: float,
226
+ output: torch.Tensor, # [total_q, num_heads, head_dim]
227
+) -> None:
228
+ """GQA block-sparse attention over the selected blocks. block_size_q == 1."""
229
+ total_q, num_heads, head_dim = q.shape
230
+ batch = cu_seqlens_q.shape[0] - 1
231
+ topk = topk_idx.shape[-1]
232
+ gqa_group_size = num_heads // num_kv_heads
233
+ use_fp8 = kv_cache.dtype in _FP8_DTYPES
234
+ grid = (max_query_len, num_kv_heads, batch)
235
+ _gqa_sparse_fwd_kernel[grid](
236
+ q,
237
+ kv_cache,
238
+ topk_idx,
239
+ output,
240
+ block_table,
241
+ cu_seqlens_q,
242
+ cu_seqlens_q, # cu_seqblocks_q == cu_seqlens_q when block_size_q == 1
243
+ seq_lens,
244
+ prefix_lens,
245
+ num_kv_heads,
246
+ gqa_group_size,
247
+ head_dim,
248
+ topk,
249
+ 1, # num_q_loop
250
+ sm_scale,
251
+ q.stride(0),
252
+ q.stride(1),
253
+ q.stride(2),
254
+ kv_cache.stride(0),
255
+ kv_cache.stride(1),
256
+ kv_cache.stride(2),
257
+ kv_cache.stride(3),
258
+ kv_cache.stride(4),
259
+ topk_idx.stride(0),
260
+ topk_idx.stride(1),
261
+ topk_idx.stride(2),
262
+ output.stride(0),
263
+ output.stride(1),
264
+ output.stride(2),
265
+ block_table.stride(0),
266
+ BLOCK_SIZE_Q=1,
267
+ BLOCK_SIZE_K=SPARSE_BLOCK_SIZE,
268
+ USE_FP8=use_fp8,
269
+ SUB_K=_SPARSE_ATTN_SUB_K,
270
+ **_sparse_attn_prefill_kwargs(),
271
+ )
vllm/models/minimax_m3/common/indexer.py
+14
-5
@@ -27,12 +27,21 @@ from vllm.distributed import get_tensor_model_parallel_world_size
27
from vllm.forward_context import get_forward_context
28
from vllm.logger import init_logger
29
from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase
30
-from vllm.models.minimax_m3.common.ops.index_topk import (
31
- minimax_m3_index_decode,
32
- minimax_m3_index_score,
33
- minimax_m3_index_topk,
34
-)
30
from vllm.platforms import current_platform
31
+
32
+if current_platform.is_rocm():
33
+ from vllm.models.minimax_m3.amd.ops.index_topk import (
34
+ minimax_m3_index_decode,
35
+ minimax_m3_index_score,
36
+ minimax_m3_index_topk,
37
+ )
38
+else:
39
+ from vllm.models.minimax_m3.common.ops.index_topk import (
40
+ minimax_m3_index_decode,
41
+ minimax_m3_index_score,
42
+ minimax_m3_index_topk,
43
+ )
44
+
45
from vllm.v1.attention.backend import (
46
AttentionBackend,
47
AttentionCGSupport,
vllm/models/minimax_m3/common/ops/sparse_attn.py
-26
@@ -31,30 +31,6 @@ _FP8_DTYPES = (
31
torch.float8_e5m2fnuz,
32
)
33
34
-_SPARSE_ATTN_NUM_STAGES_KWARG: dict | None = None
35
-
36
-
37
-def _sparse_attn_num_stages_kwarg() -> dict:
38
- """Triton ``num_stages`` override for the sparse-attn GEMM kernels.
39
-
40
- Forced only where required: CDNA3 (gfx942) caps LDS at
41
- 64 KB, and the default 2-stage pipeline double-buffers the 128x128 K/V tiles
42
- to ~66 KB ("out of resource: shared memory"), so pin gfx942 to a single
43
- stage (~32 KB, which fits). Everywhere else (NVIDIA, CDNA4 gfx950) return an
44
- empty kwarg and let Triton keep its own default -- don't second-guess it.
45
- Cached: the arch is fixed per process.
46
- """
47
- global _SPARSE_ATTN_NUM_STAGES_KWARG
48
- if _SPARSE_ATTN_NUM_STAGES_KWARG is None:
49
- kwarg: dict = {}
50
- if current_platform.is_rocm():
51
- from vllm.platforms.rocm import on_gfx942
52
-
53
- if on_gfx942():
54
- kwarg = {"num_stages": 1}
55
- _SPARSE_ATTN_NUM_STAGES_KWARG = kwarg
56
- return _SPARSE_ATTN_NUM_STAGES_KWARG
57
-
34
35
# ---------------------------------------------------------------------------
36
# GQA block-sparse attention (paged). Main heads attend only to the selected
@@ -498,7 +474,6 @@ def minimax_m3_sparse_attn(
474
BLOCK_SIZE_Q=1,
475
BLOCK_SIZE_K=SPARSE_BLOCK_SIZE,
476
USE_FP8=use_fp8,
501
- **_sparse_attn_num_stages_kwarg(),
477
)
478
479
@@ -574,7 +549,6 @@ def minimax_m3_sparse_attn_decode(
549
NUM_TOPK_CHUNKS=num_topk_chunks,
550
USE_FP8=use_fp8,
551
USE_PDL=use_pdl,
577
- **_sparse_attn_num_stages_kwarg(),
552
**pdl_launch,
553
)
554
merge_grid = (total_q, num_heads)
vllm/models/minimax_m3/common/sparse_attention.py
+14
-5
@@ -24,12 +24,21 @@ from vllm.config import VllmConfig
24
from vllm.config.cache import CacheDType
25
from vllm.forward_context import get_forward_context
26
from vllm.logger import init_logger
27
-from vllm.models.minimax_m3.common.ops.sparse_attn import (
28
- SPARSE_BLOCK_SIZE,
29
- minimax_m3_sparse_attn,
30
- minimax_m3_sparse_attn_decode,
31
-)
27
+from vllm.models.minimax_m3.common.ops.sparse_attn import SPARSE_BLOCK_SIZE
28
from vllm.platforms import current_platform
29
+
30
+# AMD/ROCm uses the gfx942/gfx950-optimized block-sparse kernels in amd.ops;
31
+# every other platform uses the generic common.ops implementation.
32
+if current_platform.is_rocm():
33
+ from vllm.models.minimax_m3.amd.ops.sparse_attn import (
34
+ minimax_m3_sparse_attn,
35
+ minimax_m3_sparse_attn_decode,
36
+ )
37
+else:
38
+ from vllm.models.minimax_m3.common.ops.sparse_attn import (
39
+ minimax_m3_sparse_attn,
40
+ minimax_m3_sparse_attn_decode,
41
+ )
42
from vllm.v1.attention.backend import (
43
AttentionBackend,
44
AttentionCGSupport,