[ROCm][DSv4.1] Paged MXFP4 sparse indexer on aiter's MQA-logits kernel (#58671)
Signed-off-by: Mehmet Cagri Kaymak <mehmet.kaymak@amd.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Shanshan Shen <467638484@qq.com>
Mehmet Cagri committed
Sep 29, 2026 at 01:37 UTC
63b9931b7399c9bfa7a8f7cdd60e2991ba87fbf9
9 files changed
+2276
-9
tests/kernels/attention/test_rocm_paged_mxfp4_indexer.py
new
+599
@@ -0,0 +1,599 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""DeepSeek V4.1's ROCm MXFP4 sparse indexer against a torch reference.
4
+
5
+The indexer K cache is a view into vLLM's block-major KV pool: its pages are
6
+a block stride apart with other layers' pages in between, so the tests put
7
+it there. The reference reads the keys back from a second cache the same
8
+writer fills in natural order, so it shares none of the preshuffled
9
+addressing it checks.
10
+"""
11
+
12
+import itertools
13
+import types
14
+
15
+import pytest
16
+import torch
17
+
18
+from vllm.platforms import current_platform
19
+
20
+if not current_platform.is_rocm():
21
+ pytest.skip("ROCm-only", allow_module_level=True)
22
+
23
+from vllm.config import CUDAGraphMode
24
+from vllm.models.deepseek_v41.amd.rocm import (
25
+ rocm_mxfp4_indexer_k_store,
26
+ rocm_mxfp4_indexer_q_quant,
27
+)
28
+from vllm.platforms.rocm import get_cdna_version
29
+from vllm.utils.math_utils import cdiv
30
+from vllm.utils.torch_utils import set_random_seed
31
+from vllm.v1.attention.backends.mla.indexer import (
32
+ DeepseekV32IndexerPrefillChunkMetadata,
33
+)
34
+from vllm.v1.attention.backends.mla.rocm_paged_mxfp4_indexer import (
35
+ RocmMxfp4IndexerMetadata,
36
+ native_decode,
37
+ plan_gather_launches,
38
+ plan_prefill_chunks,
39
+)
40
+from vllm.v1.attention.ops import rocm_paged_mxfp4_indexer as ops
41
+from vllm.v1.worker.workspace import init_workspace_manager, reset_workspace_manager
42
+
43
+if get_cdna_version() != 4:
44
+ pytest.skip("CDNA4-only", allow_module_level=True)
45
+
46
+HEADS, HEAD_DIM = 32, 128
47
+WIDTH = HEAD_DIM // 2 + HEAD_DIM // 32
48
+# V4.1's 8-token blocks, with the pool (2048 blocks) and top-k (512) scaled
49
+# down so short test contexts still select.
50
+CAND_BLOCK, POOL_BLOCKS, TOPK = 8, 16, 32
51
+MAX_LEN = 4096
52
+DEVICE = "cuda"
53
+
54
+_E2M1 = torch.tensor([0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0])
55
+E2M1 = torch.cat([_E2M1, -_E2M1])
56
+
57
+
58
+def _dequant(packed, e8m0):
59
+ """[..., D/2] e2m1 pairs and [..., D/32] e8m0 to [..., D] fp32."""
60
+ table = E2M1.to(packed.device)
61
+ vals = torch.stack([table[(packed & 0xF).long()], table[(packed >> 4).long()]], -1)
62
+ scale = torch.pow(2.0, e8m0.float() - 127)
63
+ return (vals.flatten(-2).unflatten(-1, (-1, 32)) * scale[..., None]).flatten(-2)
64
+
65
+
66
+@pytest.fixture(autouse=True)
67
+def _workspace():
68
+ init_workspace_manager(torch.device(DEVICE))
69
+ yield
70
+ reset_workspace_manager()
71
+
72
+
73
+class _Case:
74
+ """Requests with their keys written into a block-major pool (ratio-2 and
75
+ ratio-1 pages side by side in every block) and into natural-order caches
76
+ the reference reads. The bf16 inputs are kept for the end-to-end test."""
77
+
78
+ def __init__(self, seq_lens, block):
79
+ from aiter.ops.triton.fusions.k_norm_rope_mxfp4_cache import (
80
+ k_norm_rope_mxfp4_cache,
81
+ )
82
+
83
+ self.seq_lens = seq_lens
84
+ num_blocks = sum(cdiv(n, block) for n in seq_lens) + 3
85
+ perm = torch.randperm(num_blocks)
86
+ width = max(cdiv(n, block) for n in seq_lens)
87
+ self.block_table = torch.zeros(len(seq_lens), width, dtype=torch.int32)
88
+ used = 0
89
+ for req, n in enumerate(seq_lens):
90
+ self.block_table[req, : cdiv(n, block)] = perm[used : used + cdiv(n, block)]
91
+ used += cdiv(n, block)
92
+ self.block_table = self.block_table.to(DEVICE)
93
+
94
+ def align(n):
95
+ return cdiv(n, 576) * 576
96
+
97
+ page2, page1 = block // 2 * WIDTH, block * WIDTH
98
+ stride = align(page2) + align(page1) + 576
99
+ self.pool = torch.zeros(num_blocks * stride, dtype=torch.uint8, device=DEVICE)
100
+ self.page_bytes = torch.zeros_like(self.pool, dtype=torch.bool)
101
+ for i in range(num_blocks):
102
+ base = i * stride
103
+ self.page_bytes[base : base + page2] = True
104
+ base += align(page2)
105
+ self.page_bytes[base : base + page1] = True
106
+ self.cache = {
107
+ 2: torch.as_strided(
108
+ self.pool, (num_blocks, block // 2, WIDTH), (stride, WIDTH, 1), 0
109
+ ),
110
+ 1: torch.as_strided(
111
+ self.pool,
112
+ (num_blocks, block, WIDTH),
113
+ (stride, WIDTH, 1),
114
+ align(page2),
115
+ ),
116
+ }
117
+ self.offsets = [0]
118
+ for n in seq_lens:
119
+ self.offsets.append(self.offsets[-1] + n)
120
+ # the same keys in natural order, one entry per page
121
+ self.natural = {
122
+ r: torch.zeros(self.offsets[-1], 1, WIDTH, dtype=torch.uint8, device=DEVICE)
123
+ for r in (1, 2)
124
+ }
125
+ self.cos_sin = cos_sin = torch.randn(MAX_LEN, 64, device=DEVICE)
126
+ self.norm = norm = torch.rand(HEAD_DIM, device=DEVICE) + 0.5
127
+ self.k_pre = {}
128
+ for req, n in enumerate(seq_lens):
129
+ if n == 0:
130
+ continue
131
+ k_pre = torch.randn(n, HEAD_DIM, device=DEVICE).to(torch.bfloat16)
132
+ self.k_pre[req] = k_pre
133
+ pos = torch.arange(n, device=DEVICE)
134
+ for ratio, cache in self.cache.items():
135
+ comp = pos // ratio
136
+ boundary = (pos + 1) % ratio == 0
137
+ entries = cache.shape[1]
138
+ page = self.block_table[req, comp // entries].long()
139
+ slot = torch.where(boundary, page * entries + comp % entries, -1)
140
+ nat = torch.where(boundary, self.offsets[req] + comp, -1)
141
+ rocm_mxfp4_indexer_k_store(
142
+ k_pre,
143
+ pos,
144
+ cos_sin,
145
+ norm,
146
+ 1e-6,
147
+ cache,
148
+ slot,
149
+ ratio,
150
+ True,
151
+ num_heads=HEADS,
152
+ )
153
+ k_norm_rope_mxfp4_cache(
154
+ k_pre, pos, cos_sin, norm, 1e-6, self.natural[ratio], nat, ratio
155
+ )
156
+
157
+ def keys(self, ratio, req, n):
158
+ nat = self.natural[ratio][self.offsets[req] : self.offsets[req] + n, 0]
159
+ return _dequant(nat[:, : HEAD_DIM // 2], nat[:, HEAD_DIM // 2 :])
160
+
161
+
162
+def _queries(num_rows):
163
+ q = torch.randint(0, 256, (num_rows, HEADS, HEAD_DIM // 2), dtype=torch.uint8)
164
+ scales = torch.randint(122, 130, (num_rows, HEADS, HEAD_DIM // 32))
165
+ q, scales = q.to(DEVICE), scales.to(torch.uint8).to(DEVICE)
166
+ weights = torch.randn(num_rows, HEADS, device=DEVICE)
167
+ # the fused Q kernel returns the scales as one int32 per head
168
+ return q, scales.view(torch.int32).squeeze(-1), weights, _dequant(q, scales)
169
+
170
+
171
+def _scores(q, w, keys):
172
+ return (torch.relu(q @ keys.T) * w[:, None]).sum(0)
173
+
174
+
175
+def _assert_topk(row, scores, k):
176
+ """``row`` is a top-k of the entries of ``scores`` above -inf, up to the
177
+ fp32 reduce order."""
178
+ valid = scores > float("-inf")
179
+ want = min(k, int(valid.sum()))
180
+ sel = row[row >= 0].long()
181
+ assert sel.numel() == want and sel.unique().numel() == want
182
+ if want:
183
+ assert bool(valid[sel].all())
184
+ kth = scores[valid].topk(want).values[-1]
185
+ finite = scores[torch.isfinite(scores)]
186
+ tol = 1e-4 * float(finite.abs().max()) if finite.numel() else 0.0
187
+ assert bool((scores[sel] >= kth - tol).all())
188
+
189
+
190
+def _block_maxima(scores):
191
+ end = scores.numel()
192
+ if end == 0:
193
+ return scores
194
+ padded = torch.full((cdiv(end, CAND_BLOCK) * CAND_BLOCK,), float("-inf"))
195
+ padded[:end] = scores.cpu()
196
+ out = padded.view(-1, CAND_BLOCK).amax(1)
197
+ out[(end - 1) // CAND_BLOCK] = float("inf")
198
+ return out.to(scores.device)
199
+
200
+
201
+def _forward_context(monkeypatch, metadata):
202
+ context = types.SimpleNamespace(
203
+ attn_metadata={"indexer": metadata},
204
+ cudagraph_runtime_mode=CUDAGraphMode.NONE,
205
+ )
206
+ monkeypatch.setattr(ops, "get_forward_context", lambda: context)
207
+
208
+
209
+def _decode_metadata(case, rows, ratio, query_lens):
210
+ lens = torch.tensor([(p + 1) // ratio for _, p in rows], dtype=torch.int32)
211
+ lens = lens.to(DEVICE)
212
+ block_table = case.block_table[[req for req, _ in rows]].contiguous()
213
+ entries = case.cache[ratio].shape[1]
214
+ next_n = max(query_lens)
215
+ context_lens = (torch.tensor(case.seq_lens, dtype=torch.int32) // ratio).to(DEVICE)
216
+ native = native_decode(lens, block_table, query_lens, next_n, context_lens)
217
+ schedule = torch.empty(
218
+ ops.rocm_mxfp4_decode_schedule_words(HEADS, HEAD_DIM, entries, next_n),
219
+ dtype=torch.int32,
220
+ device=DEVICE,
221
+ )
222
+ return RocmMxfp4IndexerMetadata(
223
+ seq_lens=None,
224
+ max_seq_len=max(case.seq_lens),
225
+ slot_mapping=None,
226
+ num_decodes=len(case.seq_lens),
227
+ num_decode_tokens=len(rows),
228
+ num_prefills=0,
229
+ num_prefill_tokens=0,
230
+ decode=types.SimpleNamespace(block_table=block_table, seq_lens=lens[:, None]),
231
+ decode_row_lens=lens,
232
+ decode_block_ends=(lens + CAND_BLOCK - 1) // CAND_BLOCK,
233
+ decode_native=native,
234
+ decode_schedule=ops.build_rocm_mxfp4_decode_schedule(
235
+ lens, HEADS, HEAD_DIM, entries, schedule, MAX_LEN // ratio, native
236
+ ),
237
+ )
238
+
239
+
240
+def _prefill_metadata(
241
+ case, rows, ratio, chunk_bounds, query_start_loc, gather_rows, gather
242
+):
243
+ chunks = []
244
+ for req_lo, req_hi, t0, t1 in chunk_bounds:
245
+ ends = torch.tensor(
246
+ [(rows[t][1] + 1) // ratio for t in range(t0, t1)], dtype=torch.int32
247
+ ).to(DEVICE)
248
+ chunks.append(
249
+ DeepseekV32IndexerPrefillChunkMetadata(
250
+ block_table=case.block_table[req_lo:req_hi],
251
+ cu_seqlen_ks=torch.zeros_like(ends),
252
+ cu_seqlen_ke=ends,
253
+ cu_seq_lens=torch.zeros(1, dtype=torch.int32, device=DEVICE),
254
+ token_to_seq=torch.zeros(1, dtype=torch.int32, device=DEVICE),
255
+ total_seq_lens=1,
256
+ token_start=t0,
257
+ token_end=t1,
258
+ num_reqs=req_hi - req_lo,
259
+ )
260
+ )
261
+ seq_lens = torch.tensor(case.seq_lens)
262
+ context_lens = (seq_lens // ratio).int().to(DEVICE)
263
+ plans = plan_prefill_chunks(
264
+ chunks,
265
+ query_start_loc,
266
+ seq_lens,
267
+ context_lens,
268
+ ratio,
269
+ CAND_BLOCK,
270
+ 0.0 if gather else None,
271
+ torch.tensor(query_start_loc, dtype=torch.int32, device=DEVICE),
272
+ )
273
+ return RocmMxfp4IndexerMetadata(
274
+ seq_lens=None,
275
+ max_seq_len=max(case.seq_lens),
276
+ slot_mapping=None,
277
+ num_decodes=0,
278
+ num_decode_tokens=0,
279
+ num_prefills=len(case.seq_lens),
280
+ num_prefill_tokens=len(rows),
281
+ prefill=types.SimpleNamespace(chunks=chunks),
282
+ prefill_plans=plans,
283
+ gather_launches=plan_gather_launches(chunks, plans, gather_rows),
284
+ )
285
+
286
+
287
+# The kernel prefers 128-token pages.
288
+BLOCKS = pytest.mark.parametrize("block", [128, 64])
289
+
290
+
291
+@BLOCKS
292
+def test_k_store_stays_inside_its_pages(block):
293
+ """The writer touches only its pages of the block-major pool. What they hold
294
+ is checked by the layer tests, which read them back through aiter's kernel."""
295
+ set_random_seed(0)
296
+ case = _Case([700, 333], block)
297
+ assert int(case.pool[~case.page_bytes].count_nonzero()) == 0
298
+
299
+
300
+@pytest.mark.parametrize(
301
+ "rows, width, k",
302
+ # vLLM's kernel below 128 rows, aiter's from 128 rows and on every k=2048
303
+ [(8, 4096, 512), (128, 4096, 512), (8, 16384, 2048)],
304
+)
305
+def test_topk_matches_torch_on_both_kernels(rows, width, k):
306
+ set_random_seed(0)
307
+ logits = torch.randn(rows, width, device=DEVICE)
308
+ lengths = torch.randint(1, width + 1, (rows,), dtype=torch.int32, device=DEVICE)
309
+ lengths[0] = k // 2 # shorter than k, so the rest of the row pads with -1
310
+ out = torch.full((rows, k), 7, dtype=torch.int32, device=DEVICE)
311
+ ops._topk(logits, lengths, out, k)
312
+ for r in range(rows):
313
+ n = min(k, int(lengths[r]))
314
+ want = torch.topk(logits[r, : int(lengths[r])], n).indices.sort().values
315
+ got = out[r]
316
+ assert torch.equal(got[got >= 0].sort().values, want.int())
317
+ assert int((got == -1).sum()) == k - n
318
+
319
+
320
+def _run_layers(monkeypatch, case, rows, metadata):
321
+ """Ratio-2 dense layer, the candidate source, and a consumer both ways;
322
+ each checked against the torch reference."""
323
+ num_rows = len(rows)
324
+ q, q_scale, weights, q_ref = _queries(num_rows)
325
+ hidden = torch.empty(num_rows, 1, device=DEVICE)
326
+
327
+ def topk_buffer():
328
+ return torch.full((num_rows + 2, TOPK), 7, dtype=torch.int32, device=DEVICE)
329
+
330
+ _forward_context(monkeypatch, metadata(2))
331
+ out = topk_buffer()
332
+ ops.rocm_mxfp4_sparse_attn_indexer(
333
+ hidden,
334
+ "indexer",
335
+ case.cache[2],
336
+ q,
337
+ q_scale,
338
+ weights,
339
+ TOPK,
340
+ HEAD_DIM,
341
+ MAX_LEN // 2,
342
+ out,
343
+ compress_ratio=2,
344
+ )
345
+ for i, (req, pos) in enumerate(rows):
346
+ end = (pos + 1) // 2
347
+ keys = case.keys(2, req, end)
348
+ _assert_topk(out[i], _scores(q_ref[i], weights[i], keys), TOPK)
349
+
350
+ source = metadata(1)
351
+ _forward_context(monkeypatch, source)
352
+ pool = torch.full((num_rows + 2, POOL_BLOCKS), 5, dtype=torch.int32, device=DEVICE)
353
+ out = topk_buffer()
354
+ ops.rocm_mxfp4_sparse_attn_indexer(
355
+ hidden,
356
+ "indexer",
357
+ case.cache[1],
358
+ q,
359
+ q_scale,
360
+ weights,
361
+ TOPK,
362
+ HEAD_DIM,
363
+ MAX_LEN,
364
+ out,
365
+ compress_ratio=1,
366
+ candidate_blocks=pool,
367
+ candidate_block_size=CAND_BLOCK,
368
+ candidate_write=True,
369
+ )
370
+ allowed = []
371
+ for i, (req, pos) in enumerate(rows):
372
+ scores = _scores(q_ref[i], weights[i], case.keys(1, req, pos + 1))
373
+ _assert_topk(out[i], scores, TOPK)
374
+ _assert_topk(pool[i], _block_maxima(scores), POOL_BLOCKS)
375
+ mask = torch.zeros_like(scores, dtype=torch.bool)
376
+ for block in pool[i][pool[i] >= 0].tolist():
377
+ mask[block * CAND_BLOCK : (block + 1) * CAND_BLOCK] = True
378
+ allowed.append(torch.where(mask, scores, float("-inf")))
379
+
380
+ pa = ops._aiter()
381
+ build = pa.build_candidate_gather
382
+ builds = []
383
+
384
+ def counting_build(*args, **kwargs):
385
+ builds.append(1)
386
+ return build(*args, **kwargs)
387
+
388
+ monkeypatch.setattr(pa, "build_candidate_gather", counting_build)
389
+ for gather in (True, False):
390
+ consumer = metadata(1, gather)
391
+ consumer.decode_use_gather = gather
392
+ _forward_context(monkeypatch, consumer)
393
+ builds.clear()
394
+ # Two consumer layers of one step: the second reuses the first's pools.
395
+ for _ in range(2):
396
+ out = topk_buffer()
397
+ ops.rocm_mxfp4_sparse_mqa_indexer(
398
+ hidden,
399
+ "indexer",
400
+ case.cache[1],
401
+ q,
402
+ q_scale,
403
+ weights,
404
+ TOPK,
405
+ HEAD_DIM,
406
+ MAX_LEN,
407
+ out,
408
+ 1,
409
+ pool,
410
+ CAND_BLOCK,
411
+ POOL_BLOCKS * CAND_BLOCK,
412
+ )
413
+ for i in range(num_rows):
414
+ _assert_topk(out[i], allowed[i], TOPK)
415
+ if gather:
416
+ launches = len(consumer.gather_launches) + (consumer.decode is not None)
417
+ assert len(builds) == launches
418
+
419
+
420
+@BLOCKS
421
+@pytest.mark.parametrize(
422
+ "query_lens",
423
+ # Uniform steps launch the dense layers on next_n-row sequences, also with
424
+ # cudagraph padding (query length 0) after them; a ragged step keeps a row
425
+ # per token.
426
+ [[6, 6, 6, 6], [2, 2, 2], [2, 2, 2, 0], [2, 1, 2]],
427
+ ids=["native6", "native2", "padded", "ragged"],
428
+)
429
+def test_decode_layers_match_reference(monkeypatch, block, query_lens):
430
+ set_random_seed(0)
431
+ next_n = max(query_lens)
432
+ case = _Case([q and n for q, n in zip(query_lens, [900, 333, 610, 1200])], block)
433
+ # a padding request has next_n rows of its own and no context
434
+ rows = [
435
+ (req, n - q + j if q else -1)
436
+ for req, (n, q) in enumerate(zip(case.seq_lens, query_lens))
437
+ for j in range(q or next_n)
438
+ ]
439
+ _run_layers(
440
+ monkeypatch,
441
+ case,
442
+ rows,
443
+ lambda r, gather=False: _decode_metadata(case, rows, r, query_lens),
444
+ )
445
+
446
+
447
+@pytest.mark.parametrize(
448
+ "seq_lens,new_tokens,chunks",
449
+ [
450
+ # one request past a cached prefix, then one sliced into two chunks
451
+ ([760, 500], [300, 500], [(0, 1, 0, 300), (1, 2, 300, 560), (1, 2, 560, 800)]),
452
+ # three requests in one chunk, the first two with equal rows
453
+ ([600, 450, 900], [200, 200, 120], [(0, 3, 0, 520)]),
454
+ # four requests with four different query lengths in one chunk
455
+ ([900, 600, 450, 700], [150, 90, 200, 60], [(0, 4, 0, 500)]),
456
+ ],
457
+ ids=["sliced", "batched", "ragged"],
458
+)
459
+# The consumers rejoin a request's rows across chunks, then cut them at most
460
+# gather_rows a launch.
461
+@pytest.mark.parametrize("gather_rows", [1 << 20, 128], ids=["joined", "cut"])
462
+@BLOCKS
463
+def test_prefill_layers_match_reference(
464
+ monkeypatch, seq_lens, new_tokens, chunks, gather_rows, block
465
+):
466
+ set_random_seed(0)
467
+ case = _Case(seq_lens, block)
468
+ rows = [
469
+ (req, n - q + i)
470
+ for req, (n, q) in enumerate(zip(seq_lens, new_tokens))
471
+ for i in range(q)
472
+ ]
473
+ query_start_loc = [0, *itertools.accumulate(new_tokens)]
474
+ _run_layers(
475
+ monkeypatch,
476
+ case,
477
+ rows,
478
+ lambda r, gather=True: _prefill_metadata(
479
+ case, rows, r, chunks, query_start_loc, gather_rows, gather
480
+ ),
481
+ )
482
+
483
+
484
+def _rope_gptj(x, cos_sin, positions):
485
+ """GPT-J RoPE on the last 64 dims, as the indexer rotates Q and K: bf16 in
486
+ and out."""
487
+ x = x.bfloat16().float()
488
+ cos, sin = cos_sin[positions].chunk(2, dim=-1)
489
+ if x.dim() == 3:
490
+ cos, sin = cos[:, None], sin[:, None]
491
+ rot = x[..., -64:]
492
+ even, odd = rot[..., 0::2].clone(), rot[..., 1::2].clone()
493
+ rot[..., 0::2] = even * cos - odd * sin
494
+ rot[..., 1::2] = odd * cos + even * sin
495
+ return x.bfloat16()
496
+
497
+
498
+def _mxfp4_round_trip(x):
499
+ """MXFP4 round trip of x, rounded as the writers do: a scale of
500
+ 2^ceil(log2(amax / 6)) per 32 values, then the nearest e2m1 value, ties to
501
+ even."""
502
+ blocks = x.float().unflatten(-1, (-1, 32))
503
+ amax = blocks.abs().amax(-1, keepdim=True).clamp(min=2**-126)
504
+ scale = torch.exp2(torch.ceil(torch.log2(amax / 6)))
505
+ y = (blocks / scale).clamp(-6, 6)
506
+ # Even codes first: argmin keeps the first of two equal distances.
507
+ grid = torch.cat([_E2M1[0::2], _E2M1[1::2]]).to(x.device)
508
+ idx = (y.abs()[..., None] - grid).abs().argmin(-1)
509
+ return (grid[idx] * y.sign() * scale).flatten(-2)
510
+
511
+
512
+def _torch_keys(case, ratio, req):
513
+ """A request's compressed keys in torch: k_norm of each group's last
514
+ token, rotated at the group's first position, through MXFP4."""
515
+ k = case.k_pre[req][ratio - 1 :: ratio].float()
516
+ k = k * torch.rsqrt(k.pow(2).mean(-1, keepdim=True) + 1e-6) * case.norm
517
+ pos = torch.arange(k.shape[0], device=DEVICE) * ratio
518
+ return _mxfp4_round_trip(_rope_gptj(k, case.cos_sin, pos))
519
+
520
+
521
+def _topk_indices(q, weights, keys, ends, k):
522
+ """Top-k of sum_h relu(q_h . key) * weights_h over each row's [0, end)."""
523
+ scores = (torch.einsum("thd,cd->thc", q, keys).relu() * weights[..., None]).sum(1)
524
+ cols = torch.arange(keys.shape[0], device=DEVICE)
525
+ return scores.masked_fill(cols >= ends[:, None], float("-inf")).topk(k).indices
526
+
527
+
528
+def _recall(selected, reference):
529
+ """Per row, the fraction of the reference top-k that ``selected`` picked."""
530
+ return (selected[:, :, None] == reference[:, None, :]).any(1).float().mean(1)
531
+
532
+
533
+@BLOCKS
534
+@pytest.mark.parametrize("ratio", [2, 1])
535
+@pytest.mark.parametrize("step", ["decode", "prefill"])
536
+def test_selection_from_bf16_matches_torch(monkeypatch, step, ratio, block):
537
+ """End to end from bf16 K and Q: vLLM's MXFP4 K store and Q quant, the
538
+ paged pool, metadata from the planning functions the builder calls, and
539
+ the indexer op pick what the same MXFP4 math picks in torch."""
540
+ set_random_seed(0)
541
+ if step == "decode":
542
+ case = _Case([3000, 2600, 1900, 3500, 1200, 2200, 3900, 1600], block)
543
+ rows = [(req, n - 1) for req, n in enumerate(case.seq_lens)]
544
+ metadata = _decode_metadata(case, rows, ratio, [1] * len(rows))
545
+ else:
546
+ new_tokens = [300, 200, 250]
547
+ case = _Case([1500, 900, 2000], block)
548
+ rows = [
549
+ (req, n - q + i)
550
+ for req, (n, q) in enumerate(zip(case.seq_lens, new_tokens))
551
+ for i in range(q)
552
+ ]
553
+ metadata = _prefill_metadata(
554
+ case,
555
+ rows,
556
+ ratio,
557
+ [(0, len(new_tokens), 0, len(rows))],
558
+ [0, *itertools.accumulate(new_tokens)],
559
+ 1 << 20,
560
+ gather=False,
561
+ )
562
+ num_rows = len(rows)
563
+ q_bf16 = torch.randn(num_rows, HEADS, HEAD_DIM, device=DEVICE).to(torch.bfloat16)
564
+ w = torch.randn(num_rows, HEADS, device=DEVICE)
565
+ pos = torch.tensor([p for _, p in rows], device=DEVICE)
566
+ scales = (HEAD_DIM**-0.5, HEADS**-0.5)
567
+
568
+ (q, q_scale), weights = rocm_mxfp4_indexer_q_quant(
569
+ pos, q_bf16, case.cos_sin, w, *scales
570
+ )
571
+ _forward_context(monkeypatch, metadata)
572
+ out = torch.full((num_rows, TOPK), 7, dtype=torch.int32, device=DEVICE)
573
+ ops.rocm_mxfp4_sparse_attn_indexer(
574
+ torch.empty(num_rows, 1, device=DEVICE),
575
+ "indexer",
576
+ case.cache[ratio],
577
+ q,
578
+ q_scale,
579
+ weights,
580
+ TOPK,
581
+ HEAD_DIM,
582
+ MAX_LEN // ratio,
583
+ out,
584
+ compress_ratio=ratio,
585
+ )
586
+ ends = (pos + 1) // ratio
587
+ assert bool(((out >= 0) & (out < ends[:, None])).all())
588
+ assert all(row.unique().numel() == TOPK for row in out)
589
+
590
+ q_ref = _mxfp4_round_trip(_rope_gptj(q_bf16, case.cos_sin, pos))
591
+ w_ref = w * scales[0] * scales[1]
592
+ agree = []
593
+ for req in range(len(case.seq_lens)):
594
+ mine = torch.tensor([r == req for r, _ in rows], device=DEVICE)
595
+ ref = _topk_indices(
596
+ q_ref[mine], w_ref[mine], _torch_keys(case, ratio, req), ends[mine], TOPK
597
+ )
598
+ agree.append(_recall(out[mine], ref))
599
+ assert torch.cat(agree).mean().item() >= 0.99
tests/v1/attention/test_rocm_paged_mxfp4_indexer_plan.py
new
+162
@@ -0,0 +1,162 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""The ROCm MXFP4 indexer reads the paged cache in place, so each prefill
4
+chunk is split back into its requests' rows, which one launch finds through
5
+the chunk's query_start_loc. A chunk is whole requests or one query slice of a
6
+long request, and both have to come back with the right rows and the right
7
+block-table rows.
8
+"""
9
+
10
+import pytest
11
+import torch
12
+
13
+from vllm.platforms import current_platform
14
+
15
+if not current_platform.is_rocm():
16
+ pytest.skip("ROCm-only", allow_module_level=True)
17
+
18
+from vllm.v1.attention.backends.mla.indexer import (
19
+ DeepseekV32IndexerPrefillChunkMetadata,
20
+)
21
+from vllm.v1.attention.backends.mla.rocm_paged_mxfp4_indexer import (
22
+ native_decode,
23
+ plan_gather_launches,
24
+ plan_prefill_chunks,
25
+ split_prefill_chunks,
26
+)
27
+from vllm.v1.attention.ops.rocm_paged_mxfp4_indexer import rocm_mxfp4_consumer_rows
28
+
29
+
30
+def _chunk(block_table, token_start, token_end, row_ends):
31
+ ends = torch.tensor(row_ends, dtype=torch.int32)
32
+ return DeepseekV32IndexerPrefillChunkMetadata(
33
+ block_table=block_table,
34
+ cu_seqlen_ks=torch.full_like(ends, 7),
35
+ cu_seqlen_ke=ends + 7,
36
+ cu_seq_lens=torch.zeros(1, dtype=torch.int32),
37
+ token_to_seq=torch.zeros(1, dtype=torch.int32),
38
+ total_seq_lens=1,
39
+ token_start=token_start,
40
+ token_end=token_end,
41
+ num_reqs=block_table.shape[0],
42
+ )
43
+
44
+
45
+# A decode (1 token), then prefills of 3, 3, 5 and 6 query tokens; the last
46
+# one is sliced into two chunks.
47
+QUERY_START_LOC = [0, 1, 4, 7, 12, 18]
48
+SEQ_LENS = torch.tensor([40, 10, 12, 21, 64])
49
+BLOCK_TABLE = torch.arange(20, dtype=torch.int32).view(5, 4)
50
+CHUNKS = [
51
+ _chunk(BLOCK_TABLE[1:4], 1, 12, list(range(11))),
52
+ _chunk(BLOCK_TABLE[4:5], 12, 15, [29, 30, 30]),
53
+ _chunk(BLOCK_TABLE[4:5], 15, 18, [31, 31, 32]),
54
+]
55
+
56
+
57
+def _plans(min_gather_width, block=8):
58
+ return plan_prefill_chunks(
59
+ CHUNKS,
60
+ QUERY_START_LOC,
61
+ SEQ_LENS,
62
+ SEQ_LENS.int() // 2,
63
+ 2,
64
+ block,
65
+ min_gather_width,
66
+ torch.tensor(QUERY_START_LOC, dtype=torch.int32),
67
+ )
68
+
69
+
70
+def test_chunks_split_into_requests():
71
+ plans = _plans(20.0)
72
+
73
+ assert [p.requests for p in plans] == [
74
+ [(0, 3, 0), (3, 6, 1), (6, 11, 2)],
75
+ [(0, 3, 0)],
76
+ [(0, 3, 0)],
77
+ ]
78
+ # every chunk launches once, with offsets local to the chunk
79
+ assert [p.query_start_loc.tolist() for p in plans] == [
80
+ [0, 3, 6, 11],
81
+ [0, 3],
82
+ [0, 3],
83
+ ]
84
+ # the logits are as wide as the longest compressed context in the chunk
85
+ assert [p.width for p in plans] == [10, 32, 32]
86
+ assert [p.context_lens.tolist() for p in plans] == [[5, 6, 10], [32], [32]]
87
+ torch.testing.assert_close(plans[2].row_ends, torch.tensor([31, 31, 32]).int())
88
+ torch.testing.assert_close(plans[2].block_ends, torch.tensor([4, 4, 4]).int())
89
+ assert [p.use_gather for p in plans] == [False, True, True]
90
+ assert [p.first_request for p in plans] == [1, 4, 4]
91
+ # no candidate blocks, as in the model-neutral builder
92
+ for plan in _plans(None, block=0):
93
+ assert plan.block_ends is None and not plan.use_gather
94
+
95
+
96
+def test_launches_follow_the_logits_budget(monkeypatch):
97
+ """Each launch's fp32 logits fit in VLLM_SPARSE_INDEXER_MAX_LOGITS_MB. A dense
98
+ chunk's logits are [rows, longest context in the chunk]: K is read in place,
99
+ not gathered, so requests share a launch until their rows no longer fit, and
100
+ a request that does not fit alone is split on its query rows. A consumer's
101
+ logits are [rows, pool]."""
102
+ budget = 4 * 1000 * 30
103
+ contexts, queries = torch.tensor([1000, 600, 200]), torch.tensor([10, 10, 10])
104
+ assert split_prefill_chunks(contexts, queries, budget, 2) == [
105
+ (slice(2, 5), slice(0, 30))
106
+ ]
107
+ # the third request would take the chunk past the budget
108
+ queries = torch.tensor([10, 10, 11])
109
+ assert split_prefill_chunks(contexts, queries, budget) == [
110
+ (slice(0, 2), slice(0, 20)),
111
+ (slice(2, 3), slice(0, 11)),
112
+ ]
113
+ assert split_prefill_chunks(torch.tensor([1000]), torch.tensor([100]), budget) == [
114
+ (slice(0, 1), slice(lo, min(lo + 30, 100))) for lo in range(0, 100, 30)
115
+ ]
116
+ # the default 512 MiB holds 128 rows of a 1M context, or 8192 of a 16K pool
117
+ assert split_prefill_chunks(
118
+ torch.tensor([1 << 20]), torch.tensor([200]), 512 << 20
119
+ ) == [(slice(0, 1), slice(0, 128)), (slice(0, 1), slice(128, 200))]
120
+ monkeypatch.setenv("VLLM_SPARSE_INDEXER_MAX_LOGITS_MB", "512")
121
+ assert rocm_mxfp4_consumer_rows(16384) == 8192
122
+
123
+
124
+def test_gather_launches_rejoin_a_requests_rows():
125
+ """The consumers' logits are [rows, pool] whatever the context, so a
126
+ request's rows go back together across the chunks that cut them, and a
127
+ launch never mixes requests."""
128
+ # the first chunk is too narrow to gather, the last request's slices rejoin
129
+ (joined,) = plan_gather_launches(CHUNKS, _plans(20.0), 100)
130
+ assert (joined.token_start, joined.token_end) == (12, 18)
131
+ # every chunk gathers: the first chunk's three requests launch apart
132
+ launches = plan_gather_launches(CHUNKS, _plans(0.0), 100)
133
+ assert [(g.token_start, g.token_end) for g in launches] == [
134
+ (1, 4),
135
+ (4, 7),
136
+ (7, 12),
137
+ (12, 18),
138
+ ]
139
+ assert plan_gather_launches(CHUNKS, _plans(None), 100) == []
140
+
141
+
142
+def test_decode_launches_native_on_uniform_steps():
143
+ """A decode step goes to the kernel as next_n-row sequences only when its
144
+ shape says so: each request has next_n rows, and cudagraph padding (query
145
+ length 0) comes only after them. A FULL graph captured on a uniform batch
146
+ then replays the same launch on a padded one."""
147
+ rows = torch.zeros(8, dtype=torch.int32)
148
+ row_block_table = torch.arange(32, dtype=torch.int32).view(8, 4)
149
+ context_lens = torch.tensor([9, 4, 7, 0, 5], dtype=torch.int32)
150
+
151
+ native = native_decode(rows, row_block_table, [2, 2, 2, 0], 2, context_lens)
152
+ assert native is not None and native.next_n == 2
153
+ assert native.context_lens.tolist() == [9, 4, 7, 0]
154
+ # every request's first flattened row, as a view
155
+ assert native.block_table.tolist() == row_block_table[::2].tolist()
156
+ assert native.block_table.data_ptr() == row_block_table.data_ptr()
157
+
158
+ assert native_decode(rows, row_block_table, [2, 0, 2, 2], 2, context_lens) is None
159
+ # a ragged step, rows padded past the requests, and no speculation
160
+ assert native_decode(rows[:5], row_block_table, [2, 1, 2], 2, context_lens) is None
161
+ assert native_decode(rows, row_block_table, [2, 2, 2], 2, context_lens) is None
162
+ assert native_decode(rows[:4], row_block_table, [1] * 4, 1, context_lens) is None
vllm/config/attention.py
+4
-1
@@ -93,7 +93,10 @@ class AttentionConfig:
93
an SM100-class GPU, DeepGEMM >= 2.8 and the DeepSelect top-k extension
94
(the top-k runs on the kernels' bf16 logits). The sparse path costs
95
O(candidate blocks) per query regardless of context length, so it pays off
96
- for long contexts (roughly 32K tokens and beyond) and is slower below."""
96
+ for long contexts (roughly 32K tokens and beyond) and is slower below.
97
+ On ROCm gfx950 it runs aiter's paged MXFP4 MQA-logits kernel over the
98
+ candidate pool instead, with fp32 logits, and keeps the masked dense walk
99
+ for steps whose contexts are too short for the pool to pay."""
100
101
hisparse_config: HiSparseConfig | None = None
102
"""HiSparse host-resident KV configuration. Setting this enables experimental
vllm/model_executor/layers/rocm_paged_mxfp4_indexer.py
new
+116
@@ -0,0 +1,116 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Sparse attention indexer layers on aiter's paged MXFP4 kernels, for ROCm.
4
+
5
+With ``indexer_kv_dtype="mxfp4"`` on gfx950, the model writes the indexer K
6
+cache in the order aiter's MQA-logits kernel reads (see
7
+`rocm_paged_mxfp4_cache_layout`), and the kernel scores the cache in place.
8
+Requires `RocmMxfp4IndexerMetadataBuilder` metadata.
9
+"""
10
+
11
+import torch
12
+from torch import nn
13
+
14
+from vllm.config import get_current_vllm_config
15
+from vllm.model_executor.layers.sparse_attn_indexer import SparseAttnIndexer
16
+from vllm.v1.attention.ops.rocm_paged_mxfp4_indexer import (
17
+ rocm_mxfp4_sparse_attn_indexer,
18
+ rocm_mxfp4_sparse_mqa_indexer,
19
+)
20
+
21
+
22
+class RocmSparseAttnIndexer(SparseAttnIndexer):
23
+ """`SparseAttnIndexer` whose HIP path walks the paged MXFP4 cache with
24
+ aiter's kernel. A two-level indexer's source layer also publishes its
25
+ candidate pool here."""
26
+
27
+ def forward_hip(
28
+ self,
29
+ hidden_states: torch.Tensor,
30
+ q_quant: torch.Tensor | tuple[torch.Tensor, torch.Tensor],
31
+ k: torch.Tensor | None,
32
+ weights: torch.Tensor,
33
+ ):
34
+ assert isinstance(q_quant, tuple) and self.skip_k_cache_insert, (
35
+ "the ROCm MXFP4 indexer takes (values, scales) Q and a K cache "
36
+ "the model already wrote"
37
+ )
38
+ q_values, q_scale = q_quant
39
+ return rocm_mxfp4_sparse_attn_indexer(
40
+ hidden_states,
41
+ self.k_cache.prefix,
42
+ self.k_cache.kv_cache,
43
+ q_values,
44
+ q_scale,
45
+ weights,
46
+ self.topk_tokens,
47
+ self.head_dim,
48
+ self.max_model_len,
49
+ self.topk_indices_buffer,
50
+ compress_ratio=self.compress_ratio,
51
+ candidate_blocks=self.candidate_blocks,
52
+ candidate_block_size=self.candidate_block_size,
53
+ candidate_write=self.candidate_write,
54
+ )
55
+
56
+
57
+class RocmSparseMQAIndexer(nn.Module):
58
+ """DeepSeek-V4.1's candidate-consuming indexer on aiter's paged MXFP4
59
+ MQA-logits kernel: the consumers score only the candidate pool the source
60
+ layer published, read straight from the paged cache.
61
+
62
+ Built and called as `SparseMQAIndexer` is, so the model can take either.
63
+ """
64
+
65
+ weights_dtype = torch.float32
66
+ """The scorer's weights and logits stay fp32: gfx950 has no packed bf16
67
+ arithmetic, so its head reduce costs the same in either dtype."""
68
+
69
+ def __init__(
70
+ self,
71
+ k_cache,
72
+ topk_tokens: int,
73
+ head_dim: int,
74
+ max_total_seq_len: int,
75
+ topk_indices_buffer: torch.Tensor,
76
+ candidate_blocks: torch.Tensor,
77
+ candidate_block_size: int,
78
+ ):
79
+ super().__init__()
80
+ self.k_cache = k_cache
81
+ self.topk_tokens = topk_tokens
82
+ self.head_dim = head_dim
83
+ # aiter's kernel bounds a row by the model length in compressed
84
+ # positions, not by the prefill buffer SparseMQAIndexer sizes.
85
+ model_config = get_current_vllm_config().model_config
86
+ self.max_model_len = model_config.max_model_len // k_cache.compress_ratio
87
+ self.topk_indices_buffer = topk_indices_buffer
88
+ self.candidate_blocks = candidate_blocks
89
+ self.candidate_block_size = candidate_block_size
90
+ self.num_candidate_cols = candidate_blocks.shape[1] * candidate_block_size
91
+
92
+ def forward(
93
+ self,
94
+ hidden_states: torch.Tensor,
95
+ q_quant: tuple[torch.Tensor, torch.Tensor],
96
+ k: torch.Tensor | None,
97
+ weights: torch.Tensor,
98
+ ) -> torch.Tensor:
99
+ assert k is None, "the model writes the indexer K cache"
100
+ q_values, q_scale = q_quant
101
+ return rocm_mxfp4_sparse_mqa_indexer(
102
+ hidden_states,
103
+ self.k_cache.prefix,
104
+ self.k_cache.kv_cache,
105
+ q_values,
106
+ q_scale,
107
+ weights,
108
+ self.topk_tokens,
109
+ self.head_dim,
110
+ self.max_model_len,
111
+ self.topk_indices_buffer,
112
+ self.k_cache.compress_ratio,
113
+ self.candidate_blocks,
114
+ self.candidate_block_size,
115
+ self.num_candidate_cols,
116
+ )
vllm/models/deepseek_v41/amd/rocm.py
+149
-1
@@ -2,8 +2,9 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
import functools
5
+from collections.abc import Callable
6
from dataclasses import dataclass
6
-from typing import cast
7
+from typing import Any, cast
8
9
import torch
10
@@ -15,8 +16,14 @@ from vllm.forward_context import get_forward_context
16
from vllm.logger import init_logger
17
from vllm.model_executor.layers.fusion.quant_activation import QuantizedActivation
18
from vllm.model_executor.layers.quantization.utils.quant_utils import kMxfp8Dynamic
19
+from vllm.model_executor.layers.rocm_paged_mxfp4_indexer import (
20
+ RocmSparseAttnIndexer,
21
+ RocmSparseMQAIndexer,
22
+)
23
from vllm.models.deepseek_v41.attention import (
24
DeepseekV4Attention,
25
+ DeepseekV4Indexer,
26
+ DeepseekV4IndexerCache,
27
_replace_layer_index,
28
)
29
from vllm.models.deepseek_v41.common.ops import dequantize_and_gather_k_cache
@@ -32,6 +39,7 @@ from vllm.triton_utils import tl, triton
39
from vllm.utils.multi_stream_utils import execute_in_parallel
40
from vllm.v1.attention.backend import (
41
CommonAttentionMetadata,
42
+ MultipleOf,
43
)
44
from vllm.v1.attention.backends.mla.sparse_swa import (
45
DeepseekSparseSWABackend,
@@ -47,6 +55,9 @@ from vllm.v1.attention.ops.rocm_aiter_mla_sparse import (
55
rocm_sparse_attn_decode,
56
rocm_sparse_attn_prefill,
57
)
58
+from vllm.v1.attention.ops.rocm_paged_mxfp4_indexer import (
59
+ rocm_paged_mxfp4_cache_layout,
60
+)
61
from vllm.v1.worker.workspace import current_workspace_manager
62
63
logger = init_logger(__name__)
@@ -517,11 +528,141 @@ class DeepseekV4ROCMAiterSparseSWAMetadataBuilder(DeepseekV41SparseSWAMetadataBu
528
)
529
530
531
+@functools.cache
532
+def _aiter_indexer_cache_ops() -> tuple[Callable[..., None], Callable[..., tuple]]:
533
+ """The aiter indexer key writer and query quantizer."""
534
+ from aiter.ops.triton.fusions.k_norm_rope_mxfp4_cache import (
535
+ k_norm_rope_mxfp4_cache,
536
+ )
537
+ from aiter.ops.triton.rope.q_rope_mxfp4_quant import q_rope_mxfp4_quant
538
+
539
+ return k_norm_rope_mxfp4_cache, q_rope_mxfp4_quant
540
+
541
+
542
+def rocm_mxfp4_indexer_k_store(
543
+ k_pre: torch.Tensor,
544
+ positions: torch.Tensor,
545
+ cos_sin_cache: torch.Tensor,
546
+ rms_norm_weight: torch.Tensor,
547
+ rms_norm_eps: float,
548
+ k_cache: torch.Tensor,
549
+ kv_slot_mapping: torch.Tensor,
550
+ compress_ratio: int,
551
+ use_fp4_cache: bool,
552
+ *,
553
+ num_heads: int,
554
+) -> None:
555
+ """`indexer_k_norm_rope_store` for the ROCm MXFP4 cache: aiter's cache op
556
+ writes the key in the order its MQA-logits kernel reads with ``num_heads``
557
+ query heads."""
558
+ assert use_fp4_cache, "the ROCm indexer cache op writes MXFP4 only"
559
+ layout = rocm_paged_mxfp4_cache_layout(num_heads, k_pre.shape[1], k_cache.shape[1])
560
+ k_norm_rope_mxfp4_cache, _ = _aiter_indexer_cache_ops()
561
+ k_norm_rope_mxfp4_cache(
562
+ k_pre,
563
+ positions,
564
+ cos_sin_cache,
565
+ rms_norm_weight,
566
+ rms_norm_eps,
567
+ k_cache,
568
+ kv_slot_mapping,
569
+ compress_ratio,
570
+ shuffle=layout,
571
+ )
572
+
573
+
574
+def rocm_mxfp4_indexer_q_quant(
575
+ positions: torch.Tensor,
576
+ index_q: torch.Tensor,
577
+ index_q_cos_sin_cache: torch.Tensor,
578
+ index_weights: torch.Tensor,
579
+ index_weights_softmax_scale: float,
580
+ index_weights_head_scale: float,
581
+ use_fp4: bool = True,
582
+ weights_out_dtype: torch.dtype = torch.float32,
583
+) -> tuple[tuple[torch.Tensor, torch.Tensor], torch.Tensor]:
584
+ """`fused_indexer_q_rope_quant` for the ROCm MXFP4 indexer, on aiter's op:
585
+ ((packed [T, H, D // 2], e8m0 as one int32 per head [T, H]), fp32
586
+ weights)."""
587
+ assert use_fp4 and weights_out_dtype == torch.float32, (
588
+ "the ROCm indexer quantizes Q to MXFP4 and scores with fp32 weights"
589
+ )
590
+ _, q_rope_mxfp4_quant = _aiter_indexer_cache_ops()
591
+ q_packed, q_scale, weights_out = q_rope_mxfp4_quant(
592
+ index_q,
593
+ positions,
594
+ index_q_cos_sin_cache,
595
+ index_weights,
596
+ index_weights_softmax_scale * index_weights_head_scale,
597
+ )
598
+ return (q_packed, q_scale.view(torch.int32).squeeze(-1)), weights_out
599
+
600
+
601
+class DeepseekV41RocmMxfp4Indexer(DeepseekV4Indexer):
602
+ """The indexer on aiter's paged MXFP4 cache: its K store and Q quant write
603
+ in the order aiter's MQA-logits kernel reads, and its layers score with it.
604
+ ``_produce_k`` and ``forward_q`` mirror ``DeepseekV4Indexer``'s except for
605
+ those two calls."""
606
+
607
+ mqa_cls = RocmSparseMQAIndexer
608
+ attn_cls = RocmSparseAttnIndexer
609
+
610
+ def _produce_k(
611
+ self,
612
+ latent: torch.Tensor | None,
613
+ positions: torch.Tensor,
614
+ rotary_emb: torch.nn.Module,
615
+ ) -> None:
616
+ attn_metadata = get_forward_context().attn_metadata
617
+ if not isinstance(attn_metadata, dict) or latent is None:
618
+ return
619
+ assert self.owns_k
620
+ indexer_metadata = cast(Any, attn_metadata[self.k_cache.prefix])
621
+ k_pre, _ = self.wk(latent)
622
+ rocm_mxfp4_indexer_k_store(
623
+ k_pre,
624
+ positions,
625
+ rotary_emb.cos_sin_cache,
626
+ self.k_norm.weight,
627
+ self.k_norm.variance_epsilon,
628
+ self.k_cache.kv_cache,
629
+ indexer_metadata.slot_mapping,
630
+ self.compress_ratio,
631
+ self.use_fp4_kv,
632
+ num_heads=self.n_head,
633
+ )
634
+
635
+ def forward_q(
636
+ self,
637
+ qr: torch.Tensor | QuantizedActivation,
638
+ qr_scale: torch.Tensor | None,
639
+ indexer_weights: torch.Tensor,
640
+ positions: torch.Tensor,
641
+ rotary_emb: torch.nn.Module,
642
+ ) -> tuple[torch.Tensor | None, torch.Tensor | None, torch.Tensor | None]:
643
+ q = self._wq_b_proj(qr, qr_scale).view(-1, self.n_head, self.head_dim)
644
+ (q, q_scale), weights = rocm_mxfp4_indexer_q_quant(
645
+ positions,
646
+ q,
647
+ rotary_emb.cos_sin_cache,
648
+ indexer_weights,
649
+ self.softmax_scale,
650
+ self.n_head**-0.5,
651
+ use_fp4=self.use_fp4_kv,
652
+ weights_out_dtype=self.indexer_weights_dtype,
653
+ )
654
+ return q, q_scale, weights
655
+
656
+
657
class DeepseekV4ROCMAiterMLASparseBackend(DeepseekV4SparseMLABackend):
658
@staticmethod
659
def get_name() -> str:
660
return "ROCM_FLASHMLA_SPARSE_DSV4"
661
662
+ @staticmethod
663
+ def get_supported_kernel_block_sizes(kv_cache_spec=None) -> list[int | MultipleOf]:
664
+ return [64, 128]
665
+
666
@staticmethod
667
def get_builder_cls() -> type[DeepseekV4SparseMLAMetadataBuilder]:
668
return DeepseekV4SparseMLAMetadataBuilder
@@ -540,6 +681,13 @@ class DeepseekV41ROCMAiterMLAAttention(DeepseekV4Attention):
681
swa_backend_cls = DeepseekV41ROCMAiterSparseSWABackend
682
_use_aiter_sparse_mla = False
683
684
+ def _indexer_cls(
685
+ self, k_cache: DeepseekV4IndexerCache | None
686
+ ) -> type[DeepseekV4Indexer]:
687
+ if k_cache is not None and k_cache.rocm_mxfp4:
688
+ return DeepseekV41RocmMxfp4Indexer
689
+ return super()._indexer_cls(k_cache)
690
+
691
def __init__(self, *args, **kwargs):
692
vllm_config = args[0] if args else kwargs["vllm_config"]
693
super().__init__(*args, **kwargs)
vllm/models/deepseek_v41/attention.py
+34
-6
@@ -35,6 +35,9 @@ from vllm.models.deepseek_v41.common.ops import (
35
36
if TYPE_CHECKING:
37
from vllm.model_executor.kernels.linear.cute_dsl.gemm_rs_ar import GemmRsAr
38
+ from vllm.model_executor.layers.rocm_paged_mxfp4_indexer import (
39
+ RocmSparseMQAIndexer,
40
+ )
41
from vllm.v1.attention.backends.mla.sparse_swa import (
42
DeepseekSparseSWAMetadata,
43
)
@@ -261,6 +264,13 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
264
"""Return whether this instance stores fp8 KV in fp8_ds_mla layout."""
265
return self.use_fp8_ds_mla_layout
266
267
+ def _indexer_cls(
268
+ self, k_cache: "DeepseekV4IndexerCache | None"
269
+ ) -> type["DeepseekV4Indexer"]:
270
+ """The indexer for this layer's index K cache. A platform subclass
271
+ returns one that writes and scores its own cache layout."""
272
+ return DeepseekV4Indexer
273
+
274
def __init__(
275
self,
276
vllm_config: VllmConfig,
@@ -449,7 +459,7 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
459
)
460
is_candidate_source = layer_id == self.candidate_source_layer
461
uses_candidates = 0 <= self.candidate_source_layer < layer_id
452
- self.indexer = DeepseekV4Indexer(
462
+ self.indexer = self._indexer_cls(index_k_cache)(
463
vllm_config,
464
config=config,
465
hidden_size=self.hidden_size,
@@ -1152,6 +1162,10 @@ class DeepseekV4IndexerCache(torch.nn.Module, AttentionLayerBase):
1162
self.compress_ratio = compress_ratio
1163
vllm_config = get_current_vllm_config()
1164
self.sparse_logits = vllm_config.attention_config.indexer_sparse_logits
1165
+ # aiter's paged MXFP4 kernels write this cache and read it in place.
1166
+ self.rocm_mxfp4 = current_platform.is_rocm() and dsa_indexer_uses_fp4(
1167
+ vllm_config
1168
+ )
1169
compilation_config = vllm_config.compilation_config
1170
if prefix in compilation_config.static_forward_context:
1171
raise ValueError(f"Duplicate layer name: {prefix}")
@@ -1190,13 +1204,21 @@ class DeepseekV4IndexerCache(torch.nn.Module, AttentionLayerBase):
1204
# it 512B-aligned; the main KV record read from the same blocks
1205
# needs its own TMA stride, so keep both.
1206
block_stride_alignment=(
1193
- math.lcm(512, page_alignment) if self.sparse_logits else None
1207
+ math.lcm(512, page_alignment)
1208
+ if self.sparse_logits and not self.rocm_mxfp4
1209
+ else None
1210
),
1211
)
1212
1213
def forward(self): ...
1214
1215
def get_attn_backend(self) -> type[AttentionBackend]:
1216
+ if self.rocm_mxfp4:
1217
+ from vllm.v1.attention.backends.mla.rocm_paged_mxfp4_indexer import (
1218
+ DeepseekV41RocmMxfp4IndexerBackend,
1219
+ )
1220
+
1221
+ return DeepseekV41RocmMxfp4IndexerBackend
1222
if self.sparse_logits:
1223
return DeepseekV41SparseIndexerBackend
1224
return DeepseekV41IndexerBackend
@@ -1218,6 +1240,12 @@ class DeepseekV4Indexer(nn.Module):
1240
later indexers mask their scores to those blocks before their own top-k.
1241
"""
1242
1243
+ # The scoring layers; a platform subclass swaps in its own.
1244
+ mqa_cls: ClassVar[type["SparseMQAIndexer | RocmSparseMQAIndexer"]] = (
1245
+ SparseMQAIndexer
1246
+ )
1247
+ attn_cls: ClassVar[type[SparseAttnIndexer]] = SparseAttnIndexer
1248
+
1249
def __init__(
1250
self,
1251
vllm_config: VllmConfig,
@@ -1305,7 +1333,7 @@ class DeepseekV4Indexer(nn.Module):
1333
and candidate_block_buffer is not None
1334
and not candidate_write
1335
)
1308
- self.indexer_op: SparseAttnIndexer | SparseMQAIndexer
1336
+ self.indexer_op: SparseAttnIndexer | SparseMQAIndexer | RocmSparseMQAIndexer
1337
if use_sparse_logits:
1338
if not self.use_fp4_kv:
1339
raise ValueError(
@@ -1314,7 +1342,7 @@ class DeepseekV4Indexer(nn.Module):
1342
)
1343
assert candidate_block_buffer is not None
1344
assert topk_indices_buffer is not None
1317
- self.indexer_op = SparseMQAIndexer(
1345
+ self.indexer_op = self.mqa_cls(
1346
self.k_cache,
1347
self.topk_tokens,
1348
self.head_dim,
@@ -1324,7 +1352,7 @@ class DeepseekV4Indexer(nn.Module):
1352
candidate_block_size,
1353
)
1354
else:
1327
- self.indexer_op = SparseAttnIndexer(
1355
+ self.indexer_op = self.attn_cls(
1356
self.k_cache,
1357
self.quant_block_size,
1358
self.scale_fmt,
@@ -1343,7 +1371,7 @@ class DeepseekV4Indexer(nn.Module):
1371
# The fused Q kernel writes the per-head weights in the dtype the
1372
# scoring kernels take, so no cast runs per step.
1373
self.indexer_weights_dtype = (
1346
- SparseMQAIndexer.weights_dtype if use_sparse_logits else torch.float32
1374
+ self.mqa_cls.weights_dtype if use_sparse_logits else torch.float32
1375
)
1376
1377
def _produce_k(
vllm/v1/attention/backends/mla/indexer.py
+23
-1
@@ -51,7 +51,7 @@ from vllm.v1.kv_cache_interface import (
51
logger = init_logger(__name__)
52
53
# The DSA indexer K cache is always quantized; "auto" means fp8 (V3.2 layout)
54
-# and mxfp4 is the opt-in Blackwell path.
54
+# and mxfp4 is the opt-in Blackwell and gfx950 path.
55
DSA_INDEXER_KV_DTYPES = ("fp8", "mxfp4")
56
57
@@ -64,6 +64,28 @@ def dsa_indexer_uses_fp4(vllm_config: VllmConfig) -> bool:
64
f"sparse indexer (expected one of {DSA_INDEXER_KV_DTYPES})."
65
)
66
use_fp4 = kv_dtype == "mxfp4"
67
+ if use_fp4 and current_platform.is_rocm():
68
+ from vllm._aiter_ops import rocm_aiter_ops
69
+ from vllm.platforms.rocm import get_cdna_version
70
+
71
+ # Only DeepSeek-V4.1 is wired to the ROCm MXFP4 cache; other DSA models
72
+ # would silently keep their FP8 one.
73
+ model_config = vllm_config.model_config
74
+ if model_config is None or model_config.hf_config.model_type != "deepseek_v41":
75
+ raise ValueError(
76
+ "indexer_kv_dtype='mxfp4' on ROCm is only supported for "
77
+ "DeepSeek-V4.1-Flash."
78
+ )
79
+ if get_cdna_version() != 4:
80
+ raise ValueError(
81
+ "indexer_kv_dtype='mxfp4' on ROCm requires CDNA4 (MI350X/MI355X)."
82
+ )
83
+ if not rocm_aiter_ops.is_enabled():
84
+ raise ValueError(
85
+ "indexer_kv_dtype='mxfp4' on ROCm runs on aiter's kernels; enable "
86
+ "aiter with VLLM_ROCM_USE_AITER=1."
87
+ )
88
+ return True
89
if use_fp4 and not current_platform.is_device_capability_family(100):
90
raise ValueError(
91
"indexer_kv_dtype='mxfp4' requires Blackwell datacenter GPUs "
vllm/v1/attention/backends/mla/rocm_paged_mxfp4_indexer.py
new
+513
@@ -0,0 +1,513 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Indexer metadata for the ROCm paged MXFP4 path.
4
+
5
+Extends the dense indexer metadata with what aiter's paged MXFP4 MQA-logits
6
+kernel needs to read the cache in place: each prefill chunk as its requests,
7
+packed through query_start_loc into one launch, and the decode rows as
8
+next_n-row sequences on uniform steps. `RocmMxfp4IndexerMetadataBuilder` plans
9
+this for any model's indexer, and a two-level indexer's candidate blocks once
10
+a subclass sets their size. DeepSeek-V4.1's builder does, and with
11
+``AttentionConfig.indexer_sparse_logits`` adds the per-step state the
12
+candidate consumers share.
13
+"""
14
+
15
+import bisect
16
+from dataclasses import dataclass, field, fields
17
+
18
+import torch
19
+
20
+from vllm.config import VllmConfig
21
+from vllm.logger import init_logger
22
+from vllm.v1.attention.backend import (
23
+ AttentionCGSupport,
24
+ CommonAttentionMetadata,
25
+ MultipleOf,
26
+)
27
+from vllm.v1.attention.backends.mla.indexer import (
28
+ DeepseekV32IndexerMetadata,
29
+ DeepseekV32IndexerMetadataBuilder,
30
+ DeepseekV32IndexerPrefillChunkMetadata,
31
+ DeepseekV41IndexerBackend,
32
+)
33
+from vllm.v1.attention.ops.rocm_paged_mxfp4_indexer import (
34
+ build_rocm_mxfp4_decode_schedule,
35
+ rocm_mxfp4_consumer_rows,
36
+ rocm_mxfp4_decode_schedule_words,
37
+ rocm_paged_mxfp4_cache_layout,
38
+)
39
+from vllm.v1.kv_cache_interface import AttentionSpec, KVCacheSpec
40
+
41
+logger = init_logger(__name__)
42
+
43
+# The consumers walk the pool instead of the context once the context is this
44
+# many pools long. Set from kernel timings, not end-to-end runs: the gather
45
+# loads more per key than the dense walk, so it only wins on a large cut.
46
+_GATHER_MIN_CUT_DECODE = 3.0
47
+_GATHER_MIN_CUT_PREFILL = 1.5
48
+
49
+
50
+@dataclass
51
+class RocmMxfp4PrefillPlan:
52
+ """One prefill chunk as the requests the kernel launches on, all in one
53
+ launch: the kernel finds each row's request through query_start_loc, and
54
+ a request's rows share its KV tiles."""
55
+
56
+ requests: list[tuple[int, int, int]]
57
+ """(first row, end row, request) per request with rows in the chunk; rows
58
+ count from ``chunk.token_start``, requests index ``chunk.block_table``."""
59
+ width: int
60
+ """Logits columns: an upper bound on the chunk's compressed contexts."""
61
+ row_ends: torch.Tensor
62
+ """[rows] int32 exclusive compressed key bound of each row."""
63
+ context_lens: torch.Tensor
64
+ """[chunk.num_reqs] int32 compressed context of each request."""
65
+ query_start_loc: torch.Tensor
66
+ """[chunk.num_reqs + 1] int32 row offsets of the chunk's requests, local to
67
+ the chunk."""
68
+ first_request: int
69
+ """The step's index of the chunk's first request."""
70
+ block_ends: torch.Tensor | None = None
71
+ """[rows] int32 candidate blocks each row sees, for the source's pool."""
72
+ use_gather: bool = False
73
+
74
+
75
+@dataclass
76
+class RocmMxfp4GatherLaunch:
77
+ """Query rows [token_start, token_end) of one request, which the candidate
78
+ consumers score against the pool in one launch."""
79
+
80
+ token_start: int
81
+ token_end: int
82
+ block_table: torch.Tensor
83
+ """[1, max_blocks] int32, the request's block table row."""
84
+ context_len: torch.Tensor
85
+ """[1] int32 compressed context of the request."""
86
+ row_ends: torch.Tensor
87
+ """[rows] int32 exclusive compressed key bound of each row."""
88
+ pool: tuple[dict, torch.Tensor] | None = None
89
+ """The resolved candidate lists, built by the first consumer layer and
90
+ reused by the rest."""
91
+
92
+
93
+@dataclass
94
+class RocmMxfp4NativeDecode:
95
+ """A decode step whose requests all have next_n query rows, as the
96
+ kernel's sequences: a workgroup then walks a KV tile once for up to
97
+ next_n rows instead of once per row."""
98
+
99
+ next_n: int
100
+ context_lens: torch.Tensor
101
+ """[requests] int32 compressed context of each request."""
102
+ block_table: torch.Tensor
103
+ """[requests, max_blocks] int32, each request's first flattened row."""
104
+
105
+
106
+def native_decode(
107
+ row_lens: torch.Tensor,
108
+ row_block_table: torch.Tensor,
109
+ query_lens: list[int],
110
+ next_n: int,
111
+ context_lens: torch.Tensor,
112
+) -> RocmMxfp4NativeDecode | None:
113
+ """The step as next_n-row sequences, or None when a request has fewer rows.
114
+
115
+ ``row_lens`` and ``row_block_table`` are the flattened rows' bounds and
116
+ block table, ``query_lens`` the decode requests' query lengths. Trailing
117
+ empty requests are cudagraph padding: they keep their next_n rows, with no
118
+ context. Only the step's shape decides, so a FULL graph captured on a
119
+ uniform batch replays the same launch on a padded one.
120
+ """
121
+ num_reqs = len(query_lens)
122
+ num_full = next((i for i, n in enumerate(query_lens) if n != next_n), num_reqs)
123
+ if (
124
+ next_n <= 1
125
+ or row_lens.shape[0] != num_reqs * next_n
126
+ or any(query_lens[num_full:])
127
+ ):
128
+ return None
129
+ return RocmMxfp4NativeDecode(
130
+ next_n, context_lens[:num_reqs], row_block_table[::next_n]
131
+ )
132
+
133
+
134
+@dataclass
135
+class RocmMxfp4IndexerMetadata(DeepseekV32IndexerMetadata):
136
+ prefill_plans: list[RocmMxfp4PrefillPlan] = field(default_factory=list)
137
+ """Parallel to ``prefill.chunks``."""
138
+ decode_row_lens: torch.Tensor | None = None
139
+ """[rows] int32 compressed context of each decode query row."""
140
+ decode_block_ends: torch.Tensor | None = None
141
+ """[rows] int32 candidate blocks each decode row sees, for the source's
142
+ pool."""
143
+ decode_native: RocmMxfp4NativeDecode | None = None
144
+ """The same rows as next_n-row sequences, for the dense launches."""
145
+ decode_schedule: torch.Tensor | None = None
146
+ """The dense decode launches' work schedule, built once for the step."""
147
+ decode_use_gather: bool = False
148
+ """Whether the candidate consumers gather for the decode rows."""
149
+ decode_gather: tuple[dict, torch.Tensor] | None = None
150
+ """The pool the first consumer resolved for the decode rows."""
151
+ gather_launches: list[RocmMxfp4GatherLaunch] = field(default_factory=list)
152
+ """The prefill rows of the chunks that gather, as the consumers launch
153
+ them."""
154
+
155
+
156
+def plan_prefill_chunks(
157
+ chunks: list[DeepseekV32IndexerPrefillChunkMetadata],
158
+ query_start_loc: list[int],
159
+ seq_lens_cpu: torch.Tensor,
160
+ context_lens: torch.Tensor,
161
+ compress_ratio: int,
162
+ candidate_block_size: int,
163
+ min_gather_width: float | None,
164
+ query_start_loc_device: torch.Tensor,
165
+) -> list[RocmMxfp4PrefillPlan]:
166
+ """Split each chunk into its requests.
167
+
168
+ ``query_start_loc`` and ``seq_lens_cpu`` are the step's host copies (the
169
+ latter an upper bound), ``context_lens`` the device compressed lengths.
170
+ A chunk covers whole requests, or a query slice of one request; either
171
+ way it launches once, packed through the chunk's part of the device
172
+ ``query_start_loc``.
173
+ """
174
+ block = candidate_block_size
175
+ plans = []
176
+ for chunk in chunks:
177
+ t0, t1 = chunk.token_start, chunk.token_end
178
+ first = bisect.bisect_right(query_start_loc, t0) - 1
179
+ requests = []
180
+ for req in range(chunk.num_reqs):
181
+ lo = max(query_start_loc[first + req], t0) - t0
182
+ hi = min(query_start_loc[first + req + 1], t1) - t0
183
+ if hi > lo:
184
+ requests.append((lo, hi, req))
185
+ reqs = slice(first, first + chunk.num_reqs)
186
+ packed = torch.clamp(query_start_loc_device[first : reqs.stop + 1], t0, t1)
187
+ packed -= t0
188
+ width = int(seq_lens_cpu[reqs].max()) // compress_ratio
189
+ row_ends = chunk.cu_seqlen_ke - chunk.cu_seqlen_ks
190
+ plans.append(
191
+ RocmMxfp4PrefillPlan(
192
+ requests=requests,
193
+ width=width,
194
+ row_ends=row_ends,
195
+ context_lens=context_lens[reqs],
196
+ query_start_loc=packed,
197
+ first_request=first,
198
+ block_ends=(row_ends + block - 1) // block if block else None,
199
+ use_gather=min_gather_width is not None and width >= min_gather_width,
200
+ )
201
+ )
202
+ return plans
203
+
204
+
205
+def split_prefill_chunks(
206
+ context_lens: torch.Tensor,
207
+ query_lens: torch.Tensor,
208
+ max_logits_bytes: int,
209
+ request_offset: int = 0,
210
+) -> list[tuple[slice, slice]]:
211
+ """(request slice, query slice) chunks, as the base chunker returns them.
212
+
213
+ Nothing gathers K here, so the requests' contexts do not add up: a chunk's
214
+ logits are [rows, widest compressed context] fp32, at most
215
+ ``max_logits_bytes``. A request too wide to launch whole is cut on its
216
+ query rows.
217
+ """
218
+
219
+ def max_rows(width: int) -> int:
220
+ return max(1, max_logits_bytes // (4 * max(width, 1)))
221
+
222
+ chunks: list[tuple[slice, slice]] = []
223
+ end = 0
224
+ while end < len(context_lens):
225
+ start, rows, width = end, 0, 0
226
+ while end < len(context_lens):
227
+ q = int(query_lens[end])
228
+ w = max(width, int(context_lens[end]))
229
+ if rows and rows + q > max_rows(w):
230
+ break
231
+ rows, width = rows + q, w
232
+ end += 1
233
+ step = max_rows(width)
234
+ reqs = slice(start + request_offset, end + request_offset)
235
+ chunks.extend(
236
+ (reqs, slice(lo, min(lo + step, rows))) for lo in range(0, rows, step)
237
+ )
238
+ return chunks
239
+
240
+
241
+def plan_gather_launches(
242
+ chunks: list[DeepseekV32IndexerPrefillChunkMetadata],
243
+ plans: list[RocmMxfp4PrefillPlan],
244
+ max_rows: int,
245
+) -> list[RocmMxfp4GatherLaunch]:
246
+ """The candidate consumers' launches over the chunks that gather.
247
+
248
+ A consumer's logits are [rows, pool] however long the context, so the
249
+ dense split, sized for the context, would launch it far more often than
250
+ its memory needs. Each request's rows go back together across the chunks
251
+ that cut them, then out again at most ``max_rows`` a launch.
252
+ """
253
+ # Per request: (first token, [(plan, first row, end row)]), and the chunk
254
+ # and request index its block table and context come from.
255
+ runs: list[tuple[int, list, DeepseekV32IndexerPrefillChunkMetadata, int]] = []
256
+ last: tuple[int, int] | None = None
257
+ for chunk, plan in zip(chunks, plans):
258
+ if not plan.use_gather:
259
+ last = None
260
+ continue
261
+ for lo, hi, req in plan.requests:
262
+ request, t_lo = plan.first_request + req, chunk.token_start + lo
263
+ if last != (request, t_lo):
264
+ runs.append((t_lo, [], chunk, req))
265
+ runs[-1][1].append((plan, lo, hi))
266
+ last = (request, chunk.token_start + hi)
267
+ launches = []
268
+ for token_start, pieces, chunk, req in runs:
269
+ ends = [plan.row_ends[lo:hi] for plan, lo, hi in pieces]
270
+ row_ends = ends[0] if len(ends) == 1 else torch.cat(ends)
271
+ block_table = chunk.block_table[req : req + 1]
272
+ context_len = pieces[0][0].context_lens[req : req + 1]
273
+ for lo in range(0, row_ends.shape[0], max_rows):
274
+ hi = min(lo + max_rows, row_ends.shape[0])
275
+ launches.append(
276
+ RocmMxfp4GatherLaunch(
277
+ token_start + lo,
278
+ token_start + hi,
279
+ block_table,
280
+ context_len,
281
+ row_ends[lo:hi],
282
+ )
283
+ )
284
+ return launches
285
+
286
+
287
+class DeepseekV41RocmMxfp4IndexerBackend(DeepseekV41IndexerBackend):
288
+ @staticmethod
289
+ def get_name() -> str:
290
+ return "DEEPSEEK_V41_ROCM_MXFP4_INDEXER"
291
+
292
+ @staticmethod
293
+ def get_supported_kernel_block_sizes(
294
+ kv_cache_spec: KVCacheSpec | None = None,
295
+ ) -> list[int | MultipleOf]:
296
+ # --block-size 128 preferred.
297
+ return [64, 128]
298
+
299
+ @staticmethod
300
+ def get_builder_cls() -> type["DeepseekV41RocmMxfp4IndexerMetadataBuilder"]:
301
+ return DeepseekV41RocmMxfp4IndexerMetadataBuilder
302
+
303
+
304
+class RocmMxfp4IndexerMetadataBuilder(DeepseekV32IndexerMetadataBuilder):
305
+ """Dense indexer metadata plus the in-place paged launches' layout.
306
+
307
+ Requirements are checked here, at engine start.
308
+ """
309
+
310
+ @classmethod
311
+ def get_cudagraph_support(
312
+ cls,
313
+ vllm_config: VllmConfig,
314
+ kv_cache_spec: KVCacheSpec,
315
+ ) -> AttentionCGSupport:
316
+ # Decode rows are flattened into persistent buffers, and whether a
317
+ # step launches them as next_n-row sequences depends on its shape
318
+ # alone (see native_decode).
319
+ return AttentionCGSupport.ALWAYS
320
+
321
+ def __init__(
322
+ self,
323
+ kv_cache_spec: AttentionSpec,
324
+ layer_names: list[str],
325
+ vllm_config: VllmConfig,
326
+ device: torch.device,
327
+ **kwargs,
328
+ ) -> None:
329
+ super().__init__(kv_cache_spec, layer_names, vllm_config, device, **kwargs)
330
+ name = "the ROCm MXFP4 indexer"
331
+ if self.dcp_world_size > 1 or self.use_pcp:
332
+ raise NotImplementedError(
333
+ f"{name} does not support decode/prefill context parallel."
334
+ )
335
+ hf_config = vllm_config.model_config.hf_text_config
336
+ num_heads, head_dim = hf_config.index_n_heads, hf_config.index_head_dim
337
+ self.num_heads, self.head_dim = num_heads, head_dim
338
+ self.page_entries = kv_cache_spec.block_size // self.compress_ratio
339
+ self.cache_layout = rocm_paged_mxfp4_cache_layout(
340
+ num_heads, head_dim, self.page_entries
341
+ )
342
+ # Ragged steps take a row per query token, as do the candidate
343
+ # consumers' gathers. Uniform steps also launch the dense layers on
344
+ # next_n-row sequences, see native_decode.
345
+ self.use_flattening = True
346
+ # A two-level (candidate block) indexer's builder sets these.
347
+ self.candidate_block_size = 0
348
+ self.min_gather_width: float | None = None
349
+
350
+ self.decode_block_ends_buffer = torch.zeros(
351
+ self.arange_buffer.shape[0], dtype=torch.int32, device=device
352
+ )
353
+ self.context_lens_buffer = torch.zeros(
354
+ self.arange_buffer.shape[0], dtype=torch.int32, device=device
355
+ )
356
+ spec_config = vllm_config.speculative_config
357
+ # Adaptive verification replays a FULL graph on drafts reallocated
358
+ # across requests, so a step's shape does not fix its query lengths.
359
+ adaptive = spec_config is not None and spec_config.enable_adaptive_verification
360
+ self.native_next_n = 1 if adaptive else self.num_speculative_tokens + 1
361
+ # The indexer's logits width, which the decode schedule sizes slices by.
362
+ max_model_len = vllm_config.model_config.max_model_len
363
+ self.logits_width = max_model_len // self.compress_ratio
364
+ # Every dense layer of this group reads the same rows through the same
365
+ # page geometry, so the step builds their schedule once, not per layer.
366
+ self.decode_schedule_buffer = torch.empty(
367
+ rocm_mxfp4_decode_schedule_words(
368
+ num_heads, head_dim, self.page_entries, self.native_next_n
369
+ ),
370
+ dtype=torch.int32,
371
+ device=device,
372
+ )
373
+
374
+ def _split_indexer_prefill_chunks( # type: ignore[override]
375
+ self,
376
+ compressed_seq_lens_cpu: torch.Tensor,
377
+ prefill_query_lens_cpu: torch.Tensor,
378
+ workspace_size: int,
379
+ max_logits_bytes: int,
380
+ request_offset: int = 0,
381
+ ) -> list[tuple[slice, slice]]:
382
+ return split_prefill_chunks(
383
+ compressed_seq_lens_cpu,
384
+ prefill_query_lens_cpu,
385
+ max_logits_bytes,
386
+ request_offset,
387
+ )
388
+
389
+ def build(
390
+ self,
391
+ common_prefix_len: int,
392
+ common_attn_metadata: CommonAttentionMetadata,
393
+ fast_build: bool = False,
394
+ ) -> RocmMxfp4IndexerMetadata:
395
+ base = super().build(common_prefix_len, common_attn_metadata, fast_build)
396
+ metadata = RocmMxfp4IndexerMetadata(
397
+ **{f.name: getattr(base, f.name) for f in fields(base)}
398
+ )
399
+ cm = common_attn_metadata
400
+ context_lens = self.context_lens_buffer[: cm.num_reqs]
401
+ torch.floor_divide(
402
+ cm.seq_lens[: cm.num_reqs], self.compress_ratio, out=context_lens
403
+ )
404
+ if base.decode is not None:
405
+ lengths = base.decode.seq_lens.view(-1)
406
+ metadata.decode_row_lens = lengths
407
+ block = self.candidate_block_size
408
+ if block:
409
+ ends = self.decode_block_ends_buffer[: lengths.shape[0]]
410
+ torch.add(lengths, block - 1, out=ends)
411
+ ends.floor_divide_(block)
412
+ metadata.decode_block_ends = ends
413
+ query_start_loc = cm.query_start_loc_cpu[: base.num_decodes + 1]
414
+ metadata.decode_native = native_decode(
415
+ lengths,
416
+ base.decode.block_table,
417
+ torch.diff(query_start_loc).tolist(),
418
+ self.native_next_n,
419
+ context_lens,
420
+ )
421
+ metadata.decode_schedule = build_rocm_mxfp4_decode_schedule(
422
+ lengths,
423
+ self.num_heads,
424
+ self.head_dim,
425
+ self.page_entries,
426
+ self.decode_schedule_buffer,
427
+ self.logits_width,
428
+ metadata.decode_native,
429
+ )
430
+ if base.prefill is not None:
431
+ assert cm.seq_lens_cpu_upper_bound is not None
432
+ metadata.prefill_plans = plan_prefill_chunks(
433
+ base.prefill.chunks,
434
+ cm.query_start_loc_cpu.tolist(),
435
+ cm.seq_lens_cpu_upper_bound,
436
+ context_lens,
437
+ self.compress_ratio,
438
+ self.candidate_block_size,
439
+ self.min_gather_width,
440
+ cm.query_start_loc,
441
+ )
442
+ return metadata
443
+
444
+
445
+class DeepseekV41RocmMxfp4IndexerMetadataBuilder(RocmMxfp4IndexerMetadataBuilder):
446
+ """DeepSeek-V4.1's two-level indexer: sets the candidate block size the
447
+ base plans with and, with ``indexer_sparse_logits``, adds the consumers'
448
+ gathers."""
449
+
450
+ def __init__(
451
+ self,
452
+ kv_cache_spec: AttentionSpec,
453
+ layer_names: list[str],
454
+ vllm_config: VllmConfig,
455
+ device: torch.device,
456
+ **kwargs,
457
+ ) -> None:
458
+ super().__init__(kv_cache_spec, layer_names, vllm_config, device, **kwargs)
459
+ hf_config = vllm_config.model_config.hf_text_config
460
+ self.candidate_block_size = getattr(hf_config, "candidate_block_size", 0)
461
+ self.num_candidate_cols = 0
462
+ if vllm_config.attention_config.indexer_sparse_logits:
463
+ prefix = "attention_config.indexer_sparse_logits"
464
+ block = self.candidate_block_size
465
+ topk_blocks = getattr(hf_config, "candidate_topk_blocks", 0)
466
+ layout = self.cache_layout
467
+ if block <= 0 or topk_blocks <= 0:
468
+ raise ValueError(
469
+ f"{prefix} requires a two-level (candidate block) indexer "
470
+ "model such as DeepSeek-V4.1."
471
+ )
472
+ if topk_blocks & (topk_blocks - 1):
473
+ raise ValueError(
474
+ f"{prefix} on ROCm resolves the candidate pool with a sort "
475
+ f"that needs a power-of-two block count, not {topk_blocks}."
476
+ )
477
+ if self.page_entries % block or block > layout.n_per_tile:
478
+ raise ValueError(
479
+ f"{prefix} on ROCm needs {block}-entry candidate blocks to "
480
+ f"tile a {self.page_entries}-entry indexer page and fit in "
481
+ f"one {layout.n_per_tile}-entry shuffle group."
482
+ )
483
+ self.num_candidate_cols = topk_blocks * block
484
+ self.min_gather_width = self.num_candidate_cols * _GATHER_MIN_CUT_PREFILL
485
+ self.gather_rows = rocm_mxfp4_consumer_rows(self.num_candidate_cols)
486
+ logger.info_once(
487
+ "DeepSeek V4.1 indexer: candidate consumers score the %d "
488
+ "candidate positions with aiter's paged MXFP4 gather once the "
489
+ "context is %.1fx (decode) / %.1fx (prefill) as long.",
490
+ self.num_candidate_cols,
491
+ _GATHER_MIN_CUT_DECODE,
492
+ _GATHER_MIN_CUT_PREFILL,
493
+ )
494
+
495
+ def _gather_pays(self, context: int, cut: float) -> bool:
496
+ return self.num_candidate_cols > 0 and context >= cut * self.num_candidate_cols
497
+
498
+ def build(
499
+ self,
500
+ common_prefix_len: int,
501
+ common_attn_metadata: CommonAttentionMetadata,
502
+ fast_build: bool = False,
503
+ ) -> RocmMxfp4IndexerMetadata:
504
+ metadata = super().build(common_prefix_len, common_attn_metadata, fast_build)
505
+ if metadata.decode is not None:
506
+ metadata.decode_use_gather = self._gather_pays(
507
+ metadata.max_seq_len // self.compress_ratio, _GATHER_MIN_CUT_DECODE
508
+ )
509
+ if metadata.prefill is not None and self.num_candidate_cols:
510
+ metadata.gather_launches = plan_gather_launches(
511
+ metadata.prefill.chunks, metadata.prefill_plans, self.gather_rows
512
+ )
513
+ return metadata
vllm/v1/attention/ops/rocm_paged_mxfp4_indexer.py
new
+676
@@ -0,0 +1,676 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Sparse attention indexer on aiter's paged MXFP4 MQA-logits kernel.
4
+
5
+gfx950 only. The kernel reads the preshuffled paged indexer K cache in place,
6
+so no layer gathers K into a contiguous buffer, prefill included. Any model
7
+whose indexer supplies the inputs `rocm_mxfp4_sparse_attn_indexer` documents
8
+can run the dense path. DeepSeek-V4.1's two-level indexer adds to it: the
9
+candidate source takes its block maxima from the same walk that writes its
10
+logits, and the candidate consumers can walk the candidate pool instead of
11
+the whole context: the pool is resolved once per step and shared by all of
12
+them.
13
+"""
14
+
15
+import functools
16
+from collections.abc import Callable
17
+from dataclasses import dataclass
18
+from typing import TYPE_CHECKING, NamedTuple
19
+
20
+import torch
21
+
22
+import vllm.envs as envs
23
+from vllm.compilation.breakable_cudagraph import eager_break_during_capture
24
+from vllm.config import CUDAGraphMode
25
+from vllm.forward_context import get_forward_context
26
+from vllm.model_executor.kernels.attention.dsa.candidate_blocks import (
27
+ apply_candidate_mask,
28
+)
29
+from vllm.triton_utils import tl, triton
30
+from vllm.v1.attention.ops.rocm_aiter_mla_sparse import (
31
+ _apply_candidate_mask_strided,
32
+ _max_decode_logits_rows,
33
+)
34
+from vllm.v1.worker.workspace import current_workspace_manager
35
+
36
+if TYPE_CHECKING:
37
+ from vllm.v1.attention.backends.mla.indexer import (
38
+ DeepseekV32IndexerPrefillChunkMetadata,
39
+ )
40
+ from vllm.v1.attention.backends.mla.rocm_paged_mxfp4_indexer import (
41
+ RocmMxfp4GatherLaunch,
42
+ RocmMxfp4IndexerMetadata,
43
+ RocmMxfp4NativeDecode,
44
+ RocmMxfp4PrefillPlan,
45
+ )
46
+
47
+MXFP4_BLOCK_SIZE = 32
48
+
49
+
50
+@functools.cache
51
+def _aiter():
52
+ """The aiter module with the paged MXFP4 MQA-logits launcher, its schedule
53
+ and cache_format."""
54
+ from aiter.ops.triton.attention import pa_mqa_logits_mxfp4
55
+
56
+ return pa_mqa_logits_mxfp4
57
+
58
+
59
+@functools.cache
60
+def _aiter_topk() -> Callable[..., None]:
61
+ """The aiter per-row top-k."""
62
+ from aiter.ops.topk import top_k_per_row_decode
63
+
64
+ return top_k_per_row_decode
65
+
66
+
67
+class RocmPagedMxfp4CacheLayout(NamedTuple):
68
+ """The byte order of an indexer K page, as the kernel reads it, and the
69
+ shuffle pattern aiter's K cache op writes it with.
70
+
71
+ A page is cut into runs of ``n_per_tile`` tokens. Inside a run, each token's
72
+ packed values are split into ``d_per_tile``-byte chunks along the head
73
+ dimension, and the run stores chunk 0 of every token, then chunk 1, and so
74
+ on: ``[chunk, token, byte]``. Its e8m0 scales are stored as
75
+ ``[scale % scale_lanes, token, scale // scale_lanes]``.
76
+ """
77
+
78
+ n_per_tile: int
79
+ d_per_tile: int
80
+ scale_lanes: int
81
+
82
+
83
+# The scale order the kernel reads: aiter's mode 1, a lane's scales innermost.
84
+# cache_format does not report the order or the lane split yet.
85
+_SCALE_ORDER = 1
86
+_WAVE_SIZE = 64
87
+
88
+
89
+@functools.cache
90
+def rocm_paged_mxfp4_cache_layout(
91
+ num_heads: int, head_dim: int, page_entries: int
92
+) -> RocmPagedMxfp4CacheLayout:
93
+ """The K page layout from aiter's ``cache_format``, the only place vLLM
94
+ reads it, so a change there cannot reach the writer unchecked.
95
+
96
+ Raises:
97
+ ValueError: aiter rejects the page size, or describes an order this
98
+ layout cannot express.
99
+
100
+ """
101
+ fmt = _aiter().cache_format(num_heads, head_dim, page_entries)
102
+ try:
103
+ n_per_tile = int(fmt["n_per_tile"])
104
+ d_per_tile = int(fmt["d_per_tile"])
105
+ scale_order = fmt.get("scale_mode", _SCALE_ORDER)
106
+ scale_lanes = int(fmt.get("scale_lanes", _WAVE_SIZE // n_per_tile))
107
+ except (KeyError, TypeError, ValueError, ZeroDivisionError) as e:
108
+ raise ValueError(f"aiter's cache_format() returned {fmt!r}") from e
109
+ if (
110
+ scale_order != _SCALE_ORDER
111
+ or min(n_per_tile, d_per_tile, scale_lanes) <= 0
112
+ or page_entries % n_per_tile
113
+ or (head_dim // 2) % d_per_tile
114
+ or (head_dim // MXFP4_BLOCK_SIZE) % scale_lanes
115
+ ):
116
+ raise ValueError(
117
+ f"aiter's cache_format() returned {fmt!r} for {num_heads} heads of "
118
+ f"{head_dim} in {page_entries}-entry pages, a K page order "
119
+ "RocmPagedMxfp4CacheLayout cannot express"
120
+ )
121
+ return RocmPagedMxfp4CacheLayout(n_per_tile, d_per_tile, scale_lanes)
122
+
123
+
124
+def rocm_mxfp4_decode_schedule_words(
125
+ num_heads: int, head_dim: int, page_entries: int, next_n: int = 1
126
+) -> int:
127
+ """int32 words of the largest schedule a decode step can build, flattened or
128
+ not. The slice cap can take it past target_wgs, up to SCHED_SLOT_CAP."""
129
+ words = 4 * _aiter().SCHED_SLOT_CAP
130
+ for rows in {1, next_n}:
131
+ config = _aiter().select_config(num_heads, head_dim, rows, page_entries)
132
+ words = max(words, 4 * config["target_wgs"])
133
+ return words
134
+
135
+
136
+def build_rocm_mxfp4_decode_schedule(
137
+ row_lens: torch.Tensor,
138
+ num_heads: int,
139
+ head_dim: int,
140
+ page_entries: int,
141
+ out: torch.Tensor,
142
+ logits_width: int,
143
+ native: "RocmMxfp4NativeDecode | None" = None,
144
+) -> torch.Tensor | None:
145
+ """Work descriptors that even out a decode step, or None where the static
146
+ grid already fills the machine. Depends only on the rows' lengths, the
147
+ cache geometry and the logits width, so one serves every layer of a group.
148
+ The width sizes the slices, capped the way the static grid caps them."""
149
+ if native is None:
150
+ return _aiter().build_schedule(
151
+ row_lens,
152
+ 1,
153
+ num_heads,
154
+ head_dim,
155
+ page_entries,
156
+ out=out,
157
+ max_model_len=logits_width,
158
+ )
159
+ return _aiter().build_schedule(
160
+ native.context_lens,
161
+ native.next_n,
162
+ num_heads,
163
+ head_dim,
164
+ page_entries,
165
+ out=out,
166
+ row_ends=row_lens,
167
+ max_model_len=logits_width,
168
+ )
169
+
170
+
171
+def _kv_view(kv_cache: torch.Tensor, head_dim: int) -> torch.Tensor:
172
+ """The indexer cache as [pages, entries, 1, bytes]. The page stride is the
173
+ block-major pool's, not the page's own size."""
174
+ num_pages, entries, width = kv_cache.shape
175
+ assert kv_cache.dtype == torch.uint8
176
+ assert width == head_dim // 2 + head_dim // MXFP4_BLOCK_SIZE, width
177
+ return torch.as_strided(
178
+ kv_cache, (num_pages, entries, 1, width), (kv_cache.stride(0), width, width, 1)
179
+ )
180
+
181
+
182
+def rocm_mxfp4_consumer_rows(num_candidate_cols: int) -> int:
183
+ """Query rows per candidate-consumer launch. Its logits are [rows, pool]
184
+ fp32 whatever the context, so the logits budget alone sizes it."""
185
+ budget = envs.VLLM_SPARSE_INDEXER_MAX_LOGITS_MB * 1024 * 1024
186
+ return max(1, budget // (4 * num_candidate_cols))
187
+
188
+
189
+def reserve_rocm_mxfp4_indexer_workspace(
190
+ hidden_states: torch.Tensor,
191
+ logits_width: int,
192
+ candidate_block_size: int = 0,
193
+ gather_block_size: int = 0,
194
+ num_candidate_cols: int = 0,
195
+) -> None:
196
+ """Profiling run: claim the decode logits workspace and the peak prefill
197
+ logits, block scores included when the layer writes them, candidate lists
198
+ when it gathers."""
199
+ rows = _max_decode_logits_rows(hidden_states.shape[0])
200
+ specs = [((rows, logits_width), torch.float32)]
201
+ budget = envs.VLLM_SPARSE_INDEXER_MAX_LOGITS_MB * 1024 * 1024
202
+ if candidate_block_size:
203
+ nblocks = triton.cdiv(logits_width, candidate_block_size)
204
+ specs.append(((rows, nblocks), torch.float32))
205
+ budget += budget // candidate_block_size
206
+ if gather_block_size:
207
+ # aiter allocates the candidate lists: each gathered row, 8 B per
208
+ # candidate block (int32 slot and position). The first consumer builds
209
+ # them and they live until the step ends, across the later layers, so
210
+ # one reservation is held for the rest of the forward.
211
+ held = get_forward_context().additional_kwargs
212
+ if "rocm_mxfp4_candidate_lists" not in held:
213
+ held["rocm_mxfp4_candidate_lists"] = torch.empty(
214
+ hidden_states.shape[0] * (num_candidate_cols // gather_block_size) * 8,
215
+ dtype=torch.uint8,
216
+ device=hidden_states.device,
217
+ )
218
+ current_workspace_manager().get_simultaneous(*specs)
219
+ torch.empty(budget, dtype=torch.uint8, device=hidden_states.device)
220
+
221
+
222
+def _topk(
223
+ logits: torch.Tensor, lengths: torch.Tensor, out: torch.Tensor, k: int
224
+) -> None:
225
+ """Top-k over each row's [0, lengths[row]); -1 pads rows shorter than k.
226
+
227
+ aiter's kernel is faster on the candidate top-k and from 128 rows on; below
228
+ that, a wide decode step is faster on vLLM's. Shape alone decides, so a FULL
229
+ graph replays the same choice at every context length.
230
+ """
231
+ rows, width = logits.shape
232
+ if rows == 0:
233
+ return
234
+ if width >= k and (k >= 2048 or rows >= 128):
235
+ _aiter_topk()(
236
+ logits,
237
+ 1,
238
+ lengths.reshape(-1),
239
+ out,
240
+ rows,
241
+ logits.stride(0),
242
+ logits.stride(1),
243
+ k=k,
244
+ )
245
+ else:
246
+ torch.ops._C.top_k_per_row_decode(
247
+ logits, 1, lengths, out, rows, logits.stride(0), logits.stride(1), k
248
+ )
249
+
250
+
251
+@triton.jit
252
+def _remap_compact_topk_kernel(
253
+ idx_ptr,
254
+ idx_stride,
255
+ pos_ptr,
256
+ pos_stride,
257
+ K: tl.constexpr,
258
+ BLOCK: tl.constexpr,
259
+ PADDED_K: tl.constexpr,
260
+):
261
+ row = tl.program_id(0).to(tl.int64)
262
+ cols = tl.arange(0, PADDED_K)
263
+ live = cols < K
264
+ slot = tl.load(idx_ptr + row * idx_stride + cols, mask=live, other=-1)
265
+ hit = slot >= 0
266
+ slot = tl.where(hit, slot, 0)
267
+ start = tl.load(
268
+ pos_ptr + row * pos_stride + slot // BLOCK, mask=live & hit, other=0
269
+ )
270
+ tl.store(
271
+ idx_ptr + row * idx_stride + cols,
272
+ tl.where(hit, start + slot % BLOCK, -1).to(tl.int32),
273
+ mask=live,
274
+ )
275
+
276
+
277
+def _remap_compact_topk(
278
+ indices: torch.Tensor, positions: torch.Tensor, block: int
279
+) -> None:
280
+ """Candidate slots to context positions, in place: slot j sits in pool
281
+ block j // block, which starts at positions[j // block]."""
282
+ rows, k = indices.shape
283
+ if rows == 0:
284
+ return
285
+ _remap_compact_topk_kernel[(rows,)](
286
+ indices,
287
+ indices.stride(0),
288
+ positions,
289
+ positions.stride(0),
290
+ K=k,
291
+ BLOCK=block,
292
+ PADDED_K=triton.next_power_of_2(k),
293
+ num_warps=4,
294
+ )
295
+
296
+
297
+@dataclass
298
+class _Layer:
299
+ """One indexer layer's inputs and outputs for this step."""
300
+
301
+ metadata: "RocmMxfp4IndexerMetadata"
302
+ kv: torch.Tensor
303
+ q: torch.Tensor
304
+ q_scale: torch.Tensor
305
+ weights: torch.Tensor
306
+ topk_buffer: torch.Tensor
307
+ topk_tokens: int
308
+ compress_ratio: int
309
+ candidates: torch.Tensor | None
310
+ block: int
311
+ full_graph: bool
312
+
313
+ @property
314
+ def num_heads(self) -> int:
315
+ return self.q.shape[1]
316
+
317
+ @property
318
+ def head_dim(self) -> int:
319
+ return self.q.shape[2] * 2
320
+
321
+ def rows(self, lo: int, hi: int, seqs: int = 1) -> tuple[torch.Tensor, ...]:
322
+ """Q, its scales and the weights of token rows [lo, hi), as ``seqs``
323
+ sequences of next_n rows each."""
324
+ return (
325
+ self.q[lo:hi].view(seqs, -1, *self.q.shape[1:]),
326
+ self.q_scale[lo:hi].view(seqs, -1, *self.q_scale.shape[1:]),
327
+ self.weights[lo:hi],
328
+ )
329
+
330
+ def decode_rows(self, n: int) -> tuple[torch.Tensor, ...]:
331
+ """The first n token rows, one sequence each."""
332
+ return (
333
+ self.q[:n].unsqueeze(1),
334
+ self.q_scale[:n].unsqueeze(1),
335
+ self.weights[:n],
336
+ )
337
+
338
+
339
+def _layer(
340
+ k_cache_prefix: str,
341
+ kv_cache: torch.Tensor,
342
+ q_values: torch.Tensor,
343
+ q_scale: torch.Tensor,
344
+ weights: torch.Tensor,
345
+ topk_indices_buffer: torch.Tensor,
346
+ topk_tokens: int,
347
+ head_dim: int,
348
+ compress_ratio: int,
349
+ candidate_blocks: torch.Tensor | None,
350
+ candidate_block_size: int,
351
+) -> _Layer:
352
+ from vllm.v1.attention.backends.mla.rocm_paged_mxfp4_indexer import (
353
+ RocmMxfp4IndexerMetadata,
354
+ )
355
+
356
+ forward_context = get_forward_context()
357
+ attn_metadata = forward_context.attn_metadata
358
+ assert isinstance(attn_metadata, dict)
359
+ metadata = attn_metadata[k_cache_prefix]
360
+ assert isinstance(metadata, RocmMxfp4IndexerMetadata), (
361
+ "the ROCm MXFP4 indexer needs RocmMxfp4IndexerMetadataBuilder metadata"
362
+ )
363
+ kv = _kv_view(kv_cache, head_dim)
364
+ num_heads = q_values.shape[1]
365
+ return _Layer(
366
+ metadata=metadata,
367
+ kv=kv,
368
+ q=q_values,
369
+ # The fused Q kernel returns one int32 per head; the kernel reads the
370
+ # four ue8m0 bytes behind it.
371
+ q_scale=q_scale.view(torch.uint8).view(q_scale.shape[0], num_heads, -1),
372
+ weights=weights,
373
+ topk_buffer=topk_indices_buffer,
374
+ topk_tokens=topk_tokens,
375
+ compress_ratio=compress_ratio,
376
+ candidates=candidate_blocks,
377
+ block=candidate_block_size,
378
+ full_graph=forward_context.cudagraph_runtime_mode == CUDAGraphMode.FULL,
379
+ )
380
+
381
+
382
+def _block_scores(layer: _Layer, scores: torch.Tensor | None) -> dict:
383
+ if scores is None:
384
+ return {}
385
+ return dict(
386
+ calc_block_scores=True, block_scores=scores, candidate_block_size=layer.block
387
+ )
388
+
389
+
390
+def _dense_prefill(
391
+ layer: _Layer,
392
+ chunk: "DeepseekV32IndexerPrefillChunkMetadata",
393
+ plan: "RocmMxfp4PrefillPlan",
394
+ candidate_write: bool,
395
+) -> None:
396
+ pa = _aiter()
397
+ t0, t1 = chunk.token_start, chunk.token_end
398
+ logits = layer.q.new_empty((t1 - t0, plan.width), dtype=torch.float32)
399
+ scores = None
400
+ if candidate_write:
401
+ nblocks = triton.cdiv(plan.width, layer.block)
402
+ scores = layer.q.new_empty((t1 - t0, nblocks), dtype=torch.float32)
403
+ # One launch for the chunk: the kernel finds each row's request from
404
+ # query_start_loc on its own grid.
405
+ pa.paged_mxfp4_mqa_logits(
406
+ layer.q[t0:t1],
407
+ layer.q_scale[t0:t1],
408
+ layer.kv,
409
+ layer.weights[t0:t1],
410
+ plan.context_lens,
411
+ chunk.block_table,
412
+ plan.width,
413
+ out_logits=logits,
414
+ clean_logits=False,
415
+ row_ends=plan.row_ends,
416
+ query_start_loc=plan.query_start_loc,
417
+ **_block_scores(layer, scores),
418
+ )
419
+ candidates = None if layer.candidates is None else layer.candidates[t0:t1]
420
+ if scores is not None:
421
+ assert candidates is not None and plan.block_ends is not None
422
+ _topk(scores, plan.block_ends, candidates, candidates.shape[1])
423
+ elif candidates is not None:
424
+ apply_candidate_mask(logits, None, plan.row_ends, candidates, layer.block)
425
+ _topk(
426
+ logits,
427
+ plan.row_ends,
428
+ layer.topk_buffer[t0:t1, : layer.topk_tokens],
429
+ layer.topk_tokens,
430
+ )
431
+
432
+
433
+def _dense_decode(layer: _Layer, logits_width: int, candidate_write: bool) -> None:
434
+ metadata = layer.metadata
435
+ lengths = metadata.decode_row_lens
436
+ assert metadata.decode is not None and lengths is not None
437
+ rows = lengths.shape[0]
438
+ specs = [((rows, logits_width), torch.float32)]
439
+ if candidate_write:
440
+ specs.append(((rows, triton.cdiv(logits_width, layer.block)), torch.float32))
441
+ logits, *scores = current_workspace_manager().get_simultaneous(*specs)
442
+ native = metadata.decode_native
443
+ if native is not None:
444
+ # A request's next_n rows go in as one sequence, so a workgroup walks
445
+ # each KV tile once for all of them.
446
+ q, q_scale, weights = layer.rows(0, rows, native.context_lens.shape[0])
447
+ context_lens, block_table, row_ends = (
448
+ native.context_lens,
449
+ native.block_table,
450
+ lengths,
451
+ )
452
+ else:
453
+ q, q_scale, weights = layer.decode_rows(rows)
454
+ context_lens, block_table, row_ends = lengths, metadata.decode.block_table, None
455
+ assert block_table.shape[0] == rows, "one block-table row per query row"
456
+ _aiter().paged_mxfp4_mqa_logits(
457
+ q,
458
+ q_scale,
459
+ layer.kv,
460
+ weights,
461
+ context_lens,
462
+ block_table,
463
+ logits_width,
464
+ out_logits=logits,
465
+ clean_logits=False,
466
+ row_ends=row_ends,
467
+ schedule=metadata.decode_schedule,
468
+ **_block_scores(layer, scores[0] if scores else None),
469
+ )
470
+ candidates = None if layer.candidates is None else layer.candidates[:rows]
471
+ if scores:
472
+ assert candidates is not None and metadata.decode_block_ends is not None
473
+ _topk(scores[0], metadata.decode_block_ends, candidates, candidates.shape[1])
474
+ elif candidates is not None:
475
+ _apply_candidate_mask_strided(logits, None, lengths, candidates, layer.block)
476
+ _topk(
477
+ logits,
478
+ lengths,
479
+ layer.topk_buffer[:rows, : layer.topk_tokens],
480
+ layer.topk_tokens,
481
+ )
482
+
483
+
484
+def _gather_prefill(
485
+ layer: _Layer, launch: "RocmMxfp4GatherLaunch", num_cols: int
486
+) -> None:
487
+ pa = _aiter()
488
+ assert layer.candidates is not None
489
+ t0, t1 = launch.token_start, launch.token_end
490
+ if launch.pool is None:
491
+ launch.pool = pa.build_candidate_gather(
492
+ layer.candidates[t0:t1],
493
+ launch.row_ends,
494
+ launch.block_table.expand(t1 - t0, -1),
495
+ layer.kv,
496
+ layer.num_heads,
497
+ layer.head_dim,
498
+ layer.block,
499
+ )
500
+ gather, slot_ends = launch.pool
501
+ compact = layer.q.new_empty((t1 - t0, num_cols), dtype=torch.float32)
502
+ q, q_scale, weights = layer.rows(t0, t1)
503
+ pa.paged_mxfp4_mqa_logits(
504
+ q,
505
+ q_scale,
506
+ layer.kv,
507
+ weights,
508
+ launch.context_len,
509
+ launch.block_table,
510
+ num_cols,
511
+ out_logits=compact,
512
+ clean_logits=False,
513
+ row_ends=slot_ends,
514
+ use_gather=True,
515
+ candidates=gather,
516
+ )
517
+ out = layer.topk_buffer[t0:t1, : layer.topk_tokens]
518
+ _topk(compact, slot_ends, out, layer.topk_tokens)
519
+ _remap_compact_topk(out, gather["positions"], layer.block)
520
+
521
+
522
+def _gather_decode(layer: _Layer, num_cols: int) -> None:
523
+ pa = _aiter()
524
+ assert layer.candidates is not None
525
+ metadata = layer.metadata
526
+ lengths = metadata.decode_row_lens
527
+ assert metadata.decode is not None and lengths is not None
528
+ block_table = metadata.decode.block_table
529
+ rows = lengths.shape[0]
530
+ if metadata.decode_gather is None:
531
+ metadata.decode_gather = pa.build_candidate_gather(
532
+ layer.candidates[:rows],
533
+ lengths,
534
+ block_table,
535
+ layer.kv,
536
+ layer.num_heads,
537
+ layer.head_dim,
538
+ layer.block,
539
+ )
540
+ gather, slot_ends = metadata.decode_gather
541
+ (compact,) = current_workspace_manager().get_simultaneous(
542
+ ((rows, num_cols), torch.float32)
543
+ )
544
+ q, q_scale, weights = layer.decode_rows(rows)
545
+ pa.paged_mxfp4_mqa_logits(
546
+ q,
547
+ q_scale,
548
+ layer.kv,
549
+ weights,
550
+ lengths,
551
+ block_table,
552
+ num_cols,
553
+ out_logits=compact,
554
+ clean_logits=False,
555
+ row_ends=slot_ends,
556
+ use_gather=True,
557
+ candidates=gather,
558
+ )
559
+ out = layer.topk_buffer[:rows, : layer.topk_tokens]
560
+ _topk(compact, slot_ends, out, layer.topk_tokens)
561
+ _remap_compact_topk(out, gather["positions"], layer.block)
562
+
563
+
564
+@eager_break_during_capture
565
+def rocm_mxfp4_sparse_attn_indexer(
566
+ hidden_states: torch.Tensor,
567
+ k_cache_prefix: str,
568
+ kv_cache: torch.Tensor,
569
+ q_values: torch.Tensor,
570
+ q_scale: torch.Tensor,
571
+ weights: torch.Tensor,
572
+ topk_tokens: int,
573
+ head_dim: int,
574
+ max_model_len: int,
575
+ topk_indices_buffer: torch.Tensor,
576
+ compress_ratio: int = 1,
577
+ candidate_blocks: torch.Tensor | None = None,
578
+ candidate_block_size: int = 0,
579
+ candidate_write: bool = False,
580
+) -> torch.Tensor:
581
+ """Dense indexer: every layer that scores the whole context, the
582
+ candidate source included. With ``candidate_blocks`` and not
583
+ ``candidate_write`` the scores are masked to the pool first, as the
584
+ shared path does.
585
+
586
+ The model supplies ``q_values`` [T, H, D // 2] packed e2m1, ``q_scale``
587
+ holding each head's D // 32 ue8m0 bytes (one int32 per head at D = 128),
588
+ fp32 ``weights`` with the softmax and head scales folded in, and
589
+ ``kv_cache`` pages in `rocm_paged_mxfp4_cache_layout` order for H heads.
590
+ """
591
+ if not isinstance(get_forward_context().attn_metadata, dict):
592
+ reserve_rocm_mxfp4_indexer_workspace(
593
+ hidden_states,
594
+ max_model_len,
595
+ candidate_block_size if candidate_write else 0,
596
+ )
597
+ return topk_indices_buffer
598
+ layer = _layer(
599
+ k_cache_prefix,
600
+ kv_cache,
601
+ q_values,
602
+ q_scale,
603
+ weights,
604
+ topk_indices_buffer,
605
+ topk_tokens,
606
+ head_dim,
607
+ compress_ratio,
608
+ candidate_blocks,
609
+ candidate_block_size,
610
+ )
611
+ metadata = layer.metadata
612
+ topk_indices_buffer[: hidden_states.shape[0]] = -1
613
+ if metadata.prefill is not None:
614
+ for chunk, plan in zip(metadata.prefill.chunks, metadata.prefill_plans):
615
+ _dense_prefill(layer, chunk, plan, candidate_write)
616
+ if metadata.decode is not None:
617
+ _dense_decode(layer, max_model_len, candidate_write)
618
+ return topk_indices_buffer
619
+
620
+
621
+@eager_break_during_capture
622
+def rocm_mxfp4_sparse_mqa_indexer(
623
+ hidden_states: torch.Tensor,
624
+ k_cache_prefix: str,
625
+ kv_cache: torch.Tensor,
626
+ q_values: torch.Tensor,
627
+ q_scale: torch.Tensor,
628
+ weights: torch.Tensor,
629
+ topk_tokens: int,
630
+ head_dim: int,
631
+ max_model_len: int,
632
+ topk_indices_buffer: torch.Tensor,
633
+ compress_ratio: int,
634
+ candidate_blocks: torch.Tensor,
635
+ candidate_block_size: int,
636
+ num_candidate_cols: int,
637
+) -> torch.Tensor:
638
+ """Candidate consumer: score only the source's pool where the builder's
639
+ length gate says it pays, else the dense walk masked to the pool."""
640
+ if not isinstance(get_forward_context().attn_metadata, dict):
641
+ reserve_rocm_mxfp4_indexer_workspace(
642
+ hidden_states,
643
+ max(max_model_len, num_candidate_cols),
644
+ gather_block_size=candidate_block_size,
645
+ num_candidate_cols=num_candidate_cols,
646
+ )
647
+ return topk_indices_buffer
648
+ layer = _layer(
649
+ k_cache_prefix,
650
+ kv_cache,
651
+ q_values,
652
+ q_scale,
653
+ weights,
654
+ topk_indices_buffer,
655
+ topk_tokens,
656
+ head_dim,
657
+ compress_ratio,
658
+ candidate_blocks,
659
+ candidate_block_size,
660
+ )
661
+ metadata = layer.metadata
662
+ topk_indices_buffer[: hidden_states.shape[0]] = -1
663
+ if metadata.prefill is not None:
664
+ for chunk, plan in zip(metadata.prefill.chunks, metadata.prefill_plans):
665
+ if not plan.use_gather:
666
+ _dense_prefill(layer, chunk, plan, candidate_write=False)
667
+ for launch in metadata.gather_launches:
668
+ _gather_prefill(layer, launch, num_candidate_cols)
669
+ if metadata.decode is not None:
670
+ # A FULL graph cannot follow the per-step gate; the gather is the side
671
+ # that stays flat in the context length.
672
+ if metadata.decode_use_gather or layer.full_graph:
673
+ _gather_decode(layer, num_candidate_cols)
674
+ else:
675
+ _dense_decode(layer, max_model_len, candidate_write=False)
676
+ return topk_indices_buffer