[ROCm][Perf] Route large DSV4 sparse prefill to AITER OPUS (#54855)

Signed-off-by: jiacao-amd <jiahui.cao@amd.com> Co-authored-by: OpenAI Codex <codex@openai.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

jiacao-amd committed Sep 9, 2026 at 23:39 UTC 7e917606504f48f22491eb9d04ce50c28618d45f
2 files changed +318
tests/kernels/attention/test_rocm_triton_attn_dsv4.py
+211
@@ -381,6 +381,217 @@ def test_sparse_attn_prefill_ragged_kernel() -> None:
381 torch.testing.assert_close(actual, expected, atol=2e-2, rtol=2e-2)
382
383
384 +@pytest.mark.parametrize(
385 + ("num_queries", "on_gfx950", "expected"),
386 + [(1023, True, False), (1024, True, True), (1024, False, False)],
387 +)
388 +def test_aiter_sparse_prefill_opus_selection(
389 + num_queries: int, on_gfx950: bool, expected: bool
390 +) -> None:
391 + from vllm.v1.attention.ops.rocm_aiter_mla_sparse import (
392 + _can_use_aiter_sparse_prefill_opus,
393 + )
394 +
395 + device = torch.device("cuda")
396 + q = torch.empty(num_queries, 16, HEAD_DIM, dtype=torch.bfloat16, device=device)
397 + kv = torch.empty(num_queries, HEAD_DIM, dtype=torch.bfloat16, device=device)
398 + attn_sink = torch.empty(16, dtype=torch.float32, device=device)
399 + output = torch.empty_like(q)
400 +
401 + assert (
402 + _can_use_aiter_sparse_prefill_opus(
403 + q, kv, attn_sink, output, on_gfx950=on_gfx950
404 + )
405 + is expected
406 + )
407 +
408 +
409 +def test_aiter_sparse_prefill_opus_selection_rejects_incompatible_inputs() -> None:
410 + from vllm.v1.attention.ops.rocm_aiter_mla_sparse import (
411 + _can_use_aiter_sparse_prefill_opus,
412 + )
413 +
414 + device = torch.device("cuda")
415 + q = torch.empty(1024, 16, HEAD_DIM, dtype=torch.bfloat16, device=device)
416 + kv = torch.empty(1024, HEAD_DIM, dtype=torch.bfloat16, device=device)
417 + attn_sink = torch.empty(16, dtype=torch.float32, device=device)
418 + output = torch.empty_like(q)
419 +
420 + assert _can_use_aiter_sparse_prefill_opus(q, kv, attn_sink, output, on_gfx950=True)
421 + assert not _can_use_aiter_sparse_prefill_opus(
422 + q,
423 + kv,
424 + attn_sink,
425 + torch.empty(16, 1024, HEAD_DIM, dtype=q.dtype, device=device).transpose(0, 1),
426 + on_gfx950=True,
427 + )
428 + assert not _can_use_aiter_sparse_prefill_opus(
429 + q, kv, attn_sink[:-1], output, on_gfx950=True
430 + )
431 + assert not _can_use_aiter_sparse_prefill_opus(
432 + q.cpu(), kv.cpu(), attn_sink.cpu(), output.cpu(), on_gfx950=True
433 + )
434 +
435 +
436 +def test_sparse_attn_prefill_aiter_opus_routing(monkeypatch) -> None:
437 + from vllm.v1.attention.ops import rocm_aiter_mla_sparse as mod
438 +
439 + q = torch.empty(2, 1, HEAD_DIM, dtype=torch.bfloat16)
440 + kv = torch.empty(2, 1, HEAD_DIM, dtype=torch.bfloat16)
441 + indices = torch.tensor([[0], [1]], dtype=torch.int32)
442 + topk_length = torch.ones(2, dtype=torch.int32)
443 + attn_sink = torch.empty(1, dtype=torch.float32)
444 + output = torch.empty_like(q)
445 + opus_calls = 0
446 +
447 + def fake_opus(*args, out):
448 + nonlocal opus_calls
449 + opus_calls += 1
450 + assert args[2].dtype == torch.int32
451 + assert args[3].dtype == torch.int32
452 + assert args[5].numel() == 0
453 + assert torch.count_nonzero(args[6]) == 0
454 + out.zero_()
455 + return out
456 +
457 + monkeypatch.setattr(mod, "_can_use_aiter_sparse_prefill_opus", lambda *args: True)
458 + monkeypatch.setattr(mod, "_get_aiter_sparse_prefill_opus", lambda: fake_opus)
459 + monkeypatch.setattr(
460 + mod,
461 + "build_ragged_indices_from_dense",
462 + lambda *args, **kwargs: (
463 + torch.tensor([0, 1], dtype=torch.int32),
464 + torch.tensor([0, 1, 2], dtype=torch.int32),
465 + ),
466 + )
467 + monkeypatch.setattr(
468 + mod,
469 + "_rocm_sparse_attn_prefill_triton",
470 + lambda *args, **kwargs: pytest.fail("unexpected dense Triton fallback"),
471 + )
472 + monkeypatch.setattr(
473 + mod,
474 + "_rocm_sparse_attn_prefill_ragged_triton",
475 + lambda *args, **kwargs: pytest.fail("unexpected ragged Triton fallback"),
476 + )
477 +
478 + mod.rocm_sparse_attn_prefill(
479 + q=q,
480 + kv=kv,
481 + indices=indices,
482 + topk_length=topk_length,
483 + scale=HEAD_DIM**-0.5,
484 + head_dim=HEAD_DIM,
485 + nope_head_dim=NOPE_HEAD_DIM,
486 + rope_head_dim=ROPE_HEAD_DIM,
487 + attn_sink=attn_sink,
488 + output=output,
489 + )
490 +
491 + assert opus_calls == 1
492 + assert torch.count_nonzero(output) == 0
493 +
494 +
495 +def test_sparse_attn_prefill_preserves_dense_triton_fallback(monkeypatch) -> None:
496 + from vllm.v1.attention.ops import rocm_aiter_mla_sparse as mod
497 +
498 + q = torch.empty(2, 1, HEAD_DIM, dtype=torch.bfloat16)
499 + kv = torch.empty(2, 1, HEAD_DIM, dtype=torch.bfloat16)
500 + indices = torch.tensor([[0], [1]], dtype=torch.int32)
501 + topk_length = torch.ones(2, dtype=torch.int32)
502 + attn_sink = torch.empty(1, dtype=torch.float32)
503 + output = torch.empty_like(q)
504 + dense_fallback_calls = 0
505 +
506 + def fake_dense_fallback(*args, **kwargs):
507 + nonlocal dense_fallback_calls
508 + dense_fallback_calls += 1
509 + return torch.zeros_like(q)
510 +
511 + monkeypatch.setattr(mod, "_can_use_aiter_sparse_prefill_opus", lambda *args: True)
512 + monkeypatch.setattr(mod, "_get_aiter_sparse_prefill_opus", lambda: None)
513 + monkeypatch.setattr(mod, "_rocm_sparse_attn_prefill_triton", fake_dense_fallback)
514 + monkeypatch.setattr(
515 + mod,
516 + "_rocm_sparse_attn_prefill_ragged_triton",
517 + lambda *args, **kwargs: pytest.fail("unexpected ragged Triton fallback"),
518 + )
519 +
520 + mod.rocm_sparse_attn_prefill(
521 + q=q,
522 + kv=kv,
523 + indices=indices,
524 + topk_length=topk_length,
525 + scale=HEAD_DIM**-0.5,
526 + head_dim=HEAD_DIM,
527 + nope_head_dim=NOPE_HEAD_DIM,
528 + rope_head_dim=ROPE_HEAD_DIM,
529 + attn_sink=attn_sink,
530 + output=output,
531 + )
532 +
533 + assert dense_fallback_calls == 1
534 + assert torch.count_nonzero(output) == 0
535 +
536 +
537 +@requires_gfx950
538 +@torch.inference_mode()
539 +def test_sparse_attn_prefill_ragged_aiter_opus(monkeypatch) -> None:
540 + opus_mod = pytest.importorskip("aiter.ops.pa_sparse_prefill_opus")
541 + from vllm.v1.attention.ops import rocm_aiter_mla_sparse as mod
542 +
543 + monkeypatch.setattr(
544 + mod, "_get_aiter_sparse_prefill_opus", lambda: opus_mod.pa_sparse_prefill_opus
545 + )
546 +
547 + device = torch.device("cuda")
548 + torch.manual_seed(4)
549 + num_queries = 8
550 + num_heads = 16
551 + q = (
552 + torch.randn(
553 + num_queries,
554 + num_heads,
555 + HEAD_DIM,
556 + dtype=torch.bfloat16,
557 + device=device,
558 + )
559 + * 0.125
560 + )
561 + kv = torch.randn(num_queries, HEAD_DIM, dtype=torch.bfloat16, device=device) * 0.125
562 + rows = [list(range(query_idx + 1)) for query_idx in range(num_queries)]
563 + indices = torch.tensor(
564 + [index for row in rows for index in row], dtype=torch.int32, device=device
565 + )
566 + indptr = torch.tensor(
567 + [0]
568 + + [
569 + sum(len(row) for row in rows[: query_idx + 1])
570 + for query_idx in range(num_queries)
571 + ],
572 + dtype=torch.int32,
573 + device=device,
574 + )
575 + attn_sink = torch.linspace(
576 + -0.25, 0.25, num_heads, dtype=torch.float32, device=device
577 + )
578 + output = torch.empty_like(q)
579 + scale = HEAD_DIM**-0.5
580 +
581 + assert mod._rocm_sparse_attn_prefill_ragged_aiter_opus(
582 + q=q,
583 + kv=kv,
584 + indices=indices,
585 + indptr=indptr,
586 + scale=scale,
587 + attn_sink=attn_sink,
588 + output=output,
589 + )
590 + expected = _ref_sparse_prefill_ragged(q, kv, rows, scale, attn_sink)
591 +
592 + torch.testing.assert_close(output, expected, atol=2e-2, rtol=2e-2)
593 +
594 +
595 @torch.inference_mode()
596 def test_sparse_attn_decode_ragged_kernel() -> None:
597 from vllm.v1.attention.ops.rocm_aiter_mla_sparse import (
vllm/v1/attention/ops/rocm_aiter_mla_sparse.py
+107
@@ -13,6 +13,7 @@ import vllm.envs as envs
13 from vllm.compilation.breakable_cudagraph import eager_break_during_capture
14 from vllm.config import CUDAGraphMode
15 from vllm.forward_context import get_forward_context
16 +from vllm.logger import init_logger
17 from vllm.platforms import current_platform
18 from vllm.triton_utils import tl, triton
19 from vllm.utils.torch_utils import LayerNameType
@@ -26,6 +27,8 @@ else:
27 _ON_GFX942 = False
28 _ON_GFX950 = False
29
30 +logger = init_logger(__name__)
31 +
32
33 @functools.cache
34 def _get_aiter_topk_ops() -> tuple[Callable[..., None], Callable[..., None]] | None:
@@ -39,8 +42,25 @@ def _get_aiter_topk_ops() -> tuple[Callable[..., None], Callable[..., None]] | N
42 return top_k_per_row_prefill, top_k_per_row_decode
43
44
45 +@functools.cache
46 +def _get_aiter_sparse_prefill_opus() -> Callable[..., torch.Tensor] | None:
47 + from vllm._aiter_ops import rocm_aiter_ops
48 +
49 + if not rocm_aiter_ops.is_mla_enabled():
50 + return None
51 + try:
52 + from aiter.ops.pa_sparse_prefill_opus import pa_sparse_prefill_opus
53 + except ImportError:
54 + return None
55 + logger.info_once("Using AITER OPUS for large sparse MLA prefill on gfx950")
56 + return pa_sparse_prefill_opus
57 +
58 +
59 _GFX950_C4A_AITER_MAX_COMPRESSED_SEQ_LEN = 64 * 1024
60 _GFX950_C4A_NATIVE_MAX_ROWS = 256
61 +# Conservative perf gate, not a correctness bound: OPUS is correct for any query
62 +# count, but Triton stays faster below this measured crossover.
63 +_GFX950_AITER_SPARSE_PREFILL_OPUS_MIN_QUERIES = 1024
64
65
66 def _get_aiter_top_k_kernel(
@@ -2667,6 +2687,66 @@ def _rocm_sparse_attn_prefill_triton(
2687 )
2688
2689
2690 +def _can_use_aiter_sparse_prefill_opus(
2691 + q: torch.Tensor,
2692 + kv: torch.Tensor,
2693 + attn_sink: torch.Tensor | None,
2694 + output: torch.Tensor,
2695 + on_gfx950: bool = _ON_GFX950,
2696 +) -> bool:
2697 + return (
2698 + on_gfx950
2699 + and q.shape[0] >= _GFX950_AITER_SPARSE_PREFILL_OPUS_MIN_QUERIES
2700 + and q.is_cuda
2701 + and output.shape == q.shape
2702 + and kv.shape[-1] == q.shape[-1]
2703 + and q.dtype in (torch.bfloat16, torch.float16)
2704 + and kv.dtype == q.dtype
2705 + and output.dtype == q.dtype
2706 + and kv.device == q.device
2707 + and output.device == q.device
2708 + and q.stride(-1) == 1
2709 + and kv.stride(-1) == 1
2710 + and output.stride() == q.stride()
2711 + and attn_sink is not None
2712 + and attn_sink.shape == (q.shape[1],)
2713 + and attn_sink.dtype == torch.float32
2714 + and attn_sink.device == q.device
2715 + )
2716 +
2717 +
2718 +def _rocm_sparse_attn_prefill_ragged_aiter_opus(
2719 + q: torch.Tensor,
2720 + kv: torch.Tensor,
2721 + indices: torch.Tensor,
2722 + indptr: torch.Tensor,
2723 + scale: float,
2724 + attn_sink: torch.Tensor,
2725 + output: torch.Tensor,
2726 +) -> bool:
2727 + pa_sparse_prefill_opus = _get_aiter_sparse_prefill_opus()
2728 + if pa_sparse_prefill_opus is None:
2729 + return False
2730 +
2731 + indices = _as_int32_contiguous_1d(indices)
2732 + indptr = _as_int32_contiguous_1d(indptr)
2733 + empty_indices = indices[:0]
2734 + empty_indptr = torch.zeros_like(indptr)
2735 + pa_sparse_prefill_opus(
2736 + q,
2737 + kv,
2738 + indices,
2739 + indptr,
2740 + kv[:1],
2741 + empty_indices,
2742 + empty_indptr,
2743 + attn_sink.contiguous(),
2744 + float(scale),
2745 + out=output,
2746 + )
2747 + return True
2748 +
2749 +
2750 @functools.lru_cache
2751 def _decode_cu_count() -> int:
2752 try:
@@ -3170,6 +3250,33 @@ def rocm_sparse_attn_prefill(
3250 rope_head_dim,
3251 "rocm_sparse_attn_prefill",
3252 )
3253 + opus_attn_sink = None if attn_sink is None else attn_sink[: q.shape[1]]
3254 + if (
3255 + _can_use_aiter_sparse_prefill_opus(q, kv.squeeze(1), opus_attn_sink, output)
3256 + and _get_aiter_sparse_prefill_opus() is not None
3257 + ):
3258 + if ragged_indices is None or ragged_indptr is None:
3259 + assert indices is not None
3260 + indices_2d = indices.reshape(indices.shape[0], -1)
3261 + ragged_indices, ragged_indptr = build_ragged_indices_from_dense(
3262 + indices_2d,
3263 + topk_length
3264 + if topk_length is not None
3265 + else (indices_2d >= 0).sum(dim=-1, dtype=torch.int32),
3266 + num_rows=kv.shape[0],
3267 + )
3268 + assert opus_attn_sink is not None
3269 + if _rocm_sparse_attn_prefill_ragged_aiter_opus(
3270 + q=q,
3271 + kv=kv.squeeze(1),
3272 + indices=ragged_indices,
3273 + indptr=ragged_indptr,
3274 + scale=scale,
3275 + attn_sink=opus_attn_sink,
3276 + output=output,
3277 + ):
3278 + return
3279 +
3280 if ragged_indices is not None and ragged_indptr is not None:
3281 output_chunk = _rocm_sparse_attn_prefill_ragged_triton(
3282 q=q,