[ROCm][Perf] Fuse SWA q/kv RMSNorm and q FP8 group quant for DeepSeek-V4 (#53540)
Signed-off-by: Shanshan Shen <87969357+shen-shanshan@users.noreply.github.com>
Shanshan Shen committed
Aug 28, 2026 at 00:37 UTC
32ad1400d7fa08872430de3f1d0f981dc8db5cb6
4 files changed
+333
-12
tests/kernels/core/test_rocm_aiter_ops.py
+76
-1
@@ -24,7 +24,7 @@ import pytest
24
import torch
25
26
import vllm._aiter_ops # noqa: F401 - ensure ops are registered
27
-from tests.kernels.utils import bf16_ulp_distance
27
+from tests.kernels.utils import bf16_ulp_distance, fp8_ulp_distance
28
from vllm.platforms import current_platform
29
from vllm.utils.torch_utils import set_random_seed
30
@@ -531,6 +531,81 @@ def test_rocm_aiter_rmsnorm_with_add_fp8_group_quant_residual_accuracy():
531
)
532
533
534
+@pytest.mark.parametrize("m", [1, 32])
535
+def test_rocm_aiter_mla_dual_rmsnorm_group_quant_vs_sequential(m):
536
+ """Fused q/kv RMSNorm + q group-quant matches rms_norm -> group_fp8_quant.
537
+
538
+ M=1 covers the single-token decode case. M=0 is guarded by the caller
539
+ (the ROCm DeepSeek-V4 attention checks num_tokens > 0) and the op has
540
+ no zero-row guard itself, so it is intentionally not tested here.
541
+ """
542
+ require_aiter()
543
+ require_fp8()
544
+ from vllm._aiter_ops import rocm_aiter_ops
545
+
546
+ nq, nk, group_size = 1536, 576, 128
547
+ q = torch.randn(m, nq, dtype=torch.bfloat16)
548
+ kv = torch.randn(m, nk, dtype=torch.bfloat16)
549
+ q_weight = torch.ones(nq, dtype=torch.bfloat16)
550
+ kv_weight = torch.ones(nk, dtype=torch.bfloat16)
551
+ eps = 1e-5
552
+ fp8_dtype = current_platform.fp8_dtype()
553
+
554
+ fused_q, fused_scales, fused_kv = rocm_aiter_ops.fused_qk_rmsnorm_group_quant(
555
+ q, q_weight, eps, kv, kv_weight, eps, group_size=group_size
556
+ )
557
+ _assert_quant_shapes(fused_q, fused_scales, m, nq, fp8_dtype, nq // group_size)
558
+ assert fused_kv.shape == (m, nk)
559
+ assert fused_kv.dtype == kv.dtype
560
+
561
+ # q: dequantized comparison against the sequential composition.
562
+ normed_q = rocm_aiter_ops.rms_norm(q, q_weight, eps)
563
+ ref_q, ref_scales = rocm_aiter_ops.group_fp8_quant(normed_q, group_size)
564
+ _assert_rel_error_quality(
565
+ _dequantize_grouped(fused_q, fused_scales, group_size),
566
+ _dequantize_grouped(ref_q, ref_scales, group_size),
567
+ label="mla_dual_rmsnorm_group_quant",
568
+ mean_limit=0.05,
569
+ preferred_rel=0.05,
570
+ max_rel=0.5,
571
+ max_fail_rate=0.01,
572
+ )
573
+
574
+ # kv: both sides are aiter RMSNorm kernels; allow one bf16 ULP.
575
+ ref_kv = rocm_aiter_ops.rms_norm(kv, kv_weight, eps)
576
+ max_ulp = int(bf16_ulp_distance(fused_kv, ref_kv).max().item())
577
+ assert max_ulp <= 1, f"fused kv: max ULP {max_ulp} > 1"
578
+
579
+
580
+def test_rocm_aiter_mla_dual_rmsnorm_group_quant_strided_split_view():
581
+ """Split-view inputs, as produced by the DeepSeek-V4 q/kv split, match
582
+ contiguous inputs."""
583
+ require_aiter()
584
+ require_fp8()
585
+ from vllm._aiter_ops import rocm_aiter_ops
586
+
587
+ m, nq, nk, group_size = 16, 1536, 576, 128
588
+ qr_kv = torch.randn(m, nq + nk, dtype=torch.bfloat16)
589
+ q_view, kv_view = qr_kv.split([nq, nk], dim=-1)
590
+ assert not q_view.is_contiguous()
591
+ q_weight, kv_weight = torch.ones(nq + nk, dtype=torch.bfloat16).split([nq, nk])
592
+ eps = 1e-5
593
+
594
+ def run(q, kv):
595
+ return rocm_aiter_ops.fused_qk_rmsnorm_group_quant(
596
+ q, q_weight, eps, kv, kv_weight, eps, group_size=group_size
597
+ )
598
+
599
+ out_strided = run(q_view, kv_view)
600
+ out_contig = run(q_view.contiguous(), kv_view.contiguous())
601
+
602
+ q_ulp = int(fp8_ulp_distance(out_strided[0], out_contig[0]).max().item())
603
+ kv_ulp = int(bf16_ulp_distance(out_strided[2], out_contig[2]).max().item())
604
+ assert q_ulp <= 1, f"strided q: max fp8 ULP {q_ulp} > 1"
605
+ assert kv_ulp <= 1, f"strided kv: max bf16 ULP {kv_ulp} > 1"
606
+ torch.testing.assert_close(out_strided[1], out_contig[1])
607
+
608
+
609
# -- End-to-end inference chain test ---------------------------------------
610
611
vllm/_aiter_ops.py
+94
@@ -1455,6 +1455,70 @@ def _fused_mla_dual_rms_norm_per_token_quant_fake(
1455
return q_out, q_scale, kv_normed
1456
1457
1458
+def _fused_mla_dual_rms_norm_group_quant_impl(
1459
+ q: torch.Tensor,
1460
+ q_weight: torch.Tensor,
1461
+ kv: torch.Tensor,
1462
+ kv_weight: torch.Tensor,
1463
+ q_epsilon: float,
1464
+ kv_epsilon: float,
1465
+ group_size: int,
1466
+ transpose_scale: bool,
1467
+) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
1468
+ """Fused MLA q/kv RMSNorm (+ FP8 per-1x128 group quant on q) via AITER.
1469
+
1470
+ Backs the ``fused_mla_dual_rms_norm_group_quant`` custom op used by the
1471
+ DeepSeek-V4 ROCm attention path when the q latent is group-quantized
1472
+ (``(M, N/128)`` scales) for the block-scaled FP8 wq_b GEMMs. The q latent
1473
+ is quantized directly from the fp32 norm accumulator, skipping the bf16
1474
+ intermediate the standalone quant path would re-read; the kv latent is
1475
+ RMS-normed to bf16 and consumed by the fused insert kernel unchanged.
1476
+ """
1477
+ from aiter.ops.fused_qk_rmsnorm_group_quant import (
1478
+ fused_qk_rmsnorm_group_quant,
1479
+ )
1480
+
1481
+ mq, nq = q.shape
1482
+ q_out = torch.empty((mq, nq), dtype=FP8_DTYPE, device=q.device)
1483
+ q_scale = torch.empty((mq, nq // group_size), dtype=torch.float32, device=q.device)
1484
+ kv_normed = torch.empty(kv.shape, dtype=kv.dtype, device=kv.device)
1485
+
1486
+ # q -> RMSNorm + FP8 group quant (q slot); kv -> RMSNorm only (k slot).
1487
+ # `split` views are accepted directly (unit inner stride); the kernel
1488
+ # handles strided inputs, matching the aiter op-test usage.
1489
+ fused_qk_rmsnorm_group_quant(
1490
+ q_out_quantized=q_out,
1491
+ q_out_scale=q_scale,
1492
+ q=q,
1493
+ q_weight=q_weight,
1494
+ q_epsilon=q_epsilon,
1495
+ k_out=kv_normed,
1496
+ k=kv,
1497
+ k_weight=kv_weight,
1498
+ k_epsilon=kv_epsilon,
1499
+ group_size=group_size,
1500
+ transpose_scale=transpose_scale,
1501
+ )
1502
+ return q_out, q_scale, kv_normed
1503
+
1504
+
1505
+def _fused_mla_dual_rms_norm_group_quant_fake(
1506
+ q: torch.Tensor,
1507
+ q_weight: torch.Tensor,
1508
+ kv: torch.Tensor,
1509
+ kv_weight: torch.Tensor,
1510
+ q_epsilon: float,
1511
+ kv_epsilon: float,
1512
+ group_size: int,
1513
+ transpose_scale: bool,
1514
+) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
1515
+ mq, nq = q.shape
1516
+ q_out = torch.empty((mq, nq), dtype=FP8_DTYPE, device=q.device)
1517
+ q_scale = torch.empty((mq, nq // group_size), dtype=torch.float32, device=q.device)
1518
+ kv_normed = torch.empty(kv.shape, dtype=kv.dtype, device=kv.device)
1519
+ return q_out, q_scale, kv_normed
1520
+
1521
+
1522
def _rocm_aiter_gemm_a8wfp4_impl(
1523
x: torch.Tensor,
1524
w: torch.Tensor,
@@ -2277,6 +2341,13 @@ class rocm_aiter_ops:
2341
fake_impl=_fused_mla_dual_rms_norm_per_token_quant_fake,
2342
)
2343
2344
+ direct_register_custom_op(
2345
+ op_name="fused_mla_dual_rms_norm_group_quant",
2346
+ op_func=_fused_mla_dual_rms_norm_group_quant_impl,
2347
+ mutates_args=[],
2348
+ fake_impl=_fused_mla_dual_rms_norm_group_quant_fake,
2349
+ )
2350
+
2351
_OPS_REGISTERED = True
2352
2353
@staticmethod
@@ -2340,6 +2411,29 @@ class rocm_aiter_ops:
2411
def get_fused_mla_dual_rms_norm_per_token_quant_op() -> OpOverload:
2412
return torch.ops.vllm.fused_mla_dual_rms_norm_per_token_quant.default
2413
2414
+ @staticmethod
2415
+ def fused_qk_rmsnorm_group_quant(
2416
+ q: torch.Tensor,
2417
+ q_weight: torch.Tensor,
2418
+ q_epsilon: float,
2419
+ kv: torch.Tensor,
2420
+ kv_weight: torch.Tensor,
2421
+ kv_epsilon: float,
2422
+ group_size: int = 128,
2423
+ transpose_scale: bool = False,
2424
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
2425
+ """Fused q/kv RMSNorm + per-1x128 FP8 group quant on q via AITER."""
2426
+ return torch.ops.vllm.fused_mla_dual_rms_norm_group_quant(
2427
+ q,
2428
+ q_weight,
2429
+ kv,
2430
+ kv_weight,
2431
+ q_epsilon,
2432
+ kv_epsilon,
2433
+ group_size,
2434
+ transpose_scale,
2435
+ )
2436
+
2437
@staticmethod
2438
def rms_norm(
2439
x: torch.Tensor,
vllm/models/deepseek_v4/amd/rocm.py
+96
@@ -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 dataclasses import dataclass
6
from typing import cast
7
@@ -60,6 +61,36 @@ def _build_indptr_from_lengths(lengths: torch.Tensor) -> torch.Tensor:
61
return indptr
62
63
64
+def apply_pre_quantized_block_scaled_mm(
65
+ linear: torch.nn.Module,
66
+ x_fp8: torch.Tensor,
67
+ x_scale: torch.Tensor,
68
+) -> torch.Tensor:
69
+ """Block-scaled fp8 GEMM on pre-quantized activations.
70
+
71
+ The fused q/kv norm kernel writes fp8 qr + per-1x128 scales; this
72
+ drives the linear's block-scaled GEMM directly with them, bypassing
73
+ apply_weights which would re-quantize the fp8 input. Only valid for
74
+ the wq_b-style column/replicated linears: their output is the local
75
+ TP shard, so no all-reduce is needed.
76
+ """
77
+ from vllm.model_executor.kernels.linear.scaled_mm.BlockScaledMMLinearKernel import (
78
+ FP8BlockParams,
79
+ )
80
+
81
+ params = FP8BlockParams.from_layer(linear)
82
+ weight_scale = (
83
+ params.weight_scale
84
+ if params.weight_scale_inv is None
85
+ else params.weight_scale_inv
86
+ )
87
+ kernel = linear.quant_method.fp8_linear
88
+ out = kernel.apply_block_scaled_mm(
89
+ A=x_fp8, B=params.weight, As=x_scale, Bs=weight_scale
90
+ )
91
+ return out.to(dtype=kernel.config.out_dtype)
92
+
93
+
94
# ROCm sparse prefill keeps this dense combine local so AMD-specific SWA changes
95
# do not touch the shared DeepSeek V4 cache utilities.
96
_SPARSE_PREFILL_TOPK_ALIGNMENT = 128
@@ -621,6 +652,71 @@ class DeepseekV4ROCMAiterMLAAttention(DeepseekV4Attention):
652
indexer_weights, _ = indexer.weights_proj(hidden_states)
653
return qr_kv, kv_score, indexer_kv_score, indexer_weights
654
655
+ @functools.cached_property
656
+ def _wq_b_uses_aiter_block_scaled(self) -> bool:
657
+ """True when both wq_b GEMMs run the aiter block-scaled fp8 kernel.
658
+
659
+ Cached: the linear kernels and the aiter env gates are fixed once
660
+ the model is built, so this is evaluated at the first forward
661
+ only.
662
+
663
+ The fused norm+quant path is only valid if the quant and GEMM it
664
+ replaces are exactly the aiter ones; otherwise fall back to the
665
+ shared path.
666
+ """
667
+ from vllm._aiter_ops import rocm_aiter_ops
668
+ from vllm.model_executor.kernels.linear.scaled_mm import (
669
+ Fp8BlockScaledMMLinearKernel,
670
+ )
671
+
672
+ if not rocm_aiter_ops.is_linear_fp8_enabled():
673
+ return False
674
+
675
+ linears = [self.wq_b]
676
+ if self.indexer is not None:
677
+ linears.append(self.indexer.wq_b)
678
+ for linear in linears:
679
+ kernel = getattr(getattr(linear, "quant_method", None), "fp8_linear", None)
680
+ if not isinstance(kernel, Fp8BlockScaledMMLinearKernel):
681
+ return False
682
+ return True
683
+
684
+ def _split_qkv_and_norm(
685
+ self, qr_kv: torch.Tensor
686
+ ) -> tuple[torch.Tensor, torch.Tensor | None, torch.Tensor]:
687
+ """Fuse q/kv RMSNorm + per-1x128 fp8 q quant into one aiter kernel.
688
+
689
+ The shared path norms q and kv in one triton kernel and the wq_b
690
+ linears then re-read the bf16 qr to quantize it. The aiter kernel
691
+ computes both RMSNorms (fp32 accumulate) and the fp8 group quant
692
+ in a single pass, writing fp8 qr + group scales directly; both
693
+ wq_b GEMMs (attention and indexer) then consume that pair and
694
+ skip their own input quant. kv stays bf16: the fused insert
695
+ kernel RoPE/quantizes it itself. Falls back to the shared path
696
+ when the aiter linear path is not active.
697
+ """
698
+ qr, kv = qr_kv.split([self.q_lora_rank, self.head_dim], dim=-1)
699
+ if not (
700
+ qr.dim() == 2
701
+ and qr.shape[0] > 0
702
+ and self.q_lora_rank % 128 == 0
703
+ and self._wq_b_uses_aiter_block_scaled
704
+ ):
705
+ return super()._split_qkv_and_norm(qr_kv)
706
+
707
+ from vllm._aiter_ops import rocm_aiter_ops
708
+
709
+ return rocm_aiter_ops.fused_qk_rmsnorm_group_quant(
710
+ q=qr,
711
+ q_weight=self.q_norm.weight.data,
712
+ q_epsilon=self.eps,
713
+ kv=kv,
714
+ kv_weight=self.kv_norm.weight.data,
715
+ kv_epsilon=self.eps,
716
+ group_size=128,
717
+ transpose_scale=False,
718
+ )
719
+
720
def _o_proj(self, o: torch.Tensor, positions: torch.Tensor) -> torch.Tensor:
721
# ROCm BF16 reference wo_a path (inverse RoPE + einsum) + wo_b.
722
z = rocm_inv_rope_einsum(
vllm/models/deepseek_v4/attention.py
+67
-11
@@ -373,19 +373,13 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
373
qr_kv, kv_score, indexer_kv_score, indexer_weights = (
374
self._run_parallel_input_projections(hidden_states)
375
)
376
- qr, kv = qr_kv.split([self.q_lora_rank, self.head_dim], dim=-1)
377
- qr, kv = fused_q_kv_rmsnorm(
378
- qr,
379
- kv,
380
- self.q_norm.weight.data,
381
- self.kv_norm.weight.data,
382
- self.eps,
383
- )
376
+ qr, qr_scale, kv = self._split_qkv_and_norm(qr_kv)
377
378
self._prepare_and_attn_fn(
379
hidden_states,
380
qr,
381
kv,
382
+ qr_scale,
383
kv_score,
384
indexer_kv_score,
385
indexer_weights,
@@ -397,12 +391,32 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
391
# Inverse-RoPE + wo_a + wo_b output projection (platform-specific).
392
return self._o_proj(o, positions)
393
394
+ def _split_qkv_and_norm(
395
+ self, qr_kv: torch.Tensor
396
+ ) -> tuple[torch.Tensor, torch.Tensor | None, torch.Tensor]:
397
+ """Split the fused q-lora / kv projection and RMSNorm both halves.
398
+
399
+ ``qr_scale`` is None on the shared path. The ROCm subclass returns
400
+ a pre-quantized fp8 ``qr`` together with its per-1x128 fp32 scales,
401
+ which the downstream wq_b projections consume directly.
402
+ """
403
+ qr, kv = qr_kv.split([self.q_lora_rank, self.head_dim], dim=-1)
404
+ qr, kv = fused_q_kv_rmsnorm(
405
+ qr,
406
+ kv,
407
+ self.q_norm.weight.data,
408
+ self.kv_norm.weight.data,
409
+ self.eps,
410
+ )
411
+ return qr, None, kv
412
+
413
@eager_break_during_capture
414
def _prepare_and_attn_eager(
415
self,
416
hidden_states: torch.Tensor,
417
qr: torch.Tensor,
418
kv: torch.Tensor,
419
+ qr_scale: torch.Tensor | None,
420
kv_score: torch.Tensor,
421
indexer_kv_score: torch.Tensor,
422
indexer_weights: torch.Tensor,
@@ -418,6 +432,7 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
432
hidden_states,
433
qr,
434
kv,
435
+ qr_scale,
436
kv_score,
437
indexer_kv_score,
438
indexer_weights,
@@ -430,6 +445,7 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
445
hidden_states: torch.Tensor,
446
qr: torch.Tensor,
447
kv: torch.Tensor,
448
+ qr_scale: torch.Tensor | None,
449
kv_score: torch.Tensor,
450
indexer_kv_score: torch.Tensor,
451
indexer_weights: torch.Tensor,
@@ -446,7 +462,9 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
462
aux_streams = self.aux_stream_list
463
464
def project_query_and_cache_kv() -> torch.Tensor:
449
- q = self.wq_b(qr).view(-1, self.n_local_heads, self.head_dim)
465
+ q = self._wq_b_proj(qr, qr_scale).view(
466
+ -1, self.n_local_heads, self.head_dim
467
+ )
468
return self._fused_qnorm_rope_kv_insert(q, kv, positions, attn_metadata)
469
470
index_q: torch.Tensor | None = None
@@ -468,6 +486,7 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
486
indexer_weights,
487
positions,
488
self.indexer_rotary_emb,
489
+ qr_scale,
490
),
491
lambda: compressor(kv_score, positions, self.rotary_emb),
492
],
@@ -507,6 +526,24 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
526
qr_kv, _ = self.fused_wqa_wkv(hidden_states)
527
return qr_kv
528
529
+ def _wq_b_proj(
530
+ self, qr: torch.Tensor, qr_scale: torch.Tensor | None = None
531
+ ) -> torch.Tensor:
532
+ """Project the q-lora input to the query heads.
533
+
534
+ ``qr_scale`` is only set by the ROCm fused path, where ``qr`` is
535
+ already fp8-quantized with per-1x128 scales and must bypass the
536
+ linear's internal re-quantization (apply_weights would quantize
537
+ the fp8 input again).
538
+ """
539
+ if qr_scale is None:
540
+ return self.wq_b(qr)
541
+ from vllm.models.deepseek_v4.amd.rocm import (
542
+ apply_pre_quantized_block_scaled_mm,
543
+ )
544
+
545
+ return apply_pre_quantized_block_scaled_mm(self.wq_b, qr, qr_scale)
546
+
547
def _run_parallel_input_projections(
548
self, hidden_states: torch.Tensor
549
) -> tuple[
@@ -881,6 +918,7 @@ class DeepseekV4Indexer(nn.Module):
918
indexer_weights: torch.Tensor,
919
positions: torch.Tensor,
920
rotary_emb: nn.Module,
921
+ qr_scale: torch.Tensor | None = None,
922
) -> tuple[torch.Tensor | None, torch.Tensor | None, torch.Tensor | None]:
923
compressor = self.compressor
924
@@ -911,8 +949,7 @@ class DeepseekV4Indexer(nn.Module):
949
return None, None, None
950
951
def wq_b_and_q_quant():
914
- # ReplicatedLinear returns (output, bias); bias is None.
915
- q, _ = self.wq_b(qr)
952
+ q = self._wq_b_proj(qr, qr_scale)
953
q = q.view(-1, self.n_head, self.head_dim)
954
return fused_indexer_q_rope_quant(
955
positions,
@@ -938,3 +975,22 @@ class DeepseekV4Indexer(nn.Module):
975
else:
976
q, q_scale = q_quant, None
977
return q, q_scale, weights
978
+
979
+ def _wq_b_proj(
980
+ self, qr: torch.Tensor, qr_scale: torch.Tensor | None = None
981
+ ) -> torch.Tensor:
982
+ """Project the q-lora input to the indexer query heads.
983
+
984
+ Same bypass as ``DeepseekV4Attention._wq_b_proj``: on ROCm the fused
985
+ norm path hands over pre-quantized fp8 ``qr`` with per-1x128 scales,
986
+ so the linear's internal re-quantization must be skipped.
987
+ """
988
+ if qr_scale is None:
989
+ # ReplicatedLinear returns (output, bias); bias is None.
990
+ q, _ = self.wq_b(qr)
991
+ return q
992
+ from vllm.models.deepseek_v4.amd.rocm import (
993
+ apply_pre_quantized_block_scaled_mm,
994
+ )
995
+
996
+ return apply_pre_quantized_block_scaled_mm(self.wq_b, qr, qr_scale)