[ROCm][MLA] Fuse MLA q/kv RMSNorm + FP8 per-token quant in the FP8 attention path (#44977)

Signed-off-by: Xavier Aguilar <xavier.aguilarfruto@amd.com> Signed-off-by: Xavier Aguilar <Xavier.AguilarFruto@amd.com>

xaguilar-amd committed Jul 2, 2026 at 07:00 UTC 09663abde0f50944a8d5ea30120666024b503faa
4 files changed +371 -5
docs/design/fusions.md
+25 -4
@@ -30,7 +30,7 @@ or just on the low or high end.
30 | [RMSNorm + Quant](#rmsnorm--quantization-fuse_norm_quant) | `fuse_norm_quant` | RMSNorm (+residual add) → FP8/FP4 quant | O1 (conditional) | 1-4% | No | Always |
31 | [SiLU+Mul + Quant](#silumul--quantization-fuse_act_quant) | `fuse_act_quant` | SiLU+Mul activation → FP8/FP4 quant | O1 (conditional) | 1-4% | No | Always |
32 | [RMSNorm + Padding](#rmsnorm--padding-fuse_act_padding) | `fuse_act_padding` | Residual add + RMSNorm → padding | O1 (ROCm/AITER only) | TBD | No | Always |
33 -| [MLA Dual RMSNorm](#mla-dual-rmsnorm-fuse_mla_dual_rms_norm) | `fuse_mla_dual_rms_norm` | Paired Q + KV RMSNorm → single kernel | O1 (ROCm/AITER only) | ~2% | No | Always |
33 +| [MLA Dual RMSNorm](#mla-dual-rmsnorm-fuse_mla_dual_rms_norm) | `fuse_mla_dual_rms_norm` | Paired Q + KV RMSNorm (+ FP8 quant) → 1 kernel | O1 (ROCm/AITER only) | 1-2% | No | Always |
34
35 ## Support Matrix
36
@@ -381,11 +381,32 @@ q_normed, kv_normed = fused_mla_dual_rms_norm(
381 Requires: AMD ROCm with AITER enabled. Enabled by default at optimization level O1 and above
382 when AITER is available.
383
384 +**FP8 attention variant (per-token quant).** With a per-token FP8 `q_b_proj`,
385 +only the *q* latent is FP8-quantized while *kv* stays bf16.
386 +`RocmAiterRMSNormQuantFusionPass` first folds the q side into
387 +`rocm_aiter_rmsnorm_fused_dynamic_quant`, leaving kv a plain
388 +`rms_norm` — breaking the symmetric pattern above. The same pass then matches
389 +this asymmetric pair and lowers it to `fused_mla_dual_rms_norm_per_token_quant`.
390 +
391 +```text
392 +# Unfused (q norm+quant fused; kv still plain rms_norm):
393 +q_c, kv_lora = split(projected, [q_dim, kv_dim])
394 +kv_c, k_pe = split(kv_lora, [kv_c_dim, k_pe_dim])
395 +q_fp8, q_scale = rocm_aiter_rmsnorm_fused_dynamic_quant(q_c, q_weight, eps, fp8)
396 +kv_normed = rms_norm(kv_c, kv_weight, eps) # bf16
397 +
398 +# Fused:
399 +q_c, kv_lora = split(projected, [q_dim, kv_dim])
400 +kv_c, k_pe = split(kv_lora, [kv_c_dim, k_pe_dim])
401 +q_fp8, q_scale, kv_normed = fused_mla_dual_rms_norm_per_token_quant(
402 + q_c, q_weight, kv_c, kv_weight, eps1, eps2)
403 +```
404 +
405 **Code locations.**
406
386 -- Pass: [`vllm/compilation/passes/fusion/rocm_aiter_fusion.py`](https://github.com/vllm-project/vllm/blob/main/vllm/compilation/passes/fusion/rocm_aiter_fusion.py) (`MLADualRMSNormFusionPass`)
387 -- Custom op: [`vllm/_aiter_ops.py`](https://github.com/vllm-project/vllm/blob/main/vllm/_aiter_ops.py) (`fused_mla_dual_rms_norm`)
388 -- AITER kernel: [`fused_qk_rmsnorm`](https://github.com/ROCm/aiter/pull/2442)
407 +- Pass: [`vllm/compilation/passes/fusion/rocm_aiter_fusion.py`](https://github.com/vllm-project/vllm/blob/main/vllm/compilation/passes/fusion/rocm_aiter_fusion.py) (`MLADualRMSNormFusionPass`, `MLADualRMSPerTokenQuantPattern`)
408 +- Custom op: [`vllm/_aiter_ops.py`](https://github.com/vllm-project/vllm/blob/main/vllm/_aiter_ops.py) (`fused_mla_dual_rms_norm`, `fused_mla_dual_rms_norm_per_token_quant`)
409 +- AITER kernels: [`fused_qk_rmsnorm`](https://github.com/ROCm/aiter/pull/2442), `fused_qk_rmsnorm_per_token_quant`
410
411 ## See Also
412
tests/compile/passes/test_fuse_mla_dual_rms_norm.py
+143 -1
@@ -12,7 +12,10 @@ import torch
12
13 import vllm.config
14 from tests.compile.backend import TestBackend
15 -from vllm._aiter_ops import is_aiter_found_and_supported, rocm_aiter_ops
15 +from vllm._aiter_ops import (
16 + is_aiter_found_and_supported,
17 + rocm_aiter_ops,
18 +)
19 from vllm.compilation.passes.utility.noop_elimination import NoOpEliminationPass
20 from vllm.compilation.passes.utility.post_cleanup import PostCleanupPass
21 from vllm.config import (
@@ -23,6 +26,7 @@ from vllm.config import (
26 VllmConfig,
27 )
28 from vllm.model_executor.layers.layernorm import RMSNorm
29 +from vllm.platforms import current_platform
30
31 # MLA attention geometry for DeepSeek-V3 / Kimi-K2
32 Q_DIM = 1536
@@ -30,6 +34,8 @@ KV_C_DIM = 512
34 K_PE_DIM = 64
35 EPS = 1e-6
36
37 +FP8_DTYPE = current_platform.fp8_dtype()
38 +
39
40 class MLADualRMSNormTestModel(torch.nn.Module):
41 """
@@ -146,3 +152,139 @@ def test_fuse_mla_dual_rms_norm(
152
153 backend.check_before_ops(model.ops_in_model_before())
154 backend.check_after_ops(model.ops_in_model_after())
155 +
156 +
157 +class MLADualRMSNormFp8PerTokenTestModel(torch.nn.Module):
158 + """
159 + Minimal model reproducing the FP8 MLA attention path with *per-token* quant:
160 + linear -> split([q_dim, kv_dim])
161 + +-- q_c (getitem 0) -> rocm_aiter_rmsnorm_fused_dynamic_quant -> dequant
162 + +-- kv_lora (getitem 1) -> split([kv_c_dim, k_pe_dim])
163 + +-- kv_c (getitem 0) -> rms_norm (bf16)
164 + +-- k_pe
165 + """
166 +
167 + def __init__(
168 + self,
169 + hidden_size: int,
170 + q_dim: int = Q_DIM,
171 + kv_c_dim: int = KV_C_DIM,
172 + k_pe_dim: int = K_PE_DIM,
173 + eps: float = EPS,
174 + ):
175 + super().__init__()
176 + self.q_dim = q_dim
177 + self.kv_dim = kv_c_dim + k_pe_dim
178 + self.kv_c_dim = kv_c_dim
179 + self.k_pe_dim = k_pe_dim
180 + self.eps = eps
181 +
182 + self.proj = torch.nn.Linear(hidden_size, q_dim + self.kv_dim, bias=False)
183 + self.q_weight = torch.nn.Parameter(torch.ones(q_dim))
184 + self.kv_norm = RMSNorm(kv_c_dim, eps=eps)
185 +
186 + def _dequant(self, x_fp8: torch.Tensor, scale: torch.Tensor) -> torch.Tensor:
187 + # Per-token: a single (M, 1) scale broadcast across the row.
188 + return (x_fp8.to(torch.float32) * scale).to(torch.bfloat16)
189 +
190 + def forward(self, x: torch.Tensor):
191 + # Avoid graph input being a direct arg to a matched pattern node
192 + x = torch.relu(x)
193 +
194 + projected = self.proj(x)
195 +
196 + q_c, kv_lora = projected.split([self.q_dim, self.kv_dim], dim=-1)
197 + kv_c, k_pe = kv_lora.split([self.kv_c_dim, self.k_pe_dim], dim=-1)
198 +
199 + q_fp8, q_scale = torch.ops.vllm.rocm_aiter_rmsnorm_fused_dynamic_quant(
200 + q_c, self.q_weight, self.eps, FP8_DTYPE
201 + )
202 + kv_normed = self.kv_norm(kv_c)
203 +
204 + return self._dequant(q_fp8, q_scale), kv_normed, k_pe
205 +
206 + def ops_in_model_before(self):
207 + return [
208 + torch.ops.vllm.rocm_aiter_rmsnorm_fused_dynamic_quant.default,
209 + torch.ops.vllm_ir.rms_norm.default,
210 + ]
211 +
212 + def ops_in_model_after(self):
213 + return [torch.ops.vllm.fused_mla_dual_rms_norm_per_token_quant.default]
214 +
215 +
216 +@pytest.mark.parametrize("dtype", [torch.bfloat16])
217 +@pytest.mark.parametrize("hidden_size", [7168])
218 +@pytest.mark.skipif(
219 + not is_aiter_found_and_supported(),
220 + reason="Only test on ROCm with AITER installed and supported",
221 +)
222 +def test_fuse_mla_dual_rms_norm_fp8_per_token(
223 + dtype: torch.dtype,
224 + hidden_size: int,
225 + monkeypatch: pytest.MonkeyPatch,
226 +):
227 + torch._dynamo.reset()
228 +
229 + vllm_config = VllmConfig(
230 + model_config=ModelConfig(dtype=dtype),
231 + compilation_config=CompilationConfig(
232 + mode=CompilationMode.VLLM_COMPILE,
233 + custom_ops=["+rms_norm"],
234 + pass_config=PassConfig(
235 + fuse_mla_dual_rms_norm=True,
236 + eliminate_noops=True,
237 + ),
238 + ),
239 + )
240 +
241 + with vllm.config.set_current_vllm_config(vllm_config), monkeypatch.context() as m:
242 + from vllm.compilation.passes.fusion.rocm_aiter_fusion import (
243 + MLADualRMSNormFusionPass,
244 + )
245 +
246 + torch.set_default_device("cuda")
247 + torch.set_default_dtype(dtype)
248 + torch.manual_seed(42)
249 +
250 + m.setenv("VLLM_ROCM_USE_AITER", "1")
251 + rocm_aiter_ops.refresh_env_variables()
252 +
253 + fusion_pass = MLADualRMSNormFusionPass(vllm_config)
254 + passes = [
255 + NoOpEliminationPass(vllm_config),
256 + fusion_pass,
257 + PostCleanupPass(vllm_config),
258 + ]
259 + backend = TestBackend(*passes)
260 + model = MLADualRMSNormFp8PerTokenTestModel(hidden_size)
261 +
262 + x = torch.randn(4, hidden_size)
263 + torch._dynamo.mark_dynamic(x, 0)
264 +
265 + with torch.inference_mode():
266 + outputs_unfused = model(x)
267 +
268 + model_fused = torch.compile(model, backend=backend)
269 + outputs_fused = model_fused(x)
270 +
271 + q_deq_u, kv_normed_u, k_pe_u = outputs_unfused
272 + q_deq_f, kv_normed_f, k_pe_f = outputs_fused
273 +
274 + torch.testing.assert_close(k_pe_u, k_pe_f, atol=0, rtol=0)
275 +
276 + torch.testing.assert_close(kv_normed_u, kv_normed_f, atol=1e-2, rtol=1e-2)
277 +
278 + E4M3_STEP = 0.125
279 + exact_frac = (q_deq_u == q_deq_f).float().mean().item()
280 + assert exact_frac > 0.99, (
281 + f"q: only {exact_frac:.4f} of elements bit-exact; scales likely differ"
282 + )
283 + torch.testing.assert_close(q_deq_u, q_deq_f, atol=1e-2, rtol=E4M3_STEP)
284 +
285 + assert fusion_pass.matched_count == 1, (
286 + f"Expected 1 fused pair, got {fusion_pass.matched_count}"
287 + )
288 +
289 + backend.check_before_ops(model.ops_in_model_before())
290 + backend.check_after_ops(model.ops_in_model_after())
vllm/_aiter_ops.py
+68
@@ -1355,6 +1355,63 @@ def _fused_mla_dual_rms_norm_fake(
1355 return (torch.empty_like(x1), torch.empty_like(x2))
1356
1357
1358 +def _fused_mla_dual_rms_norm_per_token_quant_impl(
1359 + q: torch.Tensor,
1360 + q_weight: torch.Tensor,
1361 + kv: torch.Tensor,
1362 + kv_weight: torch.Tensor,
1363 + q_epsilon: float,
1364 + kv_epsilon: float,
1365 +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
1366 + """Fused MLA q/kv RMSNorm (+ FP8 per-token quant on q) via AITER.
1367 +
1368 + Backs the ``fused_mla_dual_rms_norm_per_token_quant`` custom op used by the
1369 + MLA FP8 attention fusion when the q latent is quantized *per token* (a single
1370 + ``(M, 1)`` scale). Only the *q* latent is FP8 quantized (it feeds the
1371 + FP8 ``q_b_proj`` GEMM); the *kv* latent is RMS-normed and consumed by attention as bf16.
1372 + """
1373 + from aiter.ops.fused_qk_rmsnorm_group_quant import (
1374 + fused_qk_rmsnorm_per_token_quant,
1375 + )
1376 +
1377 + mq, nq = q.shape
1378 + q_out = torch.empty((mq, nq), dtype=FP8_DTYPE, device=q.device)
1379 + q_scale = torch.empty((mq, 1), dtype=torch.float32, device=q.device)
1380 + kv_normed = torch.empty(kv.shape, dtype=kv.dtype, device=kv.device)
1381 +
1382 + # q -> RMSNorm + FP8 per-token quant (q slot); kv -> RMSNorm only (k slot).
1383 + # `split` views are accepted directly (unit inner stride); the kernel
1384 + # handles strided inputs, matching the aiter op-test usage.
1385 + fused_qk_rmsnorm_per_token_quant(
1386 + q_out_quantized=q_out,
1387 + q_out_scale=q_scale,
1388 + q=q,
1389 + q_weight=q_weight,
1390 + q_epsilon=q_epsilon,
1391 + k_out=kv_normed,
1392 + k=kv,
1393 + k_weight=kv_weight,
1394 + k_epsilon=kv_epsilon,
1395 + gemma_norm=False,
1396 + )
1397 + return q_out, q_scale, kv_normed
1398 +
1399 +
1400 +def _fused_mla_dual_rms_norm_per_token_quant_fake(
1401 + q: torch.Tensor,
1402 + q_weight: torch.Tensor,
1403 + kv: torch.Tensor,
1404 + kv_weight: torch.Tensor,
1405 + q_epsilon: float,
1406 + kv_epsilon: float,
1407 +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
1408 + mq, nq = q.shape
1409 + q_out = torch.empty((mq, nq), dtype=FP8_DTYPE, device=q.device)
1410 + q_scale = torch.empty((mq, 1), dtype=torch.float32, device=q.device)
1411 + kv_normed = torch.empty(kv.shape, dtype=kv.dtype, device=kv.device)
1412 + return q_out, q_scale, kv_normed
1413 +
1414 +
1415 def _rocm_aiter_gemm_a8wfp4_impl(
1416 x: torch.Tensor,
1417 w: torch.Tensor,
@@ -2046,6 +2103,13 @@ class rocm_aiter_ops:
2103 fake_impl=_fused_mla_dual_rms_norm_fake,
2104 )
2105
2106 + direct_register_custom_op(
2107 + op_name="fused_mla_dual_rms_norm_per_token_quant",
2108 + op_func=_fused_mla_dual_rms_norm_per_token_quant_impl,
2109 + mutates_args=[],
2110 + fake_impl=_fused_mla_dual_rms_norm_per_token_quant_fake,
2111 + )
2112 +
2113 _OPS_REGISTERED = True
2114
2115 @staticmethod
@@ -2120,6 +2184,10 @@ class rocm_aiter_ops:
2184 def get_fused_mla_dual_rms_norm_op() -> OpOverload:
2185 return torch.ops.vllm.fused_mla_dual_rms_norm.default
2186
2187 + @staticmethod
2188 + def get_fused_mla_dual_rms_norm_per_token_quant_op() -> OpOverload:
2189 + return torch.ops.vllm.fused_mla_dual_rms_norm_per_token_quant.default
2190 +
2191 @staticmethod
2192 def w8a8_gemm(
2193 A: torch.Tensor,
vllm/compilation/passes/fusion/rocm_aiter_fusion.py
+135
@@ -922,11 +922,145 @@ class MLADualRMSNormPattern(
922 return _replacement
923
924
925 +class MLADualRMSPerTokenQuantPattern(
926 + VllmPatternReplacement[
927 + ...,
928 + tuple[
929 + torch.Tensor,
930 + torch.Tensor,
931 + torch.Tensor,
932 + torch.Tensor,
933 + ],
934 + ]
935 +):
936 + """
937 + Fuse the MLA FP8 attention path -- q-latent RMSNorm + FP8 *per-token* quant
938 + plus kv-latent RMSNorm -- into AITER's ``fused_qk_rmsnorm_per_token_quant``.
939 +
940 + With a per-token FP8 ``q_b_proj`` (Quark / ModelOpt), the earlier
941 + ``RocmAiterRMSNormQuantFusionPass`` folds the q side into
942 + ``rocm_aiter_rmsnorm_fused_dynamic_quant`` and leaves the kv side a plain
943 + ``vllm_ir.rms_norm``. This pattern matches that asymmetric pair::
944 +
945 + gemm -> split_with_sizes([q_dim, kv_dim])
946 + +-- q_c -> rocm_aiter_rmsnorm_fused_dynamic_quant -> (q_fp8, q_scale)
947 + +-- kv_lora -> split_with_sizes([kv_c_dim, k_pe_dim])
948 + +-- kv_c -> vllm_ir.rms_norm -> kv_normed (bf16)
949 + +-- k_pe
950 + """
951 +
952 + DYNAMIC_QUANT_OP = rocm_aiter_ops.get_rmsnorm_fused_dynamic_quant_op()
953 + FUSED_OP = rocm_aiter_ops.get_fused_mla_dual_rms_norm_per_token_quant_op()
954 +
955 + def __init__(self, epsilon: float) -> None:
956 + self._epsilon = epsilon
957 +
958 + def get_inputs(self) -> list[torch.Tensor]:
959 + q_dim, kv_c_dim, k_pe_dim = 256, 128, 64
960 + return [
961 + self.empty_bf16(5, q_dim + kv_c_dim + k_pe_dim),
962 + self.empty_bf16(q_dim),
963 + self.empty_bf16(kv_c_dim),
964 + ]
965 +
966 + @property
967 + def pattern(
968 + self,
969 + ) -> Callable[
970 + ...,
971 + tuple[
972 + torch.Tensor,
973 + torch.Tensor,
974 + torch.Tensor,
975 + torch.Tensor,
976 + ],
977 + ]:
978 + eps = self._epsilon
979 + dynamic_quant_op = self.DYNAMIC_QUANT_OP
980 +
981 + def _pattern(
982 + projected: torch.Tensor,
983 + q_weight: torch.Tensor,
984 + kv_weight: torch.Tensor,
985 + ) -> tuple[
986 + torch.Tensor,
987 + torch.Tensor,
988 + torch.Tensor,
989 + torch.Tensor,
990 + ]:
991 + q_dim = q_weight.shape[0]
992 + kv_dim = projected.shape[-1] - q_dim
993 + kv_c_dim = kv_weight.shape[0]
994 + k_pe_dim = kv_dim - kv_c_dim
995 + q_c, kv_lora = projected.split([q_dim, kv_dim], dim=-1)
996 + kv_c, k_pe = kv_lora.split([kv_c_dim, k_pe_dim], dim=-1)
997 + q_quant = dynamic_quant_op(
998 + x=q_c,
999 + weight=q_weight,
1000 + epsilon=eps,
1001 + quant_dtype=FP8_DTYPE,
1002 + )
1003 + kv_normed = vllm.ir.ops.rms_norm(kv_c, kv_weight, eps)
1004 + return q_quant[0], q_quant[1], kv_normed, k_pe
1005 +
1006 + return _pattern
1007 +
1008 + @property
1009 + def replacement(
1010 + self,
1011 + ) -> Callable[
1012 + ...,
1013 + tuple[
1014 + torch.Tensor,
1015 + torch.Tensor,
1016 + torch.Tensor,
1017 + torch.Tensor,
1018 + ],
1019 + ]:
1020 + eps = self._epsilon
1021 + fused_op = self.FUSED_OP
1022 +
1023 + def _replacement(
1024 + projected: torch.Tensor,
1025 + q_weight: torch.Tensor,
1026 + kv_weight: torch.Tensor,
1027 + ) -> tuple[
1028 + torch.Tensor,
1029 + torch.Tensor,
1030 + torch.Tensor,
1031 + torch.Tensor,
1032 + ]:
1033 + q_dim = q_weight.shape[0]
1034 + kv_dim = projected.shape[-1] - q_dim
1035 + kv_c_dim = kv_weight.shape[0]
1036 + k_pe_dim = kv_dim - kv_c_dim
1037 + q_c, kv_lora = projected.split([q_dim, kv_dim], dim=-1)
1038 + kv_c, k_pe = kv_lora.split([kv_c_dim, k_pe_dim], dim=-1)
1039 + at = fused_op(
1040 + q_c,
1041 + q_weight,
1042 + kv_c,
1043 + kv_weight,
1044 + eps,
1045 + eps,
1046 + )
1047 + # q_fp8, q_scale, kv_normed, k_pe
1048 + return at[0], at[1], at[2], k_pe
1049 +
1050 + return _replacement
1051 +
1052 +
1053 class MLADualRMSNormFusionPass(VllmFusionPatternMatcherPass):
1054 """
1055 Post-grad PatternMatcher pass that fuses paired q / kv RMS norms in
1056 MLA attention into ``fused_mla_dual_rms_norm`` backed by aiter's
1057 ``fused_qk_rmsnorm`` HIP kernel.
1058 +
1059 + The FP8 attention path is also handled via
1060 + :class:`MLADualRMSPerTokenQuantPattern`, which fuses the q-latent RMSNorm +
1061 + FP8 per-token quant together with the kv-latent RMSNorm into
1062 + ``fused_mla_dual_rms_norm_per_token_quant`` backed by aiter's
1063 + ``fused_qk_rmsnorm_per_token_quant`` HIP kernel.
1064 """
1065
1066 def __init__(self, config: VllmConfig) -> None:
@@ -934,3 +1068,4 @@ class MLADualRMSNormFusionPass(VllmFusionPatternMatcherPass):
1068
1069 for epsilon in [1e-5, 1e-6]:
1070 self.register(MLADualRMSNormPattern(epsilon))
1071 + self.register(MLADualRMSPerTokenQuantPattern(epsilon))