[ROCm][MLA] Reach FULL cudagraphs for AITER MLA speculative decoding (#51171)
Signed-off-by: yudigege86 <naqin@amd.com> Co-authored-by: yudigege86 <naqin@amd.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Andreas Karatzas <akaratza@amd.com>
Matt Qin committed
Aug 29, 2026 at 21:49 UTC
dbf662c9e810ef4c3f466c7c4b2b3282ba889622
4 files changed
+181
-127
tests/kernels/attention/test_rocm_aiter_mla_causal_verify_mask.py
+48
-71
@@ -1,21 +1,17 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-"""Regression test for causal masking in the AITER MLA verify flatten.
3
+"""Regression test for causal masking in the AITER MLA verify MTP path.
4
5
When ``num_heads < 16`` the ROCm AITER MLA backend cannot use the ASM decode
6
-kernel for a multi-token block, so ``AiterMLAImpl.forward_mqa`` flattens a
7
-``1 + num_speculative_tokens`` verify block into that many single-token Gluon
8
-decodes. Every resulting row used to be handed its request's entire paged-KV
9
-range. ``paged_kv_indptr`` is the cumulative sum of ``seq_lens``, which counts
10
-the tokens scheduled in the current step, so that range already spans the
11
-verify block: each verify position could attend to the draft tokens after it,
12
-which are the tokens it is supposed to be checking. Nothing crashes and nothing
13
-warns, so the only way to catch it is to look at the KV window per row.
14
-
15
-The test drives the real builder and the real ``forward_mqa`` over a
16
-multi-token decode batch, intercepts the ``(page_table, seq_info)`` actually
17
-handed to the Gluon kernel, and pins verify row ``t`` of request ``r`` to the
18
-``context_r + t + 1`` entries of that request's ascending slice.
6
+kernel for a multi-token block, so ``AiterMLAImpl.forward_mqa`` routes verify
7
+through ``mla_gluon``'s native 4-D MTP entry (``q`` shaped
8
+``[batch, qlen, nhead, dim]`` with ``use_2d_view=False``). aiter applies the
9
+causal tail internally: position ``t`` attends KV ``[0, seq_len - qlen + t]``.
10
+
11
+The test drives the real builder and ``forward_mqa`` over a multi-token decode
12
+batch, intercepts the arguments actually handed to the Gluon kernel, and pins
13
+that the 4-D MTP contract is used with per-request ``block_table`` and
14
+``cache_seqlens`` rather than a flattened per-token paged-KV view.
15
"""
16
17
import types
@@ -32,15 +28,15 @@ def _on_rocm_with_aiter() -> bool:
28
return current_platform.is_rocm() and is_aiter_found()
29
30
35
-# The production verify flatten is gfx950-only. This test replaces the Gluon
31
+# The production verify MTP path is gfx950-only. This test replaces the Gluon
32
# kernel with a spy and forces the architecture feature probe so the metadata
33
# regression remains testable on every ROCm AITER CI runner.
34
pytestmark = pytest.mark.skipif(
35
not _on_rocm_with_aiter(),
40
- reason="ROCM_AITER_MLA verify flatten requires ROCm and AITER",
36
+ reason="ROCM_AITER_MLA verify MTP requires ROCm and AITER",
37
)
38
43
-# Below the 16-head threshold that selects the flatten. DeepSeek-V3 / R1 at
39
+# Below the 16-head threshold that selects the MTP path. DeepSeek-V3 / R1 at
40
# TP=16 and Kimi-K3 at TP=8 both land here.
41
NUM_QUERY_HEADS = 12
42
KV_LORA_RANK = 512
@@ -54,8 +50,7 @@ QLEN = 8
50
# Committed context per request, including a fresh request at zero context
51
# where the whole KV range is the verify block itself.
52
CONTEXT_LENS = [0, 1, 37, 512]
57
-# A cudagraph padding request: seq_len 0, so every one of its rows must clamp
58
-# to an empty window.
53
+# A cudagraph padding request: seq_len 0.
54
PADDING_ROWS = 1
55
56
# The Gluon path flattens the KV cache to one page per token.
@@ -68,16 +63,10 @@ def _seq_lens() -> list[int]:
63
return [c + QLEN for c in CONTEXT_LENS] + [0] * PADDING_ROWS
64
65
71
-def _expected_row_lens() -> list[int]:
72
- """Causal row lengths: row r*QLEN + t sees seq_len_r - (QLEN - 1) + t."""
73
- return [max(0, s - (QLEN - 1) + t) for s in _seq_lens() for t in range(QLEN)]
74
-
75
-
66
def _run_verify_block():
67
"""Drive the real builder + forward_mqa over one multi-token verify block.
68
79
- Returns ``(metadata, captured)`` where ``captured`` holds the ``page_table``,
80
- ``seq_info`` and ``min_kv_seq_len`` the backend passed to the Gluon kernel.
69
+ Returns ``(metadata, captured)`` where ``captured`` holds the Gluon kwargs.
70
"""
71
from tests.v1.attention.utils import (
72
BatchSpec,
@@ -144,9 +133,10 @@ def _run_verify_block():
133
captured: dict = {}
134
135
def spy(**kwargs):
136
+ captured.update(kwargs)
137
+ captured["q_nope"] = kwargs["q_nope"].detach().clone()
138
captured["page_table"] = kwargs["page_table"].detach().clone()
139
captured["seq_info"] = kwargs["seq_info"].detach().clone()
149
- captured["min_kv_seq_len"] = kwargs["min_kv_seq_len"]
140
141
with set_current_vllm_config(vllm_config):
142
builder = builder_cls(spec, [layer_name], vllm_config, device)
@@ -206,12 +196,12 @@ def _run_verify_block():
196
return metadata, captured
197
198
209
-def test_verify_flatten_rows_are_causal():
210
- """Verify row t must see the committed prefix plus tokens 0..t, and no more.
199
+def test_verify_mtp_uses_native_4d_gluon_entry():
200
+ """Verify uses 4-D q with per-request paged_kv metadata.
201
212
- Regression guard: before the fix every row of a request got that request's
213
- whole paged-KV range, so verify position t attended to the draft tokens
214
- after it. Fails on unmodified upstream.
202
+ Regression guard: before the fix every flattened row of a request got that
203
+ request's whole paged-KV range, so verify position t attended to the draft
204
+ tokens after it. The native MTP path delegates causal masking to aiter.
205
"""
206
metadata, captured = _run_verify_block()
207
@@ -219,50 +209,37 @@ def test_verify_flatten_rows_are_causal():
209
assert decode is not None, "batch was not classified as a decode"
210
assert decode.max_qo_len == QLEN, (
211
f"expected a {QLEN}-token verify block, got max_qo_len="
222
- f"{decode.max_qo_len}; the flatten under test was not reached"
212
+ f"{decode.max_qo_len}; the MTP path under test was not reached"
213
)
214
assert captured, "forward_mqa did not reach the Gluon kernel"
215
216
seq_lens = _seq_lens()
227
- indptr = captured["seq_info"].tolist()
228
- page_table = captured["page_table"]
229
- got_row_lens = [indptr[i + 1] - indptr[i] for i in range(len(indptr) - 1)]
230
- want_row_lens = _expected_row_lens()
231
-
232
- assert got_row_lens == want_row_lens, (
233
- "AITER MLA verify flatten is not causal: verify row r*qlen+t must get "
234
- f"seq_len_r - {QLEN - 1} + t KV entries.\n"
235
- f" seq_lens {seq_lens}\n"
236
- f" got {got_row_lens}\n"
237
- f" expected {want_row_lens}\n"
238
- "Rows longer than expected let a verify position attend to the draft "
239
- "tokens it is supposed to be checking."
240
- )
217
+ num_reqs = len(seq_lens)
218
242
- # arange_block_indices lays request r's pages out ascending from
243
- # r * max_blocks, so each causal window must be that slice's prefix.
244
- max_blocks = max(seq_lens)
245
- for r, seq_len in enumerate(seq_lens):
246
- for t in range(QLEN):
247
- row = r * QLEN + t
248
- window = page_table[indptr[row] : indptr[row + 1]].tolist()
249
- n = max(0, seq_len - (QLEN - 1) + t)
250
- want = [r * max_blocks + j for j in range(n)]
251
- assert window == want, (
252
- f"request {r} (seq_len {seq_len}) verify token {t} reads KV "
253
- f"pages {window}, expected the ascending causal prefix {want}"
254
- )
255
-
256
- padding_rows = got_row_lens[len(CONTEXT_LENS) * QLEN :]
257
- assert padding_rows == [0] * (PADDING_ROWS * QLEN), (
258
- f"cudagraph padding requests (seq_len 0) must clamp to empty windows, "
259
- f"got {padding_rows}"
219
+ q_nope = captured["q_nope"]
220
+ assert q_nope.dim() == 4, (
221
+ f"verify must pass 4-D q to mla_gluon, got {q_nope.dim()}-D shape "
222
+ f"{tuple(q_nope.shape)}"
223
)
224
+ assert q_nope.shape[:2] == (num_reqs, QLEN), (
225
+ f"expected q shape [num_reqs={num_reqs}, qlen={QLEN}, ...], "
226
+ f"got {tuple(q_nope.shape)}"
227
+ )
228
+ assert q_nope.shape[2] == NUM_QUERY_HEADS
229
262
- # min_kv_seq_len tells Gluon how short the shortest row is, so it must be
263
- # the minimum over the causal rows actually submitted, not over the
264
- # per-request lengths they were cut from.
265
- assert captured["min_kv_seq_len"] == min(want_row_lens), (
266
- f"min_kv_seq_len={captured['min_kv_seq_len']} does not match the "
267
- f"shortest submitted row ({min(want_row_lens)})"
230
+ assert captured["use_2d_view"] is False
231
+ page_table = captured["page_table"]
232
+ seq_info = captured["seq_info"]
233
+ assert page_table.dim() == 1, (
234
+ f"verify MTP passes the 1-D paged_kv_indices buffer, got {page_table.dim()}-D"
235
+ )
236
+ assert seq_info.dim() == 1 and seq_info.numel() == num_reqs + 1, (
237
+ f"verify MTP passes paged_kv_indptr [num_reqs + 1], got shape "
238
+ f"{tuple(seq_info.shape)}"
239
+ )
240
+ assert decode is not None and decode.paged_kv_indptr is not None
241
+ assert torch.equal(seq_info, decode.paged_kv_indptr[: num_reqs + 1])
242
+ assert torch.equal(
243
+ page_table[: int(seq_info[-1].item())],
244
+ decode.paged_kv_indices[: int(seq_info[-1].item())],
245
)
tests/v1/attention/test_rocm_aiter_mla_mtp_split.py
+48
-2
@@ -40,7 +40,6 @@ class _ExpandPageIndicesKernel:
40
block_table_tensor,
41
stride,
42
paged_kv_indptr,
43
- seq_lens_for_kernel,
43
*,
44
KERNEL_BLOCK_SIZE,
45
BLOCK_SIZE,
@@ -48,7 +47,9 @@ class _ExpandPageIndicesKernel:
47
self.kernel_block_size = KERNEL_BLOCK_SIZE
48
for req_idx in range(self.grid[0]):
49
out_start = int(paged_kv_indptr[req_idx].item())
51
- seq_len = int(seq_lens_for_kernel[req_idx].item())
50
+ seq_len = int(
51
+ (paged_kv_indptr[req_idx + 1] - paged_kv_indptr[req_idx]).item()
52
+ )
53
for token_idx in range(seq_len):
54
block_id = int(
55
block_table_tensor[req_idx, token_idx // KERNEL_BLOCK_SIZE].item()
@@ -249,6 +250,51 @@ def test_mtp_decode_qlen4_keeps_uniform_rows_with_metadata(monkeypatch):
250
assert get_mla_metadata_v1.call_args.kwargs["uni_seqlen_qo"] == 4
251
252
253
+def test_min_kv_seq_len_ignores_cudagraph_padding_rows(monkeypatch):
254
+ """min_kv_seq_len must not be driven by padded dummy requests.
255
+
256
+ Full-CG verify pins zero-qo rows to max_qo_len for paged_kv metadata; taking
257
+ per_req_len.min() over all rows then reports qlen instead of a real KV length
258
+ and collapses Gluon KV-split parallelism on single-request decode.
259
+ """
260
+ monkeypatch.setitem(
261
+ sys.modules,
262
+ "aiter",
263
+ SimpleNamespace(get_mla_metadata_v1=mock.MagicMock()),
264
+ )
265
+ monkeypatch.setattr(
266
+ rocm_aiter_mla, "_expand_page_indices_kernel", _NoOpTritonKernel()
267
+ )
268
+ monkeypatch.setattr(rocm_aiter_mla, "_gluon_mla_decode_supported", lambda: True)
269
+ monkeypatch.setattr(rocm_aiter_mla, "_aiter_mla_small_head_mode", lambda: "auto")
270
+
271
+ mtp_qlen = 8
272
+ num_reqs = 8
273
+ active_seq_len = 1032
274
+ seq_lens = torch.tensor([active_seq_len] + [0] * (num_reqs - 1), dtype=torch.int32)
275
+ query_start_loc = torch.tensor(
276
+ [0, mtp_qlen] + [mtp_qlen] * (num_reqs - 1), dtype=torch.int32
277
+ )
278
+
279
+ metadata = AiterMLAMetadataBuilder._build_decode(
280
+ _builder(
281
+ mtp_decode_qlen=mtp_qlen,
282
+ has_full_cudagraphs=True,
283
+ max_decode_rows=num_reqs,
284
+ num_heads=12,
285
+ ),
286
+ block_table_tensor=torch.zeros(num_reqs, active_seq_len, dtype=torch.int32),
287
+ seq_lens_device=seq_lens,
288
+ max_seq_len=active_seq_len,
289
+ query_start_loc_cpu=query_start_loc,
290
+ query_start_loc_device=query_start_loc,
291
+ num_decode_tokens=num_reqs * mtp_qlen,
292
+ dcp_tot_seq_lens_device=None,
293
+ )
294
+
295
+ assert metadata.min_kv_seq_len == active_seq_len
296
+
297
+
298
def test_full_cudagraph_padded_uniform_mtp_synthesizes_decode_indptr(
299
monkeypatch,
300
):
vllm/v1/attention/backends/mla/rocm_aiter_mla.py
+55
-54
@@ -729,7 +729,6 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
729
block_table_tensor,
730
block_table_tensor.stride(0),
731
paged_kv_indptr,
732
- seq_lens_for_kernel,
732
KERNEL_BLOCK_SIZE=self.kernel_block_size,
733
BLOCK_SIZE=1024,
734
)
@@ -834,6 +833,26 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
833
)
834
has_persistent_metadata = True
835
836
+ # Small-head multi-token verify uses mla_gluon's 4-D MTP entry over the
837
+ # ordinary per-request paged-KV view, so there is no expanded per-token
838
+ # buffer to build here. mla_gluon still wants a lower bound on the KV
839
+ # length it is asked to split; on the verify path that bound is over
840
+ # active requests, not cudagraph padding rows pinned to max_qo_len.
841
+ # The .item() below runs in the builder, outside the captured region,
842
+ # so it does not abort HIP graph capture the way the per-layer syncs
843
+ # in forward_mqa did.
844
+ min_kv_seq_len = 1
845
+ if AiterMLAHelper.use_gluon_verify(
846
+ self.num_heads, int(max_qo_len), self._kv_cache_dtype_str
847
+ ):
848
+ per_req_len = paged_kv_indptr[1:] - paged_kv_indptr[:-1]
849
+ if pad_uniform_mtp:
850
+ active = qo_lens_device > 0
851
+ if active.any():
852
+ min_kv_seq_len = int(per_req_len[active].min().item())
853
+ else:
854
+ min_kv_seq_len = int(per_req_len.min().item())
855
+
856
attn_metadata = AiterMLADecodeMetadata(
857
block_table=block_table_tensor,
858
seq_lens=seq_lens_for_kernel,
@@ -843,6 +862,7 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
862
qo_indptr=qo_indptr,
863
dcp_tot_seq_lens=dcp_tot_seq_lens_device,
864
max_qo_len=max_qo_len,
865
+ min_kv_seq_len=min_kv_seq_len,
866
use_gluon_decode=use_gluon_decode,
867
attn_out_dtype=self.decode_attn_out_dtype,
868
has_persistent_metadata=has_persistent_metadata,
@@ -884,7 +904,6 @@ def _expand_page_indices_kernel(
904
block_table,
905
block_table_stride,
906
cu_num_tokens,
887
- seq_lens,
907
KERNEL_BLOCK_SIZE: tl.constexpr,
908
BLOCK_SIZE: tl.constexpr,
909
):
@@ -904,7 +923,7 @@ def _expand_page_indices_kernel(
923
req_idx = tl.program_id(0)
924
row_ptr = block_table + req_idx * block_table_stride
925
start_idx = tl.load(cu_num_tokens + req_idx)
907
- num_tokens = tl.load(seq_lens + req_idx)
926
+ num_tokens = tl.load(cu_num_tokens + req_idx + 1) - start_idx
927
928
offset = tl.arange(0, BLOCK_SIZE)
929
for i in tl.range(0, num_tokens, BLOCK_SIZE):
@@ -1056,11 +1075,12 @@ class AiterMLAHelper:
1075
1076
@staticmethod
1077
def use_gluon_verify(num_heads: int, max_qo_len: int, kv_cache_dtype: str) -> bool:
1059
- """Whether a small-head multi-token verify is flattened onto Gluon.
1078
+ """Whether a small-head multi-token verify uses native Gluon MTP.
1079
1061
- bf16 has no gqa<16, qseqlen>1 asm kernel, so the verify is flattened
1062
- into per-token Gluon decodes. fp8 has one via the q-row fold and must
1063
- not come here: the flatten hands Gluon the batch size its fp8 regime
1080
+ bf16 has no gqa<16, qseqlen>1 asm kernel, so verify goes through
1081
+ ``mla_gluon``'s 4-D MTP entry (``q`` shaped ``[batch, qlen, nhead, dim]``)
1082
+ with ``use_2d_view=False``. fp8 has one via the q-row fold and must not
1083
+ come here: the MTP path hands Gluon the batch size its fp8 regime
1084
asserts against. A predicate rather than inline in forward_mqa so the
1085
builder sees the same answer the impl acts on.
1086
"""
@@ -1391,19 +1411,17 @@ class AiterMLAImpl(MLACommonImpl[AiterMLAMetadata]):
1411
return o, None
1412
1413
# 12-head (<16) multi-token verify (DSpark): the asm path has no
1394
- # gqa<16, qseqlen>1 kernel. Flatten each verify token to its own
1395
- # qseqlen=1 gluon decode, mirroring the TRITON_MLA / sparse-backend
1396
- # flatten but on the fast gluon kernel. The block is causal -- the
1397
- # target is checking draft tokens, so position t must not see t+1 --
1398
- # and attention rows are independent, so giving row t the KV range
1399
- # [0, context + t] is exactly causal multi-token attention.
1414
+ # gqa<16, qseqlen>1 kernel, so the block goes to the gluon kernel's 4-D
1415
+ # MTP entry, which serves a whole (1 + num_spec) block in one launch.
1416
+ # The block is causal -- the target is checking draft tokens, so
1417
+ # position t must not see t+1 -- and the kernel bounds each query
1418
+ # position's scores itself.
1419
# Arch, mode and dtype gating all live in use_gluon_verify, so that the
1420
# builder -- which has to know whether the asm decode will run -- sees
1421
# the same answer as this branch.
1422
if AiterMLAHelper.use_gluon_verify(
1423
self.num_heads, int(decode.max_qo_len), self.kv_cache_dtype
1424
):
1406
- qlen = int(decode.max_qo_len)
1425
if type(q) is tuple:
1426
q_nope, q_pe = q
1427
else:
@@ -1419,56 +1437,39 @@ class AiterMLAImpl(MLACommonImpl[AiterMLAMetadata]):
1437
device=q_nope.device,
1438
)
1439
kv_buffer = kv_c_and_k_pe_cache.reshape(-1, kv_c_and_k_pe_cache.shape[-1])
1422
- # Expand per-request paged-KV to per-verify-token. Row r*qlen+t is
1423
- # request r's verify token t, and seq_lens counts the tokens
1424
- # scheduled in this step, so a request's KV range already spans its
1425
- # whole verify block and context_r = seq_len_r - qlen. Token t may
1426
- # attend to [0, context_r + t], i.e. seq_len_r - (qlen - 1) + t
1427
- # entries. paged_kv_indices lists a request's pages in ascending
1428
- # position order, so each row's causal window is a prefix of that
1429
- # request's slice and only the row length changes. Rows clamp to
1430
- # zero for cudagraph padding requests, whose seq_len is 0. Fully
1431
- # vectorized (no host loop).
1432
- old_indptr = decode.paged_kv_indptr
1433
- per_req_len = old_indptr[1:] - old_indptr[:-1]
1434
- dev = q_nope.device
1435
- row_req = torch.arange(per_req_len.shape[0], device=dev).repeat_interleave(
1436
- qlen
1440
+ assert attn_metadata.causal, (
1441
+ "AITER MLA small-head verify MTP is causal-only"
1442
)
1438
- row_len = (
1439
- (
1440
- per_req_len.unsqueeze(1)
1441
- - (qlen - 1)
1442
- + torch.arange(qlen, device=dev, dtype=per_req_len.dtype)
1443
+ # Hand mla_gluon its 4-D MTP entry instead of an expanded
1444
+ # per-verify-token paged-KV view. The flat query layout is already
1445
+ # row-major (r * qlen + t), so unflatten is a free view. mla_gluon
1446
+ # applies the per-position causal bound
1447
+ # score_end = min(split_kv_end, seq_len - qlen + q_pos + 1)
1448
+ # in-kernel, and seq_lens already spans the verify block, so that
1449
+ # bound is context_r + q_pos + 1 -- the same window the expanded
1450
+ # view supplied by truncating each row's page list.
1451
+ qlen = int(decode.max_qo_len)
1452
+ num_reqs = B // qlen
1453
+ if num_reqs * qlen != B:
1454
+ raise ValueError(
1455
+ f"verify block {B} rows is not a multiple of qlen {qlen}"
1456
)
1444
- .clamp_(min=0)
1445
- .flatten()
1446
- )
1447
- new_indptr = torch.cat([old_indptr.new_zeros(1), row_len.cumsum(0)]).to(
1448
- torch.int32
1449
- )
1450
- total = int(new_indptr[-1].item())
1451
- within = torch.arange(total, device=dev, dtype=torch.int64) - new_indptr[
1452
- :-1
1453
- ].to(torch.int64).repeat_interleave(row_len)
1454
- src = (
1455
- old_indptr[row_req].to(torch.int64).repeat_interleave(row_len) + within
1456
- )
1457
- new_indices = decode.paged_kv_indices[src]
1457
+ assert decode.paged_kv_indptr is not None
1458
+ assert decode.paged_kv_indices is not None
1459
mla_gluon = _get_mla_gluon()
1460
mla_gluon(
1460
- q_nope=q_nope,
1461
- q_pe=q_pe,
1461
+ q_nope=q_nope.unflatten(0, (num_reqs, qlen)),
1462
+ q_pe=q_pe.unflatten(0, (num_reqs, qlen)),
1463
kv_c=kv_buffer,
1463
- o=o,
1464
- page_table=new_indices,
1465
- seq_info=new_indptr,
1464
+ o=o.unflatten(0, (num_reqs, qlen)),
1465
+ page_table=decode.paged_kv_indices,
1466
+ seq_info=decode.paged_kv_indptr,
1467
sm_scale=self.scale,
1468
k_pe=None,
1469
kv_pe_offset=self.kv_lora_rank,
1470
use_2d_view=False,
1471
kv_scale=1.0,
1471
- min_kv_seq_len=int(row_len.min()),
1472
+ min_kv_seq_len=decode.min_kv_seq_len,
1473
)
1474
return o, None
1475
vllm/v1/attention/backends/mla/triton_mla.py
+30
@@ -6,6 +6,7 @@ from typing import ClassVar
6
import torch
7
8
import vllm.envs as envs
9
+from vllm.config import VllmConfig
10
from vllm.config.cache import CacheDType
11
from vllm.logger import init_logger
12
from vllm.model_executor.layers.attention.mla_attention import (
@@ -25,6 +26,7 @@ from vllm.v1.attention.backend import (
26
MultipleOf,
27
)
28
from vllm.v1.attention.ops.triton_decode_attention import decode_attention_fwd
29
+from vllm.v1.kv_cache_interface import KVCacheSpec
30
from vllm.v1.worker.workspace import (
31
current_workspace_manager,
32
is_workspace_manager_initialized,
@@ -55,6 +57,34 @@ class TritonMLAMetadataBuilder(MLACommonMetadataBuilder[MLACommonMetadata]):
57
# forward_mqa, so no intra-block causal masking is required.
58
supports_non_causal_multi_token_decode: ClassVar[bool] = True
59
60
+ @classmethod
61
+ def get_cudagraph_support(
62
+ cls,
63
+ vllm_config: VllmConfig,
64
+ kv_cache_spec: KVCacheSpec,
65
+ ) -> AttentionCGSupport:
66
+ """Report UNIFORM_BATCH where a non-causal multi-token block is served.
67
+
68
+ ``_cudagraph_support`` is a class constant, so serving the DSpark
69
+ draft's (1 + num_spec) block through the decode path reports
70
+ UNIFORM_SINGLE_TOKEN_DECODE and, because the engine takes the minimum
71
+ over all attention groups, downgrades the *whole* engine off full
72
+ cudagraphs. ``forward_mqa`` flattens that block with
73
+ ``repeat_interleave`` on a Python int and performs no device->host
74
+ sync, so it does satisfy the UNIFORM_BATCH contract.
75
+
76
+ ``non_causal_multi_token_decode`` is a KV-cache-group property, not a
77
+ per-layer one: ``MLAAttentionSpec.merge`` ORs it over every layer in
78
+ the group, so a group holding both a draft and its target reports it
79
+ for both. That is the same predicate ``__init__`` below already uses to
80
+ raise ``reorder_batch_threshold``, so the two stay consistent, but it
81
+ does mean this lifts a causal target sharing the draft's KV cache group
82
+ as well.
83
+ """
84
+ if getattr(kv_cache_spec, "non_causal_multi_token_decode", False):
85
+ return AttentionCGSupport.UNIFORM_BATCH
86
+ return cls._cudagraph_support
87
+
88
def __init__(self, kv_cache_spec, layer_names, vllm_config, device):
89
super().__init__(kv_cache_spec, layer_names, vllm_config, device)
90
# DCP local sequence lengths are not advanced between draft steps.