|
| 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 |
0 commit comments