[ROCm][DSv4] Fix sparse-indexer logits collapse on gfx950/gfx942 (#50455)

Signed-off-by: Frida Andersson <fanderss@amd.com> Signed-off-by: frida-andersson <fanderss@amd.com>

frida-andersson committed Sep 18, 2026 at 18:57 UTC 71fc70d3ae1df53300a23ec69f6d97b7207a109f
2 files changed +373 -5
tests/kernels/attention/test_rocm_triton_attn_dsv4.py
+186
@@ -1430,3 +1430,189 @@ def test_get_cached_wo_a_bf16_fp8_blockscale_caches() -> None:
1430
1431 # Second call returns the same cached object.
1432 assert _get_cached_wo_a_bf16(wo_a, n_local_groups, o_lora_rank, hidden_dim) is out
1433 +
1434 +
1435 +@requires_split_decode_arch
1436 +@torch.inference_mode()
1437 +def test_fp8_paged_mqa_logits_triton_matches_torch_ref() -> None:
1438 + """Block-flat Triton decode logits vs the eager torch reference.
1439 +
1440 + Compare only valid key positions: the kernel skips the -inf pad that
1441 + the torch ref writes past seq_len.
1442 + """
1443 + from vllm.v1.attention.ops import rocm_aiter_mla_sparse as mod
1444 + from vllm.v1.worker.workspace import init_workspace_manager
1445 +
1446 + device = torch.device("cuda")
1447 + init_workspace_manager(device)
1448 + torch.manual_seed(0)
1449 +
1450 + fp8_dtype = current_platform.fp8_dtype()
1451 + batch_size = 2
1452 + next_n = 1
1453 + num_heads = 4
1454 + head_size = 128
1455 + block_size = 64
1456 + max_model_len = 256
1457 + seq_lens = [180, 64]
1458 + num_pages = [(seq_len + block_size - 1) // block_size for seq_len in seq_lens]
1459 + num_blocks = sum(num_pages)
1460 +
1461 + q = torch.randn(
1462 + batch_size, next_n, num_heads, head_size, device=device, dtype=torch.bfloat16
1463 + ).to(fp8_dtype)
1464 + weights = torch.rand(
1465 + batch_size * next_n, num_heads, device=device, dtype=torch.float32
1466 + )
1467 + context_lens = torch.tensor(seq_lens, device=device, dtype=torch.int32)
1468 + block_tables = torch.full(
1469 + (batch_size, max(num_pages)), 0, device=device, dtype=torch.int32
1470 + )
1471 + page = 0
1472 + for i, n_pages in enumerate(num_pages):
1473 + block_tables[i, :n_pages] = torch.arange(
1474 + page, page + n_pages, device=device, dtype=torch.int32
1475 + )
1476 + page += n_pages
1477 +
1478 + kv_cache = torch.empty(
1479 + num_blocks, block_size, 1, head_size + 4, device=device, dtype=torch.uint8
1480 + )
1481 + values = torch.randn(
1482 + num_blocks, block_size, head_size, device=device, dtype=torch.bfloat16
1483 + ).to(fp8_dtype)
1484 + scales = torch.rand(
1485 + num_blocks, block_size, device=device, dtype=torch.float32
1486 + ).clamp(min=1e-3)
1487 + kv_flat = kv_cache.view(num_blocks, -1)
1488 + scale_off = block_size * head_size
1489 + kv_flat[:, :scale_off] = values.reshape(num_blocks, -1).view(torch.uint8)
1490 + kv_flat[:, scale_off:] = scales.contiguous().view(torch.uint8).view(num_blocks, -1)
1491 +
1492 + got = mod.rocm_fp8_paged_mqa_logits_triton(
1493 + q, kv_cache, weights, context_lens, block_tables, max_model_len
1494 + )
1495 + ref = mod.fp8_paged_mqa_logits_torch(
1496 + q, kv_cache, weights, context_lens, block_tables, max_model_len
1497 + )
1498 + for i, seq_len in enumerate(seq_lens):
1499 + torch.testing.assert_close(
1500 + got[i, :seq_len], ref[i, :seq_len], atol=2e-3, rtol=2e-3
1501 + )
1502 +
1503 +
1504 +def test_indexer_k_is_c4a_block_flat_gate() -> None:
1505 + from vllm.v1.attention.ops.rocm_aiter_mla_sparse import (
1506 + _indexer_k_is_c4a_block_flat,
1507 + )
1508 +
1509 + assert not _indexer_k_is_c4a_block_flat(1)
1510 + assert not _indexer_k_is_c4a_block_flat(2)
1511 + assert _indexer_k_is_c4a_block_flat(4)
1512 + assert not _indexer_k_is_c4a_block_flat(128)
1513 +
1514 +
1515 +@requires_split_decode_arch
1516 +@torch.inference_mode()
1517 +def test_paged_mqa_logits_gate_v41_shuffle_vs_c4a_flat() -> None:
1518 + """Ratio 1/2 stay on AITER; ratio 4 takes Triton."""
1519 + from vllm.v1.attention.ops.rocm_aiter_mla_sparse import (
1520 + fp8_paged_mqa_logits_torch,
1521 + indexer_k_quant_and_cache_triton,
1522 + rocm_fp8_paged_mqa_logits,
1523 + rocm_fp8_paged_mqa_logits_triton,
1524 + )
1525 + from vllm.v1.worker.workspace import init_workspace_manager
1526 +
1527 + device = torch.device("cuda")
1528 + init_workspace_manager(device)
1529 + torch.manual_seed(1)
1530 +
1531 + fp8_dtype = current_platform.fp8_dtype()
1532 + batch_size = 1
1533 + next_n = 1
1534 + num_heads = 32
1535 + head_size = 128
1536 + block_size = 64
1537 + seq_len = 180
1538 + max_model_len = 256
1539 + num_pages = (seq_len + block_size - 1) // block_size
1540 + num_tokens = num_pages * block_size
1541 +
1542 + q = torch.randn(
1543 + batch_size, next_n, num_heads, head_size, device=device, dtype=torch.bfloat16
1544 + ).to(fp8_dtype)
1545 + weights = torch.rand(
1546 + batch_size * next_n, num_heads, device=device, dtype=torch.float32
1547 + )
1548 + context_lens = torch.tensor([seq_len], device=device, dtype=torch.int32)
1549 + block_tables = torch.arange(num_pages, device=device, dtype=torch.int32).view(1, -1)
1550 + dummy_sched = torch.zeros(8, 2, device=device, dtype=torch.int32)
1551 +
1552 + k_bf16 = torch.randn(num_tokens, head_size, device=device, dtype=torch.bfloat16)
1553 + slot = torch.arange(num_tokens, device=device, dtype=torch.int32)
1554 + cache_shuffle = torch.zeros(
1555 + num_pages, block_size, head_size + 4, device=device, dtype=torch.uint8
1556 + )
1557 + indexer_k_quant_and_cache_triton(k_bf16, cache_shuffle, slot, 128, "ue8m0")
1558 + cache_shuffle = cache_shuffle.unsqueeze(2)
1559 +
1560 + aiter = rocm_fp8_paged_mqa_logits(
1561 + q,
1562 + cache_shuffle,
1563 + weights,
1564 + context_lens,
1565 + block_tables,
1566 + dummy_sched,
1567 + max_model_len,
1568 + compress_ratio=1,
1569 + )
1570 + for ratio in (1, 2):
1571 + got = rocm_fp8_paged_mqa_logits(
1572 + q,
1573 + cache_shuffle,
1574 + weights,
1575 + context_lens,
1576 + block_tables,
1577 + dummy_sched,
1578 + max_model_len,
1579 + compress_ratio=ratio,
1580 + )
1581 + torch.testing.assert_close(got[:, :seq_len], aiter[:, :seq_len], atol=0, rtol=0)
1582 +
1583 + cache_flat = torch.empty(
1584 + num_pages, block_size, 1, head_size + 4, device=device, dtype=torch.uint8
1585 + )
1586 + values = torch.randn(
1587 + num_pages, block_size, head_size, device=device, dtype=torch.bfloat16
1588 + ).to(fp8_dtype)
1589 + scales = torch.rand(
1590 + num_pages, block_size, device=device, dtype=torch.float32
1591 + ).clamp(min=1e-3)
1592 + kv_flat = cache_flat.view(num_pages, -1)
1593 + scale_off = block_size * head_size
1594 + kv_flat[:, :scale_off] = values.reshape(num_pages, -1).view(torch.uint8)
1595 + kv_flat[:, scale_off:] = scales.contiguous().view(torch.uint8).view(num_pages, -1)
1596 +
1597 + gated_c4a = rocm_fp8_paged_mqa_logits(
1598 + q,
1599 + cache_flat,
1600 + weights,
1601 + context_lens,
1602 + block_tables,
1603 + dummy_sched,
1604 + max_model_len,
1605 + compress_ratio=4,
1606 + )
1607 + triton = rocm_fp8_paged_mqa_logits_triton(
1608 + q, cache_flat, weights, context_lens, block_tables, max_model_len
1609 + )
1610 + ref = fp8_paged_mqa_logits_torch(
1611 + q, cache_flat, weights, context_lens, block_tables, max_model_len
1612 + )
1613 + torch.testing.assert_close(
1614 + gated_c4a[:, :seq_len], triton[:, :seq_len], atol=0, rtol=0
1615 + )
1616 + torch.testing.assert_close(
1617 + gated_c4a[:, :seq_len], ref[:, :seq_len], atol=2e-3, rtol=2e-3
1618 + )
vllm/v1/attention/ops/rocm_aiter_mla_sparse.py
+187 -5
@@ -66,6 +66,11 @@ _GFX950_DSV4_NATIVE_MAX_COLUMNS = 1024 * 1024
66 _GFX950_AITER_SPARSE_PREFILL_OPUS_MIN_QUERIES = 1024
67
68
69 +def _indexer_k_is_c4a_block_flat(compress_ratio: int) -> bool:
70 + """V4.0 C4A is block-flat (NORMAL). Ratio 1 and 2 are 16×16 SHUFFLE."""
71 + return compress_ratio == 4
72 +
73 +
74 def _get_aiter_top_k_kernel(
75 *,
76 is_prefill: bool,
@@ -458,6 +463,7 @@ def cp_gather_indexer_k_quant_cache_triton(
463 token_to_seq: torch.Tensor,
464 block_tile_size: int = 16,
465 head_tile_size: int = 16,
466 + cache_layout: str | None = None,
467 ):
468 num_tokens = k_fp8.size(0)
469 block_size = k_cache.size(1)
@@ -470,7 +476,10 @@ def cp_gather_indexer_k_quant_cache_triton(
476 k_cache_scale = k_cache[:, block_size * head_dim :].view(torch.float32)
477 grid = (num_tokens,)
478 k_fp8_scale = k_fp8_scale.view(torch.float32)
473 - layout = "NORMAL" if block_size == 1 else "SHUFFLE"
479 + if cache_layout is None:
480 + layout = "NORMAL" if block_size == 1 else "SHUFFLE"
481 + else:
482 + layout = cache_layout
483 kernel_args = (
484 k_cache_value,
485 k_cache_scale,
@@ -607,6 +616,158 @@ def fp8_paged_mqa_logits_torch(
616 return logits
617
618
619 +@triton.jit
620 +def _fp8_paged_mqa_logits_decode_kernel(
621 + q_ptr, # fp8 [B, NEXT_N, H, D]
622 + kv_val_ptr, # fp8, block-flat: [num_blocks, block_size*(D+4)]
623 + kv_scale_ptr, # fp32, block-flat: [num_blocks, block_size*(D+4)//4]
624 + weights_ptr, # fp32 [B*NEXT_N, H]
625 + ctx_lens_ptr, # int32 [B*NEXT_N] if CTX_PER_ROW else [B]
626 + block_tables_ptr, # int32 [B, max_blocks]
627 + logits_ptr, # fp32 [B*NEXT_N, max_model_len]
628 + stride_q_b,
629 + stride_q_n,
630 + stride_q_h,
631 + stride_w_row,
632 + stride_kvblk_fp8,
633 + stride_kvblk_f32,
634 + scale_region_off, # block_size*D // 4 (fp32 offset of scale region within a block)
635 + stride_bt_b,
636 + stride_logits_row,
637 + max_blocks, # block_tables width; guards the block-table gather
638 + max_model_len,
639 + NUM_HEADS: tl.constexpr,
640 + HEAD_SIZE: tl.constexpr,
641 + BLOCK_SIZE: tl.constexpr, # paged-cache page size
642 + BLOCK_KV: tl.constexpr, # positions per tile; must divide BLOCK_SIZE
643 + N_SPLITS: tl.constexpr, # KV-tile parallelism factor (grid dim 1)
644 + NEXT_N: tl.constexpr, # query positions per batch (1 = decode; >1 = MTP verify)
645 + CTX_PER_ROW: tl.constexpr, # ctx_lens is already per-(b, n)
646 +):
647 + # scale_region_off = block_size*D//4 needs D % 4 == 0 (D=128).
648 + tl.static_assert(HEAD_SIZE % 4 == 0)
649 + # Grid (B*NEXT_N, N_SPLITS): disjoint KV tiles per program, no host sync.
650 + row = tl.program_id(0)
651 + split = tl.program_id(1)
652 + b = row // NEXT_N
653 + n = row % NEXT_N
654 + if CTX_PER_ROW:
655 + seq_len = tl.load(ctx_lens_ptr + row)
656 + else:
657 + seq_len = tl.load(ctx_lens_ptr + b) - NEXT_N + n + 1
658 + seq_len = tl.minimum(tl.maximum(seq_len, 0), max_model_len)
659 +
660 + h = tl.arange(0, NUM_HEADS)
661 + d = tl.arange(0, HEAD_SIZE)
662 + # keep q/kv fp8 -> fp8 MFMA (f32 accum), not the slow f32 path
663 + q = tl.load(
664 + q_ptr + b * stride_q_b + n * stride_q_n + h[:, None] * stride_q_h + d[None, :]
665 + )
666 + w = tl.load(weights_ptr + row * stride_w_row + h).to(tl.float32) # [H]
667 +
668 + kv_col = tl.arange(0, BLOCK_KV)
669 + for kv_start in tl.range(split * BLOCK_KV, seq_len, N_SPLITS * BLOCK_KV):
670 + pos = kv_start + kv_col
671 + # Tiles are page-aligned (BLOCK_KV | BLOCK_SIZE), so a tile is one page.
672 + logical_blk = kv_start // BLOCK_SIZE
673 + blk_ok = logical_blk < max_blocks # guard against a wild page -> fault
674 + mask_pos = (pos < seq_len) & blk_ok
675 + page = tl.load(
676 + block_tables_ptr + b * stride_bt_b + logical_blk, mask=blk_ok, other=0
677 + ).to(tl.int64)
678 + pos_in_blk = pos - logical_blk * BLOCK_SIZE # [BLOCK_KV] == pos % BLOCK_SIZE
679 +
680 + # block-flat layout: values region, then scales region.
681 + val_off = page * stride_kvblk_fp8 + pos_in_blk[:, None] * HEAD_SIZE + d[None, :]
682 + kv = tl.load(
683 + kv_val_ptr + val_off, mask=mask_pos[:, None], other=0.0
684 + ) # [BLOCK_KV, D] fp8
685 + sc = tl.load(
686 + kv_scale_ptr + page * stride_kvblk_f32 + scale_region_off + pos_in_blk,
687 + mask=mask_pos,
688 + other=0.0,
689 + ) # [BLOCK_KV]
690 +
691 + # fp8 upcasts exactly; bit-identical on gfx942, ~1e-5 rel on gfx950
692 + # (accum order), top-k unchanged.
693 + s = tl.dot(kv, tl.trans(q)) # [BLOCK_KV, H]
694 + s = tl.maximum(s, 0.0)
695 + s = s * w[None, :]
696 + s = tl.sum(s, axis=1) # [BLOCK_KV]
697 + s = s * sc
698 + tl.store(logits_ptr + row * stride_logits_row + pos, s, mask=mask_pos)
699 +
700 +
701 +def rocm_fp8_paged_mqa_logits_triton(
702 + q_fp8: torch.Tensor,
703 + kv_cache_fp8: torch.Tensor,
704 + weights: torch.Tensor,
705 + context_lens: torch.Tensor,
706 + block_tables: torch.Tensor,
707 + max_model_len: int,
708 +) -> torch.Tensor:
709 + """Triton paged MQA-logits for decode and MTP; matches the torch ref but
710 + has no host sync, so it is safe to capture under a full CUDA graph."""
711 + batch_size, next_n, num_heads, head_size = q_fp8.shape
712 + block_size = kv_cache_fp8.shape[1]
713 + BLOCK_KV = 64
714 + assert block_size % BLOCK_KV == 0
715 +
716 + fp8_dtype = current_platform.fp8_dtype()
717 + num_blocks = kv_cache_fp8.shape[0]
718 + kv_flat = kv_cache_fp8.reshape(
719 + num_blocks, -1
720 + ) # uint8 [num_blocks, block_size*(D+4)]
721 + kv_val = kv_flat.view(fp8_dtype) # [num_blocks, block_size*(D+4)] fp8
722 + kv_scale = kv_flat.view(torch.float32) # [num_blocks, block_size*(D+4)//4] fp32
723 +
724 + cl = context_lens.reshape(-1)
725 + ctx_per_row = not (next_n > 1 and cl.numel() == batch_size)
726 +
727 + max_blocks = block_tables.shape[1]
728 + (out_logits,) = current_workspace_manager().get_simultaneous(
729 + ((batch_size * next_n, max_model_len), torch.float32),
730 + )
731 +
732 + # Memory-bound over the KV range: split each row's keys across programs so
733 + # few-row / long-context launches still fill the GPU. Cap splits at the
734 + # device CU count (304 on gfx942, 256 on gfx950) rather than a gfx950-sized
735 + # constant. All terms are static at launch, so the grid stays CUDA-graph-safe.
736 + rows = batch_size * next_n
737 + tiles_cap = (max_model_len + BLOCK_KV - 1) // BLOCK_KV
738 + N_SPLITS = max(1, min(max(1, _decode_cu_count()), tiles_cap, 1024 // rows))
739 + _fp8_paged_mqa_logits_decode_kernel[(rows, N_SPLITS)](
740 + q_fp8,
741 + kv_val,
742 + kv_scale,
743 + weights,
744 + cl,
745 + block_tables,
746 + out_logits,
747 + q_fp8.stride(0),
748 + q_fp8.stride(1),
749 + q_fp8.stride(2),
750 + weights.stride(0),
751 + kv_val.stride(0),
752 + kv_scale.stride(0),
753 + (block_size * head_size) // 4,
754 + block_tables.stride(0),
755 + out_logits.stride(0),
756 + max_blocks,
757 + max_model_len,
758 + NUM_HEADS=num_heads,
759 + HEAD_SIZE=head_size,
760 + BLOCK_SIZE=block_size,
761 + BLOCK_KV=BLOCK_KV,
762 + N_SPLITS=N_SPLITS,
763 + NEXT_N=next_n,
764 + CTX_PER_ROW=ctx_per_row,
765 + num_warps=4,
766 + num_stages=2,
767 + )
768 + return out_logits
769 +
770 +
771 @functools.lru_cache
772 def paged_mqa_logits_module():
773 paged_mqa_logits_module_path = None
@@ -632,6 +793,8 @@ def rocm_fp8_paged_mqa_logits(
793 block_tables: torch.Tensor,
794 schedule_metadata: torch.Tensor,
795 max_model_len: int,
796 + *,
797 + compress_ratio: int = 1,
798 ) -> torch.Tensor:
799 """Compute FP8 MQA logits using paged KV-cache.
800
@@ -639,8 +802,7 @@ def rocm_fp8_paged_mqa_logits(
802 q_fp8: Query tensor of shape [B, next_n, H, D]. Casted to
803 `torch.float8_e4m3fn` by caller.
804 kv_cache_fp8: Paged KV-cache in packed FP8+scale layout with shape
642 - [num_blocks, block_size, 1, D+4], dtype `torch.uint8`. The last
643 - 4 bytes per (block,pos) store the `float` dequant scale.
805 + [num_blocks, block_size, 1, D+4], dtype `torch.uint8`.
806 weights: Tensor of shape [B * next_n, H], dtype `torch.float32`.
807 context_lens: Tensor of shape [B], dtype int32; effective context length
808 for each batch element.
@@ -649,6 +811,7 @@ def rocm_fp8_paged_mqa_logits(
811 schedule_metadata: Returned by `get_paged_mqa_logits_metadata`;
812 used to distribute work across SMs.
813 max_model_len: Maximum sequence length used to size the logits output.
814 + compress_ratio: C4A (4) takes block-flat Triton; 1 and 2 stay on AITER.
815
816 Returns:
817 Logits tensor of shape [B * next_n, max_model_len], dtype
@@ -657,11 +820,26 @@ def rocm_fp8_paged_mqa_logits(
820 """
821 from vllm._aiter_ops import rocm_aiter_ops
822
660 - aiter_paged_mqa_logits_module = None
661 - # if rocm_aiter_ops.is_enabled():
823 batch_size, next_n = q_fp8.shape[:2]
824 block_size = kv_cache_fp8.shape[1]
825
826 + # C4A only: Flash/DSv3.2 also skip insert but still write SHUFFLE.
827 + if (
828 + (_ON_GFX950 or _ON_GFX942)
829 + and _indexer_k_is_c4a_block_flat(compress_ratio)
830 + and block_size > 1
831 + ):
832 + if block_size % 64 == 0:
833 + return rocm_fp8_paged_mqa_logits_triton(
834 + q_fp8, kv_cache_fp8, weights, context_lens, block_tables, max_model_len
835 + )
836 + # Non 64-aligned page size (not used in prod): eager torch ref.
837 + return fp8_paged_mqa_logits_torch(
838 + q_fp8, kv_cache_fp8, weights, context_lens, block_tables, max_model_len
839 + )
840 +
841 + aiter_paged_mqa_logits_module = None
842 +
843 if rocm_aiter_ops.is_enabled() or rocm_aiter_ops.is_rdna_aiter_enabled():
844 aiter_paged_mqa_logits_module = paged_mqa_logits_module()
845
@@ -1128,6 +1306,9 @@ def rocm_aiter_sparse_attn_indexer(
1306 chunk.block_table,
1307 chunk.cu_seq_lens,
1308 token_to_seq=chunk.token_to_seq,
1309 + cache_layout=(
1310 + "NORMAL" if _indexer_k_is_c4a_block_flat(compress_ratio) else None
1311 + ),
1312 )
1313 logits = rocm_fp8_mqa_logits(
1314 q_fp8[chunk.token_start : chunk.token_end],
@@ -1225,6 +1406,7 @@ def rocm_aiter_sparse_attn_indexer(
1406 decode_metadata.block_table,
1407 decode_metadata.schedule_metadata,
1408 max_model_len=max_model_len,
1409 + compress_ratio=compress_ratio,
1410 )
1411
1412 if candidate_blocks is not None: