Skip to content

Commit cf82c5d

Browse files
authored
[TRTLLM-14138][perf] Add fused kernels for Gemma4 serving (NVIDIA#16074)
Signed-off-by: Kaiyu Xie <26294424+kaiyux@users.noreply.github.com>
1 parent b0f3f29 commit cf82c5d

13 files changed

Lines changed: 1702 additions & 10 deletions

File tree

tensorrt_llm/_torch/attention_backend/flashinfer.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1980,6 +1980,11 @@ def forward(self,
19801980
# MLA generation: output has kv_lora_rank per head
19811981
output = q.new_empty(
19821982
[q.shape[0], self.num_heads * self.kv_lora_rank])
1983+
elif q.dtype == torch.float8_e4m3fn:
1984+
# Q may arrive pre-quantized to FP8 (fused model-side QKV
1985+
# prep); the trtllm-gen FP8 cubins emit BF16
1986+
# (QkvE4m3OBfloat16), so the output must not follow q's dtype.
1987+
output = q.new_empty(q.shape, dtype=torch.bfloat16)
19831988
else:
19841989
output = torch.empty_like(q)
19851990

tensorrt_llm/_torch/models/modeling_gemma4.py

Lines changed: 325 additions & 10 deletions
Large diffs are not rendered by default.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
"""Standalone fused elementwise/norm/quant operators shared across models.
4+
5+
Each submodule hosts one fused operator as a plain Python callable (not a
6+
registered custom op). Callers own the enablement checks and keep the
7+
unfused op chains as fallbacks for unsupported configurations; see the
8+
Gemma4 decoder (tensorrt_llm/_torch/models/modeling_gemma4.py) for the
9+
reference usage pattern.
10+
"""
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
"""Fused gelu_tanh+mul+NVFP4-quantize (single Triton kernel).
16+
17+
Replaces the unfused pair between a packed [gate | up] GEMM output and an
18+
NVFP4 GEMM consumer (e.g. a gated-MLP down_proj):
19+
20+
trtllm::flashinfer_gelu_tanh_and_mul (bf16 out, full HBM round-trip)
21+
-> trtllm::fp4_quantize (reads it back, emits FP4 + swizzled scales)
22+
23+
with one Triton kernel that reads the packed [gate | up] GEMM output once and
24+
emits the packed E2M1 payload plus the 128x4-swizzled E4M3 block scales
25+
directly - eliminating the intermediate activation write+read.
26+
27+
Numerics replicate the unfused chain byte-for-byte on SM100:
28+
- gelu_tanh uses flashinfer's exact expression order and its `tanh.approx.f32`
29+
hardware instruction (flashinfer JIT builds with -use_fast_math);
30+
- the product is rounded to bf16 (the write the unfused path performs) before
31+
quantization;
32+
- the scale/quant math mirrors cvt_warp_fp16_to_fp4 in
33+
cpp/tensorrt_llm/kernels/quantization.cuh: rcp.approx.ftz.f32 reciprocals,
34+
cvt.rn.satfinite e4m3 scale rounding, and the cvt.rn.satfinite.e2m1x2.f32
35+
payload conversion, with scales stored at the 128x4 swizzled offsets of
36+
get_sf_out_offset_128x4 (rows padded to 128; padding is zero-filled here,
37+
uninitialized in the unfused op).
38+
39+
Callers own enablement and keep the unfused pair as the fallback for
40+
configurations this kernel does not support (non-NVFP4 consumer, LoRA,
41+
torch.compile, ...); see the gelu_tanh + down_proj quantize fusion in
42+
modeling_gemma4.py.
43+
"""
44+
45+
from typing import Tuple
46+
47+
import torch
48+
import triton
49+
import triton.language as tl
50+
51+
52+
@triton.jit
53+
def _rcp_approx(x):
54+
return tl.inline_asm_elementwise(
55+
"rcp.approx.ftz.f32 $0, $1;", "=f,f", [x], dtype=tl.float32, is_pure=True, pack=1
56+
)
57+
58+
59+
@triton.jit
60+
def _gelu_tanh_mul_fp4_kernel(
61+
x_ptr, # [M, 2I] bf16 packed [gate | up], row stride SX
62+
out_ptr, # [M, I//2] uint8, two e2m1 per byte (element 0 = low nibble)
63+
sf_ptr, # [pad128(M) * 4 * NKT] uint8, swizzled 128x4 layout
64+
gs_ptr, # [1] fp32 global scale (down_proj.input_scale)
65+
M,
66+
SX, # x row stride (elements)
67+
NKT, # numKTiles = ceil((IDIM/16) / 4)
68+
IDIM: tl.constexpr, # intermediate size (elements, multiple of 16)
69+
BM: tl.constexpr,
70+
BK: tl.constexpr, # multiple of 16
71+
):
72+
rows = tl.program_id(0) * BM + tl.arange(0, BM).to(tl.int64)
73+
rmask = rows < M
74+
cols = tl.program_id(1) * BK + tl.arange(0, BK)
75+
mask = rmask[:, None] & (cols[None, :] < IDIM)
76+
77+
gate = tl.load(x_ptr + rows[:, None] * SX + cols[None, :], mask=mask, other=0.0).to(tl.float32)
78+
up = tl.load(x_ptr + rows[:, None] * SX + IDIM + cols[None, :], mask=mask, other=0.0).to(
79+
tl.float32
80+
)
81+
82+
# gelu_tanh exactly as flashinfer's JIT kernel computes it, then the bf16
83+
# round the unfused path performs when writing its output tensor.
84+
inner = 0.7978845608028654 * (gate + 0.044715 * gate * gate * gate)
85+
t = tl.inline_asm_elementwise(
86+
"tanh.approx.f32 $0, $1;", "=f,f", [inner], dtype=tl.float32, is_pure=True, pack=1
87+
)
88+
act = gate * (0.5 * (1.0 + t))
89+
v = (act * up).to(tl.bfloat16).to(tl.float32)
90+
91+
# Per-16-element block amax (exact; max is order-insensitive).
92+
v16 = tl.reshape(v, (BM, BK // 16, 16))
93+
vmax = tl.max(tl.abs(v16), axis=2) # [BM, BK/16]
94+
95+
# Scale math replicating cvt_warp_fp16_to_fp4 (e4m3 branch):
96+
# SFValue = gs * (vecMax * rcp.approx(6)); sf8 = e4m3(SFValue)
97+
# outputScale = vecMax != 0 ? rcp.approx(f32(sf8) * rcp.approx(gs)) : 0
98+
gs = tl.load(gs_ptr)
99+
rcp6 = _rcp_approx(tl.full((BM, BK // 16), 6.0, tl.float32))
100+
sf8 = (gs * (vmax * rcp6)).to(tl.float8e4nv)
101+
rcpgs = _rcp_approx(tl.full((BM, BK // 16), 0.0, tl.float32) + gs)
102+
oscale = _rcp_approx(sf8.to(tl.float32) * rcpgs)
103+
oscale = tl.where(vmax != 0.0, oscale, 0.0)
104+
105+
# Scale store at the swizzled 128x4 offsets (get_sf_out_offset_128x4).
106+
kvec = (tl.program_id(1) * (BK // 16) + tl.arange(0, BK // 16)).to(tl.int64)
107+
kvmask = rmask[:, None] & (kvec[None, :] * 16 < IDIM)
108+
m2 = rows[:, None]
109+
k2 = kvec[None, :]
110+
sfoff = (
111+
(m2 // 128) * (NKT * 512)
112+
+ (k2 // 4) * 512
113+
+ (m2 % 32) * 16
114+
+ ((m2 % 128) // 32) * 4
115+
+ (k2 % 4)
116+
)
117+
tl.store(sf_ptr + sfoff, sf8.to(tl.uint8, bitcast=True), mask=kvmask)
118+
119+
# E2M1 conversion + pairwise packing via the same PTX instruction the
120+
# CUDA quantize kernel uses (first source operand -> high nibble).
121+
y = tl.reshape(v16 * oscale[:, :, None], (BM, BK))
122+
lo, hi = tl.split(tl.reshape(y, (BM, BK // 2, 2)))
123+
byte = tl.inline_asm_elementwise(
124+
"{ .reg .b8 t; cvt.rn.satfinite.e2m1x2.f32 t, $2, $1; cvt.u16.u8 $0, t; }",
125+
"=h,f,f",
126+
[lo, hi],
127+
dtype=tl.uint16,
128+
is_pure=True,
129+
pack=1,
130+
).to(tl.uint8)
131+
132+
ocols = tl.program_id(1) * (BK // 2) + tl.arange(0, BK // 2)
133+
omask = rmask[:, None] & (ocols[None, :] * 2 < IDIM)
134+
tl.store(out_ptr + rows[:, None] * (IDIM // 2) + ocols[None, :], byte, mask=omask)
135+
136+
137+
def sf_swizzled_offsets(m: int, nvec: int, device: torch.device) -> torch.Tensor:
138+
"""Flat swizzled offsets of the valid (row, kvec) scale region.
139+
140+
Mirrors get_sf_out_offset_128x4; used by the parity tests to compare only
141+
the valid region (the unfused op leaves the 128-row padding
142+
uninitialized).
143+
"""
144+
nkt = (nvec + 3) // 4
145+
mm = torch.arange(m, device=device, dtype=torch.int64)[:, None]
146+
kk = torch.arange(nvec, device=device, dtype=torch.int64)[None, :]
147+
off = (
148+
(mm // 128) * (nkt * 512)
149+
+ (kk // 4) * 512
150+
+ (mm % 32) * 16
151+
+ ((mm % 128) // 32) * 4
152+
+ (kk % 4)
153+
)
154+
return off.reshape(-1)
155+
156+
157+
def gelu_tanh_mul_fp4_quant(
158+
x: torch.Tensor, global_scale: torch.Tensor
159+
) -> Tuple[torch.Tensor, torch.Tensor]:
160+
"""Fused gelu_tanh(gate) * up + NVFP4 block-scale quantize.
161+
162+
Args:
163+
x: [M, 2*I] bf16, packed [gate | up] (a row stride larger than the
164+
width is allowed; the innermost dim must be contiguous).
165+
global_scale: [1] fp32 tensor - the consumer Linear's static
166+
``input_scale`` (448*6/amax convention).
167+
168+
Returns:
169+
(fp4, sf): the packed E2M1 payload [M, I//2] (uint8, element 2j in
170+
the low nibble of byte j) and the E4M3 block scales (uint8, 1D,
171+
swizzled 128x4 layout padded to 128 rows, padding zero-filled) -
172+
byte-compatible with ``trtllm::fp4_quantize``'s outputs and
173+
consumable as ``Fp4QuantizedTensor(fp4, sf)``.
174+
"""
175+
assert x.dim() == 2 and x.stride(-1) == 1
176+
assert x.dtype == torch.bfloat16
177+
assert global_scale.dtype == torch.float32
178+
m, two_i = x.shape
179+
i = two_i // 2
180+
assert two_i % 32 == 0, "intermediate size must be a multiple of 16"
181+
182+
nkt = triton.cdiv(i // 16, 4)
183+
out = torch.empty((m, i // 2), dtype=torch.uint8, device=x.device)
184+
sf = torch.zeros((((m + 127) // 128) * 128 * nkt * 4,), dtype=torch.uint8, device=x.device)
185+
if m == 0:
186+
return out, sf
187+
188+
# B200-tuned; fixed (no runtime autotune) so launches stay deterministic
189+
# under CUDA-graph capture. Measured 234 -> 145 us vs the unfused pair
190+
# at [6455, 2x21504]; 13.6 -> 7.6 us graph-replayed at the 228-token
191+
# decode shape.
192+
bm, bk = 8, 512
193+
grid = (triton.cdiv(m, bm), triton.cdiv(i, bk))
194+
_gelu_tanh_mul_fp4_kernel[grid](
195+
x, out, sf, global_scale, m, x.stride(0), nkt, IDIM=i, BM=bm, BK=bk, num_warps=8
196+
)
197+
return out, sf
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
"""Fused RMSNorm + NVFP4 quantize (flashinfer CuTe-DSL ``rmsnorm_fp4quant``).
16+
17+
Replaces the unfused pair in front of an NVFP4 GEMM whose input is the
18+
RMSNorm of a bf16 activation (e.g. a pre-feedforward norm feeding a
19+
gate_up projection):
20+
21+
trtllm::flashinfer_rmsnorm (bf16 out, full [M, H] HBM round-trip)
22+
-> trtllm::fp4_quantize (reads it back, emits FP4 + swizzled scales)
23+
24+
with flashinfer's CuTe-DSL ``rmsnorm_fp4quant`` (SM100+), which reads the
25+
input once and emits the packed E2M1 payload plus the 128x4-swizzled E4M3
26+
block scales directly - eliminating the intermediate bf16 activation
27+
write+read.
28+
29+
TRT-LLM's static ``input_scale`` (448*6/amax convention) is exactly
30+
flashinfer's ``global_scale``: both the unfused ``trtllm::fp4_quantize`` and
31+
the fused kernel store ``e4m3(gs * blockAmax / 6)`` as the block scale, and
32+
``is_sf_swizzled_layout=True`` emits the same 128x4 layout as
33+
``get_sf_out_offset_128x4`` with the identical padded size
34+
(``ceil(M/128) * 512 * numKTiles``) - so the outputs are consumable as
35+
``Fp4QuantizedTensor(fp4, sf)`` wherever the unfused op's would be.
36+
37+
Numerics: the fused kernel quantizes the fp32 norm result directly, without
38+
the intermediate bf16 round the unfused chain performs when materializing
39+
the normed tensor. Outputs are therefore near- but not byte-identical to the
40+
unfused pair (~1.4% of payload nibbles differ by one code step at Gemma4
41+
serving shapes); the quantization error against the fp32 norm is statistically
42+
identical (see tests/unittest/_torch/modules/fused_ops/test_rmsnorm_fp4_quant.py).
43+
44+
Callers own enablement and keep the unfused pair as the fallback for
45+
configurations this kernel does not support (non-NVFP4 consumer, LoRA,
46+
torch.compile, flashinfer's CuTe-DSL kernels unavailable, ...); see the
47+
pre-feedforward norm + gate_up quantize fusion in modeling_gemma4.py.
48+
"""
49+
50+
from typing import Tuple
51+
52+
import torch
53+
54+
from ...flashinfer_utils import IS_FLASHINFER_AVAILABLE
55+
56+
if IS_FLASHINFER_AVAILABLE:
57+
try:
58+
# None (rather than an ImportError) when flashinfer's optional
59+
# CuTe-DSL dependency (nvidia-cutlass-dsl) is not importable.
60+
from flashinfer.norm import rmsnorm_fp4quant as _rmsnorm_fp4quant
61+
except ImportError: # flashinfer version without the CuTe-DSL kernels
62+
_rmsnorm_fp4quant = None
63+
else:
64+
_rmsnorm_fp4quant = None
65+
66+
67+
def rmsnorm_fp4_quant_available() -> bool:
68+
"""Whether the flashinfer CuTe-DSL fused norm+quant kernel is usable."""
69+
return _rmsnorm_fp4quant is not None
70+
71+
72+
def rmsnorm_fp4_quant(
73+
x: torch.Tensor,
74+
norm_weight: torch.Tensor,
75+
eps: float,
76+
global_scale: torch.Tensor,
77+
) -> Tuple[torch.Tensor, torch.Tensor]:
78+
"""Fused RMSNorm + NVFP4 block-scale quantize.
79+
80+
Args:
81+
x: [M, H] bf16 input (a row stride larger than the width is
82+
allowed; the innermost dim must be contiguous).
83+
norm_weight: [H] bf16 RMSNorm weight (plain multiplier convention,
84+
``use_gemma=False``).
85+
eps: RMSNorm epsilon.
86+
global_scale: [1] fp32 tensor - the consumer Linear's static
87+
``input_scale`` (448*6/amax convention).
88+
89+
Returns:
90+
(fp4, sf): the packed E2M1 payload [M, H//2] (uint8, element 2j in
91+
the low nibble of byte j) and the E4M3 block scales (uint8, 1D,
92+
swizzled 128x4 layout padded to 128 rows) - layout-compatible with
93+
``trtllm::fp4_quantize``'s outputs and consumable as
94+
``Fp4QuantizedTensor(fp4, sf)``.
95+
"""
96+
assert rmsnorm_fp4_quant_available()
97+
assert x.dim() == 2 and x.stride(-1) == 1
98+
assert x.dtype == torch.bfloat16
99+
assert global_scale.dtype == torch.float32
100+
m, h = x.shape
101+
assert h % 16 == 0 and h >= 64, "hidden size must be a multiple of 16 and >= 64"
102+
assert norm_weight.shape == (h,)
103+
104+
if m == 0:
105+
fp4 = torch.empty((0, h // 2), dtype=torch.uint8, device=x.device)
106+
sf = torch.empty((0,), dtype=torch.uint8, device=x.device)
107+
return fp4, sf
108+
109+
fp4, sf = _rmsnorm_fp4quant(
110+
x,
111+
norm_weight,
112+
global_scale=global_scale.reshape(1),
113+
eps=eps,
114+
block_size=16,
115+
scale_format="e4m3",
116+
is_sf_swizzled_layout=True,
117+
)
118+
return fp4.view(torch.uint8), sf.view(torch.uint8)

0 commit comments

Comments
 (0)