[ROCm][DSv4.1][Perf] Emit MXFP8 from the sparse decode reduce and run wo_a as a grouped FP8 GEMM (#58456)

Signed-off-by: fai <fangzhouai@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

Fangzhou Ai committed Sep 24, 2026 at 07:58 UTC 721d0e5c112a2e4e0bed9078fc8d953eaaf5abc2
3 files changed +664 -29
tests/kernels/attention/test_rocm_triton_attn_dsv4.py
+177 -2
@@ -1,6 +1,7 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 +import functools
5 from types import SimpleNamespace
6
7 import pytest
@@ -261,14 +262,19 @@ def _launch_sparse_decode_reduce(
262 adaptive_splits: bool,
263 positions: torch.Tensor | None = None,
264 cos_sin_cache: torch.Tensor | None = None,
265 + out_dtype: torch.dtype = torch.bfloat16,
266 + out_scale: torch.Tensor | None = None,
267 ) -> torch.Tensor:
265 - """Run the reduce kernel, with the inverse-RoPE epilogue if given positions."""
268 + """Run the reduce kernel, with the inverse-RoPE epilogue if given positions.
269 +
270 + Passing ``out_scale`` selects the MXFP8 epilogue (``out_dtype`` e4m3).
271 + """
272 from vllm.v1.attention.ops import rocm_aiter_mla_sparse as mod
273
274 num_queries, num_splits, num_heads = part_m.shape
275 out = torch.empty(
276 (num_queries, num_heads, HEAD_DIM),
271 - dtype=torch.bfloat16,
277 + dtype=out_dtype,
278 device=part_m.device,
279 )
280 attn_sink = torch.empty(1, dtype=torch.float32, device=part_m.device)
@@ -280,8 +286,11 @@ def _launch_sparse_decode_reduce(
286 out,
287 positions,
288 cos_sin_cache,
289 + out_scale,
290 out.stride(0),
291 out.stride(1),
292 + out_scale.stride(0) if out_scale is not None else 0,
293 + HEAD_DIM // 32,
294 part_m.stride(0),
295 part_m.stride(1),
296 part_acc.stride(0),
@@ -298,6 +307,7 @@ def _launch_sparse_decode_reduce(
307 FUSE_INV_ROPE=positions is not None,
308 NOPE=NOPE_HEAD_DIM,
309 HALF=ROPE_HEAD_DIM // 2,
310 + QUANT_OUT=out_scale is not None,
311 num_warps=4,
312 )
313 return out
@@ -1057,6 +1067,171 @@ def test_sparse_attn_decode_reduce_inverse_rope_epilogue(adaptive_splits: bool)
1067 assert not torch.equal(actual[..., NOPE_HEAD_DIM:], unfused[..., NOPE_HEAD_DIM:])
1068
1069
1070 +def _mxfp8_dequant(data: torch.Tensor, scale: torch.Tensor) -> torch.Tensor:
1071 + blocks = data.float().view(*data.shape[:-1], -1, 32)
1072 + return (blocks * torch.exp2(scale.float() - 127.0)[..., None]).view(data.shape)
1073 +
1074 +
1075 +def _random_cos_sin_cache(max_pos: int, device: torch.device) -> torch.Tensor:
1076 + angle = torch.randn(max_pos, ROPE_HEAD_DIM // 2, device=device)
1077 + return torch.cat((angle.cos(), angle.sin()), dim=-1).contiguous()
1078 +
1079 +
1080 +@requires_gfx950
1081 +@torch.inference_mode()
1082 +def test_sparse_attn_decode_reduce_mxfp8_epilogue() -> None:
1083 + """QUANT_OUT must equal MXFP8-quantizing the reduce's fp32 output."""
1084 + from vllm.model_executor.layers.quantization.utils.mxfp8_utils import (
1085 + _mxfp8_e4m3_quantize_torch,
1086 + )
1087 +
1088 + device = torch.device("cuda")
1089 + torch.manual_seed(7)
1090 + num_queries, num_splits, num_heads = 5, 4, 8
1091 + shape = (num_queries, num_splits, num_heads)
1092 + part_m = torch.randn(shape, device=device)
1093 + part_l = torch.rand(shape, device=device) + 0.5
1094 + part_acc = torch.randn((*shape, HEAD_DIM), device=device)
1095 + positions = torch.randint(0, 64, (num_queries,), device=device)
1096 + cos_sin_cache = _random_cos_sin_cache(64, device)
1097 + args = (part_m, part_l, part_acc, False, positions, cos_sin_cache)
1098 +
1099 + rotated = _launch_sparse_decode_reduce(*args, out_dtype=torch.float32)
1100 + expected_data, expected_scale = _mxfp8_e4m3_quantize_torch(
1101 + rotated.view(num_queries, -1)
1102 + )
1103 + scale = torch.empty_like(expected_scale)
1104 + data = _launch_sparse_decode_reduce(
1105 + *args, out_dtype=torch.float8_e4m3fn, out_scale=scale
1106 + )
1107 +
1108 + assert torch.equal(scale, expected_scale)
1109 + assert torch.equal(
1110 + data.view(num_queries, -1).view(torch.uint8), expected_data.view(torch.uint8)
1111 + )
1112 +
1113 +
1114 +@requires_gfx950
1115 +@torch.inference_mode()
1116 +def test_inverse_rope_mxfp8_rows() -> None:
1117 + """Prefill rows get the same inverse RoPE + MXFP8 as the decode epilogue."""
1118 + from vllm.model_executor.layers.quantization.utils.mxfp8_utils import (
1119 + _mxfp8_e4m3_quantize_torch,
1120 + )
1121 + from vllm.v1.attention.ops.rocm_aiter_mla_sparse import (
1122 + rocm_inverse_rope_mxfp8_rows,
1123 + )
1124 +
1125 + device = torch.device("cuda")
1126 + torch.manual_seed(5)
1127 + num_tokens, num_heads = 7, 4
1128 + o = torch.randn(num_tokens, num_heads, HEAD_DIM, device=device).bfloat16()
1129 + positions = torch.randint(0, 64, (num_tokens,), device=device)
1130 + cos_sin_cache = _random_cos_sin_cache(64, device)
1131 +
1132 + ref = o.float()
1133 + cos, sin = cos_sin_cache[positions, None].chunk(2, dim=-1)
1134 + even, odd = ref[..., NOPE_HEAD_DIM::2].clone(), ref[..., NOPE_HEAD_DIM + 1 :: 2]
1135 + ref[..., NOPE_HEAD_DIM::2] = even * cos + odd * sin
1136 + ref[..., NOPE_HEAD_DIM + 1 :: 2] = odd * cos - even * sin
1137 + ref = ref.view(num_tokens, -1)
1138 + _, expected_scale = _mxfp8_e4m3_quantize_torch(ref)
1139 +
1140 + data = torch.empty_like(ref, dtype=torch.float8_e4m3fn)
1141 + scale = torch.empty_like(expected_scale)
1142 + rocm_inverse_rope_mxfp8_rows(
1143 + o, positions, cos_sin_cache, ROPE_HEAD_DIM, data, scale
1144 + )
1145 +
1146 + assert torch.equal(scale, expected_scale)
1147 + # Within half an e4m3 step at the top of each block's range.
1148 + half_step = 16.0 * torch.exp2(scale.float() - 127.0).repeat_interleave(32, -1)
1149 + assert bool(((_mxfp8_dequant(data, scale) - ref).abs() <= half_step).all())
1150 +
1151 +
1152 +@requires_gfx950
1153 +@torch.inference_mode()
1154 +def test_sparse_attn_decode_mxfp8_output() -> None:
1155 + """``out_mxfp8`` routes the real decode through the MXFP8 epilogue."""
1156 + from vllm.v1.attention.ops.rocm_aiter_mla_sparse import (
1157 + _rocm_sparse_attn_decode_ragged_triton,
1158 + )
1159 +
1160 + device = torch.device("cuda")
1161 + torch.manual_seed(1)
1162 + num_heads = 16
1163 + q = torch.randn(2, num_heads, HEAD_DIM, device=device).bfloat16() * 0.125
1164 + kv = torch.randn(6, HEAD_DIM, device=device).bfloat16() * 0.125
1165 + decode = functools.partial(
1166 + _rocm_sparse_attn_decode_ragged_triton,
1167 + q=q,
1168 + main_cache=_pack_fp8_ds_mla_cache(kv, 4, current_platform.is_fp8_fnuz()),
1169 + main_indices=torch.tensor([0, 2, 4, 1], dtype=torch.int32, device=device),
1170 + main_indptr=torch.tensor([0, 2, 4], dtype=torch.int32, device=device),
1171 + scale=HEAD_DIM**-0.5,
1172 + attn_sink=None,
1173 + nope_head_dim=NOPE_HEAD_DIM,
1174 + rope_head_dim=ROPE_HEAD_DIM,
1175 + inv_rope_positions=torch.tensor([3, 9], device=device),
1176 + inv_rope_cos_sin_cache=_random_cos_sin_cache(16, device),
1177 + )
1178 +
1179 + expected = decode().float().view(2, -1)
1180 + data = torch.empty(
1181 + 2, num_heads * HEAD_DIM, dtype=torch.float8_e4m3fn, device=device
1182 + )
1183 + scale = torch.empty(2, num_heads * HEAD_DIM // 32, dtype=torch.uint8, device=device)
1184 + decode(out_mxfp8=(data, scale))
1185 +
1186 + torch.testing.assert_close(
1187 + _mxfp8_dequant(data, scale), expected, atol=1e-3, rtol=2**-4
1188 + )
1189 +
1190 +
1191 +@requires_gfx950
1192 +@pytest.mark.parametrize(
1193 + "num_tokens, n_groups",
1194 + # One case per tile tier of _mxfp8_wo_a_bmm_config, with partial M tiles.
1195 + [
1196 + (1, 4),
1197 + (20, 4),
1198 + (48, 4),
1199 + (77, 4),
1200 + (130, 4),
1201 + (300, 4),
1202 + (700, 4),
1203 + (1000, 4),
1204 + (1100, 8),
1205 + ],
1206 +)
1207 +@torch.inference_mode()
1208 +def test_rocm_mxfp8_wo_a_bmm(num_tokens: int, n_groups: int) -> None:
1209 + from vllm.model_executor.layers.quantization.utils.mxfp8_utils import (
1210 + _mxfp8_e4m3_quantize_torch,
1211 + )
1212 + from vllm.v1.attention.ops.rocm_aiter_mla_sparse import rocm_mxfp8_wo_a_bmm
1213 +
1214 + device = torch.device("cuda")
1215 + torch.manual_seed(3)
1216 + o_lora_rank, group_dim = 128, 2048
1217 + a, a_scale = _mxfp8_e4m3_quantize_torch(
1218 + torch.randn(num_tokens, n_groups * group_dim, device=device)
1219 + )
1220 + w, w_scale = _mxfp8_e4m3_quantize_torch(
1221 + torch.randn(n_groups * o_lora_rank, group_dim, device=device)
1222 + )
1223 + wo_a = SimpleNamespace(weight=w, weight_scale=w_scale)
1224 +
1225 + out = rocm_mxfp8_wo_a_bmm(a, a_scale, wo_a, n_groups, o_lora_rank)
1226 +
1227 + expected = torch.einsum(
1228 + "tgd,grd->tgr",
1229 + _mxfp8_dequant(a, a_scale).view(num_tokens, n_groups, group_dim),
1230 + _mxfp8_dequant(w, w_scale).view(n_groups, o_lora_rank, group_dim),
1231 + )
1232 + torch.testing.assert_close(out.float(), expected.flatten(1), atol=5e-2, rtol=1e-2)
1233 +
1234 +
1235 @requires_gfx950
1236 @pytest.mark.parametrize("extra_len", [0, 1, 31, 32, 33, 63, 64, 65])
1237 @torch.inference_mode()
vllm/models/deepseek_v41/amd/rocm.py
+136 -22
@@ -13,6 +13,8 @@ from vllm.distributed import (
13 )
14 from vllm.forward_context import get_forward_context
15 from vllm.logger import init_logger
16 +from vllm.model_executor.layers.fusion.quant_activation import QuantizedActivation
17 +from vllm.model_executor.layers.quantization.utils.quant_utils import kMxfp8Dynamic
18 from vllm.models.deepseek_v41.attention import (
19 DeepseekV4Attention,
20 _replace_layer_index,
@@ -37,7 +39,9 @@ from vllm.v1.attention.backends.mla.sparse_swa import (
39 from vllm.v1.attention.ops.rocm_aiter_mla_sparse import (
40 build_ragged_indices_from_dense,
41 rocm_inv_rope_einsum,
42 + rocm_inverse_rope_mxfp8_rows,
43 rocm_inverse_rope_rows_,
44 + rocm_mxfp8_wo_a_bmm,
45 rocm_sparse_attn_decode,
46 rocm_sparse_attn_prefill,
47 )
@@ -663,7 +667,40 @@ class DeepseekV41ROCMAiterMLAAttention(DeepseekV4Attention):
667 transpose_scale=False,
668 )
669
666 - def _o_proj(self, attn_out: torch.Tensor, positions: torch.Tensor) -> torch.Tensor:
670 + def _alloc_attn_out(
671 + self, num_tokens: int, hidden_states: torch.Tensor
672 + ) -> torch.Tensor | QuantizedActivation:
673 + if not _ON_GFX950:
674 + return super()._alloc_attn_out(num_tokens, hidden_states)
675 + # wo_a's MXFP8 input: the decode reduce writes it directly, prefill
676 + # rows are rotated and quantized after their bf16 attention.
677 + width = self.n_local_heads * self.head_dim
678 + data = torch.empty(
679 + (num_tokens, width), dtype=torch.float8_e4m3fn, device=hidden_states.device
680 + )
681 + scale = torch.empty(
682 + (num_tokens, width // 32), dtype=torch.uint8, device=hidden_states.device
683 + )
684 + return QuantizedActivation(
685 + data=data,
686 + scale=scale,
687 + orig_dtype=hidden_states.dtype,
688 + orig_shape=data.shape,
689 + quant_key=kMxfp8Dynamic,
690 + )
691 +
692 + def _o_proj(
693 + self, attn_out: torch.Tensor | QuantizedActivation, positions: torch.Tensor
694 + ) -> torch.Tensor:
695 + if isinstance(attn_out, QuantizedActivation):
696 + z = rocm_mxfp8_wo_a_bmm(
697 + attn_out.data,
698 + attn_out.scale,
699 + self.wo_a,
700 + self.n_local_groups,
701 + self.o_lora_rank,
702 + )
703 + return self._wo_b_after_wo_a(z)
704 o = attn_out[:, : self.n_local_heads, :]
705 # ROCm BF16 reference wo_a path (inverse RoPE + einsum) + wo_b.
706 z = rocm_inv_rope_einsum(
@@ -676,26 +713,29 @@ class DeepseekV41ROCMAiterMLAAttention(DeepseekV4Attention):
713 self.wo_a,
714 inverse_rope=False,
715 )
679 - zf = z.flatten(1)
716 + return self._wo_b_after_wo_a(z.flatten(1))
717 +
718 + def _wo_b_after_wo_a(self, zf: torch.Tensor) -> torch.Tensor:
719 if self._wo_b_scale is not None and zf.dim() == 2:
681 - result = self._bpre_attn_gemm(self.wo_b.weight, self._wo_b_scale, zf, True)
682 - else:
683 - result = self.wo_b(zf)
684 - return result
720 + return self._bpre_attn_gemm(self.wo_b.weight, self._wo_b_scale, zf, True)
721 + return self.wo_b(zf)
722
723 def forward_mqa(
724 self,
725 q: torch.Tensor,
726 kv: torch.Tensor,
727 positions: torch.Tensor,
691 - output: torch.Tensor,
728 + output: torch.Tensor | QuantizedActivation,
729 ) -> None:
693 - assert output.shape == q.shape, (
694 - f"output buffer shape {output.shape} must match q shape {q.shape}"
695 - )
696 - assert output.dtype == q.dtype, (
697 - f"output buffer dtype {output.dtype} must match q dtype {q.dtype}"
698 - )
730 + mxfp8_out = output if isinstance(output, QuantizedActivation) else None
731 + if mxfp8_out is None:
732 + assert isinstance(output, torch.Tensor)
733 + assert output.shape == q.shape, (
734 + f"output buffer shape {output.shape} must match q shape {q.shape}"
735 + )
736 + assert output.dtype == q.dtype, (
737 + f"output buffer dtype {output.dtype} must match q dtype {q.dtype}"
738 + )
739
740 forward_context = get_forward_context()
741 attn_metadata = forward_context.attn_metadata
@@ -713,9 +753,13 @@ class DeepseekV41ROCMAiterMLAAttention(DeepseekV4Attention):
753 )
754 M = N + self.window_size + self.max_num_batched_tokens
755 current_workspace_manager().get_simultaneous(
716 - ((self.PREFILL_CHUNK_SIZE, M, q.shape[-1]), torch.bfloat16),
756 + *self._prefill_workspace_shapes(M, self.max_num_batched_tokens, q)
757 )
718 - output.zero_()
758 + if mxfp8_out is None:
759 + output.zero_()
760 + else:
761 + mxfp8_out.data.zero_()
762 + mxfp8_out.scale.zero_()
763 return
764
765 assert isinstance(attn_metadata, dict)
@@ -745,10 +789,41 @@ class DeepseekV41ROCMAiterMLAAttention(DeepseekV4Attention):
789 positions=positions[num_decode_tokens:],
790 compressed_k_cache=self_kv_cache,
791 swa_k_cache=swa_kv_cache,
748 - output=output[num_decode_tokens:],
792 + output=(
793 + output[num_decode_tokens:]
794 + if isinstance(output, torch.Tensor)
795 + else None
796 + ),
797 attn_metadata=rocm_metadata,
798 swa_metadata=swa_metadata,
799 + mxfp8_out=(
800 + None
801 + if mxfp8_out is None
802 + else (
803 + mxfp8_out.data[num_decode_tokens:],
804 + mxfp8_out.scale[num_decode_tokens:],
805 + )
806 + ),
807 )
808 + if mxfp8_out is not None:
809 + # The reduce epilogue rotates and quantizes every decode row and
810 + # the prefill path already did its own, so nothing is owed here.
811 + if num_decodes > 0:
812 + self._forward_decode(
813 + q=q[:num_decode_tokens],
814 + positions=positions[:num_decode_tokens],
815 + kv_cache=self_kv_cache,
816 + swa_metadata=swa_metadata,
817 + attn_metadata=rocm_metadata,
818 + swa_only=swa_only,
819 + output=None,
820 + output_mxfp8=(
821 + mxfp8_out.data[:num_decode_tokens],
822 + mxfp8_out.scale[:num_decode_tokens],
823 + ),
824 + )
825 + return
826 + assert isinstance(output, torch.Tensor)
827 rotated = 0
828 if num_decodes > 0:
829 rotated = self._forward_decode(
@@ -816,7 +891,8 @@ class DeepseekV41ROCMAiterMLAAttention(DeepseekV4Attention):
891 swa_metadata: DeepseekV4ROCMAiterSparseSWAMetadata,
892 attn_metadata: DeepseekV4FlashMLAMetadata | None,
893 swa_only: bool,
819 - output: torch.Tensor,
894 + output: torch.Tensor | None,
895 + output_mxfp8: tuple[torch.Tensor, torch.Tensor] | None = None,
896 ) -> int:
897 """Returns how many leading rows the decode epilogue inverse-RoPE'd."""
898 num_decodes = swa_metadata.num_decodes
@@ -856,6 +932,7 @@ class DeepseekV41ROCMAiterMLAAttention(DeepseekV4Attention):
932 nope_head_dim=self.nope_head_dim,
933 rope_head_dim=self.rope_head_dim,
934 output=output,
935 + output_mxfp8=output_mxfp8,
936 inv_rope_positions=positions,
937 inv_rope_cos_sin_cache=self.rotary_emb.cos_sin_cache,
938 extra_cache_nan_free=_trust_dsv4_extra_cache_nan_free(
@@ -871,10 +948,16 @@ class DeepseekV41ROCMAiterMLAAttention(DeepseekV4Attention):
948 positions: torch.Tensor,
949 compressed_k_cache: torch.Tensor | None,
950 swa_k_cache: torch.Tensor,
874 - output: torch.Tensor,
951 + output: torch.Tensor | None,
952 attn_metadata: DeepseekV4FlashMLAMetadata | None,
953 swa_metadata: DeepseekV4ROCMAiterSparseSWAMetadata,
954 + mxfp8_out: tuple[torch.Tensor, torch.Tensor] | None = None,
955 ) -> None:
956 + """Sparse prefill into bf16 ``output``, or into ``mxfp8_out``.
957 +
958 + With ``mxfp8_out`` the rows go to a bf16 workspace first and are then
959 + inverse-RoPE'd and quantized into it in one pass.
960 + """
961 swa_only = attn_metadata is None
962
963 num_prefills = swa_metadata.num_prefills
@@ -910,10 +993,13 @@ class DeepseekV41ROCMAiterMLAAttention(DeepseekV4Attention):
993 self.PREFILL_CHUNK_SIZE
994 )
995
913 - workspace_manager = current_workspace_manager()
914 - kv = workspace_manager.get_simultaneous(
915 - ((self.PREFILL_CHUNK_SIZE, M, q.shape[-1]), torch.bfloat16),
916 - )[0]
996 + workspace = current_workspace_manager().get_simultaneous(
997 + *self._prefill_workspace_shapes(M, num_prefill_tokens, q)
998 + )
999 + kv = workspace[0]
1000 + if mxfp8_out is not None:
1001 + output = workspace[1]
1002 + assert output is not None
1003 for chunk_idx in range(num_chunks):
1004 chunk_start = chunk_idx * self.PREFILL_CHUNK_SIZE
1005 chunk_end = min(chunk_start + self.PREFILL_CHUNK_SIZE, num_prefills)
@@ -978,3 +1064,31 @@ class DeepseekV41ROCMAiterMLAAttention(DeepseekV4Attention):
1064 attn_sink=self.attn_sink,
1065 output=output[query_start:query_end],
1066 )
1067 + if mxfp8_out is not None:
1068 + rocm_inverse_rope_mxfp8_rows(
1069 + output,
1070 + positions[:num_prefill_tokens],
1071 + self.rotary_emb.cos_sin_cache,
1072 + self.rope_head_dim,
1073 + mxfp8_out[0][:num_prefill_tokens],
1074 + mxfp8_out[1][:num_prefill_tokens],
1075 + )
1076 +
1077 + def _prefill_workspace_shapes(
1078 + self, M: int, num_prefill_tokens: int, q: torch.Tensor
1079 + ) -> list[tuple[tuple[int, ...], torch.dtype]]:
1080 + """The prefill gather buffer, plus bf16 rows for the MXFP8 output.
1081 +
1082 + One ``get_simultaneous`` call: separate calls alias the same memory.
1083 + """
1084 + shapes: list[tuple[tuple[int, ...], torch.dtype]] = [
1085 + ((self.PREFILL_CHUNK_SIZE, M, q.shape[-1]), torch.bfloat16)
1086 + ]
1087 + if _ON_GFX950:
1088 + shapes.append(
1089 + (
1090 + (num_prefill_tokens, self.n_local_heads, self.head_dim),
1091 + torch.bfloat16,
1092 + )
1093 + )
1094 + return shapes
vllm/v1/attention/ops/rocm_aiter_mla_sparse.py
+351 -5
@@ -16,7 +16,7 @@ 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
19 +from vllm.utils.torch_utils import LayerNameType, direct_register_custom_op
20 from vllm.v1.attention.backends.mla.indexer import DeepseekV32IndexerMetadata
21 from vllm.v1.attention.ops.common import pack_seq_triton, unpack_seq_triton
22 from vllm.v1.worker.workspace import current_workspace_manager
@@ -1637,6 +1637,90 @@ def rocm_inverse_rope_rows_(
1637 _fused_inverse_rope_gptj(o, positions, cos_sin_cache, rope_head_dim, out=o)
1638
1639
1640 +@triton.jit
1641 +def _inverse_rope_mxfp8_quant_kernel(
1642 + o_ptr, # [T, H, D] bf16
1643 + q_ptr, # [T, H * D] e4m3
1644 + s_ptr, # [T, H * D // 32] uint8 (E8M0)
1645 + pos_ptr,
1646 + cos_sin_ptr,
1647 + s_t,
1648 + s_h,
1649 + qs_t,
1650 + ss_t,
1651 + cs_stride,
1652 + HEAD_DIM: tl.constexpr,
1653 + NOPE: tl.constexpr,
1654 + HALF: tl.constexpr,
1655 +):
1656 + t = tl.program_id(0)
1657 + h = tl.program_id(1)
1658 + lanes = tl.arange(0, HEAD_DIM)
1659 + x = tl.load(o_ptr + t * s_t + h * s_h + lanes).to(tl.float32)
1660 + # Same lane-pair formulation as the reduce epilogue: NoPE pairs take
1661 + # cos=1 / sin=0, so one expression rotates the whole row.
1662 + pos = tl.load(pos_ptr + t).to(tl.int64)
1663 + pair_idx = tl.arange(0, HEAD_DIM // 2) - (NOPE // 2)
1664 + is_rope = pair_idx >= 0
1665 + k = tl.where(is_rope, pair_idx, 0)
1666 + cos = tl.where(is_rope, tl.load(cos_sin_ptr + pos * cs_stride + k), 1.0)
1667 + sin = tl.where(is_rope, tl.load(cos_sin_ptr + pos * cs_stride + HALF + k), 0.0)
1668 + even, odd = tl.split(tl.reshape(x, (HEAD_DIM // 2, 2)))
1669 + x = tl.reshape(
1670 + tl.join(even * cos + odd * sin, odd * cos - even * sin), (1, HEAD_DIM)
1671 + )
1672 + xq, bits = _mxfp8_quantize_rows(x, 1, HEAD_DIM)
1673 + tl.store(
1674 + q_ptr + t * qs_t + h * HEAD_DIM + lanes,
1675 + tl.reshape(xq, (HEAD_DIM,)).to(q_ptr.dtype.element_ty),
1676 + )
1677 + scale_offsets = tl.arange(0, HEAD_DIM // 32)
1678 + tl.store(
1679 + s_ptr + t * ss_t + h * (HEAD_DIM // 32) + scale_offsets,
1680 + tl.reshape(bits, (HEAD_DIM // 32,)).to(tl.uint8),
1681 + )
1682 +
1683 +
1684 +def rocm_inverse_rope_mxfp8_rows(
1685 + o: torch.Tensor,
1686 + positions: torch.Tensor,
1687 + cos_sin_cache: torch.Tensor,
1688 + rope_head_dim: int,
1689 + out_data: torch.Tensor,
1690 + out_scale: torch.Tensor,
1691 +) -> None:
1692 + """Inverse-RoPE bf16 attention rows and MXFP8-quantize them for wo_a.
1693 +
1694 + The counterpart of ``rocm_inverse_rope_rows_`` for layers whose attention
1695 + output is MXFP8: rows the decode reduce did not emit (prefill) go through
1696 + here. ``o`` is [T, H, D]; ``out_data`` [T, H * D] e4m3 and ``out_scale``
1697 + [T, H * D // 32] E8M0, the layout the reduce epilogue writes.
1698 + """
1699 + num_tokens, num_heads, head_dim = o.shape
1700 + if num_tokens == 0:
1701 + return
1702 + assert o.stride(-1) == 1 and out_data.stride(-1) == 1 and out_scale.stride(-1) == 1
1703 + assert out_data.shape == (num_tokens, num_heads * head_dim)
1704 + assert out_scale.shape == (num_tokens, num_heads * head_dim // 32)
1705 + assert cos_sin_cache.shape[-1] == rope_head_dim
1706 + _inverse_rope_mxfp8_quant_kernel[(num_tokens, num_heads)](
1707 + o,
1708 + out_data,
1709 + out_scale,
1710 + positions,
1711 + cos_sin_cache,
1712 + o.stride(0),
1713 + o.stride(1),
1714 + out_data.stride(0),
1715 + out_scale.stride(0),
1716 + cos_sin_cache.stride(0),
1717 + HEAD_DIM=head_dim,
1718 + NOPE=head_dim - rope_head_dim,
1719 + HALF=rope_head_dim // 2,
1720 + num_warps=4,
1721 + )
1722 +
1723 +
1724 def _get_cached_wo_a_bf16(
1725 wo_a: torch.nn.Module,
1726 n_local_groups: int,
@@ -1722,6 +1806,175 @@ def rocm_inv_rope_einsum(
1806 return torch.einsum("tgd,grd->tgr", o_ref, wo_a_weight)
1807
1808
1809 +@triton.jit
1810 +def _mxfp8_wo_a_bmm_kernel(
1811 + a_ptr, # [T, G * K] e4m3
1812 + as_ptr, # [T, G * K // 32] E8M0
1813 + w_ptr, # [G * R, K] e4m3
1814 + ws_ptr, # [G * R, K // 32] E8M0
1815 + out_ptr, # [T, G * R]
1816 + num_tokens,
1817 + stride_at,
1818 + stride_ast,
1819 + stride_wn,
1820 + stride_wsn,
1821 + stride_out,
1822 + R: tl.constexpr,
1823 + K: tl.constexpr,
1824 + BLOCK_M: tl.constexpr,
1825 + BLOCK_N: tl.constexpr,
1826 + BLOCK_K: tl.constexpr,
1827 +):
1828 + # Output columns are group-major, so a BLOCK_N tile never straddles two
1829 + # groups (R % BLOCK_N == 0) and picks its group's K slice of A.
1830 + pid_n = tl.program_id(0)
1831 + pid_m = tl.program_id(1)
1832 + group = (pid_n * BLOCK_N) // R
1833 + offs_n = pid_n * BLOCK_N + tl.arange(0, BLOCK_N)
1834 + offs_m = pid_m * BLOCK_M + tl.arange(0, BLOCK_M)
1835 + m_mask = offs_m < num_tokens
1836 + offs_k = tl.arange(0, BLOCK_K)
1837 + offs_sk = tl.arange(0, BLOCK_K // 32)
1838 +
1839 + a_ptrs = a_ptr + offs_m[:, None] * stride_at + (group * K + offs_k)[None, :]
1840 + as_ptrs = (
1841 + as_ptr + offs_m[:, None] * stride_ast + (group * (K // 32) + offs_sk)[None, :]
1842 + )
1843 + w_ptrs = w_ptr + offs_n[:, None] * stride_wn + offs_k[None, :]
1844 + ws_ptrs = ws_ptr + offs_n[:, None] * stride_wsn + offs_sk[None, :]
1845 +
1846 + acc = tl.zeros((BLOCK_M, BLOCK_N), dtype=tl.float32)
1847 + for _ in range(K // BLOCK_K):
1848 + a = tl.load(a_ptrs, mask=m_mask[:, None], other=0.0)
1849 + a_s = tl.load(as_ptrs, mask=m_mask[:, None], other=127)
1850 + w = tl.load(w_ptrs)
1851 + w_s = tl.load(ws_ptrs)
1852 + acc += tl.dot_scaled(a, a_s, "e4m3", w.T, w_s, "e4m3")
1853 + a_ptrs += BLOCK_K
1854 + as_ptrs += BLOCK_K // 32
1855 + w_ptrs += BLOCK_K
1856 + ws_ptrs += BLOCK_K // 32
1857 +
1858 + tl.store(
1859 + out_ptr + offs_m[:, None] * stride_out + offs_n[None, :],
1860 + acc.to(out_ptr.dtype.element_ty),
1861 + mask=m_mask[:, None],
1862 + )
1863 +
1864 +
1865 +def _mxfp8_wo_a_bmm_config(num_tokens: int, n_groups: int) -> tuple[int, ...]:
1866 + """(BLOCK_M, BLOCK_N, BLOCK_K, num_warps, num_stages) for gfx950.
1867 +
1868 + Tuned under HIP graphs with a cold weight at G = 4 and 2, over every
1869 + decode shape of conc 1-128 x 0-5 spec tokens plus prefill chunks up to
1870 + 8K tokens. The best tile tracks the total work T * G, so the tiers are
1871 + keyed on it.
1872 +
1873 + This will be replaced after new GEMM kernel from AITER with proper 32x32 scale
1874 + shape GEMM fp8 enabled.
1875 + """
1876 + work = num_tokens * n_groups
1877 + if work <= 64:
1878 + return 16, 16, 1024, 2, 3
1879 + if work <= 128:
1880 + return 32, 16, 1024, 2, 3
1881 + if work <= 256:
1882 + return 32, 32, 512, 2, 3
1883 + if work <= 512:
1884 + return 64, 32, 512, 2, 3
1885 + if work <= 1024:
1886 + return 64, 64, 512, 4, 2
1887 + if work <= 2048:
1888 + return 64, 64, 256, 4, 2
1889 + if work <= 3072:
1890 + return 64, 64, 256, 2, 1
1891 + if work <= 4096:
1892 + return 128, 128, 256, 8, 2
1893 + return 128, 128, 128, 4, 2
1894 +
1895 +
1896 +def _rocm_mxfp8_wo_a_bmm_impl(
1897 + a: torch.Tensor,
1898 + a_scale: torch.Tensor,
1899 + weight: torch.Tensor,
1900 + weight_scale: torch.Tensor,
1901 + n_groups: int,
1902 + o_lora_rank: int,
1903 +) -> torch.Tensor:
1904 + num_tokens = a.shape[0]
1905 + group_dim = a.shape[1] // n_groups
1906 + out = torch.empty(
1907 + (num_tokens, n_groups * o_lora_rank), dtype=torch.bfloat16, device=a.device
1908 + )
1909 + if num_tokens == 0:
1910 + return out
1911 + block_m, block_n, block_k, num_warps, num_stages = _mxfp8_wo_a_bmm_config(
1912 + num_tokens, n_groups
1913 + )
1914 + grid = (n_groups * o_lora_rank // block_n, triton.cdiv(num_tokens, block_m))
1915 + _mxfp8_wo_a_bmm_kernel[grid](
1916 + a,
1917 + a_scale,
1918 + weight,
1919 + weight_scale,
1920 + out,
1921 + num_tokens,
1922 + a.stride(0),
1923 + a_scale.stride(0),
1924 + weight.stride(0),
1925 + weight_scale.stride(0),
1926 + out.stride(0),
1927 + R=o_lora_rank,
1928 + K=group_dim,
1929 + BLOCK_M=block_m,
1930 + BLOCK_N=block_n,
1931 + BLOCK_K=block_k,
1932 + num_warps=num_warps,
1933 + num_stages=num_stages,
1934 + )
1935 + return out
1936 +
1937 +
1938 +def _rocm_mxfp8_wo_a_bmm_fake(
1939 + a: torch.Tensor,
1940 + a_scale: torch.Tensor,
1941 + weight: torch.Tensor,
1942 + weight_scale: torch.Tensor,
1943 + n_groups: int,
1944 + o_lora_rank: int,
1945 +) -> torch.Tensor:
1946 + return a.new_empty((a.shape[0], n_groups * o_lora_rank), dtype=torch.bfloat16)
1947 +
1948 +
1949 +# An opaque op: the tile choice branches on the token count, which the
1950 +# compiled o_proj must not freeze at its trace-time value.
1951 +direct_register_custom_op(
1952 + op_name="rocm_dsv41_mxfp8_wo_a_bmm",
1953 + op_func=_rocm_mxfp8_wo_a_bmm_impl,
1954 + fake_impl=_rocm_mxfp8_wo_a_bmm_fake,
1955 +)
1956 +
1957 +
1958 +def rocm_mxfp8_wo_a_bmm(
1959 + a: torch.Tensor,
1960 + a_scale: torch.Tensor,
1961 + wo_a: torch.nn.Module,
1962 + n_groups: int,
1963 + o_lora_rank: int,
1964 +) -> torch.Tensor:
1965 + """Grouped MXFP8 wo_a: ``out[t, g, :] = a[t, g, :] @ W[g].T``, bf16 out.
1966 +
1967 + ``a`` is the [T, G * K] e4m3 attention output and ``a_scale`` its
1968 + [T, G * K // 32] E8M0 scales, as the sparse decode reduce writes them.
1969 + The weight is the checkpoint's MXFP8 ``wo_a`` as loaded, [G * R, K] with
1970 + [G * R, K // 32] scales, so there is no dequantized copy to keep.
1971 + Returns [T, G * R].
1972 + """
1973 + return torch.ops.vllm.rocm_dsv41_mxfp8_wo_a_bmm(
1974 + a, a_scale, wo_a.weight, wo_a.weight_scale, n_groups, o_lora_rank
1975 + )
1976 +
1977 +
1978 _DSV4_SPARSE_NOPE_DIM = 448
1979 _DSV4_SPARSE_ROPE_DIM = 64
1980
@@ -2922,6 +3175,33 @@ def _sparse_attn_decode_gfx950_partial_kernel(
3175 )
3176
3177
3178 +@triton.jit
3179 +def _mxfp8_scale_bits(amax):
3180 + """Biased E8M0 exponent that puts ``amax`` at the top of the e4m3 range.
3181 +
3182 + Same rounding as ``mxfp8_e4m3_quantize``, so the output is bit-identical to
3183 + quantizing the tensor there.
3184 + """
3185 + amax = tl.maximum(amax, 1.1754943508222875e-38)
3186 + bits = tl.ceil(tl.log2(amax / 448.0)) + 127.0
3187 + return tl.minimum(tl.maximum(bits, 0.0), 254.0)
3188 +
3189 +
3190 +@triton.jit
3191 +def _mxfp8_quantize_rows(x, ROWS: tl.constexpr, COLS: tl.constexpr):
3192 + """MXFP8-quantize ``x`` [ROWS, COLS] in registers, one scale per 32 lanes.
3193 +
3194 + Returns the rescaled fp32 values (to be cast to e4m3 on store) and the
3195 + [ROWS, COLS // 32] biased E8M0 exponents.
3196 + """
3197 + blocks = tl.reshape(x, (ROWS, COLS // 32, 32))
3198 + bits = _mxfp8_scale_bits(tl.max(tl.abs(blocks), axis=2))
3199 + # Multiply by the reciprocal: a divisor of 2**-127 would be subnormal and
3200 + # flush to zero, turning an all-zero block into NaN.
3201 + q = blocks * tl.exp2(127.0 - bits)[:, :, None]
3202 + return tl.reshape(q, (ROWS, COLS)), bits
3203 +
3204 +
3205 @triton.jit
3206 def _sparse_attn_decode_reduce_kernel(
3207 part_m_ptr,
@@ -2931,8 +3211,11 @@ def _sparse_attn_decode_reduce_kernel(
3211 out_ptr,
3212 pos_ptr,
3213 cos_sin_ptr,
3214 + out_scale_ptr,
3215 out_stride0,
3216 out_stride1,
3217 + os_stride0,
3218 + os_stride1,
3219 pm_stride0,
3220 pm_stride_s,
3221 pa_stride0,
@@ -2949,6 +3232,7 @@ def _sparse_attn_decode_reduce_kernel(
3232 FUSE_INV_ROPE: tl.constexpr,
3233 NOPE: tl.constexpr,
3234 HALF: tl.constexpr,
3235 + QUANT_OUT: tl.constexpr,
3236 ):
3237 query_idx = tl.program_id(0)
3238 pid_h = tl.program_id(1)
@@ -3061,9 +3345,22 @@ def _sparse_attn_decode_reduce_kernel(
3345 out_row_ptr = (
3346 out_ptr + query_idx * out_stride0 + head_offsets[:, None] * out_stride1
3347 )
3348 + if QUANT_OUT:
3349 + # Emit wo_a's MXFP8 input directly instead of a bf16 row that a
3350 + # separate pass would read back to quantize.
3351 + out, scale_bits = _mxfp8_quantize_rows(out, BLOCK_H, COMB_DIM)
3352 + scale_offsets = tl.arange(0, COMB_DIM // 32)
3353 + tl.store(
3354 + out_scale_ptr
3355 + + query_idx * os_stride0
3356 + + head_offsets[:, None] * os_stride1
3357 + + scale_offsets[None, :],
3358 + scale_bits.to(tl.uint8),
3359 + mask=head_mask[:, None],
3360 + )
3361 tl.store(
3362 out_row_ptr + comb_offsets[None, :],
3066 - out,
3363 + out.to(out_ptr.dtype.element_ty),
3364 mask=head_mask[:, None],
3365 )
3366
@@ -3380,7 +3677,14 @@ def _rocm_sparse_attn_decode_ragged_triton(
3677 adaptive_splits: bool = False,
3678 inv_rope_positions: torch.Tensor | None = None,
3679 inv_rope_cos_sin_cache: torch.Tensor | None = None,
3680 + out_mxfp8: tuple[torch.Tensor, torch.Tensor] | None = None,
3681 ) -> torch.Tensor:
3682 + """Split-K sparse decode; returns the attention output.
3683 +
3684 + With ``out_mxfp8 = (data, scale)`` the reduce writes MXFP8 instead of
3685 + bf16: ``data`` is [b, h * d] e4m3 and ``scale`` [b, h * d // 32] E8M0, and
3686 + ``data`` viewed as [b, h, d] is returned.
3687 + """
3688 assert q.ndim == 3, f"expected q=[b,h,d], got {q.shape}"
3689 assert main_cache.ndim == 3, (
3690 f"expected main_cache=[blocks,block,bytes], got {main_cache.shape}"
@@ -3445,7 +3749,28 @@ def _rocm_sparse_attn_decode_ragged_triton(
3749 extra_indptr = torch.zeros(num_queries + 1, device=q.device, dtype=torch.int32)
3750
3751 block_h = 16
3448 - if out is None:
3752 + out_scale = None
3753 + if out_mxfp8 is not None:
3754 + assert out is None, "out and out_mxfp8 are mutually exclusive"
3755 + assert _ON_GFX950, "the MXFP8 reduce epilogue is gfx950-only"
3756 + assert inv_rope_positions is not None, (
3757 + "the MXFP8 output feeds wo_a, so it must be inverse-RoPE'd first"
3758 + )
3759 + out_data, out_scale = out_mxfp8
3760 + assert out_data.dtype == torch.float8_e4m3fn and out_scale.dtype == (
3761 + torch.uint8
3762 + ), f"expected e4m3/uint8 MXFP8 buffers, got {out_data.dtype}/{out_scale.dtype}"
3763 + assert out_data.shape == (num_queries, num_heads * head_dim), (
3764 + f"expected MXFP8 data [{num_queries}, {num_heads * head_dim}], "
3765 + f"got {tuple(out_data.shape)}"
3766 + )
3767 + assert out_scale.shape == (num_queries, num_heads * head_dim // 32), (
3768 + f"expected MXFP8 scale [{num_queries}, {num_heads * head_dim // 32}], "
3769 + f"got {tuple(out_scale.shape)}"
3770 + )
3771 + assert out_data.stride(-1) == 1 and out_scale.stride(-1) == 1
3772 + out = out_data.view(num_queries, num_heads, head_dim)
3773 + elif out is None:
3774 out = torch.empty_like(q, dtype=torch.bfloat16)
3775 else:
3776 assert out.shape == q.shape, f"expected out shape {q.shape}, got {out.shape}"
@@ -3643,8 +3968,11 @@ def _rocm_sparse_attn_decode_ragged_triton(
3968 out,
3969 inv_rope_positions,
3970 inv_rope_cos_sin_cache,
3971 + out_scale,
3972 out.stride(0),
3973 out.stride(1),
3974 + out_scale.stride(0) if out_scale is not None else 0,
3975 + head_dim // 32,
3976 part_m.stride(0),
3977 part_m.stride(1),
3978 part_acc.stride(0),
@@ -3661,6 +3989,7 @@ def _rocm_sparse_attn_decode_ragged_triton(
3989 FUSE_INV_ROPE=fuse_inv_rope,
3990 NOPE=nope_head_dim,
3991 HALF=rope_head_dim // 2,
3992 + QUANT_OUT=out_scale is not None,
3993 num_warps=4,
3994 )
3995 return out
@@ -3687,6 +4016,7 @@ def _rocm_sparse_attn_decode_triton(
4016 adaptive_splits: bool = False,
4017 inv_rope_positions: torch.Tensor | None = None,
4018 inv_rope_cos_sin_cache: torch.Tensor | None = None,
4019 + out_mxfp8: tuple[torch.Tensor, torch.Tensor] | None = None,
4020 ) -> torch.Tensor:
4021 if main_ragged_indices is None or main_ragged_indptr is None:
4022 main_ragged_indices, main_ragged_indptr = build_ragged_indices_from_dense(
@@ -3727,6 +4057,7 @@ def _rocm_sparse_attn_decode_triton(
4057 adaptive_splits=adaptive_splits,
4058 inv_rope_positions=inv_rope_positions,
4059 inv_rope_cos_sin_cache=inv_rope_cos_sin_cache,
4060 + out_mxfp8=out_mxfp8,
4061 )
4062
4063
@@ -3825,11 +4156,12 @@ def rocm_sparse_attn_decode(
4156 head_dim: int,
4157 nope_head_dim: int,
4158 rope_head_dim: int,
3828 - output: torch.Tensor,
4159 + output: torch.Tensor | None,
4160 extra_cache_nan_free: bool = False,
4161 adaptive_splits: bool = False,
4162 inv_rope_positions: torch.Tensor | None = None,
4163 inv_rope_cos_sin_cache: torch.Tensor | None = None,
4164 + output_mxfp8: tuple[torch.Tensor, torch.Tensor] | None = None,
4165 ) -> int:
4166 """Run sparse MLA decode into ``output``.
4167
@@ -3837,6 +4169,11 @@ def rocm_sparse_attn_decode(
4169 epilogue. Returns how many leading rows of ``output`` came back rotated,
4170 so a caller mixing in a decode path that does not fuse still knows what it
4171 owes the standalone pass. Read it from the eager attention segment only.
4172 +
4173 + ``output_mxfp8 = (data, scale)`` replaces ``output``: the reduce also
4174 + MXFP8-quantizes the rotated rows for the FP8 wo_a (see
4175 + ``_rocm_sparse_attn_decode_ragged_triton``). It needs gfx950 and the fused
4176 + inverse RoPE, and always covers every row.
4177 """
4178 assert swa_k_cache.dtype == torch.uint8, (
4179 "ROCm Triton sparse decode expects uint8 fp8_ds_mla SWA cache, "
@@ -3866,7 +4203,12 @@ def rocm_sparse_attn_decode(
4203 if topk_indices is not None:
4204 extra_indices = topk_indices.reshape(topk_indices.shape[0], -1)
4205
3869 - direct_out = output if _ON_GFX950 and output.dtype == torch.bfloat16 else None
4206 + if output_mxfp8 is not None:
4207 + assert output is None, "output and output_mxfp8 are mutually exclusive"
4208 + direct_out = None
4209 + else:
4210 + assert output is not None
4211 + direct_out = output if _ON_GFX950 and output.dtype == torch.bfloat16 else None
4212 attn_out = _rocm_sparse_attn_decode_triton(
4213 q=q,
4214 main_cache=swa_k_cache,
@@ -3888,7 +4230,11 @@ def rocm_sparse_attn_decode(
4230 adaptive_splits=adaptive_splits,
4231 inv_rope_positions=inv_rope_positions,
4232 inv_rope_cos_sin_cache=inv_rope_cos_sin_cache,
4233 + out_mxfp8=output_mxfp8,
4234 )
4235 + if output_mxfp8 is not None:
4236 + return q.shape[0]
4237 + assert output is not None
4238 if direct_out is None:
4239 output.copy_(attn_out.to(output.dtype))
4240 return output.shape[0] if inv_rope_positions is not None else 0