[ROCm][Perf] Avoid extra reshape kernel in Qwen GDN output norm (#47842)

Signed-off-by: Mikko Tukiainen <Mikko.Tukiainen@amd.com> Co-authored-by: Cursor <cursoragent@cursor.com>

Mikko Tukiainen committed Sep 22, 2026 at 09:38 UTC 68363d64ec476bbbf5025b30ced0f6a2425b1291
6 files changed +170 -33
tests/compile/passes/test_fusion.py
+21 -5
@@ -550,7 +550,9 @@ class TestGatedModel(torch.nn.Module):
550 """Model that uses RMSNormGated + reshape + group FP8 quant + linear.
551
552 Mimics GatedDeltaNetAttention's output projection path where:
553 - - RMSNormGated operates on per-head tensors (N*H, D)
553 + - RMSNormGated operates on per-head tensors, either (N, H, D) as
554 + QwenGatedDeltaNetAttention passes them or flattened to (N*H, D) as
555 + OlmoHybridGatedDeltaNetAttention does
556 - Output is reshaped to (N, H*D) before group quantization + linear
557 """
558
@@ -563,10 +565,12 @@ class TestGatedModel(torch.nn.Module):
565 group_shape: GroupShape,
566 dtype: torch.dtype,
567 use_aiter_quant: bool = True,
568 + flatten_heads: bool = True,
569 ):
570 super().__init__()
571 self.num_heads = num_heads
572 self.head_dim = head_dim
573 + self.flatten_heads = flatten_heads
574 hidden_dim = num_heads * head_dim
575
576 self.norm = RMSNormGated(
@@ -599,8 +603,11 @@ class TestGatedModel(torch.nn.Module):
603 hidden_dim = num_heads * head_dim
604 x = torch.relu(x)
605 z = torch.relu(z)
602 - x_heads = x.reshape(-1, num_heads, head_dim).reshape(-1, head_dim)
603 - z_heads = z.reshape(-1, num_heads, head_dim).reshape(-1, head_dim)
606 + x_heads = x.reshape(-1, num_heads, head_dim)
607 + z_heads = z.reshape(-1, num_heads, head_dim)
608 + if self.flatten_heads:
609 + x_heads = x_heads.reshape(-1, head_dim)
610 + z_heads = z_heads.reshape(-1, head_dim)
611 normed = self.norm(x_heads, z_heads)
612 merged = normed.reshape(-1, hidden_dim)
613 out = self.fp8_linear(merged)
@@ -638,6 +645,8 @@ class _MockGDNLayer:
645 @pytest.mark.parametrize("head_dim", [128])
646 @pytest.mark.parametrize("num_tokens", [8])
647 @pytest.mark.parametrize("eps", [1e-5, 1e-6])
648 +@pytest.mark.parametrize("flatten_heads", [True, False])
649 +@pytest.mark.parametrize("quant_fp8_op", ["-quant_fp8", "+quant_fp8"])
650 @pytest.mark.skipif(
651 (not current_platform.is_rocm() or not IS_AITER_FOUND),
652 reason="Only test on ROCm with aiter package installed",
@@ -648,6 +657,8 @@ def test_aiter_fusion_rmsnorm_gated_quant(
657 head_dim: int,
658 num_tokens: int,
659 eps: float,
660 + flatten_heads: bool,
661 + quant_fp8_op: str,
662 monkeypatch: pytest.MonkeyPatch,
663 ):
664 group_shape = GroupShape(1, 128)
@@ -655,7 +666,7 @@ def test_aiter_fusion_rmsnorm_gated_quant(
666 model_config=ModelConfig(dtype=dtype),
667 compilation_config=CompilationConfig(
668 mode=CompilationMode.VLLM_COMPILE,
658 - custom_ops=["-rms_norm", "-silu_and_mul", "-quant_fp8"],
669 + custom_ops=["-rms_norm", "-silu_and_mul", quant_fp8_op],
670 pass_config=PassConfig(fuse_norm_quant=True, eliminate_noops=True),
671 ),
672 )
@@ -688,6 +699,7 @@ def test_aiter_fusion_rmsnorm_gated_quant(
699 group_shape=group_shape,
700 dtype=dtype,
701 use_aiter_quant=True,
702 + flatten_heads=flatten_heads,
703 )
704
705 noop_pass = NoOpEliminationPass(vllm_config)
@@ -708,7 +720,11 @@ def test_aiter_fusion_rmsnorm_gated_quant(
720 model_unfused = torch.compile(model, backend=backend2)
721 result_unfused = model_unfused(x, z)
722
711 - torch.testing.assert_close(result_fused, result_unfused, atol=1e-2, rtol=1e-2)
723 + # The opaque AITER quant op preserves a BF16 intermediate in the
724 + # unfused path, while the fused kernel normalizes and quantizes
725 + # directly. Allow for that extra rounding before the FP8 GEMM.
726 + tol = 2e-2 if quant_fp8_op == "+quant_fp8" else 1e-2
727 + torch.testing.assert_close(result_fused, result_unfused, atol=tol, rtol=tol)
728
729 assert fusion_pass.matched_count == 1
730 backend.check_after_ops(model.ops_in_model_after())
tests/kernels/mamba/test_gdn_output_projection.py new
+53
@@ -0,0 +1,53 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Shape contract for ``QwenGatedDeltaNetAttention._output_projection``.
4 +
5 +Every ``forward_*`` already allocates ``core_attn_out`` and ``z`` as
6 +``(N, H, D)``. The helper now norms those tensors as-is and flattens once
7 +for ``out_proj``; it used to be rank-agnostic via ``reshape(z_shape_og)``.
8 +"""
9 +
10 +from __future__ import annotations
11 +
12 +import types
13 +
14 +import pytest
15 +import torch
16 +
17 +from vllm.model_executor.layers.layernorm import RMSNormGated
18 +from vllm.model_executor.layers.mamba.gdn.qwen_gdn_linear_attn import (
19 + QwenGatedDeltaNetAttention,
20 +)
21 +
22 +
23 +@pytest.mark.parametrize("dtype", [torch.float32])
24 +@torch.inference_mode()
25 +def test_output_projection_norms_per_head_and_flattens(
26 + default_vllm_config,
27 + dtype: torch.dtype,
28 +) -> None:
29 + num_tokens, num_heads, head_dim = 3, 4, 8
30 + hidden = num_heads * head_dim
31 +
32 + layer = types.SimpleNamespace()
33 + layer.norm = RMSNormGated(
34 + head_dim,
35 + eps=1e-5,
36 + group_size=None,
37 + norm_before_gate=True,
38 + device="cpu",
39 + dtype=dtype,
40 + )
41 + layer.out_proj = lambda x: (x, None)
42 + layer._output_projection = types.MethodType(
43 + QwenGatedDeltaNetAttention._output_projection, layer
44 + )
45 +
46 + core_attn_out = torch.randn(num_tokens, num_heads, head_dim, dtype=dtype)
47 + z = torch.randn(num_tokens, num_heads, head_dim, dtype=dtype)
48 + out = layer._output_projection(core_attn_out, z)
49 +
50 + assert core_attn_out.shape == (num_tokens, num_heads, head_dim)
51 + assert z.shape == (num_tokens, num_heads, head_dim)
52 + assert out.shape == (num_tokens, hidden)
53 + assert out.dtype == dtype
tests/kernels/test_fla_layernorm_guard.py
+44
@@ -501,6 +501,50 @@ def test_rmsnorm_gated_forward_native_dtype(
501 torch.testing.assert_close(out, ref_out, atol=1e-2, rtol=1e-2)
502
503
504 +@pytest.mark.parametrize("dtype", [torch.bfloat16])
505 +@torch.inference_mode()
506 +def test_rmsnorm_gated_3d_matches_flattened_2d(
507 + default_vllm_config,
508 + dtype: torch.dtype,
509 +) -> None:
510 + """Qwen GDN now norms (N, H, D) in place of a flatten to (N*H, D).
511 +
512 + Last-dim RMS + gate must match the flattened layout on both the native
513 + and CUDA/HIP kernels, including a 3D z view that is not contiguous.
514 + """
515 + from vllm.model_executor.layers.layernorm import RMSNormGated
516 +
517 + set_random_seed(42)
518 + device = torch.device(DEVICE)
519 + num_tokens, num_heads, head_dim = 5, 4, 64
520 + layer = RMSNormGated(
521 + head_dim,
522 + eps=1e-5,
523 + group_size=None,
524 + norm_before_gate=True,
525 + device=device,
526 + dtype=dtype,
527 + )
528 +
529 + packed = torch.randn(num_tokens, num_heads, 2, head_dim, dtype=dtype, device=device)
530 + x3 = packed[:, :, 0, :]
531 + z3 = packed[:, :, 1, :]
532 + assert not x3.is_contiguous()
533 + assert not z3.is_contiguous()
534 + x2 = x3.reshape(-1, head_dim).contiguous()
535 + z2 = z3.reshape(-1, head_dim).contiguous()
536 +
537 + native_3d = layer.forward_native(x3, z3)
538 + native_2d = layer.forward_native(x2, z2).reshape(num_tokens, num_heads, head_dim)
539 + torch.testing.assert_close(native_3d, native_2d, atol=0, rtol=0)
540 +
541 + cuda_3d = layer.forward_cuda(x3, z3)
542 + cuda_2d = layer.forward_cuda(x2, z2).reshape(num_tokens, num_heads, head_dim)
543 + torch.testing.assert_close(cuda_3d, cuda_2d, atol=1e-3, rtol=1e-2)
544 + torch.testing.assert_close(cuda_3d, native_3d, atol=1e-2, rtol=1e-2)
545 + assert cuda_3d.shape == (num_tokens, num_heads, head_dim)
546 +
547 +
548 if __name__ == "__main__":
549 # Run a quick smoke test
550 test_layer_norm_fwd_basic(128, 1024, torch.float16, 42, False)
vllm/compilation/passes/fusion/rocm_aiter_fusion.py
+28 -5
@@ -30,6 +30,7 @@ from ..vllm_inductor_pass import (
30 VllmPatternReplacement,
31 _fx_view_to_reshape,
32 fold_consecutive_reshapes,
33 + remove_noop_reshapes,
34 )
35 from .matcher_utils import (
36 MatcherQuantFP8,
@@ -452,9 +453,9 @@ class AiterRMSNormGatedFp8GroupQuantPattern(AiterRMSNormQuantPattern):
453 """Matches decomposed RMSNormGated + reshape + group FP8 quant and replaces
454 with rocm_aiter_fused_rms_gated_fp8_group_quant.
455
455 - The norm operates per-head on (N*H, D) tensors. The compiler folds the
456 - reshape chain so after norm the result goes through reshape->merge->quant.
457 - The pattern reshapes from (N*H, D) to (N, H*D) before calling
456 + The norm operates per-head, on either (N*H, D) or (N, H, D). The compiler
457 + folds the reshape chain so after norm the result goes through
458 + reshape->merge->quant. The pattern reshapes to (N, H*D) before calling
459 MatcherQuantFP8 so that _quantize_group_native sees the full hidden dim
460 and computes the correct num_groups.
461 """
@@ -482,6 +483,16 @@ class AiterRMSNormGatedFp8GroupQuantPattern(AiterRMSNormQuantPattern):
483 self.head_dim = head_dim
484
485 def register(self, pm_pass: PatternMatcherPass) -> None:
486 + self._register_layout(pm_pass, flatten_heads=True)
487 + # Both layouts reach the norm. With an opaque quant op they trace to
488 + # the same pattern; with a native quant they differ, because its group
489 + # reshape back to (N, H, D) is a no-op the compiler already dropped.
490 + if not self.quant_matcher.enabled:
491 + self._register_layout(pm_pass, flatten_heads=False)
492 +
493 + def _register_layout(
494 + self, pm_pass: PatternMatcherPass, *, flatten_heads: bool
495 + ) -> None:
496 num_heads = self.num_heads
497 head_dim = self.head_dim
498 hidden_dim = num_heads * head_dim
@@ -502,6 +513,11 @@ class AiterRMSNormGatedFp8GroupQuantPattern(AiterRMSNormQuantPattern):
513 z: torch.Tensor,
514 weight: torch.Tensor,
515 ) -> tuple[torch.Tensor, torch.Tensor]:
516 + # The pattern matches on op structure rather than rank, so either
517 + # per-head layout can land here. The fused op takes (M, D), and
518 + # both operands are contiguous, so collapsing the heads is a view.
519 + x = x.reshape(-1, head_dim)
520 + z = z.reshape(-1, head_dim)
521 fused = self.FUSED_OP(
522 x=x,
523 weight=weight,
@@ -519,14 +535,21 @@ class AiterRMSNormGatedFp8GroupQuantPattern(AiterRMSNormQuantPattern):
535 return fp8_reshaped, scales_reshaped
536
537 n_tokens = 2
522 - x = self.empty(n_tokens * num_heads, head_dim)
523 - z = self.empty(n_tokens * num_heads, head_dim)
538 + shape = (
539 + (n_tokens * num_heads, head_dim)
540 + if flatten_heads
541 + else (n_tokens, num_heads, head_dim)
542 + )
543 + x = self.empty(*shape)
544 + z = self.empty(*shape)
545 w = self.empty(head_dim)
546
547 def trace_fn(*args, **kwargs):
548 gm = pm.fwd_only(*args, **kwargs)
549 _fx_view_to_reshape(gm)
550 fold_consecutive_reshapes(gm)
551 + if not flatten_heads:
552 + remove_noop_reshapes(gm)
553 return gm
554
555 pm.register_replacement(
vllm/compilation/passes/vllm_inductor_pass.py
+21
@@ -277,6 +277,27 @@ def fold_consecutive_reshapes(gm: fx.GraphModule) -> None:
277 gm.graph.erase_node(inp)
278
279
280 +def remove_noop_reshapes(gm: fx.GraphModule) -> None:
281 + """Drop reshape ops whose output shape equals their input shape.
282 +
283 + Companion to :func:`fold_consecutive_reshapes`. ``make_fx`` records a
284 + reshape to the shape the input already has; the compiled graph has already
285 + dropped it, so the pattern only matches once we drop it too.
286 + """
287 + aten_reshape = torch.ops.aten.reshape.default
288 + for node in list(gm.graph.nodes):
289 + if not is_func(node, aten_reshape):
290 + continue
291 + inp = node.args[0]
292 + if not isinstance(inp, fx.Node):
293 + continue
294 + val, inp_val = node.meta.get("val"), inp.meta.get("val")
295 + if val is None or inp_val is None or val.shape != inp_val.shape:
296 + continue
297 + node.replace_all_uses_with(inp)
298 + gm.graph.erase_node(node)
299 +
300 +
301 def _remove_noop_permutes(gm: fx.GraphModule) -> None:
302 for node in gm.graph.nodes:
303 if not is_func(node, torch.ops.aten.permute.default):
vllm/model_executor/layers/mamba/gdn/qwen_gdn_linear_attn.py
+3 -23
@@ -852,13 +852,8 @@ class QwenGatedDeltaNetAttention(GatedDeltaNetAttention):
852 The RMSNormGated + quant sequence is eligible for fusion
853 by the compilation pass when fuse_norm_quant is enabled.
854 """
855 - z_shape_og = z.shape
856 - core_attn_out = core_attn_out.reshape(-1, core_attn_out.shape[-1])
857 - z = z.reshape(-1, z.shape[-1])
855 core_attn_out = self.norm(core_attn_out, z)
859 - core_attn_out = core_attn_out.reshape(z_shape_og)
860 - core_attn_out = core_attn_out.flatten(-2) # ... h d -> ... (h d)
861 - output, _ = self.out_proj(core_attn_out)
856 + output, _ = self.out_proj(core_attn_out.flatten(-2))
857 return output
858
859 def forward_hip(
@@ -1012,15 +1007,7 @@ class QwenGatedDeltaNetAttention(GatedDeltaNetAttention):
1007 # ============================================================
1008 # Part 3: Output Projection
1009 # ============================================================
1015 - z_shape_og = z.shape
1016 - # Reshape input data into 2D tensor
1017 - core_attn_out = core_attn_out.reshape(-1, core_attn_out.shape[-1])
1018 - z = z.reshape(-1, z.shape[-1])
1019 - core_attn_out = self.norm(core_attn_out, z)
1020 - core_attn_out = core_attn_out.reshape(z_shape_og)
1021 - core_attn_out = core_attn_out.flatten(-2) # ... h d -> ... (h d)
1022 - out, _ = self.out_proj(core_attn_out)
1023 - return out
1010 + return self._output_projection(core_attn_out, z)
1011
1012 def forward_cpu(
1013 self,
@@ -1063,14 +1050,7 @@ class QwenGatedDeltaNetAttention(GatedDeltaNetAttention):
1050 _encode_layer_name(self.prefix),
1051 )
1052
1066 - z_shape_og = z.shape
1067 - core_attn_out = core_attn_out.reshape(-1, core_attn_out.shape[-1])
1068 - z = z.reshape(-1, z.shape[-1])
1069 - core_attn_out = self.norm(core_attn_out, z)
1070 - core_attn_out = core_attn_out.reshape(z_shape_og)
1071 - core_attn_out = core_attn_out.flatten(-2) # ... h d -> ... (h d)
1072 - out, _ = self.out_proj(core_attn_out)
1073 - return out
1053 + return self._output_projection(core_attn_out, z)
1054
1055 def _warmup_prefill_kernels(self, qkv_or_qkvz: torch.Tensor, v_dim: int) -> None:
1056 """Warm up GDN prefill kernels during V1 profiling.