[CI][ROCM] Add the Fusion E2E TP2 Quick group on MI355, and the AITER MLA fix it needs (#58369)
Signed-off-by: Stefan Koncarevic <Stefan.Koncarevic@amd.com> Signed-off-by: Andreas Karatzas <andreas.karatzas@protonmail.com> Co-authored-by: Andreas Karatzas <akaratza@amd.com> Co-authored-by: Andreas Karatzas <andreas.karatzas@protonmail.com> Co-authored-by: Codex <noreply@openai.com>
stefankoncarevic committed
Sep 24, 2026 at 04:01 UTC
e9f167129c09d5330805c2042c16329e9b218c5a
7 files changed
+220
-16
.buildkite/test-amd.yaml
+41
@@ -3773,6 +3773,47 @@ steps:
3773
- pytest -v -s tests/distributed/test_rocm_quick_reduce.py
3774
- pytest -v -s tests/distributed/test_quick_all_reduce.py
3775
3776
+- label: ":amd: (MI355) Fusion E2E TP2 Quick" # TBD
3777
+ timeout_in_minutes: 60
3778
+ mirror_hardwares: [amdexperimental, amdproduction, amdgfx950nightly, amdmi355]
3779
+ dind: false
3780
+ agent_pool: mi355_2
3781
+ num_gpus: 2
3782
+ optional: true
3783
+ working_dir: "/vllm-workspace/"
3784
+ source_file_dependencies:
3785
+ - csrc/quantization/
3786
+ - vllm/compilation/
3787
+ - vllm/distributed/
3788
+ - vllm/model_executor/
3789
+ - vllm/v1/attention/
3790
+ - tests/compile/fusions_e2e/
3791
+ - vllm/_aiter_ops.py
3792
+ - vllm/platforms/rocm.py
3793
+ commands:
3794
+ - export VLLM_TEST_CLEAN_GPU_MEMORY=1
3795
+ - >-
3796
+ VLLM_ROCM_USE_AITER=1
3797
+ VLLM_ROCM_USE_AITER_CUSTOM_AR=1
3798
+ pytest -v -s tests/compile/fusions_e2e/test_tp2_ar_rms.py
3799
+ -k 'test_tp2_ar_rms_fp8_fusions and inductor_partition and
3800
+ not +quant_fp8 and not +rms_norm and Llama-3 and
3801
+ ROCM_AITER_UNIFIED_ATTN'
3802
+ - >-
3803
+ VLLM_ROCM_USE_AITER=1
3804
+ VLLM_ROCM_USE_AITER_CUSTOM_AR=1
3805
+ pytest -v -s tests/compile/fusions_e2e/test_tp2_ar_rms.py
3806
+ -k 'test_tp2_ar_rms_fp8_fusions and inductor_partition and
3807
+ +quant_fp8 and +rms_norm and qwen3 and
3808
+ ROCM_AITER_UNIFIED_ATTN'
3809
+ - >-
3810
+ VLLM_ROCM_USE_AITER=1
3811
+ VLLM_ROCM_USE_AITER_CUSTOM_AR=1
3812
+ pytest -v -s tests/compile/fusions_e2e/test_tp2_ar_rms.py
3813
+ -k 'test_tp2_ar_rms_fp8_fusions and inductor_partition and
3814
+ not +quant_fp8 and not +rms_norm and DeepSeek-Coder and
3815
+ ROCM_AITER_MLA'
3816
+
3817
#-------------------------------------------------------- mi355 · entrypoints --------------------------------------------------------#
3818
3819
- label: ":amd: (MI355) Entrypoints Integration (API Server) %N"
.buildkite/test_areas/compile.yaml
+41
@@ -206,6 +206,47 @@ steps:
206
# Run all models and attn backends but only Inductor partition and native custom ops
207
- pytest -v -s tests/compile/fusions_e2e/test_tp2_ar_rms.py -k "inductor_partition and not +rms_norm and (not +quant_fp8 or +quant_fp8 and (qwen3 or deepseek))"
208
- pytest -v -s tests/compile/fusions_e2e/test_tp2_async_tp.py -k "inductor_partition and not +rms_norm and (not +quant_fp8 or +quant_fp8 and (qwen3 or deepseek))"
209
+ mirror:
210
+ amd:
211
+ label: ":amd: (MI355) Fusion E2E TP2 Quick"
212
+ dind: false
213
+ device: mi355_2
214
+ timeout_in_minutes: 60
215
+ depends_on:
216
+ - image-build-amd
217
+ working_dir: /vllm-workspace/
218
+ source_file_dependencies:
219
+ - csrc/quantization/
220
+ - vllm/compilation/
221
+ - vllm/distributed/
222
+ - vllm/model_executor/
223
+ - vllm/v1/attention/
224
+ - tests/compile/fusions_e2e/
225
+ - vllm/_aiter_ops.py
226
+ - vllm/platforms/rocm.py
227
+ commands:
228
+ - export VLLM_TEST_CLEAN_GPU_MEMORY=1
229
+ - >-
230
+ VLLM_ROCM_USE_AITER=1
231
+ VLLM_ROCM_USE_AITER_CUSTOM_AR=1
232
+ pytest -v -s tests/compile/fusions_e2e/test_tp2_ar_rms.py
233
+ -k 'test_tp2_ar_rms_fp8_fusions and inductor_partition and
234
+ not +quant_fp8 and not +rms_norm and Llama-3 and
235
+ ROCM_AITER_UNIFIED_ATTN'
236
+ - >-
237
+ VLLM_ROCM_USE_AITER=1
238
+ VLLM_ROCM_USE_AITER_CUSTOM_AR=1
239
+ pytest -v -s tests/compile/fusions_e2e/test_tp2_ar_rms.py
240
+ -k 'test_tp2_ar_rms_fp8_fusions and inductor_partition and
241
+ +quant_fp8 and +rms_norm and qwen3 and
242
+ ROCM_AITER_UNIFIED_ATTN'
243
+ - >-
244
+ VLLM_ROCM_USE_AITER=1
245
+ VLLM_ROCM_USE_AITER_CUSTOM_AR=1
246
+ pytest -v -s tests/compile/fusions_e2e/test_tp2_ar_rms.py
247
+ -k 'test_tp2_ar_rms_fp8_fusions and inductor_partition and
248
+ not +quant_fp8 and not +rms_norm and DeepSeek-Coder and
249
+ ROCM_AITER_MLA'
250
251
- label: ":nvidia: (H100) Fusion E2E TP2 AR-RMS Config Sweep"
252
key: fusion-e2e-tp2-ar-rms-config-sweep-h100
tests/compile/fusions_e2e/models.py
+9
@@ -44,6 +44,15 @@ ROCM_AITER_UNIFIED_ATTN = pytest.param(
44
),
45
)
46
47
+ROCM_AITER_MLA_ATTN = pytest.param(
48
+ AttentionBackendCase(backend=AttentionBackendEnum.ROCM_AITER_MLA),
49
+ id="ROCM_AITER_MLA",
50
+ marks=pytest.mark.skipif(
51
+ not is_aiter_found_and_supported(),
52
+ reason="ROCM_AITER_MLA only for AMD when AITER is installed",
53
+ ),
54
+)
55
+
56
FLASHINFER_MLA_ATTN = pytest.param(
57
AttentionBackendCase(backend=AttentionBackendEnum.FLASHINFER_MLA),
58
id="FLASHINFER_MLA",
tests/compile/fusions_e2e/test_tp2_ar_rms.py
+57
-8
@@ -19,6 +19,7 @@ from .common import (
19
from .models import (
20
FLASHINFER_ATTN,
21
FLASHINFER_MLA_ATTN,
22
+ ROCM_AITER_MLA_ATTN,
23
ROCM_AITER_UNIFIED_ATTN,
24
ROCM_ATTN,
25
TRITON_ATTN,
@@ -40,25 +41,35 @@ pytestmark = pytest.mark.skipif(
41
)
42
43
43
-@multi_gpu_test(num_gpus=2)
44
-@pytest.mark.parametrize(
45
- "model_name, matches_fn, model_kwargs, hf_overrides",
46
- # qwen3 & dsv3 should still fuse AR+rms even though group quant is not yet supported
44
+# qwen3 & dsv3 should still fuse AR+rms even though group quant is not yet
45
+# supported. ROCm carries only the payloads its two backends support.
46
+TP2_FP8_MODELS = (
47
[
48
llama3_8b_fp8,
49
llama4_scout_fp8,
50
qwen3_a3b_fp8,
51
deepseek_coder_v2_lite_fp8,
52
deepseek_v3_fp8,
53
- ],
53
+ ]
54
+ if current_platform.is_cuda()
55
+ else [llama3_8b_fp8, qwen3_a3b_fp8, deepseek_coder_v2_lite_fp8]
56
+)
57
+
58
+TP2_FP8_ATTN_BACKENDS = (
59
+ [TRITON_ATTN, FLASHINFER_ATTN, FLASHINFER_MLA_ATTN]
60
+ if current_platform.is_cuda()
61
+ else [ROCM_AITER_UNIFIED_ATTN, ROCM_AITER_MLA_ATTN]
62
)
63
+
64
+
65
+@multi_gpu_test(num_gpus=2)
66
@pytest.mark.parametrize(
56
- "attn_backend", [TRITON_ATTN, FLASHINFER_ATTN, FLASHINFER_MLA_ATTN]
67
+ "model_name, matches_fn, model_kwargs, hf_overrides", TP2_FP8_MODELS
68
)
69
+@pytest.mark.parametrize("attn_backend", TP2_FP8_ATTN_BACKENDS)
70
@pytest.mark.parametrize("n_layers", [4])
71
@pytest.mark.parametrize("custom_ops", custom_ops_combos("quant_fp8", "rms_norm"))
72
@pytest.mark.parametrize("inductor_graph_partition", INDUCTOR_GRAPH_PARTITION)
61
-@pytest.mark.skipif(not current_platform.is_cuda(), reason="Only test CUDA")
73
def test_tp2_ar_rms_fp8_fusions(
74
model_name: str,
75
matches_fn: Callable[[int], Matches],
@@ -69,10 +80,27 @@ def test_tp2_ar_rms_fp8_fusions(
80
custom_ops: str,
81
inductor_graph_partition: bool,
82
run_e2e_fusion_test,
83
+ monkeypatch,
84
):
85
matches = matches_fn(n_layers)
86
75
- block_fp8 = "qwen" in model_name.lower() or "deepseek" in model_name.lower()
87
+ model_name_lower = model_name.lower()
88
+ # DeepSeek-Coder on AITER MLA is the one static-FP8 payload here, validated
89
+ # against the native QuantFP8 pattern; every other DeepSeek stays group
90
+ # quantized as on CUDA.
91
+ rocm_static_fp8_mla = (
92
+ current_platform.is_rocm()
93
+ and "deepseek-coder" in model_name_lower
94
+ and attn_backend.backend.name == "ROCM_AITER_MLA"
95
+ )
96
+ block_fp8 = "qwen" in model_name_lower or "deepseek" in model_name_lower
97
+ if rocm_static_fp8_mla:
98
+ block_fp8 = False
99
+ if custom_ops != "-quant_fp8,-rms_norm":
100
+ pytest.skip("ROCm static-FP8 MLA parity uses native quant and RMSNorm")
101
+ # AITER's MoE GEMM rejects this reduced dummy model shape; keep AITER
102
+ # for MLA and the all-reduce, and let the MoE run on Triton.
103
+ monkeypatch.setenv("VLLM_ROCM_USE_AITER_MOE", "0")
104
if block_fp8 and "-quant_fp8" in custom_ops:
105
# This is why config forces +quant_fp8 by default
106
pytest.skip("native QuantFP8 matching not supported for group quant")
@@ -83,6 +111,13 @@ def test_tp2_ar_rms_fp8_fusions(
111
model_kwargs["max_model_len"] = 1024
112
model_kwargs["kernel_config"] = {"enable_flashinfer_autotune": False}
113
model_kwargs["disable_custom_all_reduce"] = False
114
+ if current_platform.is_rocm() and "llama-3" in model_name_lower:
115
+ model_kwargs.update(
116
+ gpu_memory_utilization=0.01,
117
+ kv_cache_memory_bytes=64 * 1024**2,
118
+ max_num_batched_tokens=1024,
119
+ max_num_seqs=32,
120
+ )
121
122
compilation_config = dict(
123
use_inductor_graph_partition=inductor_graph_partition,
@@ -103,6 +138,19 @@ def test_tp2_ar_rms_fp8_fusions(
138
"ar_rms_fusion",
139
]
140
141
+ use_aiter = current_platform.is_rocm()
142
+ if use_aiter:
143
+ matches = matches._replace(aiter_ar_rms_fusion=matches.ar_rms_fusion)
144
+ # The AITER all-reduce pass registers the AR+RMS(+group-quant) patterns
145
+ # before its AR+RMS-only ones, so the standalone AITER RMS-quant pass
146
+ # does not own the post-AR sites here. ROCm's contract is native
147
+ # QK-norm+RoPE and attention-output quant fusion plus AITER AR+RMS.
148
+ matches_check = [
149
+ "norm_rope_fusion",
150
+ "attn_quant_fusion",
151
+ "aiter_ar_rms_fusion",
152
+ ]
153
+
154
run_e2e_fusion_test(
155
model_name,
156
matches,
@@ -111,6 +159,7 @@ def test_tp2_ar_rms_fp8_fusions(
159
compilation_config,
160
matches_check,
161
tp_size=2,
162
+ use_aiter=use_aiter,
163
)
164
165
tests/v1/attention/test_rocm_aiter_mla_fp8_decode_routing.py
+19
@@ -151,6 +151,25 @@ def test_decode_and_verify_are_disjoint(kv_cache_dtype, num_heads):
151
)
152
153
154
+@pytest.mark.parametrize("kv_cache_dtype", UNQUANTIZED_DTYPES)
155
+def test_oversized_kv_cache_refuses_gluon(gluon_available, kv_cache_dtype):
156
+ """Past 2 GiB per layer Gluon drops its KV bounds mask and reads out of range.
157
+
158
+ ``None`` is the profiling case, before the cache is sized: nothing to
159
+ refuse yet, so the usual route stands.
160
+ """
161
+ bound = rocm_aiter_mla._GLUON_MAX_KV_CACHE_BYTES
162
+ for kv_cache_bytes in (None, bound):
163
+ assert AiterMLAHelper.use_gluon_decode(8, 1, kv_cache_dtype, kv_cache_bytes)
164
+ assert AiterMLAHelper.use_gluon_verify(
165
+ 8, 8, kv_cache_dtype, kv_cache_bytes=kv_cache_bytes
166
+ )
167
+ assert not AiterMLAHelper.use_gluon_decode(8, 1, kv_cache_dtype, bound + 1)
168
+ assert not AiterMLAHelper.use_gluon_verify(
169
+ 8, 8, kv_cache_dtype, kv_cache_bytes=bound + 1
170
+ )
171
+
172
+
173
@pytest.mark.parametrize("num_heads", [1, 2, 3, 5, 6, 7, 8, 9, 12, 15])
174
def test_padded_query_is_contiguous(num_heads):
175
"""asm_mla.cu:805 requires Q.is_contiguous().
tests/v1/attention/test_rocm_aiter_mla_mtp_split.py
+3
-1
@@ -107,6 +107,8 @@ def _builder(
107
_dcp_verify_buffers=None,
108
_graph_seq_lens=None,
109
_kv_cache_dtype_str=kv_cache_dtype,
110
+ # Unsized as during profiling, so the Gluon KV bound stays out of the way.
111
+ _kv_cache_bytes=None,
112
paged_kv_last_page_len=torch.ones(max_decode_rows, dtype=torch.int32),
113
paged_kv_indices=torch.empty(1024, dtype=torch.int32),
114
paged_kv_indptr=torch.empty(max_decode_rows + 1, dtype=torch.int32),
@@ -585,7 +587,7 @@ def test_mtp_builder_init_sizes_native_fp8_metadata(
587
get_num_attention_heads=lambda parallel_config: num_heads,
588
),
589
scheduler_config=SimpleNamespace(max_num_seqs=2),
588
- cache_config=SimpleNamespace(cache_dtype="fp8_e4m3"),
590
+ cache_config=SimpleNamespace(cache_dtype="fp8_e4m3", num_gpu_blocks=None),
591
compilation_config=SimpleNamespace(
592
cudagraph_mode=SimpleNamespace(has_full_cudagraphs=lambda: False),
593
# Empty: the per-layer head-count probe finds no attention layer and
vllm/v1/attention/backends/mla/rocm_aiter_mla.py
+50
-7
@@ -178,6 +178,30 @@ def _gluon_mla_decode_supported() -> bool:
178
return on_gfx950()
179
180
181
+# Past 2 GiB, mla_gluon swaps its masked buffer_load for an unmasked
182
+# global_load that reads outside the cache and aborts the process. Mirrors the
183
+# kernel's own within_2gb bound, so the fallback engages exactly where it does.
184
+_GLUON_MAX_KV_CACHE_BYTES = 1 << 31
185
+
186
+
187
+def _gluon_kv_cache_in_bounds(kv_cache_bytes: int | None) -> bool:
188
+ """Whether this layer's KV cache keeps Gluon on its bounds-checked path.
189
+
190
+ The cache is one flat tensor per layer, so the bound disqualifies the layer
191
+ outright; no batch shape brings an oversized cache back in range. ``None``
192
+ is the not-yet-sized case during profiling, before any kernel runs.
193
+ """
194
+ if kv_cache_bytes is None or kv_cache_bytes <= _GLUON_MAX_KV_CACHE_BYTES:
195
+ return True
196
+ logger.warning_once(
197
+ "KV cache is %.1f GiB per layer, past the 2 GiB where the Gluon MLA "
198
+ "kernel drops its KV bounds mask; using the padded ASM decode instead. "
199
+ "Lower --gpu-memory-utilization or --max-model-len to get Gluon back.",
200
+ kv_cache_bytes / (1 << 30),
201
+ )
202
+ return False
203
+
204
+
205
@functools.lru_cache(maxsize=1)
206
def _get_segmented_mla_decode():
207
"""Load AITER's segmented MLA decode with unreduced partial output."""
@@ -564,6 +588,13 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
588
# _build_decode needs the cache dtype to pick the decode kernel; keep
589
# the normalized string instead of dropping it at the end of __init__.
590
self._kv_cache_dtype_str = kv_cache_dtype_str
591
+ # Sized per layer, matching the flat KV tensor the kernel is handed.
592
+ num_gpu_blocks = vllm_config.cache_config.num_gpu_blocks
593
+ self._kv_cache_bytes = (
594
+ None
595
+ if num_gpu_blocks is None
596
+ else num_gpu_blocks * kv_cache_spec.page_size_bytes
597
+ )
598
# MLAAttention quantizes decode Q to FP8 before calling this backend
599
# whenever the KV cache is FP8 and supports_quant_query_input is true.
600
q_dtype = (
@@ -1065,6 +1096,7 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
1096
self._decode_num_heads,
1097
int(max_qo_len),
1098
self._kv_cache_dtype_str,
1099
+ self._kv_cache_bytes,
1100
)
1101
use_gluon_verify = AiterMLAHelper.use_gluon_verify(
1102
self._decode_num_heads,
@@ -1072,6 +1104,7 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
1104
self._kv_cache_dtype_str,
1105
self.dcp_world_size,
1106
causal,
1107
+ self._kv_cache_bytes,
1108
)
1109
use_segmented_dcp_verify = (
1110
self._supports_segmented_dcp_verify and max_qo_len > 1
@@ -1481,7 +1514,12 @@ class AiterMLAHelper:
1514
return AiterMLAHelper._get_mla_unpadded_heads(num_heads, lse)
1515
1516
@staticmethod
1484
- def use_gluon_decode(num_heads: int, max_qo_len: int, kv_cache_dtype: str) -> bool:
1517
+ def use_gluon_decode(
1518
+ num_heads: int,
1519
+ max_qo_len: int,
1520
+ kv_cache_dtype: str,
1521
+ kv_cache_bytes: int | None = None,
1522
+ ) -> bool:
1523
# Small-head (<16) single-token decode takes either the Gluon kernel or
1524
# the padded asm persistent decode, selected by
1525
# VLLM_ROCM_AITER_MLA_ASM_PADDING and the arch (Gluon is gfx950 only).
@@ -1497,10 +1535,12 @@ class AiterMLAHelper:
1535
mode = _aiter_mla_small_head_mode()
1536
if mode == "asm":
1537
return False
1500
- gluon_supported = _gluon_mla_decode_supported()
1501
- if mode == "gluon":
1502
- return gluon_supported
1503
- return m % num_heads == 0 and gluon_supported
1538
+ if not _gluon_mla_decode_supported():
1539
+ return False
1540
+ if mode != "gluon" and m % num_heads != 0:
1541
+ return False
1542
+ # Last, so the size warning only fires where Gluon would have run.
1543
+ return _gluon_kv_cache_in_bounds(kv_cache_bytes)
1544
1545
@staticmethod
1546
def use_gluon_verify(
@@ -1509,6 +1549,7 @@ class AiterMLAHelper:
1549
kv_cache_dtype: str,
1550
dcp_world_size: int = 1,
1551
causal: bool = True,
1552
+ kv_cache_bytes: int | None = None,
1553
) -> bool:
1554
"""Whether a small-head multi-token verify uses native Gluon MTP.
1555
@@ -1536,8 +1577,10 @@ class AiterMLAHelper:
1577
return False
1578
if not _gluon_mla_decode_supported():
1579
return False
1539
- # Same arch and mode gating as use_gluon_decode.
1540
- return _aiter_mla_small_head_mode() != "asm"
1580
+ # Same arch, mode and cache-size gating as use_gluon_decode.
1581
+ if _aiter_mla_small_head_mode() == "asm":
1582
+ return False
1583
+ return _gluon_kv_cache_in_bounds(kv_cache_bytes)
1584
1585
@staticmethod
1586
def dcp_local_verify_row_lens(