main
py 515 lines 18 KB
Raw
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),
171 q,
172 kv,
173 metadata,
174 )
175 kv_flat = kv_ref[:, 0]
176 references = []
177 start = 0
178 for batch_idx, seq_len in enumerate(seq_lens):
179 rows = kv_flat[indices[start : start + seq_len].long()]
180 references.append(_sink_reference(q_ref[batch_idx], rows, sinks, SM_SCALE))
181 start += seq_len
182 expected, expected_lse, value_scale, score_error = (
183 torch.stack(values) for values in zip(*references)
184 )
185
186 assert output.dtype == torch.bfloat16
187 if lse is not None:
188 assert lse.dtype == torch.float32
189 assert lse.shape == expected_lse.shape
190 lse_error = (lse.double() - expected_lse).abs()
191 rounded_lse = expected_lse.float()
192 positive_inf = torch.full_like(rounded_lse, float("inf"))
193 ulp = torch.maximum(
194 torch.nextafter(rounded_lse, positive_inf) - rounded_lse,
195 rounded_lse - torch.nextafter(rounded_lse, -positive_inf),
196 ).double()
197 lse_allowance = score_error + 2 * ulp
198 assert torch.all(lse_error <= lse_allowance)
199 torch.testing.assert_close(lse[0].double(), expected_lse[0], atol=0, rtol=0)
200 else:
201 from vllm.platforms.rocm import on_gfx942
202
203 assert cache_kind == "bf16" and real_heads in (40, 48, 64) and on_gfx942()
204 _assert_sink_output_close(
205 output,
206 expected,
207 value_scale,
208 score_error,
209 q_real.dtype,
210 native=lse is not None,
211 )
212
213
214 @pytest.mark.parametrize(
215 ("q_dtype", "kv_dtype"),
216 [
217 (torch.float16, torch.bfloat16),
218 (torch.bfloat16, torch.float16),
219 ],
220 )
221 def test_sparse_mla_sink_rejects_unsupported_aiter_dtypes(
222 q_dtype: torch.dtype, kv_dtype: torch.dtype
223 ) -> None:
224 from vllm.v1.attention.backends.mla.rocm_aiter_mla_sparse import (
225 ROCMAiterMLASparseImpl,
226 )
227
228 impl = object.__new__(ROCMAiterMLASparseImpl)
229 impl.num_heads = 16
230 impl.kv_lora_rank = V_HEAD_DIM
231 impl.kv_cache_dtype = "auto"
232 impl.scale = SM_SCALE
233 impl.sinks = torch.zeros(16, dtype=torch.float32)
234 metadata = SimpleNamespace(
235 attn_out_dtype=torch.bfloat16,
236 num_prefills=0,
237 num_decodes=1,
238 num_decode_tokens=1,
239 max_query_len=1,
240 )
241
242 with pytest.raises(ValueError, match="both use BF16 or both use FP8"):
243 impl._forward_mla(
244 SimpleNamespace(_q_scale=None, _k_scale=None),
245 torch.empty(1, 16, Q_HEAD_DIM, dtype=q_dtype),
246 torch.empty(1, 1, Q_HEAD_DIM, dtype=kv_dtype),
247 metadata,
248 )
249
250
251 def _make_noncontiguous_sink() -> torch.Tensor:
252 return torch.empty(8, dtype=torch.float32)[::2]
253
254
255 @pytest.mark.parametrize(
256 ("sinks", "match"),
257 [
258 (torch.empty(4, dtype=torch.bfloat16), "must be float32"),
259 (torch.empty(2, 2, dtype=torch.float32), "must have shape"),
260 (_make_noncontiguous_sink(), "must be contiguous"),
261 ],
262 )
263 def test_sparse_mla_sink_validation(sinks: torch.Tensor, match: str) -> None:
264 from vllm.v1.attention.backends.mla.rocm_aiter_mla_sparse import (
265 ROCMAiterMLASparseImpl,
266 )
267
268 with pytest.raises(ValueError, match=match):
269 ROCMAiterMLASparseImpl(
270 num_heads=4,
271 head_size=Q_HEAD_DIM,
272 scale=SM_SCALE,
273 num_kv_heads=1,
274 alibi_slopes=None,
275 sliding_window=None,
276 kv_cache_dtype="bfloat16",
277 logits_soft_cap=None,
278 attn_type="decoder",
279 kv_sharing_target_layer_name=None,
280 sinks=sinks,
281 kv_lora_rank=V_HEAD_DIM,
282 )
283
284
285 def test_sparse_mla_backend_reports_sink_support_for_current_hardware() -> None:
286 from vllm.platforms.rocm import get_cdna_version
287 from vllm.v1.attention.backends.mla.rocm_aiter_mla_sparse import (
288 ROCMAiterMLASparseBackend,
289 )
290
291 assert ROCMAiterMLASparseBackend.supports_sink() == (get_cdna_version() in (3, 4))
292
293
294 def test_sparse_mla_backend_rejects_dcp() -> None:
295 from vllm.platforms.rocm import RocmPlatform
296 from vllm.v1.attention.backends.registry import AttentionBackendEnum
297 from vllm.v1.attention.selector import AttentionSelectorConfig
298
299 selector_config = AttentionSelectorConfig(
300 head_size=Q_HEAD_DIM,
301 dtype=torch.bfloat16,
302 kv_cache_dtype="bfloat16",
303 block_size=16,
304 use_mla=True,
305 has_sink=True,
306 use_sparse=True,
307 use_mm_prefix=False,
308 use_per_head_quant_scales=False,
309 attn_type="decoder",
310 use_dcp=True,
311 )
312
313 with pytest.raises(ValueError, match="DCP not supported"):
314 RocmPlatform.get_attn_backend_cls(
315 selected_backend=AttentionBackendEnum.ROCM_AITER_MLA_SPARSE,
316 attn_selector_config=selector_config,
317 )
318
319
320 @pytest.mark.parametrize("num_heads,block_size", [(8, 16), (20, 32)])
321 @pytest.mark.parametrize("interleaved_pages", [False, True])
322 @pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
323 @torch.inference_mode()
324 def test_sparse_mla_sink_matches_dense_attention_with_empty_rows_and_paged_cache(
325 num_heads: int,
326 block_size: int,
327 interleaved_pages: bool,
328 dtype: torch.dtype,
329 ) -> None:
330 """Preserve head-specific sinks and latent values across ragged cache pages."""
331 _require_aiter()
332 from vllm.v1.attention.backends.mla.rocm_aiter_mla import AiterMLAHelper
333 from vllm.v1.attention.backends.mla.rocm_aiter_mla_sparse import (
334 ROCMAiterMLASparseImpl,
335 )
336 from vllm.v1.attention.backends.mla.sparse_utils import flat_kv_row_view
337
338 head_dim, value_dim = 576, 512
339 generator = torch.Generator().manual_seed(421)
340 # The empty row exercises sink-only attention; the last row crosses both
341 # page boundaries and the kernel's 16-key reduction tile.
342 selected_rows = [
343 [],
344 [(2, 7)],
345 [(3, 1), (0, block_size - 1), (1, 0), (2, 4)],
346 [(i % 4, (i * 7) % block_size) for i in range(23)],
347 ]
348 q_cpu = torch.randn(
349 len(selected_rows), num_heads, head_dim, generator=generator
350 ).to(dtype)
351 cache_cpu = torch.randn(4, block_size, head_dim, generator=generator).to(dtype)
352 sinks_cpu = torch.linspace(-4.0, 6.0, num_heads)
353
354 # Layers can share a backing allocation, leaving unused rows between pages.
355 num_layers = 3 if interleaved_pages else 1
356 backing = torch.full(
357 (4, num_layers, block_size, head_dim),
358 float("nan"),
359 dtype=dtype,
360 device="cuda",
361 )
362 cache = backing[:, num_layers - 1]
363 cache.copy_(cache_cpu)
364 indices = [
365 page * num_layers * block_size + offset
366 for rows in selected_rows
367 for page, offset in rows
368 ]
369 lengths = torch.tensor([0, *(len(rows) for rows in selected_rows)])
370 metadata = SimpleNamespace(
371 block_size=block_size,
372 num_prefills=0,
373 num_decodes=len(selected_rows),
374 num_decode_tokens=len(selected_rows),
375 max_query_len=1,
376 qo_indptr=torch.arange(
377 len(selected_rows) + 1, dtype=torch.int32, device="cuda"
378 ),
379 paged_kv_last_page_len=torch.ones(
380 len(selected_rows), dtype=torch.int32, device="cuda"
381 ),
382 work_meta_data=None,
383 attn_out_dtype=dtype,
384 paged_kv_indices=torch.tensor(indices, dtype=torch.int32, device="cuda"),
385 paged_kv_indptr=lengths.cumsum(0).to(device="cuda", dtype=torch.int32),
386 )
387 impl = ROCMAiterMLASparseImpl.__new__(ROCMAiterMLASparseImpl)
388 impl.num_heads = num_heads
389 impl.head_size = head_dim
390 impl.kv_lora_rank = value_dim
391 impl.scale = head_dim**-0.5
392 impl.kv_cache_dtype = "auto"
393 impl.sinks = sinks_cpu.cuda()
394 padded_q = AiterMLAHelper.get_mla_padded_q(num_heads, q_cpu.cuda())
395
396 kv_rows, _ = flat_kv_row_view(cache, block_size)
397 actual, _ = impl._forward_mla(
398 SimpleNamespace(_q_scale=None, _k_scale=None),
399 padded_q,
400 kv_rows.unsqueeze(1),
401 metadata,
402 )
403
404 references = []
405 for query_idx, rows in enumerate(selected_rows):
406 keys = (
407 torch.stack([cache_cpu[page, offset] for page, offset in rows])
408 if rows
409 else cache_cpu.new_empty((0, head_dim))
410 )
411 references.append(
412 _sink_reference(q_cpu[query_idx], keys, sinks_cpu, impl.scale)
413 )
414 expected, _, value_scale, score_error = (
415 torch.stack(values) for values in zip(*references)
416 )
417 assert actual.shape == (len(selected_rows), num_heads, value_dim)
418 assert actual.dtype == dtype
419 _assert_sink_output_close(
420 actual,
421 expected,
422 value_scale,
423 score_error,
424 dtype,
425 native=dtype == torch.bfloat16,
426 )
427
428
429 @pytest.mark.parametrize("layout", ["LBNHC", "LBHNC", "BLNHC"])
430 def test_sparse_mla_backend_resolves_only_contiguous_layer_layouts(monkeypatch, layout):
431 from vllm.config import CacheConfig
432 from vllm.v1.attention.backends.mla.rocm_aiter_mla_sparse import (
433 ROCMAiterMLASparseBackend,
434 )
435 from vllm.v1.attention.backends.utils import resolve_kv_cache_layout
436
437 monkeypatch.setenv("VLLM_KV_CACHE_LAYOUT", layout)
438 config = SimpleNamespace(cache_config=CacheConfig())
439 supported = [
440 [x.name for x in ROCMAiterMLASparseBackend.supported_kv_cache_layouts()]
441 ]
442 if layout == "BLNHC":
443 with pytest.raises(ValueError, match="does not satisfy"):
444 resolve_kv_cache_layout(config, supported)
445 else:
446 assert resolve_kv_cache_layout(config, supported).name == layout
447
448
449 @pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
450 @torch.inference_mode()
451 def test_sparse_mla_sink_forward_mqa_preserves_split_query(dtype):
452 """The public sparse forward joins latent/RoPE queries in the model dtype."""
453 _require_aiter()
454 from vllm.v1.attention.backends.mla.rocm_aiter_mla_sparse import (
455 ROCMAiterMLASparseImpl,
456 )
457
458 set_random_seed(412)
459 num_tokens, num_heads, block_size = 2, 8, 16
460 q = torch.randn(num_tokens, num_heads, Q_HEAD_DIM, device="cuda").to(dtype)
461 kv = torch.randn(2 * block_size, 1, Q_HEAD_DIM, device="cuda").to(dtype)
462 selected = torch.tensor([[1, 7, 18], [0, 3, 20]], dtype=torch.int32, device="cuda")
463 sinks = torch.linspace(-3.0, 5.0, num_heads, device="cuda")
464 impl = object.__new__(ROCMAiterMLASparseImpl)
465 impl.num_heads = num_heads
466 impl.kv_lora_rank = V_HEAD_DIM
467 impl.kv_cache_dtype = "auto"
468 impl.scale = SM_SCALE
469 impl.sinks = sinks
470 impl.topk_indices_buffer = torch.full(
471 (num_tokens, 128), -1, dtype=torch.int32, device="cuda"
472 )
473 impl.topk_indices_buffer[:, : selected.shape[1]] = selected
474 impl.q_concat_buffer = torch.empty_like(q)
475 metadata = SimpleNamespace(
476 attn_out_dtype=dtype,
477 num_actual_tokens=num_tokens,
478 num_prefills=0,
479 num_decodes=num_tokens,
480 num_decode_tokens=num_tokens,
481 max_query_len=1,
482 block_size=block_size,
483 topk_tokens=impl.topk_indices_buffer.shape[1],
484 req_id_per_token=torch.zeros(num_tokens, dtype=torch.int32, device="cuda"),
485 block_table=torch.tensor([[0, 1]], dtype=torch.int32, device="cuda"),
486 qo_indptr=torch.arange(num_tokens + 1, dtype=torch.int32, device="cuda"),
487 paged_kv_indptr=torch.tensor([0, 3, 6], dtype=torch.int32, device="cuda"),
488 paged_kv_indices=torch.empty(
489 selected.numel(), dtype=torch.int32, device="cuda"
490 ),
491 paged_kv_last_page_len=torch.ones(num_tokens, dtype=torch.int32, device="cuda"),
492 work_meta_data=None,
493 )
494 actual, _ = impl.forward_mqa(
495 (q[..., :V_HEAD_DIM], q[..., V_HEAD_DIM:]),
496 kv,
497 metadata,
498 SimpleNamespace(_q_scale=None, _k_scale=None),
499 )
500 references = [
501 _sink_reference(q[i], kv[:, 0][selected[i].long()], sinks, SM_SCALE)
502 for i in range(num_tokens)
503 ]
504 expected, _, value_scale, score_error = (
505 torch.stack(values) for values in zip(*references)
506 )
507 assert actual.dtype == dtype
508 _assert_sink_output_close(
509 actual,
510 expected,
511 value_scale,
512 score_error,
513 dtype,
514 native=dtype == torch.bfloat16,
515 )