File size: 8,293 Bytes
be1bbb9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 | """NVFP4: E2M1 elements, per-16 E4M3 block scales, two-level weight scaling, and the scale swizzle.
THE FORMAT, and every constant here is derived rather than remembered.
E2M1 element -- 1 sign, 2 exponent (bias 1), 1 mantissa, with subnormals:
e=0 subnormal : m * 2^0 * 0.5 -> {0, 0.5}
e>0 normal : (1 + 0.5m) * 2^(e-1) -> {1, 1.5}, {2, 3}, {4, 6}
magnitudes {0, 0.5, 1, 1.5, 2, 3, 4, 6}, max 6. The 3-bit magnitude field is exactly the
index into that sorted table, because (e, m) enumerates it in order -- which is what makes
encoding a bucketize and nothing more.
NON-UNIFORM, and that is the point: fine near zero, 33% steps at the top, where INT4's
uniform grid steps 14%. It buys resolution where weights actually live.
block 16 consecutive elements along the CONTRACTION axis share one E4M3 scale.
(MXFP4 is the sibling: block 32, E8M0 power-of-two scale.)
two levels, for weights. E4M3 tops out at 448, so a lone block scale cannot span a whole
tensor's dynamic range. A per-tensor fp32 factor is applied first:
global = 448 * 6 / amax_tensor
block = amax_block * global / 6 in [0, 448] -> representable in E4M3
element= x * global / block in [-6, 6] -> representable in E2M1
dequant: x ~= element * block / global
Each step is checked by construction, so a tensor cannot silently overflow either container.
THE SCALE SWIZZLE IS MANDATORY, not an optimisation. `torch._scaled_mm_v2` rejects row-major block
scales outright: "scale_a must be swizzled to SWIZZLE_32_4_4 format". The layout tiles the scale
matrix 128 rows x 4 columns and rearranges each tile as (32, 4, 4). `swizzle_scales` implements it
and `verify_nvfp4_gemm` checks it against an fp32 reference -- a wrong permutation still produces a
well-formed GEMM of the wrong numbers, so it is verified, never assumed.
"""
from __future__ import annotations
import torch
FP4_MAX = 6.0
E4M3_MAX = 448.0
BLOCK = 16
# sorted E2M1 magnitudes; index == the 3-bit magnitude field
_E2M1 = (0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0)
# midpoints, for round-to-nearest
_BOUND = tuple((_E2M1[i] + _E2M1[i + 1]) / 2 for i in range(7))
_CACHE: dict = {}
def _tab(device):
k = str(device)
if k not in _CACHE:
_CACHE[k] = (torch.tensor(_E2M1, device=device, dtype=torch.float32),
torch.tensor(_BOUND, device=device, dtype=torch.float32))
return _CACHE[k]
def quantize_nvfp4(x: torch.Tensor, block: int = BLOCK, two_level: bool = True):
"""[.., K] -> (packed float4_e2m1fn_x2 [.., K/2], e4m3 scales [.., K/block], global fp32).
`two_level=False` drops the per-tensor factor, which is what SVDQuant's config does for
ACTIVATIONS (one level, block 16, E4M3). Weights use both levels.
"""
assert x.shape[-1] % block == 0, f"last dim {x.shape[-1]} not a multiple of block {block}"
vals, bounds = _tab(x.device)
xf = x.detach().float()
amax = xf.abs().amax().clamp(min=1e-12)
glob = (E4M3_MAX * FP4_MAX / amax) if two_level else torch.ones((), device=x.device)
xg = xf * glob
shp = xg.shape
g = xg.reshape(*shp[:-1], shp[-1] // block, block)
bmax = g.abs().amax(dim=-1, keepdim=True) # [.., nb, 1]
bs = (bmax / FP4_MAX).clamp(min=1e-12, max=E4M3_MAX)
bs_q = bs.to(torch.float8_e4m3fn).float() # round the SCALE first ...
bs_q = torch.where(bs_q > 0, bs_q, torch.full_like(bs_q, 2.0 ** -9))
e = g / bs_q # ... then divide by the
e = e.clamp(-FP4_MAX, FP4_MAX) # rounded scale, so the
# encode: nearest E2M1 magnitude, sign in bit 3 # decoder can reproduce it
idx = torch.bucketize(e.abs().contiguous(), bounds)
code = (idx | (torch.signbit(e).to(torch.uint8) << 3)).to(torch.uint8)
code = code.reshape(*shp)
lo, hi = code[..., 0::2], code[..., 1::2] # first element in LOW bits
# VIEWED AS float4_e2m1fn_x2, not left as raw bytes: `_scaled_mm_v2` type-checks its operands
# and rejects Byte outright ("Expected mat_a to be Float8 or Float4_x2 matrix got Byte"). The
# storage is identical; the dtype is what tells the kernel how to read it.
packed = (lo | (hi << 4)).contiguous().view(torch.float4_e2m1fn_x2)
scales = bs.reshape(*shp[:-1], shp[-1] // block).to(torch.float8_e4m3fn)
return packed, scales, glob.float()
def dequantize_nvfp4(packed: torch.Tensor, scales: torch.Tensor, glob, block: int = BLOCK):
"""Exact inverse of `quantize_nvfp4`. This is the reference the hardware must agree with."""
vals, _ = _tab(packed.device)
if packed.dtype != torch.uint8:
packed = packed.view(torch.uint8)
lo = (packed & 0x0F)
hi = (packed >> 4) & 0x0F
K = packed.shape[-1] * 2
code = torch.empty(*packed.shape[:-1], K, dtype=torch.uint8, device=packed.device)
code[..., 0::2] = lo
code[..., 1::2] = hi
mag = vals[(code & 0x07).long()]
sign = torch.where((code & 0x08) > 0, -1.0, 1.0)
e = mag * sign
s = scales.float().unsqueeze(-1)
x = (e.reshape(*e.shape[:-1], K // block, block) * s).reshape(*e.shape[:-1], K)
gv = glob if torch.is_tensor(glob) else torch.tensor(glob, device=packed.device)
return x / gv
def swizzle_scales(sf: torch.Tensor) -> torch.Tensor:
"""[M, K/16] E4M3 block scales -> the SWIZZLE_32_4_4 layout `_scaled_mm_v2` requires.
Tiles of 128 rows x 4 scale-columns, each stored as (32, 4, 4): row r of a tile goes to
(r % 32, (r // 32) % 4) and column c to (c % 4). M and K/16 are zero-padded up to the tile
quantum, which is why the returned buffer can be larger than the input.
"""
M, S = sf.shape
Mp = (M + 127) // 128 * 128
Sp = (S + 3) // 4 * 4
pad = torch.zeros(Mp, Sp, dtype=sf.dtype, device=sf.device)
pad[:M, :S] = sf
t = pad.reshape(Mp // 128, 4, 32, Sp // 4, 4) # (tile_m, r//32, r%32, tile_k, c%4)
t = t.permute(0, 3, 2, 1, 4) # (tile_m, tile_k, 32, 4, 4)
return t.reshape(-1).contiguous().view(torch.float8_e4m3fn)
_ONES: dict = {}
def recip_scale(g, device) -> torch.Tensor:
"""The 1-element fp32 reciprocal `_scaled_mm_v2` wants for the TensorWise level.
Build this ONCE, at load, and hand the same tensor to every call. Computing it per forward
allocates and launches for a constant, which at this model's shapes costs several times the
GEMM: 13 us of FP4 GEMM behind 40+ us of scalar bookkeeping.
"""
if g is None:
k = str(device)
if k not in _ONES:
_ONES[k] = torch.ones(1, device=device, dtype=torch.float32)
return _ONES[k]
return (1.0 / (g if torch.is_tensor(g) else torch.tensor(g))).reshape(1).float().to(device)
def nvfp4_mm(a_packed, a_scale_sw, b_packed, b_scale_sw, a_rglob=None, b_rglob=None,
out_dtype=torch.bfloat16, b_packed_t=None):
"""A[M,K] x B[K,N] on the FP4 tensor cores. Packed operands, pre-swizzled block scales.
`a_rglob` / `b_rglob` are the RECIPROCALS of the per-tensor global scales, as 1-element fp32
tensors from `recip_scale` -- precomputed, not derived here. They ride inside the kernel as a
second TensorWise scale level; dividing the output afterwards instead costs two elementwise
kernels that at these shapes exceed the GEMM itself. Both operands must carry the level or the
configuration is rejected, so a one-level activation passes a reciprocal of 1.
`b_packed_t` lets a caller hand in the transposed view once instead of re-taking it per call.
"""
ST = torch._C._ScalingType
SW = torch._C._SwizzleType
bw, tw = int(ST.BlockWise1x16.value), int(ST.TensorWise.value)
swz, nos = int(SW.SWIZZLE_32_4_4.value), int(SW.NO_SWIZZLE.value)
ra = recip_scale(None, a_packed.device) if a_rglob is None else a_rglob
rb = recip_scale(None, a_packed.device) if b_rglob is None else b_rglob
bt = b_packed.t() if b_packed_t is None else b_packed_t
return torch._scaled_mm_v2(a_packed, bt, [a_scale_sw, ra], [bw, tw], [swz, nos],
[b_scale_sw, rb], [bw, tw], [swz, nos], None, out_dtype)
|