[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>
This commit is contained in:
Mikko Tukiainen
2026-09-22 14:38:16 +08:00
committed by GitHub
co-authored by Cursor
parent 496c6472cb
commit 68363d64ec
6 changed files with 170 additions and 33 deletions
+21 -5
View File
@@ -550,7 +550,9 @@ class TestGatedModel(torch.nn.Module):
"""Model that uses RMSNormGated + reshape + group FP8 quant + linear.
Mimics GatedDeltaNetAttention's output projection path where:
- RMSNormGated operates on per-head tensors (N*H, D)
- RMSNormGated operates on per-head tensors, either (N, H, D) as
QwenGatedDeltaNetAttention passes them or flattened to (N*H, D) as
OlmoHybridGatedDeltaNetAttention does
- Output is reshaped to (N, H*D) before group quantization + linear
"""
@@ -563,10 +565,12 @@ class TestGatedModel(torch.nn.Module):
group_shape: GroupShape,
dtype: torch.dtype,
use_aiter_quant: bool = True,
flatten_heads: bool = True,
):
super().__init__()
self.num_heads = num_heads
self.head_dim = head_dim
self.flatten_heads = flatten_heads
hidden_dim = num_heads * head_dim
self.norm = RMSNormGated(
@@ -599,8 +603,11 @@ class TestGatedModel(torch.nn.Module):
hidden_dim = num_heads * head_dim
x = torch.relu(x)
z = torch.relu(z)
x_heads = x.reshape(-1, num_heads, head_dim).reshape(-1, head_dim)
z_heads = z.reshape(-1, num_heads, head_dim).reshape(-1, head_dim)
x_heads = x.reshape(-1, num_heads, head_dim)
z_heads = z.reshape(-1, num_heads, head_dim)
if self.flatten_heads:
x_heads = x_heads.reshape(-1, head_dim)
z_heads = z_heads.reshape(-1, head_dim)
normed = self.norm(x_heads, z_heads)
merged = normed.reshape(-1, hidden_dim)
out = self.fp8_linear(merged)
@@ -638,6 +645,8 @@ class _MockGDNLayer:
@pytest.mark.parametrize("head_dim", [128])
@pytest.mark.parametrize("num_tokens", [8])
@pytest.mark.parametrize("eps", [1e-5, 1e-6])
@pytest.mark.parametrize("flatten_heads", [True, False])
@pytest.mark.parametrize("quant_fp8_op", ["-quant_fp8", "+quant_fp8"])
@pytest.mark.skipif(
(not current_platform.is_rocm() or not IS_AITER_FOUND),
reason="Only test on ROCm with aiter package installed",
@@ -648,6 +657,8 @@ def test_aiter_fusion_rmsnorm_gated_quant(
head_dim: int,
num_tokens: int,
eps: float,
flatten_heads: bool,
quant_fp8_op: str,
monkeypatch: pytest.MonkeyPatch,
):
group_shape = GroupShape(1, 128)
@@ -655,7 +666,7 @@ def test_aiter_fusion_rmsnorm_gated_quant(
model_config=ModelConfig(dtype=dtype),
compilation_config=CompilationConfig(
mode=CompilationMode.VLLM_COMPILE,
custom_ops=["-rms_norm", "-silu_and_mul", "-quant_fp8"],
custom_ops=["-rms_norm", "-silu_and_mul", quant_fp8_op],
pass_config=PassConfig(fuse_norm_quant=True, eliminate_noops=True),
),
)
@@ -688,6 +699,7 @@ def test_aiter_fusion_rmsnorm_gated_quant(
group_shape=group_shape,
dtype=dtype,
use_aiter_quant=True,
flatten_heads=flatten_heads,
)
noop_pass = NoOpEliminationPass(vllm_config)
@@ -708,7 +720,11 @@ def test_aiter_fusion_rmsnorm_gated_quant(
model_unfused = torch.compile(model, backend=backend2)
result_unfused = model_unfused(x, z)
torch.testing.assert_close(result_fused, result_unfused, atol=1e-2, rtol=1e-2)
# The opaque AITER quant op preserves a BF16 intermediate in the
# unfused path, while the fused kernel normalizes and quantizes
# directly. Allow for that extra rounding before the FP8 GEMM.
tol = 2e-2 if quant_fp8_op == "+quant_fp8" else 1e-2
torch.testing.assert_close(result_fused, result_unfused, atol=tol, rtol=tol)
assert fusion_pass.matched_count == 1
backend.check_after_ops(model.ops_in_model_after())
@@ -0,0 +1,53 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Shape contract for ``QwenGatedDeltaNetAttention._output_projection``.
Every ``forward_*`` already allocates ``core_attn_out`` and ``z`` as
``(N, H, D)``. The helper now norms those tensors as-is and flattens once
for ``out_proj``; it used to be rank-agnostic via ``reshape(z_shape_og)``.
"""
from __future__ import annotations
import types
import pytest
import torch
from vllm.model_executor.layers.layernorm import RMSNormGated
from vllm.model_executor.layers.mamba.gdn.qwen_gdn_linear_attn import (
QwenGatedDeltaNetAttention,
)
@pytest.mark.parametrize("dtype", [torch.float32])
@torch.inference_mode()
def test_output_projection_norms_per_head_and_flattens(
default_vllm_config,
dtype: torch.dtype,
) -> None:
num_tokens, num_heads, head_dim = 3, 4, 8
hidden = num_heads * head_dim
layer = types.SimpleNamespace()
layer.norm = RMSNormGated(
head_dim,
eps=1e-5,
group_size=None,
norm_before_gate=True,
device="cpu",
dtype=dtype,
)
layer.out_proj = lambda x: (x, None)
layer._output_projection = types.MethodType(
QwenGatedDeltaNetAttention._output_projection, layer
)
core_attn_out = torch.randn(num_tokens, num_heads, head_dim, dtype=dtype)
z = torch.randn(num_tokens, num_heads, head_dim, dtype=dtype)
out = layer._output_projection(core_attn_out, z)
assert core_attn_out.shape == (num_tokens, num_heads, head_dim)
assert z.shape == (num_tokens, num_heads, head_dim)
assert out.shape == (num_tokens, hidden)
assert out.dtype == dtype
+44
View File
@@ -501,6 +501,50 @@ def test_rmsnorm_gated_forward_native_dtype(
torch.testing.assert_close(out, ref_out, atol=1e-2, rtol=1e-2)
@pytest.mark.parametrize("dtype", [torch.bfloat16])
@torch.inference_mode()
def test_rmsnorm_gated_3d_matches_flattened_2d(
default_vllm_config,
dtype: torch.dtype,
) -> None:
"""Qwen GDN now norms (N, H, D) in place of a flatten to (N*H, D).
Last-dim RMS + gate must match the flattened layout on both the native
and CUDA/HIP kernels, including a 3D z view that is not contiguous.
"""
from vllm.model_executor.layers.layernorm import RMSNormGated
set_random_seed(42)
device = torch.device(DEVICE)
num_tokens, num_heads, head_dim = 5, 4, 64
layer = RMSNormGated(
head_dim,
eps=1e-5,
group_size=None,
norm_before_gate=True,
device=device,
dtype=dtype,
)
packed = torch.randn(num_tokens, num_heads, 2, head_dim, dtype=dtype, device=device)
x3 = packed[:, :, 0, :]
z3 = packed[:, :, 1, :]
assert not x3.is_contiguous()
assert not z3.is_contiguous()
x2 = x3.reshape(-1, head_dim).contiguous()
z2 = z3.reshape(-1, head_dim).contiguous()
native_3d = layer.forward_native(x3, z3)
native_2d = layer.forward_native(x2, z2).reshape(num_tokens, num_heads, head_dim)
torch.testing.assert_close(native_3d, native_2d, atol=0, rtol=0)
cuda_3d = layer.forward_cuda(x3, z3)
cuda_2d = layer.forward_cuda(x2, z2).reshape(num_tokens, num_heads, head_dim)
torch.testing.assert_close(cuda_3d, cuda_2d, atol=1e-3, rtol=1e-2)
torch.testing.assert_close(cuda_3d, native_3d, atol=1e-2, rtol=1e-2)
assert cuda_3d.shape == (num_tokens, num_heads, head_dim)
if __name__ == "__main__":
# Run a quick smoke test
test_layer_norm_fwd_basic(128, 1024, torch.float16, 42, False)
@@ -30,6 +30,7 @@ from ..vllm_inductor_pass import (
VllmPatternReplacement,
_fx_view_to_reshape,
fold_consecutive_reshapes,
remove_noop_reshapes,
)
from .matcher_utils import (
MatcherQuantFP8,
@@ -452,9 +453,9 @@ class AiterRMSNormGatedFp8GroupQuantPattern(AiterRMSNormQuantPattern):
"""Matches decomposed RMSNormGated + reshape + group FP8 quant and replaces
with rocm_aiter_fused_rms_gated_fp8_group_quant.
The norm operates per-head on (N*H, D) tensors. The compiler folds the
reshape chain so after norm the result goes through reshape->merge->quant.
The pattern reshapes from (N*H, D) to (N, H*D) before calling
The norm operates per-head, on either (N*H, D) or (N, H, D). The compiler
folds the reshape chain so after norm the result goes through
reshape->merge->quant. The pattern reshapes to (N, H*D) before calling
MatcherQuantFP8 so that _quantize_group_native sees the full hidden dim
and computes the correct num_groups.
"""
@@ -482,6 +483,16 @@ class AiterRMSNormGatedFp8GroupQuantPattern(AiterRMSNormQuantPattern):
self.head_dim = head_dim
def register(self, pm_pass: PatternMatcherPass) -> None:
self._register_layout(pm_pass, flatten_heads=True)
# Both layouts reach the norm. With an opaque quant op they trace to
# the same pattern; with a native quant they differ, because its group
# reshape back to (N, H, D) is a no-op the compiler already dropped.
if not self.quant_matcher.enabled:
self._register_layout(pm_pass, flatten_heads=False)
def _register_layout(
self, pm_pass: PatternMatcherPass, *, flatten_heads: bool
) -> None:
num_heads = self.num_heads
head_dim = self.head_dim
hidden_dim = num_heads * head_dim
@@ -502,6 +513,11 @@ class AiterRMSNormGatedFp8GroupQuantPattern(AiterRMSNormQuantPattern):
z: torch.Tensor,
weight: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor]:
# The pattern matches on op structure rather than rank, so either
# per-head layout can land here. The fused op takes (M, D), and
# both operands are contiguous, so collapsing the heads is a view.
x = x.reshape(-1, head_dim)
z = z.reshape(-1, head_dim)
fused = self.FUSED_OP(
x=x,
weight=weight,
@@ -519,14 +535,21 @@ class AiterRMSNormGatedFp8GroupQuantPattern(AiterRMSNormQuantPattern):
return fp8_reshaped, scales_reshaped
n_tokens = 2
x = self.empty(n_tokens * num_heads, head_dim)
z = self.empty(n_tokens * num_heads, head_dim)
shape = (
(n_tokens * num_heads, head_dim)
if flatten_heads
else (n_tokens, num_heads, head_dim)
)
x = self.empty(*shape)
z = self.empty(*shape)
w = self.empty(head_dim)
def trace_fn(*args, **kwargs):
gm = pm.fwd_only(*args, **kwargs)
_fx_view_to_reshape(gm)
fold_consecutive_reshapes(gm)
if not flatten_heads:
remove_noop_reshapes(gm)
return gm
pm.register_replacement(
@@ -277,6 +277,27 @@ def fold_consecutive_reshapes(gm: fx.GraphModule) -> None:
gm.graph.erase_node(inp)
def remove_noop_reshapes(gm: fx.GraphModule) -> None:
"""Drop reshape ops whose output shape equals their input shape.
Companion to :func:`fold_consecutive_reshapes`. ``make_fx`` records a
reshape to the shape the input already has; the compiled graph has already
dropped it, so the pattern only matches once we drop it too.
"""
aten_reshape = torch.ops.aten.reshape.default
for node in list(gm.graph.nodes):
if not is_func(node, aten_reshape):
continue
inp = node.args[0]
if not isinstance(inp, fx.Node):
continue
val, inp_val = node.meta.get("val"), inp.meta.get("val")
if val is None or inp_val is None or val.shape != inp_val.shape:
continue
node.replace_all_uses_with(inp)
gm.graph.erase_node(node)
def _remove_noop_permutes(gm: fx.GraphModule) -> None:
for node in gm.graph.nodes:
if not is_func(node, torch.ops.aten.permute.default):
@@ -852,13 +852,8 @@ class QwenGatedDeltaNetAttention(GatedDeltaNetAttention):
The RMSNormGated + quant sequence is eligible for fusion
by the compilation pass when fuse_norm_quant is enabled.
"""
z_shape_og = z.shape
core_attn_out = core_attn_out.reshape(-1, core_attn_out.shape[-1])
z = z.reshape(-1, z.shape[-1])
core_attn_out = self.norm(core_attn_out, z)
core_attn_out = core_attn_out.reshape(z_shape_og)
core_attn_out = core_attn_out.flatten(-2) # ... h d -> ... (h d)
output, _ = self.out_proj(core_attn_out)
output, _ = self.out_proj(core_attn_out.flatten(-2))
return output
def forward_hip(
@@ -1012,15 +1007,7 @@ class QwenGatedDeltaNetAttention(GatedDeltaNetAttention):
# ============================================================
# Part 3: Output Projection
# ============================================================
z_shape_og = z.shape
# Reshape input data into 2D tensor
core_attn_out = core_attn_out.reshape(-1, core_attn_out.shape[-1])
z = z.reshape(-1, z.shape[-1])
core_attn_out = self.norm(core_attn_out, z)
core_attn_out = core_attn_out.reshape(z_shape_og)
core_attn_out = core_attn_out.flatten(-2) # ... h d -> ... (h d)
out, _ = self.out_proj(core_attn_out)
return out
return self._output_projection(core_attn_out, z)
def forward_cpu(
self,
@@ -1063,14 +1050,7 @@ class QwenGatedDeltaNetAttention(GatedDeltaNetAttention):
_encode_layer_name(self.prefix),
)
z_shape_og = z.shape
core_attn_out = core_attn_out.reshape(-1, core_attn_out.shape[-1])
z = z.reshape(-1, z.shape[-1])
core_attn_out = self.norm(core_attn_out, z)
core_attn_out = core_attn_out.reshape(z_shape_og)
core_attn_out = core_attn_out.flatten(-2) # ... h d -> ... (h d)
out, _ = self.out_proj(core_attn_out)
return out
return self._output_projection(core_attn_out, z)
def _warmup_prefill_kernels(self, qkv_or_qkvz: torch.Tensor, v_dim: int) -> None:
"""Warm up GDN prefill kernels during V1 profiling.