[ROCm][DSv4.1][Perf] Fuse the inverse RoPE into the sparse decode reduce (#57435)
Signed-off-by: Fangzhou Ai <fangzhou.ai@amd.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Fangzhou Ai committed
Sep 22, 2026 at 14:05 UTC
ca831d1c55714aa10acd826389674ce8f6a4f872
3 files changed
+184
-13
tests/kernels/attention/test_rocm_triton_attn_dsv4.py
+49
@@ -259,7 +259,10 @@ def _launch_sparse_decode_reduce(
259
part_l: torch.Tensor,
260
part_acc: torch.Tensor,
261
adaptive_splits: bool,
262
+ positions: torch.Tensor | None = None,
263
+ cos_sin_cache: torch.Tensor | None = None,
264
) -> torch.Tensor:
265
+ """Run the reduce kernel, with the inverse-RoPE epilogue if given positions."""
266
from vllm.v1.attention.ops import rocm_aiter_mla_sparse as mod
267
268
num_queries, num_splits, num_heads = part_m.shape
@@ -275,6 +278,8 @@ def _launch_sparse_decode_reduce(
278
part_acc,
279
attn_sink,
280
out,
281
+ positions,
282
+ cos_sin_cache,
283
out.stride(0),
284
out.stride(1),
285
part_m.stride(0),
@@ -282,6 +287,7 @@ def _launch_sparse_decode_reduce(
287
part_acc.stride(0),
288
part_acc.stride(1),
289
part_acc.stride(2),
290
+ cos_sin_cache.stride(0) if cos_sin_cache is not None else 0,
291
num_heads,
292
HAS_ATTN_SINK=False,
293
ADAPTIVE_SPLITS=adaptive_splits,
@@ -289,6 +295,9 @@ def _launch_sparse_decode_reduce(
295
BLOCK_H=1,
296
NUM_SPLITS=num_splits,
297
SPLITS_PAD=1 << (num_splits - 1).bit_length(),
298
+ FUSE_INV_ROPE=positions is not None,
299
+ NOPE=NOPE_HEAD_DIM,
300
+ HALF=ROPE_HEAD_DIM // 2,
301
num_warps=4,
302
)
303
return out
@@ -1008,6 +1017,46 @@ def test_sparse_attn_decode_gfx950_adaptive_reduce_ignores_stale_scratch() -> No
1017
assert torch.equal(actual, torch.full_like(actual, 2))
1018
1019
1020
+@requires_gfx950
1021
+@pytest.mark.parametrize("adaptive_splits", [False, True])
1022
+@torch.inference_mode()
1023
+def test_sparse_attn_decode_reduce_inverse_rope_epilogue(adaptive_splits: bool) -> None:
1024
+ """FUSE_INV_ROPE must land where the standalone rotation would.
1025
+
1026
+ The epilogue rotates a whole [H, nope + rope] row with one expression by
1027
+ feeding cos=1/sin=0 to the NoPE lanes, which only holds if the pair split
1028
+ lands on ``nope_head_dim // 2``; the NoPE lanes therefore have to come
1029
+ back untouched, not merely close.
1030
+ """
1031
+ from vllm.v1.attention.ops.rocm_aiter_mla_sparse import _fused_inverse_rope_gptj
1032
+
1033
+ device = torch.device("cuda")
1034
+ torch.manual_seed(7)
1035
+ num_queries, num_splits, num_heads, max_pos = 5, 4, 8, 64
1036
+ shape = (num_queries, num_splits, num_heads)
1037
+ part_m = torch.randn(shape, dtype=torch.float32, device=device)
1038
+ part_l = torch.rand(shape, dtype=torch.float32, device=device) + 0.5
1039
+ part_acc = torch.randn((*shape, HEAD_DIM), dtype=torch.float32, device=device)
1040
+ positions = torch.randint(
1041
+ 0, max_pos, (num_queries,), dtype=torch.int64, device=device
1042
+ )
1043
+ angle = torch.randn(max_pos, ROPE_HEAD_DIM // 2, device=device)
1044
+ cos_sin_cache = torch.cat((angle.cos(), angle.sin()), dim=-1).contiguous()
1045
+
1046
+ unfused = _launch_sparse_decode_reduce(part_m, part_l, part_acc, adaptive_splits)
1047
+ expected = _fused_inverse_rope_gptj(
1048
+ unfused, positions, cos_sin_cache, ROPE_HEAD_DIM
1049
+ )
1050
+ actual = _launch_sparse_decode_reduce(
1051
+ part_m, part_l, part_acc, adaptive_splits, positions, cos_sin_cache
1052
+ )
1053
+
1054
+ torch.testing.assert_close(actual, expected, atol=2e-2, rtol=2e-2)
1055
+ assert torch.equal(actual[..., :NOPE_HEAD_DIM], unfused[..., :NOPE_HEAD_DIM])
1056
+ # An epilogue that quietly did nothing would satisfy everything above.
1057
+ assert not torch.equal(actual[..., NOPE_HEAD_DIM:], unfused[..., NOPE_HEAD_DIM:])
1058
+
1059
+
1060
@requires_gfx950
1061
@pytest.mark.parametrize("extra_len", [0, 1, 31, 32, 33, 63, 64, 65])
1062
@torch.inference_mode()
vllm/models/deepseek_v41/amd/rocm.py
+22
-3
@@ -37,6 +37,7 @@ from vllm.v1.attention.backends.mla.sparse_swa import (
37
from vllm.v1.attention.ops.rocm_aiter_mla_sparse import (
38
build_ragged_indices_from_dense,
39
rocm_inv_rope_einsum,
40
+ rocm_inverse_rope_rows_,
41
rocm_sparse_attn_decode,
42
rocm_sparse_attn_prefill,
43
)
@@ -673,6 +674,7 @@ class DeepseekV41ROCMAiterMLAAttention(DeepseekV4Attention):
674
self.n_local_groups,
675
self.o_lora_rank,
676
self.wo_a,
677
+ inverse_rope=False,
678
)
679
zf = z.flatten(1)
680
if self._wo_b_scale is not None and zf.dim() == 2:
@@ -747,15 +749,28 @@ class DeepseekV41ROCMAiterMLAAttention(DeepseekV4Attention):
749
attn_metadata=rocm_metadata,
750
swa_metadata=swa_metadata,
751
)
752
+ rotated = 0
753
if num_decodes > 0:
751
- self._forward_decode(
754
+ rotated = self._forward_decode(
755
q=q[:num_decode_tokens],
756
+ positions=positions[:num_decode_tokens],
757
kv_cache=self_kv_cache,
758
swa_metadata=swa_metadata,
759
attn_metadata=rocm_metadata,
760
swa_only=swa_only,
761
output=output[:num_decode_tokens],
762
)
763
+ # Only the decode reduce rotates its own rows, and only the leading
764
+ # `rotated` of them; prefill rows and any decode path that did not
765
+ # fuse still owe the standalone pass. Settle that here rather than in
766
+ # _o_proj: the split is batch-dependent and _o_proj runs compiled,
767
+ # where such a value freezes at its trace-time value.
768
+ rocm_inverse_rope_rows_(
769
+ output[rotated:, : self.n_local_heads, :],
770
+ positions[rotated:],
771
+ self.rotary_emb.cos_sin_cache,
772
+ self.rope_head_dim,
773
+ )
774
775
def _decode_topk_ragged(
776
self,
@@ -796,12 +811,14 @@ class DeepseekV41ROCMAiterMLAAttention(DeepseekV4Attention):
811
def _forward_decode(
812
self,
813
q: torch.Tensor,
814
+ positions: torch.Tensor,
815
kv_cache: torch.Tensor | None,
816
swa_metadata: DeepseekV4ROCMAiterSparseSWAMetadata,
817
attn_metadata: DeepseekV4FlashMLAMetadata | None,
818
swa_only: bool,
819
output: torch.Tensor,
804
- ) -> None:
820
+ ) -> int:
821
+ """Returns how many leading rows the decode epilogue inverse-RoPE'd."""
822
num_decodes = swa_metadata.num_decodes
823
num_decode_tokens = swa_metadata.num_decode_tokens
824
@@ -820,7 +837,7 @@ class DeepseekV41ROCMAiterMLAAttention(DeepseekV4Attention):
837
num_decode_tokens=num_decode_tokens,
838
)
839
823
- rocm_sparse_attn_decode(
840
+ return rocm_sparse_attn_decode(
841
q=q,
842
kv_cache=kv_cache,
843
swa_k_cache=self.swa_cache_layer.kv_cache,
@@ -839,6 +856,8 @@ class DeepseekV41ROCMAiterMLAAttention(DeepseekV4Attention):
856
nope_head_dim=self.nope_head_dim,
857
rope_head_dim=self.rope_head_dim,
858
output=output,
859
+ inv_rope_positions=positions,
860
+ inv_rope_cos_sin_cache=self.rotary_emb.cos_sin_cache,
861
extra_cache_nan_free=_trust_dsv4_extra_cache_nan_free(
862
self.kv_cache_dtype,
863
self._has_kv_transfer,
vllm/v1/attention/ops/rocm_aiter_mla_sparse.py
+113
-10
@@ -1572,8 +1572,13 @@ def _fused_inverse_rope_gptj(
1572
positions: torch.Tensor,
1573
cos_sin_cache: torch.Tensor,
1574
rope_head_dim: int,
1575
+ out: torch.Tensor | None = None,
1576
) -> torch.Tensor:
1576
- """bf16 inverse GPT-J RoPE via a single fused Triton kernel."""
1577
+ """bf16 inverse GPT-J RoPE via a single fused Triton kernel.
1578
+
1579
+ ``out`` may alias ``o``: the rotation is a per-row bijection whose kernel
1580
+ reads both lanes of a pair before storing either.
1581
+ """
1582
assert o.dim() == 3 and o.stride(-1) == 1, (
1583
"_fused_inverse_rope_gptj expects a [T, H, D] input with a contiguous last dim"
1584
)
@@ -1585,9 +1590,14 @@ def _fused_inverse_rope_gptj(
1590
f"[P, {rope_head_dim}] = cos | sin, got {tuple(cos_sin_cache.shape)}"
1591
)
1592
num_tokens, num_heads, head_dim = o.shape
1588
- out = torch.empty(
1589
- (num_tokens, num_heads, head_dim), dtype=torch.bfloat16, device=o.device
1590
- )
1593
+ if out is None:
1594
+ out = torch.empty(
1595
+ (num_tokens, num_heads, head_dim), dtype=torch.bfloat16, device=o.device
1596
+ )
1597
+ else:
1598
+ assert out.dtype == torch.bfloat16, (
1599
+ f"inverse RoPE writes bf16, got an output buffer of {out.dtype}"
1600
+ )
1601
if num_tokens == 0:
1602
return out
1603
_inverse_rope_gptj_kernel[(num_tokens, num_heads)](
@@ -1608,6 +1618,25 @@ def _fused_inverse_rope_gptj(
1618
return out
1619
1620
1621
+def rocm_inverse_rope_rows_(
1622
+ o: torch.Tensor,
1623
+ positions: torch.Tensor,
1624
+ cos_sin_cache: torch.Tensor,
1625
+ rope_head_dim: int,
1626
+) -> None:
1627
+ """Inverse-RoPE attention output rows in place.
1628
+
1629
+ For rows no attention kernel rotated in its epilogue. Call it from the
1630
+ eager attention segment: which rows still owe a rotation depends on the
1631
+ prefill/decode split, and the o_proj that used to do this runs inside the
1632
+ compiled region, where a batch-dependent Python value would be frozen at
1633
+ trace time.
1634
+ """
1635
+ if o.shape[0] == 0:
1636
+ return
1637
+ _fused_inverse_rope_gptj(o, positions, cos_sin_cache, rope_head_dim, out=o)
1638
+
1639
+
1640
def _get_cached_wo_a_bf16(
1641
wo_a: torch.nn.Module,
1642
n_local_groups: int,
@@ -1664,16 +1693,27 @@ def rocm_inv_rope_einsum(
1693
n_local_groups: int,
1694
o_lora_rank: int,
1695
wo_a: torch.nn.Module,
1696
+ inverse_rope: bool = True,
1697
) -> torch.Tensor:
1698
"""Inverse-RoPE + WO_A bmm path used on ROCm.
1699
1700
Fuses the inverse GPT-J RoPE into one Triton kernel and caches the bf16
1671
- wo_a weight so the per-step dequant disappears.
1701
+ wo_a weight so the per-step dequant disappears. Callers whose attention
1702
+ already rotated every row pass ``inverse_rope=False``; that is a property
1703
+ of the attention backend, not of the batch, so it stays constant across
1704
+ steps and is safe to read from compiled code.
1705
"""
1673
- o_ref = _fused_inverse_rope_gptj(
1674
- o, positions, rotary_emb.cos_sin_cache, rope_head_dim
1675
- )
1676
- o_ref = o_ref.view(o.shape[0], n_local_groups, -1)
1706
+ if inverse_rope:
1707
+ o_ref = _fused_inverse_rope_gptj(
1708
+ o, positions, rotary_emb.cos_sin_cache, rope_head_dim
1709
+ )
1710
+ else:
1711
+ assert o.dtype == torch.bfloat16, (
1712
+ "a pre-rotated attention output feeds the wo_a bmm directly, so it "
1713
+ f"must already be bf16, got {o.dtype}"
1714
+ )
1715
+ o_ref = o
1716
+ o_ref = o_ref.reshape(o.shape[0], n_local_groups, -1)
1717
1718
wo_a_weight = _get_cached_wo_a_bf16(
1719
wo_a, n_local_groups, o_lora_rank, o_ref.shape[-1]
@@ -2889,6 +2929,8 @@ def _sparse_attn_decode_reduce_kernel(
2929
part_acc_ptr,
2930
attn_sink_ptr,
2931
out_ptr,
2932
+ pos_ptr,
2933
+ cos_sin_ptr,
2934
out_stride0,
2935
out_stride1,
2936
pm_stride0,
@@ -2896,6 +2938,7 @@ def _sparse_attn_decode_reduce_kernel(
2938
pa_stride0,
2939
pa_stride_s,
2940
pa_stride_h,
2941
+ cs_stride,
2942
num_heads,
2943
HAS_ATTN_SINK: tl.constexpr,
2944
ADAPTIVE_SPLITS: tl.constexpr,
@@ -2903,6 +2946,9 @@ def _sparse_attn_decode_reduce_kernel(
2946
BLOCK_H: tl.constexpr,
2947
NUM_SPLITS: tl.constexpr,
2948
SPLITS_PAD: tl.constexpr,
2949
+ FUSE_INV_ROPE: tl.constexpr,
2950
+ NOPE: tl.constexpr,
2951
+ HALF: tl.constexpr,
2952
):
2953
query_idx = tl.program_id(0)
2954
pid_h = tl.program_id(1)
@@ -2992,6 +3038,26 @@ def _sparse_attn_decode_reduce_kernel(
3038
3039
out = tl.where(l_final[:, None] > 0.0, acc / denom[:, None], 0.0)
3040
3041
+ if FUSE_INV_ROPE:
3042
+ # Inverse GPT-J RoPE on the trailing rope lanes, straight out of the
3043
+ # combine registers: out_even = a*cos + b*sin, out_odd = b*cos - a*sin.
3044
+ # NoPE lanes take cos=1/sin=0 so one expression covers the whole row
3045
+ # and the o_proj rotation pass disappears for these tokens.
3046
+ pos = tl.load(pos_ptr + query_idx).to(tl.int64)
3047
+ pair_idx = tl.arange(0, COMB_DIM // 2) - (NOPE // 2)
3048
+ is_rope = pair_idx >= 0
3049
+ k = tl.where(is_rope, pair_idx, 0)
3050
+ cos = tl.where(is_rope, tl.load(cos_sin_ptr + pos * cs_stride + k), 1.0)
3051
+ sin = tl.where(is_rope, tl.load(cos_sin_ptr + pos * cs_stride + HALF + k), 0.0)
3052
+ even, odd = tl.split(tl.reshape(out, (BLOCK_H, COMB_DIM // 2, 2)))
3053
+ out = tl.reshape(
3054
+ tl.join(
3055
+ even * cos[None, :] + odd * sin[None, :],
3056
+ odd * cos[None, :] - even * sin[None, :],
3057
+ ),
3058
+ (BLOCK_H, COMB_DIM),
3059
+ )
3060
+
3061
out_row_ptr = (
3062
out_ptr + query_idx * out_stride0 + head_offsets[:, None] * out_stride1
3063
)
@@ -3312,6 +3378,8 @@ def _rocm_sparse_attn_decode_ragged_triton(
3378
out: torch.Tensor | None = None,
3379
extra_cache_nan_free: bool = False,
3380
adaptive_splits: bool = False,
3381
+ inv_rope_positions: torch.Tensor | None = None,
3382
+ inv_rope_cos_sin_cache: torch.Tensor | None = None,
3383
) -> torch.Tensor:
3384
assert q.ndim == 3, f"expected q=[b,h,d], got {q.shape}"
3385
assert main_cache.ndim == 3, (
@@ -3554,12 +3622,27 @@ def _rocm_sparse_attn_decode_ragged_triton(
3622
num_warps=4,
3623
)
3624
3625
+ fuse_inv_rope = inv_rope_positions is not None
3626
+ if fuse_inv_rope:
3627
+ assert inv_rope_cos_sin_cache is not None
3628
+ assert inv_rope_cos_sin_cache.shape[-1] == rope_head_dim, (
3629
+ "fused inverse RoPE expects cos_sin_cache laid out as "
3630
+ f"[P, {rope_head_dim}] = cos | sin, got "
3631
+ f"{tuple(inv_rope_cos_sin_cache.shape)}"
3632
+ )
3633
+ assert nope_head_dim % 2 == 0 and rope_head_dim % 2 == 0, (
3634
+ "fused inverse RoPE pairs adjacent lanes, so both head dims must "
3635
+ f"be even, got nope={nope_head_dim} rope={rope_head_dim}"
3636
+ )
3637
+
3638
_sparse_attn_decode_reduce_kernel[(num_queries, num_heads)](
3639
part_m,
3640
part_l,
3641
part_acc,
3642
attn_sink,
3643
out,
3644
+ inv_rope_positions,
3645
+ inv_rope_cos_sin_cache,
3646
out.stride(0),
3647
out.stride(1),
3648
part_m.stride(0),
@@ -3567,6 +3650,7 @@ def _rocm_sparse_attn_decode_ragged_triton(
3650
part_acc.stride(0),
3651
part_acc.stride(1),
3652
part_acc.stride(2),
3653
+ inv_rope_cos_sin_cache.stride(0) if inv_rope_cos_sin_cache is not None else 0,
3654
num_heads,
3655
HAS_ATTN_SINK=has_attn_sink,
3656
ADAPTIVE_SPLITS=adaptive_splits,
@@ -3574,6 +3658,9 @@ def _rocm_sparse_attn_decode_ragged_triton(
3658
BLOCK_H=1,
3659
NUM_SPLITS=num_splits,
3660
SPLITS_PAD=triton.next_power_of_2(num_splits),
3661
+ FUSE_INV_ROPE=fuse_inv_rope,
3662
+ NOPE=nope_head_dim,
3663
+ HALF=rope_head_dim // 2,
3664
num_warps=4,
3665
)
3666
return out
@@ -3598,6 +3685,8 @@ def _rocm_sparse_attn_decode_triton(
3685
out: torch.Tensor | None = None,
3686
extra_cache_nan_free: bool = False,
3687
adaptive_splits: bool = False,
3688
+ inv_rope_positions: torch.Tensor | None = None,
3689
+ inv_rope_cos_sin_cache: torch.Tensor | None = None,
3690
) -> torch.Tensor:
3691
if main_ragged_indices is None or main_ragged_indptr is None:
3692
main_ragged_indices, main_ragged_indptr = build_ragged_indices_from_dense(
@@ -3636,6 +3725,8 @@ def _rocm_sparse_attn_decode_triton(
3725
out=out,
3726
extra_cache_nan_free=extra_cache_nan_free,
3727
adaptive_splits=adaptive_splits,
3728
+ inv_rope_positions=inv_rope_positions,
3729
+ inv_rope_cos_sin_cache=inv_rope_cos_sin_cache,
3730
)
3731
3732
@@ -3737,7 +3828,16 @@ def rocm_sparse_attn_decode(
3828
output: torch.Tensor,
3829
extra_cache_nan_free: bool = False,
3830
adaptive_splits: bool = False,
3740
-) -> None:
3831
+ inv_rope_positions: torch.Tensor | None = None,
3832
+ inv_rope_cos_sin_cache: torch.Tensor | None = None,
3833
+) -> int:
3834
+ """Run sparse MLA decode into ``output``.
3835
+
3836
+ Passing ``inv_rope_positions`` folds the inverse RoPE into the reduce
3837
+ epilogue. Returns how many leading rows of ``output`` came back rotated,
3838
+ so a caller mixing in a decode path that does not fuse still knows what it
3839
+ owes the standalone pass. Read it from the eager attention segment only.
3840
+ """
3841
assert swa_k_cache.dtype == torch.uint8, (
3842
"ROCm Triton sparse decode expects uint8 fp8_ds_mla SWA cache, "
3843
f"got {swa_k_cache.dtype}"
@@ -3786,6 +3886,9 @@ def rocm_sparse_attn_decode(
3886
out=direct_out,
3887
extra_cache_nan_free=extra_cache_nan_free,
3888
adaptive_splits=adaptive_splits,
3889
+ inv_rope_positions=inv_rope_positions,
3890
+ inv_rope_cos_sin_cache=inv_rope_cos_sin_cache,
3891
)
3892
if direct_out is None:
3893
output.copy_(attn_out.to(output.dtype))
3894
+ return output.shape[0] if inv_rope_positions is not None else 0