[AMD][Perf] Fuse QK RMSNorm + gate extraction Triton kernel for Qwen3.5 on HIP (#27656)

This commit is contained in:
jacky.cheng
2026-06-10 14:29:33 -07:00
committed by GitHub
parent 3c1b0fb226
commit 0da18f8d91
3 changed files with 315 additions and 7 deletions
@@ -0,0 +1,151 @@
import itertools
import sys
import pytest
import torch
from sglang.srt.models.utils import fused_qk_gemma_rmsnorm_with_gate
from sglang.test.ci.ci_register import register_amd_ci
register_amd_ci(est_time=20, suite="jit-kernel-unit-test-amd")
def reference_qk_gemma_rmsnorm_with_gate(
q_gate: torch.Tensor,
k: torch.Tensor,
q_weight: torch.Tensor,
k_weight: torch.Tensor,
eps: float,
head_dim: int,
num_heads: int,
):
"""Pure-PyTorch reference: deinterleave q/gate, GemmaRMSNorm q and k."""
seq_len = q_gate.shape[0]
# Deinterleave q and gate from [q_h0, gate_h0, q_h1, gate_h1, ...]
qg_3d = q_gate.view(seq_len, num_heads, 2 * head_dim)
q = qg_3d[:, :, :head_dim].contiguous().view(-1, head_dim)
gate = qg_3d[:, :, head_dim:].contiguous().view(-1, head_dim)
k_flat = k.reshape(-1, head_dim)
# GemmaRMSNorm: x * rsqrt(mean(x^2) + eps) * (weight + 1)
def gemma_rmsnorm(x, w):
x_fp32 = x.float()
var = x_fp32.pow(2).mean(dim=-1, keepdim=True)
normed = x_fp32 * (var + eps).rsqrt() * (w.float() + 1.0)
return normed.to(x.dtype)
q_out = gemma_rmsnorm(q, q_weight)
k_out = gemma_rmsnorm(k_flat, k_weight)
return q_out, k_out, gate
DEVICE = "cuda"
DTYPE = torch.bfloat16
SEQ_LENS = [1, 2, 4, 7, 16, 128]
NUM_HEADS_LIST = [8, 16, 32]
NUM_KV_HEADS_LIST = [2, 4, 8]
HEAD_DIM_LIST = [64, 128]
@pytest.mark.parametrize(
"seq_len,num_heads,num_kv_heads,head_dim",
list(itertools.product(SEQ_LENS, NUM_HEADS_LIST, NUM_KV_HEADS_LIST, HEAD_DIM_LIST)),
)
def test_fused_qk_gemma_rmsnorm_with_gate(
seq_len: int, num_heads: int, num_kv_heads: int, head_dim: int
):
if num_kv_heads > num_heads:
pytest.skip("num_kv_heads > num_heads is not a valid config")
eps = 1e-6
q_size = num_heads * head_dim
kv_size = num_kv_heads * head_dim
# Build a full qkv buffer and split — this gives non-contiguous k,
# which is the real usage pattern
qkv = torch.randn(
seq_len, q_size * 2 + kv_size + kv_size, device=DEVICE, dtype=DTYPE
)
q_gate, k, v = qkv.split([q_size * 2, kv_size, kv_size], dim=-1)
q_weight = torch.randn(head_dim, device=DEVICE, dtype=DTYPE)
k_weight = torch.randn(head_dim, device=DEVICE, dtype=DTYPE)
# Reference
q_ref, k_ref, gate_ref = reference_qk_gemma_rmsnorm_with_gate(
q_gate, k, q_weight, k_weight, eps, head_dim, num_heads
)
# Fused kernel
q_out, k_out, gate_out = fused_qk_gemma_rmsnorm_with_gate(
q_gate, k, q_weight, k_weight, eps, head_dim, num_heads
)
torch.testing.assert_close(q_out, q_ref, atol=1e-2, rtol=1e-2)
torch.testing.assert_close(k_out, k_ref, atol=1e-2, rtol=1e-2)
torch.testing.assert_close(gate_out, gate_ref, atol=0, rtol=0)
@pytest.mark.parametrize("head_dim", [64, 128, 256])
def test_gate_is_exact_copy(head_dim: int):
"""Gate output must be a bitwise-exact copy of the input gate data."""
seq_len = 4
num_heads = 16
num_kv_heads = 4
eps = 1e-6
q_size = num_heads * head_dim
kv_size = num_kv_heads * head_dim
qkv = torch.randn(
seq_len, q_size * 2 + kv_size + kv_size, device=DEVICE, dtype=DTYPE
)
q_gate, k, v = qkv.split([q_size * 2, kv_size, kv_size], dim=-1)
q_weight = torch.randn(head_dim, device=DEVICE, dtype=DTYPE)
k_weight = torch.randn(head_dim, device=DEVICE, dtype=DTYPE)
_, _, gate_out = fused_qk_gemma_rmsnorm_with_gate(
q_gate, k, q_weight, k_weight, eps, head_dim, num_heads
)
# Extract gate from interleaved buffer manually
qg_3d = q_gate.view(seq_len, num_heads, 2 * head_dim)
gate_expected = qg_3d[:, :, head_dim:].contiguous().view(-1, head_dim)
assert torch.equal(gate_out, gate_expected), "Gate must be bitwise exact"
@pytest.mark.parametrize("seq_len", [1, 8])
def test_contiguous_k_also_works(seq_len: int):
"""Kernel should work even when k is already contiguous."""
num_heads = 16
num_kv_heads = 4
head_dim = 128
eps = 1e-6
q_size = num_heads * head_dim
kv_size = num_kv_heads * head_dim
q_gate = torch.randn(seq_len, q_size * 2, device=DEVICE, dtype=DTYPE)
k = torch.randn(seq_len, kv_size, device=DEVICE, dtype=DTYPE)
assert k.is_contiguous()
q_weight = torch.randn(head_dim, device=DEVICE, dtype=DTYPE)
k_weight = torch.randn(head_dim, device=DEVICE, dtype=DTYPE)
q_ref, k_ref, gate_ref = reference_qk_gemma_rmsnorm_with_gate(
q_gate, k, q_weight, k_weight, eps, head_dim, num_heads
)
q_out, k_out, gate_out = fused_qk_gemma_rmsnorm_with_gate(
q_gate, k, q_weight, k_weight, eps, head_dim, num_heads
)
torch.testing.assert_close(q_out, q_ref, atol=1e-2, rtol=1e-2)
torch.testing.assert_close(k_out, k_ref, atol=1e-2, rtol=1e-2)
torch.testing.assert_close(gate_out, gate_ref, atol=0, rtol=0)
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v", "-s"]))
+37 -3
View File
@@ -84,7 +84,10 @@ from sglang.srt.models.qwen2_moe import Qwen2MoeMLP, Qwen2MoeSparseMoeBlock
# Models
from sglang.srt.models.qwen3_vl import Qwen3VLForConditionalGeneration
from sglang.srt.models.utils import fused_qk_gemma_rmsnorm
from sglang.srt.models.utils import (
fused_qk_gemma_rmsnorm,
fused_qk_gemma_rmsnorm_with_gate,
)
from sglang.srt.server_args import get_global_server_args
# Utils
@@ -889,6 +892,33 @@ class Qwen3_5AttentionDecoderLayer(nn.Module):
q, k = self.rotary_emb(positions, q, k)
return q, k, v, gate
def forward_prepare_hip(self, positions, hidden_states):
qkv, _ = self.qkv_proj(hidden_states)
if self.attn_output_gate:
q_gate, k, v = qkv.split(
[self.q_size * 2, self.kv_size, self.kv_size], dim=-1
)
seq_len = q_gate.shape[0]
q_flat, k_flat, gate_flat = fused_qk_gemma_rmsnorm_with_gate(
q_gate,
k,
self.q_norm.weight.data,
self.k_norm.weight.data,
self.q_norm.variance_epsilon,
self.head_dim,
self.num_heads,
)
q = q_flat.view(seq_len, -1)
k = k_flat.view(seq_len, -1)
gate = gate_flat.view(seq_len, -1)
else:
q, k, v = qkv.split([self.q_size, self.kv_size, self.kv_size], dim=-1)
gate = None
q, k = self._apply_qk_norm(q, k)
q, k = self.rotary_emb(positions, q, k)
return q, k, v, gate
def forward_prepare_npu(self, positions, hidden_states, forward_batch):
qkv, _ = self.qkv_proj(hidden_states)
# Calculate first full attention layer ID based on config
@@ -916,7 +946,12 @@ class Qwen3_5AttentionDecoderLayer(nn.Module):
forward_batch: ForwardBatch,
) -> torch.Tensor:
"""Full attention forward pass."""
if (
if _is_hip and self.attn_output_gate:
q, k, v, gate = self.forward_prepare_hip(
positions=positions,
hidden_states=hidden_states,
)
elif (
not _is_npu
or forward_batch.forward_mode.is_extend_or_draft_extend_or_mixed()
or not self.attn_output_gate
@@ -1512,7 +1547,6 @@ class Qwen3_5MoeForCausalLM(Qwen3_5ForCausalLM):
class Qwen3_5ForConditionalGeneration(Qwen3VLForConditionalGeneration):
packed_modules_mapping = Qwen3_5ForCausalLM.packed_modules_mapping
hf_to_sglang_mapper = None
+127 -4
View File
@@ -154,9 +154,12 @@ class AutoWeightsLoader:
for weight_name, weight_data in weights
)
for prefix, group in itertools.groupby(weights_by_parts, key=lambda x: x[0][0]):
yield prefix, (
("" if len(parts) == 1 else parts[1], weight_data)
for parts, weight_data in group
yield (
prefix,
(
("" if len(parts) == 1 else parts[1], weight_data)
for parts, weight_data in group
),
)
@staticmethod
@@ -375,7 +378,6 @@ def compute_cu_seqlens_from_grid_numpy(grid_thw: torch.Tensor) -> torch.Tensor:
class RotaryPosMixin:
@staticmethod
@lru_cache(maxsize=1024)
def rot_pos_ids(h: int, w: int, spatial_merge_size: int) -> torch.Tensor:
@@ -594,5 +596,126 @@ def fused_qk_gemma_rmsnorm(
return q_out, k_out
# ---------------------------------------------------------------------------
# Fused QK GemmaRMSNorm + gate extraction kernel
# For models with attn_output_gate (e.g. Qwen3.5) where q and gate are
# interleaved per head: [q_h0, gate_h0, q_h1, gate_h1, ...].
# Reads q from the interleaved buffer, normalizes it, and copies gate to a
# contiguous output — all in a single kernel launch. Eliminates two
# elementwise copy kernels that would otherwise be needed to deinterleave.
# ---------------------------------------------------------------------------
@triton.jit
def _fused_qk_gemma_rmsnorm_gate_kernel(
QG_ptr,
K_ptr,
Q_out_ptr,
K_out_ptr,
Gate_out_ptr,
QW_ptr,
KW_ptr,
qg_token_stride,
qg_head_stride,
k_token_stride,
k_head_stride,
num_heads,
num_kv_heads,
k_rows,
HEAD_DIM: tl.constexpr,
BLOCK_HD: tl.constexpr,
EPS: tl.constexpr,
FP16: tl.constexpr,
):
pid = tl.program_id(0)
cols = tl.arange(0, BLOCK_HD)
mask = cols < HEAD_DIM
out_dtype = tl.float16 if FP16 else tl.bfloat16
token_idx = pid // num_heads
head_idx = pid % num_heads
base = token_idx * qg_token_stride + head_idx * qg_head_stride
# Q norm
q = tl.load(QG_ptr + base + cols, mask=mask, other=0.0).to(tl.float32)
w_q = tl.load(QW_ptr + cols, mask=mask, other=0.0).to(tl.float32)
q_var = tl.sum(q * q, axis=0) / HEAD_DIM
q_normed = (q * tl.rsqrt(q_var + EPS) * (w_q + 1.0)).to(out_dtype)
out_off = pid * HEAD_DIM + cols
tl.store(Q_out_ptr + out_off, q_normed, mask=mask)
# Gate copy
gate = tl.load(QG_ptr + base + HEAD_DIM + cols, mask=mask, other=0.0)
tl.store(Gate_out_ptr + out_off, gate, mask=mask)
# K norm (first k_rows blocks only)
if pid < k_rows:
token_idx_k = pid // num_kv_heads
head_idx_k = pid % num_kv_heads
k_off = token_idx_k * k_token_stride + head_idx_k * k_head_stride + cols
k = tl.load(K_ptr + k_off, mask=mask, other=0.0).to(tl.float32)
w_k = tl.load(KW_ptr + cols, mask=mask, other=0.0).to(tl.float32)
k_var = tl.sum(k * k, axis=0) / HEAD_DIM
k_normed = (k * tl.rsqrt(k_var + EPS) * (w_k + 1.0)).to(out_dtype)
k_out_off = pid * HEAD_DIM + cols
tl.store(K_out_ptr + k_out_off, k_normed, mask=mask)
def fused_qk_gemma_rmsnorm_with_gate(
q_gate: torch.Tensor,
k: torch.Tensor,
q_weight: torch.Tensor,
k_weight: torch.Tensor,
eps: float,
head_dim: int,
num_heads: int,
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""Fused QK GemmaRMSNorm + gate extraction from interleaved q_gate buffer.
q_gate: (seq, q_size*2) where q and gate are interleaved per head,
i.e. [q_h0, gate_h0, q_h1, gate_h1, ...] with q_size = num_heads * head_dim.
Can be a non-contiguous view from qkv.split().
k: (seq, kv_size) — same as fused_qk_gemma_rmsnorm.
Returns (q_out, k_out, gate_out) all contiguous with shape
(seq*num_heads, head_dim), (seq*num_kv_heads, head_dim), (seq*num_heads, head_dim).
"""
seq_len = q_gate.shape[0]
qg_3d = q_gate.view(seq_len, num_heads, 2 * head_dim)
num_kv_heads = k.shape[-1] // head_dim
k_3d = k.view(seq_len, num_kv_heads, head_dim)
q_rows = seq_len * num_heads
k_rows = seq_len * num_kv_heads
q_out = torch.empty(q_rows, head_dim, dtype=q_gate.dtype, device=q_gate.device)
k_out = torch.empty(k_rows, head_dim, dtype=k.dtype, device=k.device)
gate_out = torch.empty(q_rows, head_dim, dtype=q_gate.dtype, device=q_gate.device)
BLOCK_HD = triton.next_power_of_2(head_dim)
_fused_qk_gemma_rmsnorm_gate_kernel[(q_rows,)](
qg_3d,
k_3d,
q_out,
k_out,
gate_out,
q_weight,
k_weight,
qg_3d.stride(0),
qg_3d.stride(1),
k_3d.stride(0),
k_3d.stride(1),
num_heads,
num_kv_heads,
k_rows,
HEAD_DIM=head_dim,
BLOCK_HD=BLOCK_HD,
EPS=eps,
FP16=(q_gate.dtype == torch.float16),
)
return q_out, k_out, gate_out
# Register the inplace op
fused_inplace_qknorm = register_custom_op(fused_inplace_qknorm, mutates_args=["q", "k"])