[ROCm][CI] Add attention-sink support to ROCm AITER sparse MLA (#54404)
Signed-off-by: Andreas Karatzas <Andreas.Karatzas@amd.com> Signed-off-by: Andreas Karatzas <akaratza@amd.com> Co-authored-by: OpenAI Codex <codex@openai.com>
Andreas Karatzas committed
Sep 7, 2026 at 04:26 UTC
e476556189506df612218c0888dc675e6ecbfc06
8 files changed
+997
-67
tests/kernels/attention/test_rocm_aiter_mla_op_registration.py
+43
-15
@@ -1,11 +1,11 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-"""ROCm custom op schema test for AITER MLA decode.
3
+"""ROCm custom op schema tests for AITER MLA decode.
4
5
-A single ``opcheck`` call on ``torch.ops.vllm.rocm_aiter_mla_decode_fwd``
6
-verifies that the custom op is registered and that its schema and fake
7
-implementation are consistent with the real kernel: fake-tensor support for
8
-torch.compile tracing and the ``mutates_args=["o"]`` in-place output aliasing.
5
+``opcheck`` verifies that the decode ops are registered and that their schemas
6
+and fake implementations are consistent with the real kernels: fake-tensor
7
+support for torch.compile tracing and ``mutates_args=["o"]`` in-place output
8
+aliasing.
9
"""
10
11
import pytest
@@ -14,16 +14,9 @@ import torch
14
from tests.kernels.utils import opcheck
15
from vllm.platforms import current_platform
16
17
-_SKIP_NON_MI3XX = True
18
-if current_platform.is_rocm():
19
- from vllm.platforms.rocm import on_mi3xx
20
-
21
- _SKIP_NON_MI3XX = not on_mi3xx()
22
-
23
-pytestmark = [
24
- pytest.mark.skipif(not current_platform.is_rocm(), reason="ROCm-specific tests"),
25
- pytest.mark.skipif(_SKIP_NON_MI3XX, reason="MI300/MI350 ROCm only"),
26
-]
17
+pytestmark = pytest.mark.skipif(
18
+ not current_platform.is_rocm(), reason="ROCm-specific tests"
19
+)
20
21
Q_HEAD_DIM = 576 # kv_lora_rank + qk_rope_head_dim
22
V_HEAD_DIM = 512 # kv_lora_rank
@@ -31,6 +24,10 @@ V_HEAD_DIM = 512 # kv_lora_rank
24
25
def _require_aiter():
26
from vllm._aiter_ops import is_aiter_found_and_supported
27
+ from vllm.platforms.rocm import get_cdna_version
28
+
29
+ if get_cdna_version() not in (3, 4):
30
+ pytest.skip("AITER MLA requires CDNA 3 or 4")
31
32
if not is_aiter_found_and_supported():
33
pytest.skip("aiter is required on supported ROCm hardware for this test")
@@ -77,3 +74,34 @@ def test_mla_decode_fwd_op_schema() -> None:
74
"reduce_partial_map": None,
75
},
76
)
77
+
78
+
79
+@torch.inference_mode()
80
+def test_mla_decode_fwd_lse_op_schema() -> None:
81
+ """Validate graph registration and mutation schema for LSE decode."""
82
+ _require_aiter()
83
+ # Import ensures the custom op is registered.
84
+ from vllm._aiter_ops import rocm_aiter_ops # noqa: F401
85
+
86
+ batch_size, nhead = 2, 16
87
+ q = torch.randn(batch_size, nhead, Q_HEAD_DIM, dtype=torch.bfloat16, device="cuda")
88
+ kv_buffer = torch.randn(32, 1, 1, Q_HEAD_DIM, dtype=torch.bfloat16, device="cuda")
89
+ o = torch.zeros(batch_size, nhead, V_HEAD_DIM, dtype=torch.bfloat16, device="cuda")
90
+ qo_indptr = torch.arange(batch_size + 1, dtype=torch.int32, device="cuda")
91
+ kv_indptr = torch.arange(batch_size + 1, dtype=torch.int32, device="cuda") * 16
92
+ kv_indices = torch.arange(32, dtype=torch.int32, device="cuda")
93
+ kv_last_page_lens = torch.ones(batch_size, dtype=torch.int32, device="cuda")
94
+
95
+ opcheck(
96
+ torch.ops.vllm.rocm_aiter_mla_decode_fwd_lse,
97
+ (q, kv_buffer, o, qo_indptr, 1),
98
+ {
99
+ "kv_indptr": kv_indptr,
100
+ "kv_indices": kv_indices,
101
+ "kv_last_page_lens": kv_last_page_lens,
102
+ "sm_scale": Q_HEAD_DIM**-0.5,
103
+ "logit_cap": 0.0,
104
+ "q_scale": None,
105
+ "kv_scale": None,
106
+ },
107
+ )
tests/kernels/attention/test_rocm_aiter_mla_sink.py
new
+512
@@ -0,0 +1,512 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Correctness tests for ROCm AITER sparse MLA attention sinks."""
4
+
5
+from types import SimpleNamespace
6
+
7
+import pytest
8
+import torch
9
+
10
+from vllm.platforms import current_platform
11
+from vllm.utils.torch_utils import set_random_seed
12
+
13
+pytestmark = pytest.mark.skipif(
14
+ not current_platform.is_rocm(), reason="ROCm-specific tests"
15
+)
16
+
17
+Q_HEAD_DIM = 576
18
+V_HEAD_DIM = 512
19
+SM_SCALE = Q_HEAD_DIM**-0.5
20
+
21
+
22
+def _gamma_fp32(operations: int) -> float:
23
+ u = torch.finfo(torch.float32).eps / 2
24
+ return operations * u / (1 - operations * u)
25
+
26
+
27
+def _sink_reference(q, keys, sinks, scale):
28
+ # Reference the stored operands, so input quantization is not kernel error.
29
+ q, keys, sinks = q.double(), keys.double(), sinks.double()
30
+ scores = q @ keys.T * scale
31
+ logits = torch.cat((scores, sinks[:, None]), dim=-1)
32
+ probabilities = logits.softmax(dim=-1)[:, :-1]
33
+ values = keys[:, :V_HEAD_DIM]
34
+ expected = probabilities @ values
35
+ value_scale = probabilities @ values.abs()
36
+ # FP32 dot accumulation and conversion/multiplication of the scale.
37
+ score_error = _gamma_fp32(q.shape[-1] + 2) * (q.abs() @ keys.abs().T) * abs(scale)
38
+ score_error = score_error.amax(dim=-1) if keys.shape[0] else torch.zeros_like(sinks)
39
+ return expected, logits.logsumexp(dim=-1), value_scale, score_error
40
+
41
+
42
+def _assert_sink_output_close(
43
+ actual, expected, value_scale, score_error, probability_dtype, native
44
+):
45
+ assert actual.shape == expected.shape
46
+ # Budget the identifiable rounding stages against the absolute value
47
+ # contributions: output-relative ULPs become unbounded when values cancel.
48
+ # Rounding P before P@V costs u(P) * sum(P*abs(V)).
49
+ # AITER rounds output before and after sink scaling; Triton rounds once.
50
+ u_probability = torch.finfo(probability_dtype).eps / 2
51
+ u_output = torch.finfo(actual.dtype).eps / 2
52
+ output_rounds = 2 if native else 1
53
+ amplification = (1 + u_output) ** output_rounds
54
+ relative_error = (1 + u_probability) * torch.exp(2 * score_error) - 1
55
+ allowance = (
56
+ relative_error.unsqueeze(-1) * value_scale * amplification
57
+ + (amplification - 1) * expected.abs()
58
+ + output_rounds * torch.finfo(actual.dtype).tiny * u_output
59
+ )
60
+ actual = actual.to(device=expected.device, dtype=torch.float64)
61
+ assert torch.isfinite(actual).all()
62
+ assert torch.count_nonzero(actual[value_scale == 0]) == 0
63
+ error = (actual - expected).abs()
64
+ max_ratio = (error / allowance.clamp_min(1e-300)).max().item()
65
+ assert max_ratio <= 1, f"exceeded rounding budget by {max_ratio:.3f}x"
66
+
67
+
68
+def _require_aiter() -> None:
69
+ from vllm._aiter_ops import is_aiter_found_and_supported
70
+ from vllm.platforms.rocm import get_cdna_version
71
+
72
+ if get_cdna_version() not in (3, 4):
73
+ pytest.skip("AITER MLA requires CDNA 3 or 4")
74
+
75
+ if not is_aiter_found_and_supported():
76
+ pytest.skip("aiter is required on supported ROCm hardware for this test")
77
+
78
+
79
+@pytest.mark.parametrize(
80
+ ("real_heads", "cache_kind"),
81
+ [
82
+ (1, "bf16"),
83
+ (4, "bf16"),
84
+ (8, "bf16"),
85
+ (8, "fp8"),
86
+ (32, "fp8"),
87
+ (64, "fp8"),
88
+ (12, "bf16"),
89
+ (16, "bf16"),
90
+ (24, "bf16"),
91
+ (32, "bf16"),
92
+ (40, "bf16"),
93
+ (48, "bf16"),
94
+ (64, "bf16"),
95
+ (80, "bf16"),
96
+ ],
97
+)
98
+@torch.inference_mode()
99
+def test_sparse_mla_sink_matches_ragged_reference(
100
+ real_heads: int, cache_kind: str
101
+) -> None:
102
+ """Exercise ragged sink decode, head padding, and FP8 scale forwarding."""
103
+ _require_aiter()
104
+ from vllm.v1.attention.backends.mla.rocm_aiter_mla import AiterMLAHelper
105
+ from vllm.v1.attention.backends.mla.rocm_aiter_mla_sparse import (
106
+ ROCMAiterMLASparseImpl,
107
+ )
108
+
109
+ set_random_seed(real_heads * 17 + (cache_kind == "fp8"))
110
+ device = torch.device("cuda")
111
+ seq_lens = [0, 1, 5, 23, 17]
112
+ batch_size = len(seq_lens)
113
+
114
+ q_source = torch.randn(batch_size, real_heads, Q_HEAD_DIM, device=device) * 0.2
115
+
116
+ pool_size = sum(seq_lens) + 52
117
+ kv_source = torch.randn(pool_size, 1, Q_HEAD_DIM, device=device) * 0.2
118
+ if cache_kind == "fp8":
119
+ fp8_dtype = current_platform.fp8_dtype()
120
+ q_scale = torch.tensor(0.5, dtype=torch.float32, device=device)
121
+ kv_scale = torch.tensor(0.25, dtype=torch.float32, device=device)
122
+ q_real = (q_source / q_scale).to(fp8_dtype)
123
+ kv = (kv_source / kv_scale).to(fp8_dtype)
124
+ q_ref = q_real.float() * q_scale
125
+ kv_ref = kv.float() * kv_scale
126
+ else:
127
+ q_scale = kv_scale = None
128
+ q_real = q_source.to(torch.bfloat16)
129
+ kv = kv_source.to(torch.bfloat16)
130
+ q_ref = q_real.float()
131
+ kv_ref = kv.float()
132
+ q = AiterMLAHelper.get_mla_padded_q(real_heads, q_real)
133
+
134
+ indices = torch.randperm(pool_size, device=device)[: sum(seq_lens)].to(torch.int32)
135
+ kv_indptr = torch.tensor(
136
+ [0] + [sum(seq_lens[:i]) for i in range(1, len(seq_lens) + 1)],
137
+ dtype=torch.int32,
138
+ device=device,
139
+ )
140
+
141
+ # Non-None garbage proves the sink path cannot accidentally select the
142
+ # gfx942 persistent kernel, which has no return-LSE code object.
143
+ metadata = SimpleNamespace(
144
+ attn_out_dtype=torch.bfloat16,
145
+ qo_indptr=torch.arange(batch_size + 1, dtype=torch.int32, device=device),
146
+ paged_kv_indptr=kv_indptr,
147
+ paged_kv_indices=indices,
148
+ paged_kv_last_page_len=torch.ones(batch_size, dtype=torch.int32, device=device),
149
+ work_meta_data=torch.tensor([123], dtype=torch.int32),
150
+ work_indptr=None,
151
+ work_info_set=None,
152
+ reduce_indptr=None,
153
+ reduce_final_map=None,
154
+ reduce_partial_map=None,
155
+ num_prefills=0,
156
+ num_decodes=batch_size,
157
+ num_decode_tokens=batch_size,
158
+ max_query_len=1,
159
+ )
160
+ sinks = torch.linspace(-2.0, 6.0, real_heads, device=device)
161
+
162
+ impl = object.__new__(ROCMAiterMLASparseImpl)
163
+ impl.num_heads = real_heads
164
+ impl.kv_lora_rank = V_HEAD_DIM
165
+ impl.kv_cache_dtype = cache_kind
166
+ impl.scale = SM_SCALE
167
+ impl.sinks = sinks
168
+
169
+ output, lse = impl._forward_mla(
170
+ SimpleNamespace(_q_scale=q_scale, _k_scale=kv_scale), q, kv, metadata
171
+ )
172
+ kv_flat = kv_ref[:, 0]
173
+ references = []
174
+ start = 0
175
+ for batch_idx, seq_len in enumerate(seq_lens):
176
+ rows = kv_flat[indices[start : start + seq_len].long()]
177
+ references.append(_sink_reference(q_ref[batch_idx], rows, sinks, SM_SCALE))
178
+ start += seq_len
179
+ expected, expected_lse, value_scale, score_error = (
180
+ torch.stack(values) for values in zip(*references)
181
+ )
182
+
183
+ assert output.dtype == torch.bfloat16
184
+ if lse is not None:
185
+ assert lse.dtype == torch.float32
186
+ assert lse.shape == expected_lse.shape
187
+ lse_error = (lse.double() - expected_lse).abs()
188
+ rounded_lse = expected_lse.float()
189
+ positive_inf = torch.full_like(rounded_lse, float("inf"))
190
+ ulp = torch.maximum(
191
+ torch.nextafter(rounded_lse, positive_inf) - rounded_lse,
192
+ rounded_lse - torch.nextafter(rounded_lse, -positive_inf),
193
+ ).double()
194
+ lse_allowance = score_error + 2 * ulp
195
+ assert torch.all(lse_error <= lse_allowance)
196
+ torch.testing.assert_close(lse[0].double(), expected_lse[0], atol=0, rtol=0)
197
+ else:
198
+ from vllm.platforms.rocm import on_gfx942
199
+
200
+ assert cache_kind == "bf16" and real_heads in (40, 48, 64) and on_gfx942()
201
+ _assert_sink_output_close(
202
+ output,
203
+ expected,
204
+ value_scale,
205
+ score_error,
206
+ q_real.dtype,
207
+ native=lse is not None,
208
+ )
209
+
210
+
211
+@pytest.mark.parametrize(
212
+ ("q_dtype", "kv_dtype"),
213
+ [
214
+ (torch.float16, torch.bfloat16),
215
+ (torch.bfloat16, torch.float16),
216
+ ],
217
+)
218
+def test_sparse_mla_sink_rejects_unsupported_aiter_dtypes(
219
+ q_dtype: torch.dtype, kv_dtype: torch.dtype
220
+) -> None:
221
+ from vllm.v1.attention.backends.mla.rocm_aiter_mla_sparse import (
222
+ ROCMAiterMLASparseImpl,
223
+ )
224
+
225
+ impl = object.__new__(ROCMAiterMLASparseImpl)
226
+ impl.num_heads = 16
227
+ impl.kv_lora_rank = V_HEAD_DIM
228
+ impl.kv_cache_dtype = "auto"
229
+ impl.scale = SM_SCALE
230
+ impl.sinks = torch.zeros(16, dtype=torch.float32)
231
+ metadata = SimpleNamespace(
232
+ attn_out_dtype=torch.bfloat16,
233
+ num_prefills=0,
234
+ num_decodes=1,
235
+ num_decode_tokens=1,
236
+ max_query_len=1,
237
+ )
238
+
239
+ with pytest.raises(ValueError, match="both use BF16 or both use FP8"):
240
+ impl._forward_mla(
241
+ SimpleNamespace(_q_scale=None, _k_scale=None),
242
+ torch.empty(1, 16, Q_HEAD_DIM, dtype=q_dtype),
243
+ torch.empty(1, 1, Q_HEAD_DIM, dtype=kv_dtype),
244
+ metadata,
245
+ )
246
+
247
+
248
+def _make_noncontiguous_sink() -> torch.Tensor:
249
+ return torch.empty(8, dtype=torch.float32)[::2]
250
+
251
+
252
+@pytest.mark.parametrize(
253
+ ("sinks", "match"),
254
+ [
255
+ (torch.empty(4, dtype=torch.bfloat16), "must be float32"),
256
+ (torch.empty(2, 2, dtype=torch.float32), "must have shape"),
257
+ (_make_noncontiguous_sink(), "must be contiguous"),
258
+ ],
259
+)
260
+def test_sparse_mla_sink_validation(sinks: torch.Tensor, match: str) -> None:
261
+ from vllm.v1.attention.backends.mla.rocm_aiter_mla_sparse import (
262
+ ROCMAiterMLASparseImpl,
263
+ )
264
+
265
+ with pytest.raises(ValueError, match=match):
266
+ ROCMAiterMLASparseImpl(
267
+ num_heads=4,
268
+ head_size=Q_HEAD_DIM,
269
+ scale=SM_SCALE,
270
+ num_kv_heads=1,
271
+ alibi_slopes=None,
272
+ sliding_window=None,
273
+ kv_cache_dtype="bfloat16",
274
+ logits_soft_cap=None,
275
+ attn_type="decoder",
276
+ kv_sharing_target_layer_name=None,
277
+ sinks=sinks,
278
+ kv_lora_rank=V_HEAD_DIM,
279
+ )
280
+
281
+
282
+def test_sparse_mla_backend_reports_sink_support_for_current_hardware() -> None:
283
+ from vllm.platforms.rocm import get_cdna_version
284
+ from vllm.v1.attention.backends.mla.rocm_aiter_mla_sparse import (
285
+ ROCMAiterMLASparseBackend,
286
+ )
287
+
288
+ assert ROCMAiterMLASparseBackend.supports_sink() == (get_cdna_version() in (3, 4))
289
+
290
+
291
+def test_sparse_mla_backend_rejects_dcp() -> None:
292
+ from vllm.platforms.rocm import RocmPlatform
293
+ from vllm.v1.attention.backends.registry import AttentionBackendEnum
294
+ from vllm.v1.attention.selector import AttentionSelectorConfig
295
+
296
+ selector_config = AttentionSelectorConfig(
297
+ head_size=Q_HEAD_DIM,
298
+ dtype=torch.bfloat16,
299
+ kv_cache_dtype="bfloat16",
300
+ block_size=16,
301
+ use_mla=True,
302
+ has_sink=True,
303
+ use_sparse=True,
304
+ use_mm_prefix=False,
305
+ use_per_head_quant_scales=False,
306
+ attn_type="decoder",
307
+ use_dcp=True,
308
+ )
309
+
310
+ with pytest.raises(ValueError, match="DCP not supported"):
311
+ RocmPlatform.get_attn_backend_cls(
312
+ selected_backend=AttentionBackendEnum.ROCM_AITER_MLA_SPARSE,
313
+ attn_selector_config=selector_config,
314
+ )
315
+
316
+
317
+@pytest.mark.parametrize("num_heads,block_size", [(8, 16), (20, 32)])
318
+@pytest.mark.parametrize("interleaved_pages", [False, True])
319
+@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
320
+@torch.inference_mode()
321
+def test_sparse_mla_sink_matches_dense_attention_with_empty_rows_and_paged_cache(
322
+ num_heads: int,
323
+ block_size: int,
324
+ interleaved_pages: bool,
325
+ dtype: torch.dtype,
326
+) -> None:
327
+ """Preserve head-specific sinks and latent values across ragged cache pages."""
328
+ _require_aiter()
329
+ from vllm.v1.attention.backends.mla.rocm_aiter_mla import AiterMLAHelper
330
+ from vllm.v1.attention.backends.mla.rocm_aiter_mla_sparse import (
331
+ ROCMAiterMLASparseImpl,
332
+ )
333
+ from vllm.v1.attention.backends.mla.sparse_utils import flat_kv_row_view
334
+
335
+ head_dim, value_dim = 576, 512
336
+ generator = torch.Generator().manual_seed(421)
337
+ # The empty row exercises sink-only attention; the last row crosses both
338
+ # page boundaries and the kernel's 16-key reduction tile.
339
+ selected_rows = [
340
+ [],
341
+ [(2, 7)],
342
+ [(3, 1), (0, block_size - 1), (1, 0), (2, 4)],
343
+ [(i % 4, (i * 7) % block_size) for i in range(23)],
344
+ ]
345
+ q_cpu = torch.randn(
346
+ len(selected_rows), num_heads, head_dim, generator=generator
347
+ ).to(dtype)
348
+ cache_cpu = torch.randn(4, block_size, head_dim, generator=generator).to(dtype)
349
+ sinks_cpu = torch.linspace(-4.0, 6.0, num_heads)
350
+
351
+ # Layers can share a backing allocation, leaving unused rows between pages.
352
+ num_layers = 3 if interleaved_pages else 1
353
+ backing = torch.full(
354
+ (4, num_layers, block_size, head_dim),
355
+ float("nan"),
356
+ dtype=dtype,
357
+ device="cuda",
358
+ )
359
+ cache = backing[:, num_layers - 1]
360
+ cache.copy_(cache_cpu)
361
+ indices = [
362
+ page * num_layers * block_size + offset
363
+ for rows in selected_rows
364
+ for page, offset in rows
365
+ ]
366
+ lengths = torch.tensor([0, *(len(rows) for rows in selected_rows)])
367
+ metadata = SimpleNamespace(
368
+ block_size=block_size,
369
+ num_prefills=0,
370
+ num_decodes=len(selected_rows),
371
+ num_decode_tokens=len(selected_rows),
372
+ max_query_len=1,
373
+ qo_indptr=torch.arange(
374
+ len(selected_rows) + 1, dtype=torch.int32, device="cuda"
375
+ ),
376
+ paged_kv_last_page_len=torch.ones(
377
+ len(selected_rows), dtype=torch.int32, device="cuda"
378
+ ),
379
+ work_meta_data=None,
380
+ attn_out_dtype=dtype,
381
+ paged_kv_indices=torch.tensor(indices, dtype=torch.int32, device="cuda"),
382
+ paged_kv_indptr=lengths.cumsum(0).to(device="cuda", dtype=torch.int32),
383
+ )
384
+ impl = ROCMAiterMLASparseImpl.__new__(ROCMAiterMLASparseImpl)
385
+ impl.num_heads = num_heads
386
+ impl.head_size = head_dim
387
+ impl.kv_lora_rank = value_dim
388
+ impl.scale = head_dim**-0.5
389
+ impl.kv_cache_dtype = "auto"
390
+ impl.sinks = sinks_cpu.cuda()
391
+ padded_q = AiterMLAHelper.get_mla_padded_q(num_heads, q_cpu.cuda())
392
+
393
+ kv_rows, _ = flat_kv_row_view(cache, block_size)
394
+ actual, _ = impl._forward_mla(
395
+ SimpleNamespace(_q_scale=None, _k_scale=None),
396
+ padded_q,
397
+ kv_rows.unsqueeze(1),
398
+ metadata,
399
+ )
400
+
401
+ references = []
402
+ for query_idx, rows in enumerate(selected_rows):
403
+ keys = (
404
+ torch.stack([cache_cpu[page, offset] for page, offset in rows])
405
+ if rows
406
+ else cache_cpu.new_empty((0, head_dim))
407
+ )
408
+ references.append(
409
+ _sink_reference(q_cpu[query_idx], keys, sinks_cpu, impl.scale)
410
+ )
411
+ expected, _, value_scale, score_error = (
412
+ torch.stack(values) for values in zip(*references)
413
+ )
414
+ assert actual.shape == (len(selected_rows), num_heads, value_dim)
415
+ assert actual.dtype == dtype
416
+ _assert_sink_output_close(
417
+ actual,
418
+ expected,
419
+ value_scale,
420
+ score_error,
421
+ dtype,
422
+ native=dtype == torch.bfloat16,
423
+ )
424
+
425
+
426
+@pytest.mark.parametrize("layout", ["LBNHC", "LBHNC", "BLNHC"])
427
+def test_sparse_mla_backend_resolves_only_contiguous_layer_layouts(monkeypatch, layout):
428
+ from vllm.config import CacheConfig
429
+ from vllm.v1.attention.backends.mla.rocm_aiter_mla_sparse import (
430
+ ROCMAiterMLASparseBackend,
431
+ )
432
+ from vllm.v1.attention.backends.utils import resolve_kv_cache_layout
433
+
434
+ monkeypatch.setenv("VLLM_KV_CACHE_LAYOUT", layout)
435
+ config = SimpleNamespace(cache_config=CacheConfig())
436
+ supported = [
437
+ [x.name for x in ROCMAiterMLASparseBackend.supported_kv_cache_layouts()]
438
+ ]
439
+ if layout == "BLNHC":
440
+ with pytest.raises(ValueError, match="does not satisfy"):
441
+ resolve_kv_cache_layout(config, supported)
442
+ else:
443
+ assert resolve_kv_cache_layout(config, supported).name == layout
444
+
445
+
446
+@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
447
+@torch.inference_mode()
448
+def test_sparse_mla_sink_forward_mqa_preserves_split_query(dtype):
449
+ """The public sparse forward joins latent/RoPE queries in the model dtype."""
450
+ _require_aiter()
451
+ from vllm.v1.attention.backends.mla.rocm_aiter_mla_sparse import (
452
+ ROCMAiterMLASparseImpl,
453
+ )
454
+
455
+ set_random_seed(412)
456
+ num_tokens, num_heads, block_size = 2, 8, 16
457
+ q = torch.randn(num_tokens, num_heads, Q_HEAD_DIM, device="cuda").to(dtype)
458
+ kv = torch.randn(2 * block_size, 1, Q_HEAD_DIM, device="cuda").to(dtype)
459
+ selected = torch.tensor([[1, 7, 18], [0, 3, 20]], dtype=torch.int32, device="cuda")
460
+ sinks = torch.linspace(-3.0, 5.0, num_heads, device="cuda")
461
+ impl = object.__new__(ROCMAiterMLASparseImpl)
462
+ impl.num_heads = num_heads
463
+ impl.kv_lora_rank = V_HEAD_DIM
464
+ impl.kv_cache_dtype = "auto"
465
+ impl.scale = SM_SCALE
466
+ impl.sinks = sinks
467
+ impl.topk_indices_buffer = torch.full(
468
+ (num_tokens, 128), -1, dtype=torch.int32, device="cuda"
469
+ )
470
+ impl.topk_indices_buffer[:, : selected.shape[1]] = selected
471
+ impl.q_concat_buffer = torch.empty_like(q)
472
+ metadata = SimpleNamespace(
473
+ attn_out_dtype=dtype,
474
+ num_actual_tokens=num_tokens,
475
+ num_prefills=0,
476
+ num_decodes=num_tokens,
477
+ num_decode_tokens=num_tokens,
478
+ max_query_len=1,
479
+ block_size=block_size,
480
+ topk_tokens=impl.topk_indices_buffer.shape[1],
481
+ req_id_per_token=torch.zeros(num_tokens, dtype=torch.int32, device="cuda"),
482
+ block_table=torch.tensor([[0, 1]], dtype=torch.int32, device="cuda"),
483
+ qo_indptr=torch.arange(num_tokens + 1, dtype=torch.int32, device="cuda"),
484
+ paged_kv_indptr=torch.tensor([0, 3, 6], dtype=torch.int32, device="cuda"),
485
+ paged_kv_indices=torch.empty(
486
+ selected.numel(), dtype=torch.int32, device="cuda"
487
+ ),
488
+ paged_kv_last_page_len=torch.ones(num_tokens, dtype=torch.int32, device="cuda"),
489
+ work_meta_data=None,
490
+ )
491
+ actual, _ = impl.forward_mqa(
492
+ (q[..., :V_HEAD_DIM], q[..., V_HEAD_DIM:]),
493
+ kv,
494
+ metadata,
495
+ SimpleNamespace(_q_scale=None, _k_scale=None),
496
+ )
497
+ references = [
498
+ _sink_reference(q[i], kv[:, 0][selected[i].long()], sinks, SM_SCALE)
499
+ for i in range(num_tokens)
500
+ ]
501
+ expected, _, value_scale, score_error = (
502
+ torch.stack(values) for values in zip(*references)
503
+ )
504
+ assert actual.dtype == dtype
505
+ _assert_sink_output_close(
506
+ actual,
507
+ expected,
508
+ value_scale,
509
+ score_error,
510
+ dtype,
511
+ native=dtype == torch.bfloat16,
512
+ )
tests/kernels/attention/test_rocm_aiter_mla_sparse_metadata_sync.py
+21
@@ -59,6 +59,7 @@ def _make_builder():
59
builder.paged_kv_indptr = torch.zeros(
60
max_num_batched_tokens + 1, dtype=torch.int32, device="cpu"
61
)
62
+ builder._use_persistent_metadata = True
63
builder._num_attention_heads = 16
64
builder._num_compute_units = current_platform.num_compute_units()
65
builder._mla_work_meta_data = torch.empty(1, dtype=torch.int32, device="cpu")
@@ -130,6 +131,7 @@ def _patch_build_deps(monkeypatch, events=None):
131
synchronize=lambda: events.append("sync") if events is not None else None
132
),
133
)
134
+ return fake_aiter
135
136
137
def test_build_populates_decode_only_split_fields(monkeypatch):
@@ -165,6 +167,25 @@ def test_build_populates_mixed_split_fields(monkeypatch):
167
assert md.prefill is None
168
169
170
+def test_sink_build_skips_persistent_metadata(monkeypatch):
171
+ builder = _make_builder()
172
+ builder._use_persistent_metadata = False
173
+ fake_aiter = _patch_build_deps(monkeypatch)
174
+
175
+ md = builder.build(
176
+ common_prefix_len=0, common_attn_metadata=_make_common_metadata()
177
+ )
178
+
179
+ fake_aiter.get_mla_metadata_v1.assert_not_called()
180
+ assert md.work_meta_data is None
181
+ assert md.work_indptr is None
182
+ assert md.work_info_set is None
183
+ assert md.reduce_indptr is None
184
+ assert md.reduce_final_map is None
185
+ assert md.reduce_partial_map is None
186
+ assert builder._prev_metadata_key is None
187
+
188
+
189
def test_sparse_persistent_metadata_syncs_only_after_recompute(monkeypatch):
190
builder = _make_builder()
191
common_metadata = _make_common_metadata()
tests/v1/attention/test_rocm_glm5next_sparse.py
+52
@@ -1,11 +1,14 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
+from types import SimpleNamespace
5
+
6
import pytest
7
import torch
8
9
from vllm.platforms import current_platform
10
from vllm.triton_utils import tl, triton
11
+from vllm.v1.attention.backends.mla import rocm_aiter_mla_sparse as sparse_mod
12
from vllm.v1.attention.backends.mla.rocm_aiter_mla_sparse import (
13
_use_rocm_sparse_triton,
14
fit_kpool_indices_to_aiter,
@@ -99,6 +102,55 @@ def test_rocm_sparse_triton_route(
102
)
103
104
105
+@pytest.mark.parametrize("num_heads", [8, 12])
106
+def test_rocm_sparse_triton_route_preserves_padded_sinks(monkeypatch, num_heads):
107
+ captured = {}
108
+
109
+ def fake_rocm_sparse_attn_prefill(**kwargs):
110
+ output = kwargs["output"]
111
+ captured["attn_sink"] = kwargs["attn_sink"]
112
+ output.copy_(
113
+ captured["attn_sink"].to(output.dtype).view(1, -1, 1).expand_as(output)
114
+ )
115
+
116
+ monkeypatch.setattr(
117
+ sparse_mod, "rocm_sparse_attn_prefill", fake_rocm_sparse_attn_prefill
118
+ )
119
+
120
+ impl = object.__new__(sparse_mod.ROCMAiterMLASparseImpl)
121
+ impl.num_heads = num_heads
122
+ impl.kv_lora_rank = 512
123
+ impl.kv_cache_dtype = "auto"
124
+ impl.scale = 512**-0.5
125
+ impl.sinks = torch.arange(num_heads, dtype=torch.float32)
126
+
127
+ q = torch.zeros(2, 16, 512, dtype=torch.bfloat16)
128
+ kv = torch.zeros(4, 1, 512, dtype=torch.bfloat16)
129
+ metadata = SimpleNamespace(
130
+ attn_out_dtype=torch.bfloat16,
131
+ num_prefills=1,
132
+ num_decodes=0,
133
+ num_decode_tokens=0,
134
+ max_query_len=2,
135
+ paged_kv_indices=torch.empty(0, dtype=torch.int32),
136
+ paged_kv_indptr=torch.zeros(3, dtype=torch.int32),
137
+ )
138
+
139
+ output, lse = impl._forward_mla(SimpleNamespace(), q, kv, metadata)
140
+
141
+ if num_heads == 8:
142
+ expected_sinks = impl.sinks.repeat_interleave(2)
143
+ else:
144
+ expected_sinks = torch.cat((impl.sinks, impl.sinks[:4]))
145
+ torch.testing.assert_close(captured["attn_sink"], expected_sinks)
146
+ assert output.shape == (2, num_heads, 512)
147
+ torch.testing.assert_close(
148
+ output[:, :, 0].float(),
149
+ impl.sinks.expand(2, -1),
150
+ )
151
+ assert lse is None
152
+
153
+
154
def test_rocm_sparse_attention_accepts_glm_nope_dimensions():
155
_validate_sparse_dims(512, 512, 0, "test")
156
vllm/_aiter_ops.py
+142
@@ -624,6 +624,110 @@ def _rocm_aiter_mla_decode_fwd_impl(
624
)
625
626
627
+def _rocm_aiter_mla_decode_fwd_lse_impl(
628
+ q: torch.Tensor,
629
+ kv_buffer: torch.Tensor,
630
+ o: torch.Tensor,
631
+ qo_indptr: torch.Tensor,
632
+ max_seqlen_qo: int,
633
+ kv_indptr: torch.Tensor | None = None,
634
+ kv_indices: torch.Tensor | None = None,
635
+ kv_last_page_lens: torch.Tensor | None = None,
636
+ sm_scale: float = 1.0,
637
+ logit_cap: float = 0.0,
638
+ q_scale: torch.Tensor | None = None,
639
+ kv_scale: torch.Tensor | None = None,
640
+) -> torch.Tensor:
641
+ """Run non-persistent AITER MLA decode and return natural-log LSE.
642
+
643
+ gfx942 persistent MLA kernels do not provide an LSE code object. Keeping
644
+ this as a separate op makes that constraint structural: callers that need
645
+ LSE cannot accidentally forward persistent work metadata.
646
+ """
647
+ from aiter.mla import get_meta_param, mla_decode_fwd
648
+
649
+ kwargs: dict[str, float | int | torch.Tensor | None | bool] = {
650
+ "sm_scale": sm_scale,
651
+ "logit_cap": logit_cap,
652
+ "return_lse": True,
653
+ }
654
+ if _check_aiter_mla_fp8_support():
655
+ kwargs["q_scale"] = q_scale
656
+ kwargs["kv_scale"] = kv_scale
657
+
658
+ if (
659
+ q.dtype == torch.bfloat16
660
+ and kv_buffer.dtype == torch.bfloat16
661
+ and q.shape[1] == 32
662
+ ):
663
+ from vllm.platforms.rocm import on_gfx950
664
+
665
+ if on_gfx950():
666
+ # The gfx950 H32 BF16 kernel mishandles ragged tails with split KV.
667
+ # Its single-split path returns correct output and natural-log LSE.
668
+ kwargs["num_kv_splits"] = 1
669
+ kwargs["num_kv_splits_indptr"] = torch.arange(
670
+ qo_indptr.shape[0], dtype=torch.int32, device=q.device
671
+ )
672
+
673
+ if q.dtype == FP8_DTYPE:
674
+ assert kv_indices is not None
675
+ num_kv_splits, num_kv_splits_indptr = get_meta_param(
676
+ None,
677
+ qo_indptr.shape[0] - 1,
678
+ kv_indices.numel(),
679
+ q.shape[1],
680
+ max_seqlen_qo,
681
+ q.dtype,
682
+ )
683
+ if num_kv_splits == 1:
684
+ # gfx942's one-split FP8 asm writes the final output directly but
685
+ # does not write either LSE buffer. Force the normal split reducer,
686
+ # which produces both the same output and an accurate natural LSE.
687
+ num_kv_splits = 2
688
+ num_kv_splits_indptr = torch.arange(
689
+ 0,
690
+ (qo_indptr.shape[0]) * num_kv_splits,
691
+ num_kv_splits,
692
+ dtype=torch.int32,
693
+ device=q.device,
694
+ )
695
+ kwargs["num_kv_splits"] = num_kv_splits
696
+ kwargs["num_kv_splits_indptr"] = num_kv_splits_indptr
697
+
698
+ _, final_lse = mla_decode_fwd(
699
+ q,
700
+ kv_buffer.view(-1, 1, 1, q.shape[-1]),
701
+ o,
702
+ qo_indptr,
703
+ kv_indptr,
704
+ kv_indices,
705
+ kv_last_page_lens,
706
+ max_seqlen_qo,
707
+ **kwargs,
708
+ )
709
+ if final_lse is None:
710
+ raise RuntimeError("AITER MLA decode did not return the requested LSE")
711
+ return final_lse
712
+
713
+
714
+def _rocm_aiter_mla_decode_fwd_lse_fake(
715
+ q: torch.Tensor,
716
+ kv_buffer: torch.Tensor,
717
+ o: torch.Tensor,
718
+ qo_indptr: torch.Tensor,
719
+ max_seqlen_qo: int,
720
+ kv_indptr: torch.Tensor | None = None,
721
+ kv_indices: torch.Tensor | None = None,
722
+ kv_last_page_lens: torch.Tensor | None = None,
723
+ sm_scale: float = 1.0,
724
+ logit_cap: float = 0.0,
725
+ q_scale: torch.Tensor | None = None,
726
+ kv_scale: torch.Tensor | None = None,
727
+) -> torch.Tensor:
728
+ return torch.empty(q.shape[:-1], dtype=torch.float32, device=q.device)
729
+
730
+
731
def _rocm_aiter_w8a8_gemm_impl(
732
A: torch.Tensor,
733
B: torch.Tensor,
@@ -2170,6 +2274,13 @@ class rocm_aiter_ops:
2274
mutates_args=["o"],
2275
)
2276
2277
+ direct_register_custom_op(
2278
+ op_name="rocm_aiter_mla_decode_fwd_lse",
2279
+ op_func=_rocm_aiter_mla_decode_fwd_lse_impl,
2280
+ mutates_args=["o"],
2281
+ fake_impl=_rocm_aiter_mla_decode_fwd_lse_fake,
2282
+ )
2283
+
2284
direct_register_custom_op(
2285
op_name="rocm_aiter_w8a8_gemm",
2286
op_func=_rocm_aiter_w8a8_gemm_impl,
@@ -2756,6 +2867,37 @@ class rocm_aiter_ops:
2867
reduce_partial_map=reduce_partial_map,
2868
)
2869
2870
+ @staticmethod
2871
+ def mla_decode_fwd_lse(
2872
+ q: torch.Tensor,
2873
+ kv_buffer: torch.Tensor,
2874
+ o: torch.Tensor,
2875
+ sm_scale: float,
2876
+ qo_indptr: torch.Tensor,
2877
+ max_seqlen_qo: int,
2878
+ kv_indptr: torch.Tensor | None = None,
2879
+ kv_indices: torch.Tensor | None = None,
2880
+ kv_last_page_lens: torch.Tensor | None = None,
2881
+ logit_cap: float = 0.0,
2882
+ q_scale: torch.Tensor | None = None,
2883
+ kv_scale: torch.Tensor | None = None,
2884
+ ) -> torch.Tensor:
2885
+ """Run MLA decode without persistent metadata and return its LSE."""
2886
+ return torch.ops.vllm.rocm_aiter_mla_decode_fwd_lse(
2887
+ q,
2888
+ kv_buffer.view(-1, 1, 1, q.shape[-1]),
2889
+ o,
2890
+ qo_indptr,
2891
+ max_seqlen_qo,
2892
+ kv_indptr,
2893
+ kv_indices,
2894
+ kv_last_page_lens,
2895
+ sm_scale=sm_scale,
2896
+ logit_cap=logit_cap,
2897
+ q_scale=q_scale,
2898
+ kv_scale=kv_scale,
2899
+ )
2900
+
2901
@staticmethod
2902
def per_tensor_quant(
2903
x: torch.Tensor,
vllm/v1/attention/backend.py
+9
@@ -226,6 +226,13 @@ class AttentionBackend(ABC):
226
except NotImplementedError:
227
return False
228
229
+ @classmethod
230
+ def supports_dcp(cls) -> bool:
231
+ try:
232
+ return cls.get_impl_cls().supports_dcp
233
+ except NotImplementedError:
234
+ return False
235
+
236
@classmethod
237
def supports_non_causal_dcp(cls) -> bool:
238
builder_cls = cls.get_builder_cls()
@@ -324,6 +331,8 @@ class AttentionBackend(ABC):
331
invalid_reasons.append("KV connector not supported")
332
if use_pcp and not cls.supports_pcp():
333
invalid_reasons.append("PCP not supported")
334
+ if use_dcp and not cls.supports_dcp():
335
+ invalid_reasons.append("DCP not supported")
336
if (
337
use_adaptive_verification
338
and not cls.supports_device_cpu_query_lens_mismatch()
vllm/v1/attention/backends/mla/rocm_aiter_mla_sparse.py
+216
-50
@@ -35,7 +35,7 @@ from vllm.v1.attention.backends.utils import split_decodes_and_prefills
35
from vllm.v1.attention.ops.rocm_aiter_mla_sparse import (
36
rocm_sparse_attn_prefill,
37
)
38
-from vllm.v1.kv_cache_interface import AttentionSpec
38
+from vllm.v1.kv_cache_interface import AttentionSpec, KVCacheLayout
39
from vllm.v1.worker.workspace import current_workspace_manager
40
41
if TYPE_CHECKING:
@@ -350,6 +350,17 @@ class ROCMAiterMLASparseBackend(AttentionBackend):
350
def is_sparse(cls) -> bool:
351
return True
352
353
+ @classmethod
354
+ def supported_kv_cache_layouts(cls) -> tuple[KVCacheLayout, ...]:
355
+ # Global index conversion assumes contiguous pages within each layer.
356
+ return (KVCacheLayout.LBNHC, KVCacheLayout.LBHNC)
357
+
358
+ @classmethod
359
+ def supports_sink(cls) -> bool:
360
+ from vllm.platforms.rocm import on_mi3xx
361
+
362
+ return on_mi3xx()
363
+
364
365
@dataclass
366
class ROCMAiterMLASparseMetadata(AttentionMetadata):
@@ -418,6 +429,14 @@ class ROCMAiterMLASparseMetadataBuilder(
429
self.num_heads = self.model_config.get_num_attention_heads(parallel_config)
430
self.mla_dims = get_mla_dims(self.model_config)
431
self.topk_tokens = vllm_config.model_config.hf_config.index_topk
432
+ attention_context = vllm_config.compilation_config.static_forward_context
433
+ # Sink decode must use AITER's nonpersistent path. In particular,
434
+ # gfx942 has no persistent+LSE kernel, and its metadata heuristic
435
+ # terminates for HY-V4's TP1 H64 shape.
436
+ self._use_persistent_metadata = all(
437
+ getattr(attention_context[name].impl, "sinks", None) is None
438
+ for name in layer_names
439
+ )
440
# Bounds the KV-split heuristic (see `_sparse_decode_max_split`).
441
self._num_compute_units = current_platform.num_compute_units()
442
self.max_model_len_tensor = torch.tensor(
@@ -591,14 +610,13 @@ class ROCMAiterMLASparseMetadataBuilder(
610
paged_kv_indices = self.paged_kv_indices[: num_tokens * self.topk_tokens]
611
612
# ----- Compute persistent MLA metadata -----
594
- # The aiter sparse decode kernel uses qseqlen=1 (each query token is
595
- # treated as its own batch entry), so persistent metadata can always
596
- # be precomputed here. The kernel switches to the persistent
597
- # work-stealing path automatically when work_meta_data is non-None.
598
- # The output is a deterministic function of the per-request query and
599
- # context lengths (both clamped to topk_tokens, past which per-token KV
600
- # length saturates) and num_heads; fingerprint those CPU-side and skip
601
- # the launch when nothing changed.
613
+ # The AITER sparse decode kernel uses qseqlen=1 (each query token is
614
+ # treated as its own batch entry). Build its persistent work metadata
615
+ # only when AITER is selected and no layer needs the nonpersistent LSE
616
+ # path for attention sinks. The output is a deterministic function of
617
+ # the per-request query and context lengths (both clamped to
618
+ # topk_tokens, past which per-token KV length saturates) and num_heads;
619
+ # fingerprint those CPU-side and skip the launch when nothing changed.
620
head_size = self.mla_dims.kv_lora_rank + self.mla_dims.qk_rope_head_dim
621
use_triton_sparse = _use_rocm_sparse_triton(
622
kv_cache_dtype=self.kv_cache_dtype,
@@ -615,7 +633,7 @@ class ROCMAiterMLASparseMetadataBuilder(
633
reduce_indptr = None
634
reduce_final_map = None
635
reduce_partial_map = None
618
- if not use_triton_sparse:
636
+ if self._use_persistent_metadata and not use_triton_sparse:
637
num_reqs = common_attn_metadata.num_reqs
638
clamped_seq_lens = np.minimum(
639
common_attn_metadata.seq_lens_cpu[:num_reqs].numpy(),
@@ -729,6 +747,7 @@ def reference_mla_sparse_prefill(
747
class ROCMAiterMLASparseImpl(MLAAttentionImpl[ROCMAiterMLASparseMetadata]):
748
is_sparse = True
749
supports_dense_mha_prefill = False
750
+ supports_dcp = False
751
752
def __init__(
753
self,
@@ -753,6 +772,20 @@ class ROCMAiterMLASparseImpl(MLAAttentionImpl[ROCMAiterMLASparseMetadata]):
772
self.head_size = head_size
773
self.scale = float(scale)
774
self.num_kv_heads = num_kv_heads
775
+ sinks = mla_args.pop("sinks", None)
776
+ if sinks is not None:
777
+ if sinks.dtype != torch.float32:
778
+ raise ValueError(
779
+ f"ROCm AITER MLA sinks must be float32, got {sinks.dtype}"
780
+ )
781
+ if sinks.ndim != 1 or sinks.numel() != num_heads:
782
+ raise ValueError(
783
+ "ROCm AITER MLA sinks must have shape "
784
+ f"({num_heads},), got {tuple(sinks.shape)}"
785
+ )
786
+ if not sinks.is_contiguous():
787
+ raise ValueError("ROCm AITER MLA sinks must be contiguous")
788
+ self.sinks: torch.Tensor | None = sinks
789
self.kv_cache_dtype = kv_cache_dtype
790
self.kv_lora_rank: int = mla_args["kv_lora_rank"]
791
self.softmax_scale = scale
@@ -776,16 +809,31 @@ class ROCMAiterMLASparseImpl(MLAAttentionImpl[ROCMAiterMLASparseMetadata]):
809
q: torch.Tensor, # [sq, heads, d_qk]
810
kv_c_and_k_pe_cache: torch.Tensor, # [blocks, heads, d_qk]
811
attn_metadata: ROCMAiterMLASparseMetadata,
779
- ) -> torch.Tensor:
812
+ ) -> tuple[torch.Tensor, torch.Tensor | None]:
813
num_tokens = q.shape[0]
781
- mla_num_heads = AiterMLAHelper.get_actual_mla_num_heads(self.num_heads)
782
- output = torch.empty(
783
- [num_tokens, mla_num_heads, self.kv_lora_rank],
784
- dtype=attn_metadata.attn_out_dtype,
785
- device=q.device,
814
+ base_mla_num_heads = AiterMLAHelper.get_actual_mla_num_heads(self.num_heads)
815
+ mla_num_heads = base_mla_num_heads
816
+ need_lse = self.sinks is not None
817
+ from vllm.platforms.rocm import on_gfx942
818
+
819
+ # Keep sink attention available for dtypes/head shapes without an
820
+ # AITER return-LSE kernel. Sink layers never need persistent metadata.
821
+ triton_sink_fallback = (
822
+ need_lse
823
+ and q.dtype == kv_c_and_k_pe_cache.dtype
824
+ and (
825
+ q.dtype == torch.float16
826
+ or (
827
+ q.dtype == torch.bfloat16
828
+ and (
829
+ mla_num_heads > 128
830
+ or (on_gfx942() and 32 < mla_num_heads <= 64)
831
+ )
832
+ )
833
+ )
834
)
835
788
- if _use_rocm_sparse_triton(
836
+ if triton_sink_fallback or _use_rocm_sparse_triton(
837
kv_cache_dtype=self.kv_cache_dtype,
838
head_size=q.shape[-1],
839
kv_lora_rank=self.kv_lora_rank,
@@ -794,6 +842,18 @@ class ROCMAiterMLASparseImpl(MLAAttentionImpl[ROCMAiterMLASparseMetadata]):
842
num_decode_tokens=attn_metadata.num_decode_tokens,
843
max_query_len=attn_metadata.max_query_len,
844
):
845
+ output = torch.empty(
846
+ [num_tokens, q.shape[1], self.kv_lora_rank],
847
+ dtype=attn_metadata.attn_out_dtype,
848
+ device=q.device,
849
+ )
850
+ triton_sinks = None
851
+ if self.sinks is not None:
852
+ triton_sinks = AiterMLAHelper.get_mla_padded_q(
853
+ self.num_heads,
854
+ self.sinks.reshape(1, self.num_heads, 1),
855
+ q.shape[1],
856
+ ).reshape(-1)
857
rocm_sparse_attn_prefill(
858
q=q,
859
kv=kv_c_and_k_pe_cache.view(-1, 1, q.shape[-1]),
@@ -803,44 +863,149 @@ class ROCMAiterMLASparseImpl(MLAAttentionImpl[ROCMAiterMLASparseMetadata]):
863
head_dim=q.shape[-1],
864
nope_head_dim=self.kv_lora_rank,
865
rope_head_dim=q.shape[-1] - self.kv_lora_rank,
806
- attn_sink=None,
866
+ attn_sink=triton_sinks,
867
output=output,
868
ragged_indices=attn_metadata.paged_kv_indices,
869
ragged_indptr=attn_metadata.paged_kv_indptr,
870
)
811
- return AiterMLAHelper.get_mla_unpadded_o(self.num_heads, output)
812
-
813
- # Build kwargs and forward the persistent MLA metadata when it has
814
- # been computed. The aiter mla_decode_fwd switches to its
815
- # work-stealing persistent kernel path when work_meta_data is given.
816
- mla_kwargs: dict = dict(
817
- q_scale=layer._q_scale,
818
- kv_scale=layer._k_scale,
819
- )
820
- if attn_metadata.work_meta_data is not None:
821
- mla_kwargs.update(
822
- work_meta_data=attn_metadata.work_meta_data,
823
- work_indptr=attn_metadata.work_indptr,
824
- work_info_set=attn_metadata.work_info_set,
825
- reduce_indptr=attn_metadata.reduce_indptr,
826
- reduce_final_map=attn_metadata.reduce_final_map,
827
- reduce_partial_map=attn_metadata.reduce_partial_map,
828
- )
871
+ output = AiterMLAHelper.get_mla_unpadded_o(self.num_heads, output)
872
+ return output, None
873
+
874
+ # AITER's nonpersistent return-LSE dispatch has discrete head kernels.
875
+ supported_head_buckets: tuple[int, ...] | None = None
876
+ head_dtype_name = ""
877
+ if need_lse:
878
+ if (
879
+ q.dtype == torch.bfloat16
880
+ and kv_c_and_k_pe_cache.dtype == torch.bfloat16
881
+ ):
882
+ supported_head_buckets = (16, 32, 64, 128)
883
+ head_dtype_name = "BF16"
884
+ elif (
885
+ q.dtype == current_platform.fp8_dtype()
886
+ and kv_c_and_k_pe_cache.dtype == current_platform.fp8_dtype()
887
+ ):
888
+ supported_head_buckets = (16, 128)
889
+ head_dtype_name = "FP8"
890
+ else:
891
+ raise ValueError(
892
+ "ROCm AITER MLA attention sinks require query and KV to "
893
+ "both use BF16 or both use FP8, got "
894
+ f"query={q.dtype}, KV={kv_c_and_k_pe_cache.dtype}"
895
+ )
896
830
- rocm_aiter_ops.mla_decode_fwd(
831
- q,
832
- kv_c_and_k_pe_cache,
833
- output,
834
- self.scale,
835
- attn_metadata.qo_indptr,
836
- 1,
837
- attn_metadata.paged_kv_indptr,
838
- attn_metadata.paged_kv_indices,
839
- attn_metadata.paged_kv_last_page_len,
840
- **mla_kwargs,
897
+ if supported_head_buckets is not None:
898
+ from vllm.platforms.rocm import on_mi3xx
899
+
900
+ if on_mi3xx():
901
+ supported_heads = next(
902
+ (
903
+ heads
904
+ for heads in supported_head_buckets
905
+ if heads >= mla_num_heads
906
+ ),
907
+ None,
908
+ )
909
+ if supported_heads is None:
910
+ raise ValueError(
911
+ "ROCm AITER MLA attention sinks support at most 128 "
912
+ f"padded local {head_dtype_name} heads; increase "
913
+ "tensor_parallel_size"
914
+ )
915
+ if supported_heads != mla_num_heads:
916
+ q = AiterMLAHelper.get_mla_padded_q(
917
+ mla_num_heads, q, supported_heads
918
+ )
919
+ mla_num_heads = supported_heads
920
+ output = torch.empty(
921
+ [num_tokens, mla_num_heads, self.kv_lora_rank],
922
+ dtype=attn_metadata.attn_out_dtype,
923
+ device=q.device,
924
)
925
843
- return AiterMLAHelper.get_mla_unpadded_o(self.num_heads, output)
926
+ if need_lse:
927
+ # gfx942 has no persistent MLA code object that writes final LSE.
928
+ # The split-KV path consumes the same ragged indices and is exact.
929
+ lse = rocm_aiter_ops.mla_decode_fwd_lse(
930
+ q,
931
+ kv_c_and_k_pe_cache,
932
+ output,
933
+ self.scale,
934
+ attn_metadata.qo_indptr,
935
+ 1,
936
+ attn_metadata.paged_kv_indptr,
937
+ attn_metadata.paged_kv_indices,
938
+ attn_metadata.paged_kv_last_page_len,
939
+ q_scale=layer._q_scale,
940
+ kv_scale=layer._k_scale,
941
+ )
942
+ else:
943
+ # Preserve the persistent work-stealing fast path for models that
944
+ # do not use sinks.
945
+ mla_kwargs: dict = dict(
946
+ q_scale=layer._q_scale,
947
+ kv_scale=layer._k_scale,
948
+ )
949
+ if attn_metadata.work_meta_data is not None:
950
+ mla_kwargs.update(
951
+ work_meta_data=attn_metadata.work_meta_data,
952
+ work_indptr=attn_metadata.work_indptr,
953
+ work_info_set=attn_metadata.work_info_set,
954
+ reduce_indptr=attn_metadata.reduce_indptr,
955
+ reduce_final_map=attn_metadata.reduce_final_map,
956
+ reduce_partial_map=attn_metadata.reduce_partial_map,
957
+ )
958
+
959
+ rocm_aiter_ops.mla_decode_fwd(
960
+ q,
961
+ kv_c_and_k_pe_cache,
962
+ output,
963
+ self.scale,
964
+ attn_metadata.qo_indptr,
965
+ 1,
966
+ attn_metadata.paged_kv_indptr,
967
+ attn_metadata.paged_kv_indices,
968
+ attn_metadata.paged_kv_last_page_len,
969
+ **mla_kwargs,
970
+ )
971
+ lse = None
972
+
973
+ if mla_num_heads != base_mla_num_heads:
974
+ if mla_num_heads % base_mla_num_heads == 0:
975
+ head_stride = mla_num_heads // base_mla_num_heads
976
+ output = output[:, ::head_stride]
977
+ if lse is not None:
978
+ lse = lse[:, ::head_stride]
979
+ else:
980
+ output = output[:, :base_mla_num_heads]
981
+ if lse is not None:
982
+ lse = lse[:, :base_mla_num_heads]
983
+
984
+ output = AiterMLAHelper.get_mla_unpadded_o(self.num_heads, output)
985
+ if lse is not None:
986
+ lse = AiterMLAHelper.get_mla_unpadded_o(
987
+ self.num_heads, lse.unsqueeze(-1)
988
+ ).squeeze(-1)
989
+
990
+ if self.sinks is not None:
991
+ assert lse is not None
992
+ # Empty ragged rows have only sink mass and no value contribution.
993
+ # AITER can return NaN output/LSE for those rows; do not multiply it
994
+ # by a zero normalization factor and propagate the NaN.
995
+ has_keys = (
996
+ attn_metadata.paged_kv_indptr[1:] > attn_metadata.paged_kv_indptr[:-1]
997
+ ).unsqueeze(-1)
998
+ lse = torch.where(has_keys, lse, float("-inf"))
999
+ sink_lse = torch.logaddexp(lse, self.sinks)
1000
+ sink_scale = torch.exp(lse - sink_lse)
1001
+ output = torch.where(
1002
+ has_keys.unsqueeze(-1),
1003
+ output.float() * sink_scale.unsqueeze(-1),
1004
+ 0.0,
1005
+ ).to(output.dtype)
1006
+ lse = sink_lse
1007
+
1008
+ return output, lse
1009
1010
def forward_mqa(
1011
self,
@@ -863,6 +1028,8 @@ class ROCMAiterMLASparseImpl(MLAAttentionImpl[ROCMAiterMLASparseMetadata]):
1028
q = self.q_concat_buffer[: ql_nope.shape[0]]
1029
if q_pe.shape[-1] == 0:
1030
q.copy_(ql_nope)
1031
+ elif q.dtype == torch.float16:
1032
+ torch.cat((ql_nope, q_pe), dim=-1, out=q)
1033
else:
1034
ops.concat_mla_q(ql_nope, q_pe, q)
1035
@@ -892,7 +1059,6 @@ class ROCMAiterMLASparseImpl(MLAAttentionImpl[ROCMAiterMLASparseMetadata]):
1059
q, _ = ops.scaled_fp8_quant(q.view(q.shape[0], -1), layer._q_scale)
1060
q = q.view(original_q_shape)
1061
mla_padded_q = AiterMLAHelper.get_mla_padded_q(self.num_heads, q)
895
- attn_out = self._forward_mla(
1062
+ return self._forward_mla(
1063
layer, mla_padded_q, kv_c_and_k_pe_cache, attn_metadata
1064
)
898
- return attn_out, None
vllm/v1/attention/ops/rocm_aiter_mla_sparse.py
+2
-2
@@ -2610,7 +2610,7 @@ def _rocm_sparse_attn_prefill_ragged_triton(
2610
block_d = triton.next_power_of_2(head_dim)
2611
block_k = 16 if head_dim >= 256 else 32
2612
num_warps = 4
2613
- out = torch.empty_like(q, dtype=torch.bfloat16)
2613
+ out = torch.empty_like(q)
2614
_sparse_attn_prefill_ragged_kernel[(num_queries, triton.cdiv(num_heads, block_h))](
2615
q,
2616
kv,
@@ -3195,7 +3195,7 @@ def rocm_sparse_attn_prefill(
3195
rope_head_dim=rope_head_dim,
3196
topk_length=topk_length,
3197
)
3198
- output.copy_(output_chunk.to(output.dtype))
3198
+ output.copy_(output_chunk[..., : output.shape[-1]].to(output.dtype))
3199
3200
3201
def rocm_sparse_attn_decode(