[ROCm][MLA] Add an AITER ASM round-robin decode route for DCP multi-token verify (#56861)
Signed-off-by: Xiaohu Guo <Xiaohu.Guo@amd.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: seungrokj <144636725+seungrokj@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
xiaohuguo2023 committed
Sep 28, 2026 at 19:52 UTC
25624f65cf40ff6ce62e3261331b1b1ea7fc5e0a
5 files changed
+1202
-30
tests/v1/attention/test_rocm_aiter_mla_dcp_cprr.py
new
+152
@@ -0,0 +1,152 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Routing/selection tests for the AITER ASM cprr DCP-verify route.
4
+
5
+Companion to the upstream segmented-verify tests. Everything here is pure CPU:
6
+it pins the *decisions* (which route a KV group takes, which head count the
7
+kernel runs at, which configurations are refused) rather than kernel numerics.
8
+The numerics parity test against the segmented route needs a GPU and both
9
+routes built; it is marked and skipped when unavailable.
10
+"""
11
+
12
+from __future__ import annotations
13
+
14
+import pytest
15
+
16
+rocm_aiter_mla = pytest.importorskip(
17
+ "vllm.v1.attention.backends.mla.rocm_aiter_mla",
18
+ reason="ROCm AITER MLA backend not available",
19
+)
20
+
21
+_heads = rocm_aiter_mla._asm_dcp_verify_heads
22
+_configured = rocm_aiter_mla._asm_dcp_verify_configured
23
+_select_route = rocm_aiter_mla._select_dcp_decode_route
24
+Route = rocm_aiter_mla._DCPDecodeRoute
25
+NATIVE = rocm_aiter_mla._NATIVE_CPRR_HEADS
26
+MIN_QLEN = rocm_aiter_mla._MIN_CPRR_QLEN
27
+
28
+
29
+def test_dcp_verify_env_defaults_to_segmented(monkeypatch):
30
+ monkeypatch.delenv("VLLM_ROCM_AITER_MLA_DCP_VERIFY", raising=False)
31
+ assert rocm_aiter_mla.envs.VLLM_ROCM_AITER_MLA_DCP_VERIFY == "segmented"
32
+
33
+
34
+def test_dcp_verify_env_rejects_unknown_route(monkeypatch):
35
+ monkeypatch.setenv("VLLM_ROCM_AITER_MLA_DCP_VERIFY", "unknown")
36
+ with pytest.raises(ValueError, match="Invalid value"):
37
+ _ = rocm_aiter_mla.envs.VLLM_ROCM_AITER_MLA_DCP_VERIFY
38
+
39
+
40
+# --------------------------------------------------------------------------
41
+# _asm_dcp_verify_heads -- which head count the cprr kernel runs at
42
+# --------------------------------------------------------------------------
43
+
44
+
45
+@pytest.mark.parametrize("n", NATIVE)
46
+def test_native_head_counts_are_used_as_is(n):
47
+ assert _heads(n) == n
48
+
49
+
50
+def test_non_native_head_count_pads_up_to_the_next_native():
51
+ """K3 at TP8/DCP8 gathers 96 heads for the target; the kernel has no 96
52
+ variant, so it must run at 128, not silently fall out of the asm path."""
53
+ assert _heads(96) == 128
54
+ assert _heads(1) == 16
55
+ assert _heads(17) == 32
56
+ assert _heads(65) == 128
57
+
58
+
59
+def test_head_count_above_the_largest_native_is_unservable():
60
+ """0 means 'cannot serve'; anything else would route a shape the kernel
61
+ has no build for."""
62
+ assert _heads(max(NATIVE) + 1) == 0
63
+ assert _heads(256) == 0
64
+
65
+
66
+# --------------------------------------------------------------------------
67
+# _asm_dcp_verify_configured -- reachability
68
+# --------------------------------------------------------------------------
69
+
70
+
71
+@pytest.fixture
72
+def on_gfx950(monkeypatch):
73
+ """The cprr kernels are built only for gfx950, so the gate checks the arch.
74
+ Patched rather than detected, so both branches run on any runner."""
75
+ import vllm.platforms.rocm as rocm
76
+
77
+ monkeypatch.setattr(rocm, "on_gfx950", lambda: True)
78
+
79
+
80
+def test_route_needs_dcp(on_gfx950):
81
+ assert _configured(dcp_world_size=1, cp_interleave=1, multi_token_decode=True) is (
82
+ False
83
+ )
84
+ assert _configured(dcp_world_size=8, cp_interleave=1, multi_token_decode=True) is (
85
+ True
86
+ )
87
+
88
+
89
+def test_round_robin_interleave_other_than_one_is_excluded(on_gfx950):
90
+ """The kernel's global-position causal window assumes interleave 1, the
91
+ same restriction the segmented gate carries."""
92
+ assert _configured(dcp_world_size=8, cp_interleave=2, multi_token_decode=True) is (
93
+ False
94
+ )
95
+ assert _configured(dcp_world_size=8, cp_interleave=16, multi_token_decode=True) is (
96
+ False
97
+ )
98
+
99
+
100
+def test_route_needs_multi_token_decode(on_gfx950):
101
+ """Single-token decode is already served. Enabling the route for it would
102
+ pad the head count on every step and buy nothing."""
103
+ assert _configured(dcp_world_size=8, cp_interleave=1, multi_token_decode=False) is (
104
+ False
105
+ )
106
+
107
+
108
+def test_route_needs_gfx950(monkeypatch):
109
+ """hsa/gfx942/mla and hsa/gfx1250/mla carry no cprr rows, so off gfx950 the
110
+ kernel lookup fails at the first verify step rather than falling back."""
111
+ import vllm.platforms.rocm as rocm
112
+
113
+ monkeypatch.setattr(rocm, "on_gfx950", lambda: False)
114
+ assert _configured(dcp_world_size=8, cp_interleave=1, multi_token_decode=True) is (
115
+ False
116
+ )
117
+
118
+
119
+# --------------------------------------------------------------------------
120
+# qlen floor
121
+# --------------------------------------------------------------------------
122
+
123
+
124
+def test_min_cprr_qlen_is_above_two():
125
+ """Qlen 2 is below CPRR's floor and must use segmented verification."""
126
+ assert MIN_QLEN > 2
127
+
128
+
129
+@pytest.mark.parametrize(
130
+ "supports,causal,qlen,asm,expected",
131
+ [
132
+ (True, True, 1, True, Route.PLAIN),
133
+ (True, True, 2, True, Route.SEGMENTED),
134
+ (True, True, 3, True, Route.CPRR),
135
+ (True, True, 4, True, Route.CPRR),
136
+ (True, True, 4, False, Route.SEGMENTED),
137
+ (True, False, 2, True, Route.PLAIN),
138
+ (True, False, 4, False, Route.PLAIN),
139
+ (False, True, 3, True, Route.CPRR),
140
+ ],
141
+)
142
+def test_dcp_decode_route(supports, causal, qlen, asm, expected):
143
+ assert _select_route(supports, causal, qlen, asm) is expected
144
+
145
+
146
+def test_causal_multi_token_batch_without_a_valid_route_fails():
147
+ with pytest.raises(RuntimeError, match="requires either segmented MLA"):
148
+ _select_route(False, True, 2, True)
149
+
150
+
151
+# Kernel numerics live in test_rocm_aiter_mla_dcp_cprr_numerics.py, which runs
152
+# the real builder and the real asm kernel on a gfx950 GPU.
tests/v1/attention/test_rocm_aiter_mla_dcp_cprr_numerics.py
new
+540
@@ -0,0 +1,540 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""GPU numerics for the AITER ASM cprr DCP-verify route.
4
+
5
+Runs the REAL metadata builder (``AiterMLAMetadataBuilder.build``, so
6
+``get_mla_metadata_v1`` is exercised with the cprr kwargs and the persistent
7
+schedule buffers are produced) and the REAL asm kernel, on ONE GPU with the DCP
8
+ranks simulated one after another.
9
+
10
+That is legitimate because the aiter MLA backend contains no collective on this
11
+path: the query all-gather happens in the *layer*, strictly before
12
+``forward_mqa``, so the head fold, the round-robin kernel call and the un-fold
13
+are pure per-rank local arithmetic in which ``cp_world_size``/``cp_rank`` are
14
+just numbers feeding the causal-mask maths. Restricting a shard to a residue
15
+class of global positions is exactly what DCP rank r sees.
16
+
17
+Three properties are checked:
18
+ * each shard matches an exact torch reference over the positions it holds,
19
+ with causality applied on the GLOBAL position (the thing cprr reconstructs
20
+ in-kernel and the segmented route instead carries in per-row lengths);
21
+ * the LSE merge of all shards reproduces full-context attention;
22
+ * a shard told the wrong ``cp_rank`` fails both -- without this positive
23
+ control a passing run proves nothing, since a kernel that ignored global
24
+ positions still produces plausible-looking output.
25
+"""
26
+
27
+from __future__ import annotations
28
+
29
+from types import SimpleNamespace
30
+
31
+import pytest
32
+import torch
33
+
34
+from vllm.platforms import current_platform
35
+
36
+if not current_platform.is_rocm():
37
+ pytest.skip("ROCm AITER MLA tests", allow_module_level=True)
38
+
39
+from vllm._aiter_ops import is_aiter_found # noqa: E402
40
+from vllm.v1.attention.backends.mla import rocm_aiter_mla # noqa: E402
41
+
42
+KV_LORA_RANK = 512
43
+QK_ROPE_HEAD_DIM = 64
44
+QK_NOPE_HEAD_DIM = 128
45
+HEAD_SIZE = KV_LORA_RANK + QK_ROPE_HEAD_DIM
46
+SCALE = (QK_NOPE_HEAD_DIM + QK_ROPE_HEAD_DIM) ** -0.5
47
+
48
+DCP = 8
49
+NUM_REQS = 8
50
+CTX = 256
51
+QLEN = 5 # 1 + num_speculative_tokens; must clear _MIN_CPRR_QLEN
52
+
53
+# fp8-e4m3 keeps 3 mantissa bits, so the relative error floor is ~6.2e-2. Gates
54
+# are relative for the same reason: an absolute bound would just be a bound on
55
+# the output magnitude.
56
+SHARD_RTOL = 1.0e-1
57
+MERGE_RTOL = 5.0e-2
58
+
59
+MODEL = "deepseek-ai/DeepSeek-R1"
60
+
61
+
62
+def _gpu_available() -> bool:
63
+ return is_aiter_found() and torch.accelerator.is_available()
64
+
65
+
66
+def _on_gfx950() -> bool:
67
+ if not is_aiter_found():
68
+ return False
69
+ from vllm.platforms.rocm import on_gfx950
70
+
71
+ return on_gfx950()
72
+
73
+
74
+requires_cprr = pytest.mark.skipif(
75
+ not (_gpu_available() and _on_gfx950()),
76
+ reason="cprr DCP verify needs ROCm + AITER on gfx950",
77
+)
78
+
79
+
80
+@pytest.fixture(autouse=True)
81
+def select_asm_route(monkeypatch):
82
+ monkeypatch.setenv("VLLM_ROCM_AITER_MLA_DCP_VERIFY", "asm")
83
+
84
+
85
+# --------------------------------------------------------------------------
86
+# reference maths
87
+# --------------------------------------------------------------------------
88
+
89
+
90
+def _torch_reference(q_f32, kv_f32, ctx, qlen, scale, positions):
91
+ """Exact absorbed-MLA decode over an explicit set of GLOBAL KV positions.
92
+
93
+ A shard sees only ``positions``, but causality still uses the global
94
+ position: query token i (global position ctx + i) may attend to any p in
95
+ positions with p <= ctx + i. Restricting ``positions`` to a round-robin
96
+ residue class is precisely DCP rank r's view.
97
+ """
98
+ nr, _, heads, _ = q_f32.shape
99
+ dev = q_f32.device
100
+ o = torch.zeros(nr, qlen, heads, KV_LORA_RANK, dtype=torch.float32, device=dev)
101
+ lse = torch.full((nr, qlen, heads), float("-inf"), dtype=torch.float32, device=dev)
102
+ for r in range(nr):
103
+ kv_sel = kv_f32[r].index_select(0, positions)
104
+ for i in range(qlen):
105
+ valid = positions <= (ctx + i)
106
+ if not bool(valid.any()):
107
+ continue
108
+ k = kv_sel[valid]
109
+ s = (q_f32[r, i] @ k.T) * scale
110
+ m = s.max(dim=-1, keepdim=True).values
111
+ p = torch.exp(s - m)
112
+ denom = p.sum(dim=-1, keepdim=True)
113
+ o[r, i] = (p / denom) @ k[:, :KV_LORA_RANK]
114
+ lse[r, i] = m.squeeze(-1) + torch.log(denom.squeeze(-1))
115
+ return o, lse
116
+
117
+
118
+def _merge_partials(os_, lses):
119
+ """Cross-shard softmax merge (natural-log LSE)."""
120
+ stack_lse = torch.stack(lses)
121
+ m = stack_lse.max(dim=0).values
122
+ w = torch.exp(stack_lse - m)
123
+ num = (w.unsqueeze(-1) * torch.stack(os_)).sum(dim=0)
124
+ den = w.sum(dim=0).unsqueeze(-1)
125
+ return num / den, m + torch.log(w.sum(dim=0))
126
+
127
+
128
+# --------------------------------------------------------------------------
129
+# single-process rank simulation
130
+# --------------------------------------------------------------------------
131
+
132
+
133
+class _FakeGroup:
134
+ def __init__(self, world_size: int, rank: int):
135
+ self.world_size = world_size
136
+ self.rank_in_group = rank
137
+ self.rank = rank
138
+ self.local_rank = rank
139
+
140
+ def all_gather(self, tensor, dim=0):
141
+ raise RuntimeError(
142
+ "collective called in a single-process test -- the path under test "
143
+ "is supposed to be collective-free; this indicates a real change"
144
+ )
145
+
146
+
147
+def _install_fake_groups(dcp_size: int, cp_rank: int, tp_size: int = 1) -> None:
148
+ """Point vLLM's parallel_state at rank-simulating stubs (no NCCL)."""
149
+ import vllm.distributed.parallel_state as ps
150
+ import vllm.model_executor.layers.attention.mla_attention as mla_attention
151
+ import vllm.v1.attention.backends.mla.rocm_aiter_mla as backend
152
+
153
+ dcp = _FakeGroup(dcp_size, cp_rank)
154
+ tp = _FakeGroup(tp_size, cp_rank)
155
+ ps.get_dcp_group = lambda: dcp
156
+ ps.get_tp_group = lambda: tp
157
+ # Both of these imported the symbol directly at module import time, so
158
+ # patching parallel_state alone leaves them resolving the real group (and
159
+ # the builder then reports dcp_world_size 1, silently skipping the route).
160
+ backend.get_dcp_group = lambda: dcp
161
+ mla_attention.get_dcp_group = lambda: dcp
162
+
163
+
164
+@pytest.fixture(autouse=True)
165
+def _restore_parallel_groups():
166
+ """Undo _install_fake_groups after every test.
167
+
168
+ It patches module globals in three modules, so without this the fake DCP
169
+ group leaks into the rest of the pytest session and silently breaks
170
+ unrelated tests that expect a real (or absent) group.
171
+ """
172
+ import vllm.distributed.parallel_state as ps
173
+ import vllm.model_executor.layers.attention.mla_attention as mla_attention
174
+ import vllm.v1.attention.backends.mla.rocm_aiter_mla as backend
175
+
176
+ saved = [
177
+ (mod, name, getattr(mod, name))
178
+ for mod, name in (
179
+ (ps, "get_dcp_group"),
180
+ (ps, "get_tp_group"),
181
+ (backend, "get_dcp_group"),
182
+ (mla_attention, "get_dcp_group"),
183
+ )
184
+ ]
185
+ yield
186
+ for mod, name, value in saved:
187
+ setattr(mod, name, value)
188
+
189
+
190
+class _PrefillBackendStub:
191
+ """The base builder does ``attention_layer.prefill_backend.clone()``.
192
+
193
+ prefill_backend is consulted only from the chunked-context prefill path,
194
+ which a decode-only test never enters.
195
+ """
196
+
197
+ def clone(self):
198
+ return self
199
+
200
+
201
+def _make_ctx_layer(vllm_config):
202
+ """Stand-in for the registered attention layer in static_forward_context.
203
+
204
+ ``MLACommonMetadataBuilder.__init__`` reads the MLA dims, ``dcp_manager``
205
+ and ``prefill_backend`` off this object. ``dcp_manager`` is the Direct-DCP
206
+ symmetric-memory workspace, used ONLY by the chunked-prefill KV all-gather;
207
+ it is constructed without running __init__ so no peer pointers are exchanged
208
+ and no collective is issued. That is what keeps this test single-process.
209
+ """
210
+ from vllm.v1.attention.ops.dcp import MLADCPManager
211
+
212
+ mgr = object.__new__(MLADCPManager)
213
+ mgr.init_kv_gather = lambda *a, **k: None
214
+
215
+ return SimpleNamespace(
216
+ non_causal_multi_token_decode=False,
217
+ q_lora_rank=None,
218
+ kv_lora_rank=KV_LORA_RANK,
219
+ qk_nope_head_dim=QK_NOPE_HEAD_DIM,
220
+ qk_rope_head_dim=QK_ROPE_HEAD_DIM,
221
+ v_head_dim=QK_NOPE_HEAD_DIM,
222
+ dcp_manager=mgr,
223
+ prefill_backend=_PrefillBackendStub(),
224
+ )
225
+
226
+
227
+def _ensure_workspace_manager(device: torch.device) -> None:
228
+ """Init the global workspace manager, which GPUModelRunner does in production.
229
+
230
+ ``AiterMLAMetadataBuilder.__init__`` reserves the fp8 prefill
231
+ persistent-scheduling scratch through ``current_workspace_manager()``, so
232
+ merely constructing a builder outside a serve trips its assert. Idempotent:
233
+ one builder is constructed per DCP shard. Deliberately does NOT lock the
234
+ workspace -- production locks only after warmup, and this keeps allocating
235
+ across shards.
236
+ """
237
+ from vllm.v1.worker.workspace import (
238
+ init_workspace_manager,
239
+ is_workspace_manager_initialized,
240
+ )
241
+
242
+ if not is_workspace_manager_initialized():
243
+ init_workspace_manager(device)
244
+
245
+
246
+class _Layer:
247
+ def __init__(self, device):
248
+ self._q_scale = torch.tensor(1.0, device=device)
249
+ self._k_scale = torch.tensor(1.0, device=device)
250
+ self._v_scale = torch.tensor(1.0, device=device)
251
+
252
+
253
+def _build_impl(num_heads: int, dcp: int, cp_rank: int):
254
+ """Real AiterMLAImpl.forward_mqa bound to a minimally-populated instance.
255
+
256
+ Constructing the full impl would drag in kv_b_proj and the rest of the
257
+ weight-dependent MLA setup -- i.e. model loading, which this test exists to
258
+ avoid. forward_mqa's fold path reads only the attributes set below.
259
+ """
260
+ from vllm.v1.attention.backends.mla.rocm_aiter_mla import AiterMLAImpl
261
+
262
+ impl = object.__new__(AiterMLAImpl)
263
+ impl.kv_lora_rank = KV_LORA_RANK
264
+ impl.qk_rope_head_dim = QK_ROPE_HEAD_DIM
265
+ impl.num_heads = num_heads
266
+ impl.dcp_world_size = dcp
267
+ impl.dcp_rank = cp_rank
268
+ impl.pcp_world_size = 1
269
+ impl.kv_cache_dtype = "fp8"
270
+ impl.scale = SCALE
271
+ # `_decode_num_heads` is a read-only property deriving num_heads *
272
+ # dcp_world_size, both set above; assert rather than assign.
273
+ assert impl._decode_num_heads == num_heads * dcp
274
+ return impl
275
+
276
+
277
+def _make_vllm_config(heads_per_rank: int):
278
+ """A DCP8 MLA config with no weights and no distributed init.
279
+
280
+ DeepSeek-R1's config supplies the MLA dims (kv_lora_rank 512, rope 64,
281
+ nope 128) that the aiter asm kernel is built for; only the head count is
282
+ overridden, to place the GATHERED count (heads_per_rank * DCP) on the
283
+ kernel variant under test. ``get_num_attention_heads`` reads
284
+ model_arch_config, a snapshot taken at config-creation time, so patching
285
+ hf_config alone would be inert.
286
+ """
287
+ from tests.v1.attention.utils import create_vllm_config
288
+
289
+ vllm_config = create_vllm_config(
290
+ model_name=MODEL,
291
+ tensor_parallel_size=1,
292
+ max_model_len=2048,
293
+ max_num_seqs=NUM_REQS * 2,
294
+ max_num_batched_tokens=4096,
295
+ )
296
+ vllm_config.model_config.model_arch_config.total_num_attention_heads = (
297
+ heads_per_rank
298
+ )
299
+ # Without a speculative config the batch reorder threshold stays at 1 and a
300
+ # qlen-QLEN row is classified as a PREFILL, so the decode path under test is
301
+ # never reached. _init_reorder_batch_threshold reads exactly these two
302
+ # fields, so a stand-in is enough and avoids pulling in a draft model.
303
+ vllm_config.speculative_config = SimpleNamespace(
304
+ num_speculative_tokens=QLEN - 1, parallel_drafting=False
305
+ )
306
+ vllm_config.cache_config.cache_dtype = "fp8"
307
+ vllm_config.parallel_config.decode_context_parallel_size = DCP
308
+ vllm_config.parallel_config.cp_kv_cache_interleave_size = 1
309
+ return vllm_config
310
+
311
+
312
+def _run_shard(
313
+ vllm_config,
314
+ spec,
315
+ layer_name,
316
+ device,
317
+ cp_rank,
318
+ kern_rank,
319
+ q_flat,
320
+ kv_flat,
321
+ nr,
322
+ qlen,
323
+ ctx,
324
+ dcp,
325
+ pos,
326
+):
327
+ """Build real metadata for one DCP rank and run the real asm kernel.
328
+
329
+ ``kern_rank`` is separate from ``cp_rank`` so the positive control can hand
330
+ rank r's pages to the kernel while telling it that it is rank r+1.
331
+ """
332
+ from vllm.v1.attention.backend import CommonAttentionMetadata
333
+ from vllm.v1.attention.backends.mla.rocm_aiter_mla import AiterMLAMetadataBuilder
334
+
335
+ L = ctx + qlen
336
+ local_len = int(pos.numel())
337
+
338
+ # One GLOBAL kv cache: page (req*L + p) holds request req position p. Shards
339
+ # select pages by residue class, so every shard reads the same underlying
340
+ # bytes and no re-quantisation noise is introduced between arms.
341
+ block_table = (
342
+ torch.arange(nr, device=device).view(nr, 1) * L + pos.view(1, -1)
343
+ ).to(torch.int32)
344
+
345
+ # seq_lens is the GLOBAL context; dcp_local_seq_lens is what this rank
346
+ # actually holds. Passing the global length as the local one walks the
347
+ # kernel off the end of the page table.
348
+ seq_lens_cpu = torch.full((nr,), L, dtype=torch.int32)
349
+ local_cpu = torch.full((nr,), local_len, dtype=torch.int32)
350
+
351
+ common = CommonAttentionMetadata(
352
+ query_start_loc=torch.arange(
353
+ 0, nr * qlen + 1, qlen, dtype=torch.int32, device=device
354
+ ),
355
+ query_start_loc_cpu=torch.arange(0, nr * qlen + 1, qlen, dtype=torch.int32),
356
+ seq_lens=seq_lens_cpu.to(device),
357
+ num_reqs=nr,
358
+ num_actual_tokens=nr * qlen,
359
+ max_query_len=qlen,
360
+ max_seq_len=L,
361
+ block_table_tensor=block_table,
362
+ slot_mapping=torch.arange(nr * qlen, dtype=torch.int64, device=device),
363
+ causal=True,
364
+ dcp_local_seq_lens=local_cpu.to(device),
365
+ dcp_local_seq_lens_cpu_upper_bound=local_cpu,
366
+ )
367
+
368
+ _install_fake_groups(dcp, kern_rank)
369
+ builder = AiterMLAMetadataBuilder(spec, [layer_name], vllm_config, device)
370
+ md = builder.build(0, common)
371
+
372
+ impl = _build_impl(builder.num_heads, dcp, kern_rank)
373
+ o, lse = impl.forward_mqa(q_flat, kv_flat, md, _Layer(device))
374
+ torch.accelerator.synchronize()
375
+ return md, o, lse, local_len
376
+
377
+
378
+def _numerics(heads_per_rank: int, sabotage: bool = False):
379
+ """Run every DCP shard and return (per-shard max rel err, merged rel err)."""
380
+ import aiter
381
+
382
+ from vllm.config import set_current_vllm_config
383
+
384
+ device = torch.device("cuda", 0)
385
+ torch.accelerator.set_device_index(device.index)
386
+ torch.manual_seed(0)
387
+ _ensure_workspace_manager(device)
388
+
389
+ _install_fake_groups(DCP, 0)
390
+ vllm_config = _make_vllm_config(heads_per_rank)
391
+
392
+ with set_current_vllm_config(vllm_config):
393
+ from vllm.v1.kv_cache_interface import MLAAttentionSpec
394
+
395
+ layer_name = "model.layers.0.self_attn.attn"
396
+ vllm_config.compilation_config.static_forward_context[layer_name] = (
397
+ _make_ctx_layer(vllm_config)
398
+ )
399
+ # block_size 1: one page per token, so page ids are positions and the
400
+ # residue-class page table is expressible directly.
401
+ spec = MLAAttentionSpec(
402
+ block_size=1,
403
+ num_kv_heads=1,
404
+ head_size=HEAD_SIZE,
405
+ dtype=vllm_config.model_config.dtype,
406
+ non_causal_multi_token_decode=False,
407
+ )
408
+
409
+ nr, ctx, qlen = NUM_REQS, CTX, QLEN
410
+ L = ctx + qlen
411
+ gathered_heads = heads_per_rank * DCP
412
+ kv_dtype = aiter.dtypes.fp8
413
+
414
+ kv_flat = torch.randn(
415
+ nr * L, 1, HEAD_SIZE, dtype=torch.float32, device=device
416
+ ).to(kv_dtype)
417
+ q_flat = (
418
+ torch.randn(
419
+ nr * qlen,
420
+ gathered_heads,
421
+ HEAD_SIZE,
422
+ dtype=torch.float32,
423
+ device=device,
424
+ )
425
+ .mul_(0.5)
426
+ .to(kv_dtype)
427
+ )
428
+ # Dequantise for the reference so the comparison measures the kernel,
429
+ # not the fp8 cast that both sides share.
430
+ kv_f32 = kv_flat.float().view(nr, L, HEAD_SIZE)
431
+ q_f32 = q_flat.float().view(nr, qlen, gathered_heads, HEAD_SIZE)
432
+
433
+ ref_o, _ = _torch_reference(
434
+ q_f32, kv_f32, ctx, qlen, SCALE, torch.arange(L, device=device)
435
+ )
436
+
437
+ shard_o, shard_lse = [], []
438
+ max_shard_rel = 0.0
439
+ for r in range(DCP):
440
+ pos = torch.arange(r, L, DCP, device=device) # residue class r
441
+ kern_rank = (r + 1) % DCP if sabotage else r
442
+ md, o, lse, _ = _run_shard(
443
+ vllm_config,
444
+ spec,
445
+ layer_name,
446
+ device,
447
+ r,
448
+ kern_rank,
449
+ q_flat,
450
+ kv_flat,
451
+ nr,
452
+ qlen,
453
+ ctx,
454
+ DCP,
455
+ pos,
456
+ )
457
+ # The route actually under test must have been taken; a silent
458
+ # fall-out to Triton/segmented would otherwise pass quietly.
459
+ assert md.decode.dcp_route is rocm_aiter_mla._DCPDecodeRoute.CPRR
460
+ assert md.decode.asm_decode_num_heads, "cprr asm route was not taken"
461
+
462
+ o = o.float().view(nr, qlen, gathered_heads, KV_LORA_RANK)
463
+ shard_lse.append(lse.float().view(nr, qlen, gathered_heads))
464
+ shard_o.append(o)
465
+
466
+ po, _ = _torch_reference(q_f32, kv_f32, ctx, qlen, SCALE, pos)
467
+ rel = (o - po).abs().max().item() / max(po.abs().max().item(), 1e-6)
468
+ max_shard_rel = max(max_shard_rel, rel)
469
+
470
+ merged, _ = _merge_partials(shard_o, shard_lse)
471
+ merge_rel = (merged - ref_o).abs().max().item() / max(
472
+ ref_o.abs().max().item(), 1e-6
473
+ )
474
+ return max_shard_rel, merge_rel
475
+
476
+
477
+# --------------------------------------------------------------------------
478
+# tests
479
+# --------------------------------------------------------------------------
480
+
481
+
482
+@requires_cprr
483
+@pytest.mark.parametrize(
484
+ "heads_per_rank",
485
+ [
486
+ 16, # 128 gathered heads -- a NATIVE cprr kernel, no padding
487
+ 12, # 96 gathered heads -- K3's DSpark target, must pad up to 128
488
+ ],
489
+ ids=["native-128", "padded-96-to-128"],
490
+)
491
+@torch.inference_mode()
492
+def test_cprr_shards_match_reference_and_merge_to_full_context(heads_per_rank):
493
+ """Every DCP shard must be right, and their LSE merge must reproduce
494
+ full-context attention.
495
+
496
+ A shard that ignored global positions (applying a local causal tail
497
+ instead) still produces plausible-looking output; it only diverges from the
498
+ reference on the tokens whose causal window is truncated. Checking the merge
499
+ alone would hide that, so both are asserted.
500
+ """
501
+ shard_rel, merge_rel = _numerics(heads_per_rank)
502
+ assert shard_rel < SHARD_RTOL, f"per-shard rel err {shard_rel:.3e}"
503
+ assert merge_rel < MERGE_RTOL, f"merged rel err {merge_rel:.3e}"
504
+
505
+
506
+@requires_cprr
507
+@torch.inference_mode()
508
+def test_wrong_cp_rank_breaks_the_result():
509
+ """Positive control: without it, a passing run above proves nothing.
510
+
511
+ Feeding shard r's pages to the kernel while telling it that it is rank r+1
512
+ shifts every reconstructed global position by one, so both the causal mask
513
+ and the round-robin reconstruction are wrong. If the error does NOT blow up,
514
+ the kernel is not using cp_rank and the test above has no power.
515
+ """
516
+ shard_rel, merge_rel = _numerics(16, sabotage=True)
517
+ assert shard_rel > SHARD_RTOL or merge_rel > MERGE_RTOL, (
518
+ f"sabotaged run still passed (shard {shard_rel:.3e}, merge "
519
+ f"{merge_rel:.3e}) -- cp_rank is not reaching the kernel"
520
+ )
521
+
522
+
523
+@requires_cprr
524
+def test_cprr_kernel_is_selected_for_the_k3_shape():
525
+ """The K3 DSpark target shape must actually reach the cprr route.
526
+
527
+ Guards against a silent fall-out to segmented/Triton, which is what the
528
+ stock configuration does and what this route exists to avoid.
529
+ """
530
+ from vllm.v1.attention.backends.mla import rocm_aiter_mla as m
531
+
532
+ assert (
533
+ m._asm_dcp_verify_configured(
534
+ dcp_world_size=DCP, cp_interleave=1, multi_token_decode=True
535
+ )
536
+ is True
537
+ )
538
+ assert QLEN >= m._MIN_CPRR_QLEN
539
+ assert m._select_dcp_decode_route(True, True, QLEN, True) is m._DCPDecodeRoute.CPRR
540
+ assert m._asm_dcp_verify_heads(96) in m._NATIVE_CPRR_HEADS
tests/v1/attention/test_rocm_aiter_mla_mtp_split.py
+242
-7
@@ -87,21 +87,31 @@ def _builder(
87
kv_cache_dtype: str = "auto",
88
dcp_world_size: int = 1,
89
dcp_rank: int = 0,
90
+ asm_dcp_verify: bool = False,
91
+ decode_causal: bool = True,
92
+ supports_segmented_dcp_verify: bool | None = None,
93
):
94
+ decode_num_heads = num_heads * dcp_world_size
95
+ asm_heads = (
96
+ rocm_aiter_mla._asm_dcp_verify_heads(decode_num_heads) if asm_dcp_verify else 0
97
+ )
98
+ if supports_segmented_dcp_verify is None:
99
+ supports_segmented_dcp_verify = rocm_aiter_mla._segmented_dcp_verify_supported(
100
+ dcp_world_size, 1
101
+ )
102
stub = SimpleNamespace(
103
device=torch.device("cpu"),
104
num_heads=num_heads,
105
# DCP gathers every rank's head shard before decode, and the routing
106
# predicates read the gathered count.
96
- _decode_num_heads=num_heads * dcp_world_size,
107
+ _decode_num_heads=decode_num_heads,
108
dcp_world_size=dcp_world_size,
109
dcp_rank=dcp_rank,
110
cp_kv_cache_interleave_size=1,
111
# Mirrors the production constructor: configuration decides whether the
101
- # segmented route is available, query length decides per batch.
102
- _supports_segmented_dcp_verify=rocm_aiter_mla._segmented_dcp_verify_supported(
103
- dcp_world_size, 1
104
- ),
112
+ # segmented route is available, query length decides per batch. CPRR
113
+ # preferred still keeps this True so qlen < _MIN_CPRR_QLEN can fall back.
114
+ _supports_segmented_dcp_verify=supports_segmented_dcp_verify,
115
# Derived once in the real constructor, so derive it once here too.
116
_segmented_page_size=rocm_aiter_mla._segmented_mla_page_size(kernel_block_size),
117
_dcp_verify_buffers=None,
@@ -122,7 +132,11 @@ def _builder(
132
_uniform_padded_mtp_qo_len=(AiterMLAMetadataBuilder._uniform_padded_mtp_qo_len),
133
_use_persistent_metadata=False,
134
kernel_block_size=kernel_block_size,
125
- _num_attention_heads=AiterMLAHelper.get_actual_mla_num_heads(num_heads),
135
+ _num_attention_heads=(
136
+ asm_heads
137
+ if asm_dcp_verify
138
+ else AiterMLAHelper.get_actual_mla_num_heads(num_heads)
139
+ ),
140
_mla_work_meta_data=torch.empty(1, dtype=torch.int32),
141
_mla_work_info_set=torch.empty(1, dtype=torch.int32),
142
_mla_work_indptr=torch.empty(1, dtype=torch.int32),
@@ -132,7 +146,19 @@ def _builder(
146
_mla_q_dtype=torch.bfloat16,
147
_mla_kv_dtype=torch.bfloat16,
148
decode_attn_out_dtype=torch.bfloat16,
135
- _decode_causal=True,
149
+ _decode_causal=decode_causal,
150
+ # Default off so existing cases keep exercising segmented/persistent.
151
+ # Pass asm_dcp_verify=True for the CPRR route and its qlen-floor fallback.
152
+ _asm_dcp_verify=asm_dcp_verify,
153
+ _asm_dcp_verify_heads=asm_heads,
154
+ _g_kv_indptr_buf=(
155
+ torch.zeros(max_decode_rows + 1, dtype=torch.int32)
156
+ if asm_dcp_verify and dcp_world_size > 1
157
+ else None
158
+ ),
159
+ # Only read when the asm route is on; the real builder derives it from
160
+ # the device CU count.
161
+ _mla_max_split_per_batch=256,
162
)
163
# Bound to the stub rather than faked: the verify flatten's per-row view is
164
# part of what _build_decode is being tested for.
@@ -287,6 +313,209 @@ def test_dcp_fp8_verify_build_uses_segmented(monkeypatch):
313
assert metadata.dcp_verify is not None
314
315
316
+def _cprr_decode_batch(builder, *, qlen: int, num_reqs: int = 1):
317
+ seq_lens = torch.full((num_reqs,), 16, dtype=torch.int32)
318
+ query_start_loc = torch.arange(0, (num_reqs + 1) * qlen, qlen, dtype=torch.int32)
319
+ return AiterMLAMetadataBuilder._build_decode(
320
+ builder,
321
+ block_table_tensor=torch.zeros(num_reqs, 8, dtype=torch.int32),
322
+ seq_lens_device=seq_lens,
323
+ max_seq_len=16,
324
+ query_start_loc_cpu=query_start_loc,
325
+ query_start_loc_device=query_start_loc,
326
+ num_decode_tokens=num_reqs * qlen,
327
+ max_query_len=qlen,
328
+ dcp_tot_seq_lens_device=seq_lens * 8,
329
+ )
330
+
331
+
332
+def test_asm_preferred_qlen2_falls_back_to_segmented(monkeypatch):
333
+ """K=4 can clamp to qlen 2; CPRR has no kernel, so use segmented MLA."""
334
+ monkeypatch.setattr(rocm_aiter_mla, "_segmented_mla_decode_supported", lambda: True)
335
+ get_mla_metadata_v1 = mock.MagicMock()
336
+ monkeypatch.setitem(
337
+ sys.modules,
338
+ "aiter",
339
+ SimpleNamespace(get_mla_metadata_v1=get_mla_metadata_v1),
340
+ )
341
+
342
+ metadata = _cprr_decode_batch(
343
+ _builder(
344
+ mtp_decode_qlen=5,
345
+ dcp_world_size=8,
346
+ num_heads=12,
347
+ asm_dcp_verify=True,
348
+ kernel_block_size=2,
349
+ ),
350
+ qlen=2,
351
+ )
352
+
353
+ assert metadata.dcp_verify is not None
354
+ assert metadata.g_kv_indptr is None
355
+ assert not metadata.has_persistent_metadata
356
+ get_mla_metadata_v1.assert_not_called()
357
+
358
+
359
+def test_asm_preferred_qlen_at_cprr_floor_stays_on_cprr(monkeypatch):
360
+ monkeypatch.setattr(rocm_aiter_mla, "_segmented_mla_decode_supported", lambda: True)
361
+ get_mla_metadata_v1 = mock.MagicMock()
362
+ monkeypatch.setitem(
363
+ sys.modules,
364
+ "aiter",
365
+ SimpleNamespace(get_mla_metadata_v1=get_mla_metadata_v1),
366
+ )
367
+ monkeypatch.setattr(
368
+ rocm_aiter_mla, "_expand_page_indices_kernel", _NoOpTritonKernel()
369
+ )
370
+
371
+ metadata = _cprr_decode_batch(
372
+ _builder(
373
+ mtp_decode_qlen=5,
374
+ dcp_world_size=8,
375
+ num_heads=12,
376
+ asm_dcp_verify=True,
377
+ ),
378
+ qlen=rocm_aiter_mla._MIN_CPRR_QLEN,
379
+ )
380
+
381
+ assert metadata.dcp_verify is None
382
+ assert metadata.g_kv_indptr is not None
383
+ assert metadata.has_persistent_metadata
384
+ assert get_mla_metadata_v1.call_args.kwargs.get("is_cp_round_robin") is True
385
+
386
+
387
+def test_cprr_capable_single_token_decode_keeps_persistent_metadata(monkeypatch):
388
+ """The CPRR qlen floor must not suppress the ordinary qlen-1 schedule."""
389
+ get_mla_metadata_v1 = mock.MagicMock()
390
+ monkeypatch.setitem(
391
+ sys.modules,
392
+ "aiter",
393
+ SimpleNamespace(get_mla_metadata_v1=get_mla_metadata_v1),
394
+ )
395
+ monkeypatch.setattr(
396
+ rocm_aiter_mla, "_expand_page_indices_kernel", _NoOpTritonKernel()
397
+ )
398
+
399
+ metadata = _cprr_decode_batch(
400
+ _builder(
401
+ mtp_decode_qlen=5,
402
+ dcp_world_size=8,
403
+ num_heads=12,
404
+ asm_dcp_verify=True,
405
+ ),
406
+ qlen=1,
407
+ )
408
+
409
+ assert metadata.g_kv_indptr is None
410
+ assert metadata.has_persistent_metadata
411
+ assert get_mla_metadata_v1.call_args.kwargs.get("is_cp_round_robin") is None
412
+
413
+
414
+def test_non_causal_dcp_block_uses_plain_mask0_metadata(monkeypatch):
415
+ """A non-causal block needs no CPRR global-position window or head pad."""
416
+ get_mla_metadata_v1 = mock.MagicMock()
417
+ monkeypatch.setitem(
418
+ sys.modules,
419
+ "aiter",
420
+ SimpleNamespace(get_mla_metadata_v1=get_mla_metadata_v1),
421
+ )
422
+ monkeypatch.setattr(
423
+ rocm_aiter_mla, "_expand_page_indices_kernel", _NoOpTritonKernel()
424
+ )
425
+ builder = _builder(
426
+ mtp_decode_qlen=5,
427
+ dcp_world_size=8,
428
+ num_heads=12,
429
+ asm_dcp_verify=True,
430
+ )
431
+ builder._decode_causal = False
432
+
433
+ metadata = _cprr_decode_batch(builder, qlen=4)
434
+
435
+ assert metadata.dcp_verify is None
436
+ assert metadata.g_kv_indptr is None
437
+ assert metadata.has_persistent_metadata
438
+ assert get_mla_metadata_v1.call_args.args[3] == 96
439
+ assert get_mla_metadata_v1.call_args.args[5] is False
440
+ assert get_mla_metadata_v1.call_args.kwargs.get("is_cp_round_robin") is None
441
+
442
+
443
+def test_asm_qlen2_without_segmented_fails_during_build(monkeypatch):
444
+ monkeypatch.setitem(
445
+ sys.modules,
446
+ "aiter",
447
+ SimpleNamespace(get_mla_metadata_v1=mock.MagicMock()),
448
+ )
449
+ monkeypatch.setattr(
450
+ rocm_aiter_mla, "_expand_page_indices_kernel", _NoOpTritonKernel()
451
+ )
452
+
453
+ with pytest.raises(RuntimeError, match="requires either segmented MLA"):
454
+ _cprr_decode_batch(
455
+ _builder(
456
+ mtp_decode_qlen=5,
457
+ dcp_world_size=8,
458
+ num_heads=12,
459
+ asm_dcp_verify=True,
460
+ supports_segmented_dcp_verify=False,
461
+ ),
462
+ qlen=2,
463
+ )
464
+
465
+
466
+def test_non_causal_dcp_block_bypasses_cprr_in_forward(monkeypatch):
467
+ """Process-level CPRR capability must not pad or route a non-causal batch."""
468
+ captured = {}
469
+
470
+ def fake_aiter_decode(q, kv_buffer, out, *args, **kwargs):
471
+ captured["q_heads"] = q.shape[1]
472
+ captured["kwargs"] = kwargs
473
+ return None, torch.zeros(q.shape[0], q.shape[1])
474
+
475
+ monkeypatch.setattr(
476
+ rocm_aiter_mla, "_get_aiter_mla_decode", lambda: fake_aiter_decode
477
+ )
478
+
479
+ impl = object.__new__(AiterMLAImpl)
480
+ impl.num_heads = 12
481
+ impl.dcp_world_size = 8
482
+ impl.kv_cache_dtype = "auto"
483
+ impl.kv_lora_rank = 512
484
+ impl.qk_rope_head_dim = 64
485
+ impl.scale = 576**-0.5
486
+ decode = SimpleNamespace(
487
+ max_qo_len=4,
488
+ qo_indptr=torch.tensor([0, 4], dtype=torch.int32),
489
+ paged_kv_indptr=torch.tensor([0, 1], dtype=torch.int32),
490
+ paged_kv_indices=torch.zeros(1, dtype=torch.int32),
491
+ paged_kv_last_page_len=torch.ones(1, dtype=torch.int32),
492
+ use_gluon_decode=False,
493
+ use_gluon_verify=False,
494
+ dcp_route=rocm_aiter_mla._DCPDecodeRoute.PLAIN,
495
+ dcp_verify=None,
496
+ g_kv_indptr=None,
497
+ has_persistent_metadata=False,
498
+ attn_out_dtype=torch.bfloat16,
499
+ asm_decode_num_heads=0,
500
+ mla_num_kv_splits=0,
501
+ cp_world_size=8,
502
+ cp_rank=0,
503
+ min_kv_seq_len=1,
504
+ )
505
+ attn_metadata = SimpleNamespace(decode=decode, causal=False, work_meta_data=None)
506
+ layer = SimpleNamespace(_q_scale=torch.tensor(1.0), _k_scale=torch.tensor(1.0))
507
+ q = torch.zeros(4, 96, 576, dtype=torch.bfloat16)
508
+
509
+ output, lse = impl.forward_mqa(q, torch.zeros(1, 1, 576), attn_metadata, layer)
510
+
511
+ assert captured["q_heads"] == 96
512
+ assert captured["kwargs"]["causal"] is False
513
+ assert "g_kv_indptr" not in captured["kwargs"]
514
+ assert "num_kv_splits" not in captured["kwargs"]
515
+ assert output.shape[1] == 96
516
+ assert lse is not None and lse.shape == (4, 96)
517
+
518
+
519
def test_single_token_dcp_decode_returns_unpadded_lse(monkeypatch):
520
"""Single-token DCP decode must come back with an LSE the merge can use.
521
@@ -325,9 +554,14 @@ def test_single_token_dcp_decode_returns_unpadded_lse(monkeypatch):
554
paged_kv_last_page_len=torch.ones(num_tokens, dtype=torch.int32),
555
use_gluon_decode=False,
556
use_gluon_verify=False,
557
+ dcp_route=rocm_aiter_mla._DCPDecodeRoute.PLAIN,
558
dcp_verify=None,
559
has_persistent_metadata=False,
560
attn_out_dtype=torch.bfloat16,
561
+ # 0 == the round-robin (cprr) asm route is off, this rank's decode takes
562
+ # the existing path. Mirrors the AiterMLADecodeMetadata default.
563
+ asm_decode_num_heads=0,
564
+ g_kv_indptr=None,
565
)
566
attn_metadata = SimpleNamespace(decode=decode, causal=True, work_meta_data=None)
567
layer = SimpleNamespace(_q_scale=torch.tensor(1.0), _k_scale=torch.tensor(1.0))
@@ -453,6 +687,7 @@ def test_segmented_dcp_verify_matches_causal_attention(monkeypatch):
687
),
688
use_gluon_decode=False,
689
use_gluon_verify=False,
690
+ dcp_route=rocm_aiter_mla._DCPDecodeRoute.SEGMENTED,
691
dcp_verify=view,
692
attn_out_dtype=torch.bfloat16,
693
)
vllm/envs.py
+10
@@ -145,6 +145,7 @@ if TYPE_CHECKING:
145
VLLM_ROCM_USE_AITER_RMSNORM: bool = True
146
VLLM_ROCM_USE_AITER_MLA: bool = True
147
VLLM_ROCM_AITER_MLA_ASM_PADDING: Literal["auto", "gluon", "asm"] = "auto"
148
+ VLLM_ROCM_AITER_MLA_DCP_VERIFY: Literal["asm", "segmented"] = "segmented"
149
VLLM_ROCM_USE_AITER_MHA: bool = True
150
VLLM_ROCM_USE_AITER_FP4_ASM_GEMM: bool = False
151
VLLM_ROCM_USE_AITER_TRITON_ROPE: bool = False
@@ -1309,6 +1310,15 @@ environment_variables: dict[str, Callable[[], Any]] = {
1310
"VLLM_ROCM_USE_AITER_MLA": lambda: (
1311
os.getenv("VLLM_ROCM_USE_AITER_MLA", "True").lower() in ("true", "1")
1312
),
1313
+ # Kernel for causal multi-token (spec-decode) verify steps under decode
1314
+ # context parallelism on gfx950: "asm" uses AITER's round-robin ASM
1315
+ # decode, "segmented" the Triton segmented MLA path.
1316
+ "VLLM_ROCM_AITER_MLA_DCP_VERIFY": env_with_choices(
1317
+ "VLLM_ROCM_AITER_MLA_DCP_VERIFY",
1318
+ "segmented",
1319
+ ["asm", "segmented"],
1320
+ case_sensitive=False,
1321
+ ),
1322
# Small-head (<16) AITER MLA decode kernel selection. Small head counts
1323
# (e.g. Kimi-K3: 12 heads/rank at TP8, 6 at TP16) can decode either through
1324
# the Gluon small-head kernel or through the padded persistent-scheduling
vllm/v1/attention/backends/mla/rocm_aiter_mla.py
+258
-23
@@ -3,11 +3,13 @@
3
4
import functools
5
from dataclasses import dataclass
6
+from enum import Enum
7
from pathlib import Path
8
from typing import TYPE_CHECKING, ClassVar, Final
9
10
import torch
11
12
+from vllm import envs
13
from vllm._aiter_ops import rocm_aiter_ops
14
from vllm.config import VllmConfig
15
from vllm.config.cache import CacheDType
@@ -239,6 +241,78 @@ def _segmented_dcp_verify_supported(dcp_world_size: int, cp_interleave: int) ->
241
)
242
243
244
+# DCP decode routing
245
+#
246
+# With decode context parallelism each rank holds a round-robin shard of the KV
247
+# cache (token t on rank t % dcp_world_size; requires interleave size 1) and
248
+# attends over it with all DCP-gathered query heads. Partial outputs are merged
249
+# across ranks by LSE. Each decode batch takes one route:
250
+#
251
+# * plain: qlen == 1 or a non-causal block. Every query sees every local token.
252
+# * cprr: causal qlen >= _MIN_CPRR_QLEN, gfx950, speculative decoding. AITER's
253
+# round-robin ASM decode rebuilds global positions from g_kv_indptr, cp_rank
254
+# and cp_world_size and applies the causal window in-kernel. Kernels exist only
255
+# at _NATIVE_CPRR_HEADS; other gathered head counts are padded up.
256
+# * segmented: causal qlen > 1 when cprr is not selected or qlen is below
257
+# _MIN_CPRR_QLEN. Each verify token becomes a single-query row whose KV length
258
+# encodes its causal window.
259
+#
260
+# The plain kernel must never serve a causal qlen > 1 block: it applies
261
+# causality on local indices, which is wrong over a round-robin shard.
262
+_NATIVE_CPRR_HEADS: Final = (16, 32, 64, 128)
263
+_MIN_CPRR_QLEN: Final = 3
264
+
265
+
266
+class _DCPDecodeRoute(Enum):
267
+ PLAIN = "plain"
268
+ SEGMENTED = "segmented"
269
+ CPRR = "cprr"
270
+
271
+
272
+def _asm_dcp_verify_heads(decode_num_heads: int) -> int:
273
+ """Head count the cprr asm decode runs at, or 0 if it cannot serve this shape."""
274
+ if decode_num_heads in _NATIVE_CPRR_HEADS:
275
+ return decode_num_heads
276
+ for nat in _NATIVE_CPRR_HEADS:
277
+ if nat >= decode_num_heads:
278
+ return nat
279
+ return 0
280
+
281
+
282
+def _select_dcp_decode_route(
283
+ supports_segmented: bool,
284
+ causal: bool,
285
+ max_qo_len: int,
286
+ asm_selected: bool,
287
+) -> _DCPDecodeRoute:
288
+ """Select one DCP decode route for this batch; see DCP decode routing."""
289
+ if not causal or max_qo_len <= 1:
290
+ return _DCPDecodeRoute.PLAIN
291
+ if asm_selected and max_qo_len >= _MIN_CPRR_QLEN:
292
+ return _DCPDecodeRoute.CPRR
293
+ if supports_segmented:
294
+ return _DCPDecodeRoute.SEGMENTED
295
+ raise RuntimeError(
296
+ "ROCM_AITER_MLA DCP multi-token verify requires either segmented "
297
+ "MLA or the round-robin asm decode."
298
+ )
299
+
300
+
301
+def _asm_dcp_verify_configured(
302
+ dcp_world_size: int, cp_interleave: int, multi_token_decode: bool
303
+) -> bool:
304
+ """Whether asm cprr DCP verify is reachable in this process.
305
+
306
+ Requires DCP with interleave size 1, speculative decoding, and gfx950 (the
307
+ only arch AITER builds cprr kernels for).
308
+ """
309
+ if not (dcp_world_size > 1 and cp_interleave == 1 and multi_token_decode):
310
+ return False
311
+ from vllm.platforms.rocm import on_gfx950
312
+
313
+ return bool(on_gfx950())
314
+
315
+
316
def _aiter_mla_small_head_mode() -> str:
317
"""Small-head (<16) MLA decode kernel selection.
318
@@ -410,8 +484,19 @@ class AiterMLADecodeMetadata(MLACommonDecodeMetadata):
484
max_qo_len: int | None = None
485
# Minimum KV length used by Gluon to choose a safe split count.
486
min_kv_seq_len: int = 1
487
+ # Exactly one DCP decode route is selected by the metadata builder.
488
+ dcp_route: _DCPDecodeRoute = _DCPDecodeRoute.PLAIN
489
# Set exactly when this batch routes to segmented DCP verification.
490
dcp_verify: AiterMLADCPVerifyMetadata | None = None
491
+ # cprr only: global per-request page indptr and this rank's position.
492
+ g_kv_indptr: torch.Tensor | None = None
493
+ cp_world_size: int = 1
494
+ cp_rank: int = 0
495
+ # Padded head count cprr runs at; 0 when this batch is not on cprr.
496
+ asm_decode_num_heads: int = 0
497
+ # Must equal the split cap the reduce scratch was sized with, or the kernel
498
+ # writes past it and faults.
499
+ mla_num_kv_splits: int = 0
500
# Small-head decode uses Gluon (avoids padding to 16).
501
use_gluon_decode: bool = False
502
# Small-head non-DCP multi-token verify uses Gluon's native MTP entry.
@@ -460,6 +545,9 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
545
# Set from the common metadata every build; a batch is causal unless the
546
# drafter says otherwise.
547
_decode_causal: bool = True
548
+ # A non-causal draft needs no cross-shard causal window: the ordinary
549
+ # mask0 decode returns per-row LSE for the DCP merge.
550
+ supports_non_causal_multi_token_dcp: ClassVar[bool] = True
551
552
@staticmethod
553
def _uniform_padded_mtp_qo_len(
@@ -513,15 +601,48 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
601
parallel_config.decode_context_parallel_size,
602
parallel_config.cp_kv_cache_interleave_size,
603
)
604
+ asm_dcp_verify_config = (
605
+ _asm_dcp_verify_configured(
606
+ parallel_config.decode_context_parallel_size,
607
+ parallel_config.cp_kv_cache_interleave_size,
608
+ multi_token_decode=vllm_config.speculative_config is not None,
609
+ )
610
+ and envs.VLLM_ROCM_AITER_MLA_DCP_VERIFY == "asm"
611
+ )
612
super().__init__(
613
kv_cache_spec,
614
layer_names,
615
vllm_config,
616
device,
617
AiterMLAMetadata,
522
- supports_dcp_with_varlen=supports_segmented_dcp_verify,
618
+ supports_dcp_with_varlen=(
619
+ supports_segmented_dcp_verify or asm_dcp_verify_config
620
+ ),
621
)
622
+ self._asm_dcp_verify = False
623
+ self._asm_dcp_verify_heads = 0
624
+ # The head-count half of the route decision needs self.num_heads, so only
625
+ # the config half is known before super().__init__().
626
+ if asm_dcp_verify_config:
627
+ self._asm_dcp_verify_heads = _asm_dcp_verify_heads(
628
+ self.num_heads * self.dcp_world_size
629
+ )
630
+ if not self._asm_dcp_verify_heads:
631
+ raise ValueError(
632
+ "VLLM_ROCM_AITER_MLA_DCP_VERIFY=asm, but the round-robin asm "
633
+ f"decode has no kernel for {self.num_heads * self.dcp_world_size} "
634
+ f"DCP-gathered heads (native counts: {_NATIVE_CPRR_HEADS}). "
635
+ "Set VLLM_ROCM_AITER_MLA_DCP_VERIFY=segmented."
636
+ )
637
+ self._asm_dcp_verify = True
638
self._supports_segmented_dcp_verify = supports_segmented_dcp_verify
639
+ self._mla_max_split_per_batch = 0
640
+ if self._asm_dcp_verify:
641
+ # Cap on KV splits per batch; at low batch the KV axis is the only
642
+ # parallelism. Buffer sizing and every runtime call must see this value.
643
+ self._mla_max_split_per_batch = torch.cuda.get_device_properties(
644
+ device
645
+ ).multi_processor_count
646
647
self.compilation_config = vllm_config.compilation_config
648
self.decode_attn_out_dtype = vllm_config.model_config.dtype
@@ -576,6 +697,12 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
697
self._num_attention_heads = AiterMLAHelper.get_actual_mla_num_heads(
698
self._decode_num_heads
699
)
700
+ # the cprr kernel exists only at the native head
701
+ # counts, so run (and size the persistent schedule) at the padded count.
702
+ # 96 is 16-aligned, so get_actual_mla_num_heads leaves it alone and the
703
+ # pad has to be applied here explicitly.
704
+ if self._asm_dcp_verify:
705
+ self._num_attention_heads = self._asm_dcp_verify_heads
706
kv_cache_dtype_str = getattr(vllm_config.cache_config, "cache_dtype", "auto")
707
if kv_cache_dtype_str in ("fp8", "fp8_e4m3", "fp8_e5m2"):
708
kv_cache_dtype_str = "fp8"
@@ -619,6 +746,13 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
746
kv_dtype,
747
is_sparse=False,
748
fast_mode=True,
749
+ # Must match max_split_per_batch in get_mla_metadata_v1; without it
750
+ # aiter sizes the reduce scratch from capacity (~9 GiB).
751
+ **(
752
+ dict(max_split_per_batch=self._mla_max_split_per_batch)
753
+ if self._asm_dcp_verify
754
+ else {}
755
+ ),
756
)
757
self._mla_work_meta_data = torch.empty(
758
work_meta_data_size, dtype=work_meta_data_type, device=device
@@ -664,6 +798,14 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
798
device,
799
)
800
801
+ # Full cudagraphs need a stable address for the cprr global page indptr.
802
+ # Zero-initialized; element 0 is the indptr base and is never rewritten.
803
+ self._g_kv_indptr_buf: torch.Tensor | None = None
804
+ if self._asm_dcp_verify:
805
+ self._g_kv_indptr_buf = torch.zeros(
806
+ max_num_reqs + 1, dtype=torch.int32, device=device
807
+ )
808
+
809
# Persistent buffers for segmented DCP verification. Captured graphs
810
# require stable addresses while row lengths vary between replays.
811
self._dcp_verify_buffers: AiterMLADCPVerifyMetadata | None = None
@@ -683,8 +825,11 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
825
)
826
827
if self._supports_segmented_dcp_verify and self._mtp_decode_qlen > 1:
686
- # A DCP rank's shard of the longest sequence bounds every verify
687
- # row, and full graphs need that bound to be constant.
828
+ # Allocate even when CPRR is the preferred route: a later
829
+ # replay can fall below _MIN_CPRR_QLEN and needs these
830
+ # addresses. A DCP rank's shard of the longest sequence
831
+ # bounds every verify row, and full graphs need that bound
832
+ # to be constant.
833
num_dcp_partitions = (
834
self.dcp_world_size * self.cp_kv_cache_interleave_size
835
)
@@ -1063,6 +1208,12 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
1208
pad_uniform_mtp = padded_mtp_qo_len > 0
1209
1210
seq_lens_for_kernel = seq_lens_device
1211
+ # the global lengths need the same dummy-row
1212
+ # treatment as the local ones below, but only for g_kv_indptr -- the
1213
+ # metadata field keeps the true values, which is what the segmented
1214
+ # route wants (it derives its own row lens and gives a dummy row
1215
+ # length 0, correctly).
1216
+ g_tot_seq_lens = dcp_tot_seq_lens_device
1217
num_kernel_reqs = num_reqs
1218
if pad_uniform_mtp:
1219
qo_lens_device = (
@@ -1074,6 +1225,19 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
1225
seq_lens_for_kernel,
1226
seq_lens_for_kernel.new_full((), max_qo_len),
1227
)
1228
+ # A dummy row just got a LOCAL length of max_qo_len, so its GLOBAL
1229
+ # length has to say the same thing or the cprr mask is degenerate:
1230
+ # global_len 0 puts the row query positions at {-3,-2,-1} while its
1231
+ # local shard sits at {rank, W+rank, 2W+rank}, so every entry is
1232
+ # masked and the row softmaxes over nothing. max_qo_len * W is
1233
+ # exactly the global length whose round-robin shard is max_qo_len
1234
+ # on every rank.
1235
+ if g_tot_seq_lens is not None:
1236
+ g_tot_seq_lens = torch.where(
1237
+ qo_lens_device > 0,
1238
+ g_tot_seq_lens,
1239
+ g_tot_seq_lens.new_full((), max_qo_len * self.dcp_world_size),
1240
+ )
1241
1242
if self._graph_seq_lens is not None:
1243
self._graph_seq_lens[:num_kernel_reqs].copy_(
@@ -1106,16 +1270,21 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
1270
causal,
1271
self._kv_cache_bytes,
1272
)
1109
- use_segmented_dcp_verify = (
1110
- self._supports_segmented_dcp_verify and max_qo_len > 1
1111
- )
1273
+ dcp_route = _DCPDecodeRoute.PLAIN
1274
+ if self.dcp_world_size > 1:
1275
+ dcp_route = _select_dcp_decode_route(
1276
+ self._supports_segmented_dcp_verify,
1277
+ causal,
1278
+ int(max_qo_len),
1279
+ self._asm_dcp_verify,
1280
+ )
1281
1282
# Segmented DCP verify carries its own per-row subpage table, so the
1283
# flat per-token view is dead work for it. Leave the buffer alone and
1284
# hand the metadata None, so a future reader cannot pick up whatever
1285
# the previous batch left behind.
1286
paged_kv_indices = None
1118
- if not use_segmented_dcp_verify:
1287
+ if dcp_route is not _DCPDecodeRoute.SEGMENTED:
1288
if self.compilation_config.cudagraph_mode.has_full_cudagraphs():
1289
self.paged_kv_indices.fill_(-1)
1290
@@ -1169,6 +1338,19 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
1338
else:
1339
qo_indptr = query_start_loc_device[: 1 + num_kernel_reqs]
1340
1341
+ g_kv_indptr = None
1342
+ if dcp_route is _DCPDecodeRoute.CPRR and g_tot_seq_lens is not None:
1343
+ assert self._g_kv_indptr_buf is not None
1344
+ _ngk = g_tot_seq_lens.shape[0]
1345
+ g_kv_indptr = self._g_kv_indptr_buf[: _ngk + 1]
1346
+ g_kv_indptr[1:].copy_(g_tot_seq_lens.cumsum(dim=0, dtype=torch.int32))
1347
+ # A FULL cudagraph replays at its capture-time request count, so a
1348
+ # smaller real batch leaves the tail holding the PREVIOUS step's
1349
+ # cumsum. Flatten it the way paged_kv_indptr is flattened below, so
1350
+ # padding rows read global_len == 0 and agree with their zero-length
1351
+ # local shard instead of a stale global length.
1352
+ self._g_kv_indptr_buf[_ngk + 1 :].fill_(g_kv_indptr[-1])
1353
+
1354
has_persistent_metadata = False
1355
# Only the asm decode consumes the schedule, so gate on the routing
1356
# rather than on num_heads >= 16, which denies it to a padded rank
@@ -1178,7 +1360,7 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
1360
use_persistent_metadata = (
1361
not use_gluon_decode
1362
and not use_gluon_verify
1181
- and not use_segmented_dcp_verify
1363
+ and dcp_route is not _DCPDecodeRoute.SEGMENTED
1364
# A padded rank has no bf16 persistent kernel past qlen 4 where the
1365
# gfx950 fold is absent; the non-persistent entry covers it. fp8
1366
# keeps the schedule -- its fold rejects non-persistent outright.
@@ -1218,13 +1400,28 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
1400
uni_qo_len = (
1401
max_qo_len if pad_uniform_mtp or torch.all(qo_len == max_qo_len) else -1
1402
)
1403
+ cprr_kwargs: dict = {}
1404
+ is_causal = causal
1405
+ if self._asm_dcp_verify:
1406
+ cprr_kwargs = dict(
1407
+ max_split_per_batch=self._mla_max_split_per_batch,
1408
+ intra_batch_mode=False,
1409
+ )
1410
+ if dcp_route is _DCPDecodeRoute.CPRR:
1411
+ is_causal = False
1412
+ cprr_kwargs["is_cp_round_robin"] = True
1413
+ metadata_num_heads = (
1414
+ self._asm_dcp_verify_heads
1415
+ if dcp_route is _DCPDecodeRoute.CPRR
1416
+ else AiterMLAHelper.get_actual_mla_num_heads(self._decode_num_heads)
1417
+ )
1418
get_mla_metadata_v1(
1419
qo_indptr,
1420
paged_kv_indptr,
1421
paged_kv_last_page_len,
1225
- self._num_attention_heads,
1422
+ metadata_num_heads,
1423
1,
1227
- causal,
1424
+ is_causal,
1425
self._mla_work_meta_data,
1426
self._mla_work_info_set,
1427
self._mla_work_indptr,
@@ -1238,6 +1435,7 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
1435
fast_mode=True,
1436
dtype_q=self._mla_q_dtype,
1437
dtype_kv=self._mla_kv_dtype,
1438
+ **cprr_kwargs,
1439
)
1440
has_persistent_metadata = True
1441
@@ -1261,7 +1459,7 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
1459
min_kv_seq_len = int(per_req_len.min().item())
1460
1461
dcp_verify = None
1264
- if use_segmented_dcp_verify:
1462
+ if dcp_route is _DCPDecodeRoute.SEGMENTED:
1463
assert dcp_tot_seq_lens_device is not None
1464
dcp_verify = self._build_dcp_verify_row_view(
1465
int(max_qo_len),
@@ -1279,7 +1477,19 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
1477
dcp_tot_seq_lens=dcp_tot_seq_lens_device,
1478
max_qo_len=max_qo_len,
1479
min_kv_seq_len=min_kv_seq_len,
1480
+ dcp_route=dcp_route,
1481
dcp_verify=dcp_verify,
1482
+ g_kv_indptr=g_kv_indptr,
1483
+ cp_world_size=self.dcp_world_size,
1484
+ cp_rank=self.dcp_rank,
1485
+ asm_decode_num_heads=(
1486
+ self._asm_dcp_verify_heads if dcp_route is _DCPDecodeRoute.CPRR else 0
1487
+ ),
1488
+ mla_num_kv_splits=(
1489
+ self._mla_max_split_per_batch
1490
+ if dcp_route is _DCPDecodeRoute.CPRR
1491
+ else 0
1492
+ ),
1493
use_gluon_decode=use_gluon_decode,
1494
use_gluon_verify=use_gluon_verify,
1495
attn_out_dtype=self.decode_attn_out_dtype,
@@ -2062,8 +2272,10 @@ class AiterMLAImpl(MLACommonImpl[AiterMLAMetadata]):
2272
)
2273
return o, None
2274
2065
- verify = decode.dcp_verify
2066
- if verify is not None:
2275
+ dcp_route = decode.dcp_route
2276
+ if dcp_route is _DCPDecodeRoute.SEGMENTED:
2277
+ verify = decode.dcp_verify
2278
+ assert verify is not None
2279
if type(q) is tuple:
2280
q_nope, q_pe = q
2281
else:
@@ -2085,11 +2297,6 @@ class AiterMLAImpl(MLACommonImpl[AiterMLAMetadata]):
2297
decode.attn_out_dtype,
2298
)
2299
2088
- if self.dcp_world_size > 1 and int(decode.max_qo_len) > 1:
2089
- raise RuntimeError(
2090
- "ROCM_AITER_MLA DCP multi-token verify requires segmented MLA."
2091
- )
2092
-
2300
if type(q) is tuple:
2301
q = torch.cat(q, dim=-1)
2302
@@ -2102,8 +2309,19 @@ class AiterMLAImpl(MLACommonImpl[AiterMLAMetadata]):
2309
"ROCM_AITER_MLA decode expected the DCP-gathered query head count "
2310
f"{self._decode_num_heads}, got {q.shape[1]}"
2311
)
2105
- mla_padded_q = AiterMLAHelper.get_mla_padded_q(self._decode_num_heads, q)
2106
- mla_num_heads = AiterMLAHelper.get_actual_mla_num_heads(self._decode_num_heads)
2312
+ # cprr has kernels only at native head counts, so pad q up (e.g. 96 ->
2313
+ # 128). Heads are independent in MLA; the extras are sliced off below.
2314
+ asm_dcp_heads = decode.asm_decode_num_heads
2315
+ if asm_dcp_heads > self._decode_num_heads:
2316
+ mla_num_heads = asm_dcp_heads
2317
+ mla_padded_q = AiterMLAHelper.get_mla_padded_q(
2318
+ self._decode_num_heads, q, target_heads=mla_num_heads
2319
+ )
2320
+ else:
2321
+ mla_padded_q = AiterMLAHelper.get_mla_padded_q(self._decode_num_heads, q)
2322
+ mla_num_heads = AiterMLAHelper.get_actual_mla_num_heads(
2323
+ self._decode_num_heads
2324
+ )
2325
o = torch.empty(
2326
B,
2327
mla_num_heads,
@@ -2137,6 +2355,16 @@ class AiterMLAImpl(MLACommonImpl[AiterMLAMetadata]):
2355
2356
lse = None
2357
if self.dcp_world_size > 1:
2358
+ if dcp_route is _DCPDecodeRoute.CPRR:
2359
+ # cprr only: global positions for the in-kernel causal window.
2360
+ assert decode.g_kv_indptr is not None
2361
+ mla_kwargs["g_kv_indptr"] = decode.g_kv_indptr
2362
+ mla_kwargs["cp_world_size"] = decode.cp_world_size
2363
+ mla_kwargs["cp_rank"] = decode.cp_rank
2364
+ # Must match the schedule get_mla_metadata_v1 was built with.
2365
+ assert decode.mla_num_kv_splits > 0
2366
+ mla_kwargs["num_kv_splits"] = decode.mla_num_kv_splits
2367
+ mla_kwargs["intra_batch_mode"] = False
2368
# The vLLM custom-op wrapper exposes only the in-place output and
2369
# drops aiter's final LSE, which the cross-shard merge needs, so go
2370
# through aiter's native entry point on the DCP path.
@@ -2151,6 +2379,7 @@ class AiterMLAImpl(MLACommonImpl[AiterMLAMetadata]):
2379
decode.max_qo_len,
2380
sm_scale=self.scale,
2381
return_lse=True,
2382
+ causal=attn_metadata.causal,
2383
**mla_kwargs,
2384
)
2385
assert lse is not None, (
@@ -2172,7 +2401,13 @@ class AiterMLAImpl(MLACommonImpl[AiterMLAMetadata]):
2401
**mla_kwargs,
2402
)
2403
2175
- output = AiterMLAHelper.get_mla_unpadded_o(self._decode_num_heads, o)
2176
- if lse is not None:
2177
- lse = AiterMLAHelper.get_mla_unpadded_lse(self._decode_num_heads, lse)
2404
+ if asm_dcp_heads > self._decode_num_heads:
2405
+ # Keep a view: a post-kernel copy races the symm-mem a2a combine.
2406
+ output = o[:, : self._decode_num_heads]
2407
+ if lse is not None:
2408
+ lse = lse[:, : self._decode_num_heads, ...]
2409
+ else:
2410
+ output = AiterMLAHelper.get_mla_unpadded_o(self._decode_num_heads, o)
2411
+ if lse is not None:
2412
+ lse = AiterMLAHelper.get_mla_unpadded_lse(self._decode_num_heads, lse)
2413
return output, lse