File size: 9,575 Bytes
f7cf333 | 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 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 | """Run the deflated-ASP NVFP4 checkpoint. Real 4-bit execution on the FP4 tensor cores.
Per layer, exactly the contract the export wrote and the search scored:
x~ = (x / s) H
y = (x~ V)(W~V)^T + NVFP4GEMM( (I - VV^T) x~ , W~(I-VV^T) ) + bias
The weight never leaves E2M1: it is packed nibbles with pre-swizzled E4M3 block scales, handed
straight to `torch._scaled_mm_v2` with recipe BlockWise1x16. Nothing is dequantised to bf16 and no
fp16 GEMM runs on the bulk path -- that is what makes this real quantisation rather than a
simulation of it.
The ACTIVATION is quantised per forward: block 16, E4M3 scales, one level (SVDQuant's nvfp4 recipe
for inputs), then swizzled. The swizzle is unavoidable -- `_scaled_mm_v2` rejects row-major scales
-- and it is done in torch here rather than fused, so this path is correct first and fast later.
The rank-r branch stays in bf16 by design: it carries the directions the action depends on, which
is the whole point of protecting them.
FW_ASP_CKPT=/path/to/ur3_step7000_asp_nvfp4.pt
"""
from __future__ import annotations
import math
import os
import torch
import torch.nn as nn
from nvfp4 import nvfp4_mm, quantize_nvfp4, recip_scale, swizzle_scales
def _fwht(x):
"""Orthonormal fast Walsh-Hadamard transform along the last dim (size a power of 2)."""
orig = x.shape; m = orig[-1]; x = x.reshape(-1, m).clone(); h = 1
while h < m:
x = x.view(-1, m // (2 * h), 2, h); a = x[:, :, 0, :]; b = x[:, :, 1, :]
x = torch.stack([a + b, a - b], dim=2).reshape(-1, m); h *= 2
return (x / math.sqrt(m)).reshape(orig)
_HMAT_CACHE = {}
def _hadamard_matrix(B, dtype, device):
"""H with x @ H == _fwht(x). Built by running _fwht on the identity, so the butterfly
ORDERING and the 1/sqrt(B) normalisation match the ones the weights were rotated with at
export. A Sylvester construction would give a different ordering, and the resulting error
would look like a quantisation regression rather than a transform mismatch."""
key = (B, dtype, str(device))
if key not in _HMAT_CACHE:
eye = torch.eye(B, device=device, dtype=torch.float32)
_HMAT_CACHE[key] = _fwht(eye).to(dtype).contiguous()
return _HMAT_CACHE[key]
_H: dict = {}
def _hmat(B, device, dtype):
k = (B, str(device), dtype)
if k not in _H:
_H[k] = _hadamard_matrix(B, dtype, device)
return _H[k]
class ASPNVFP4Linear(nn.Module):
"""One exported Linear: NVFP4 deflated weight + bf16 rank-r action subspace."""
def __init__(self, e: dict, dtype, device):
super().__init__()
self.in_features = int(e["in_features"])
self.out_features = int(e["out_features"])
self.block = int(e["fwht_block"])
self.a_block = int(e["a_block"])
self.rank = int(e["asp_rank"])
self.register_buffer("wq", e["wq"].to(device))
self.register_buffer("wsc", e["wscale_swizzled"].to(device))
# The kernel wants the RECIPROCAL of the per-tensor scale, and it is a constant: built
# once here, not per forward. The transposed weight view is hoisted for the same reason.
self.register_buffer("w_rglob", recip_scale(e["wglobal"].to(device), device))
self.register_buffer("a_rglob", recip_scale(None, device).clone())
# KEPT IN FP32, NOT THE MODEL DTYPE. These are stored fp16; casting them to bf16 throws
# away three mantissa bits before a 4-bit grid ever sees them, and the grid's codes are
# ~33% apart, so those bits decide which code an element lands on. Measured: bf16 buffers
# put the layer output 2e-2 to 5e-2 from the fp32 reference; fp16 -> fp32 is exact and
# costs a few hundred MB across the model for tensors that are r=32 columns wide.
self.register_buffer("inv_smooth", e["inv_smooth"].to(device=device, dtype=torch.float32))
self.register_buffer("bias", e["bias"].to(device=device, dtype=torch.float32)
if "bias" in e else None)
# V as [in, r] for the projection, W~V as [out, r] for the epilogue
self.register_buffer("V", e["lr_a"].t().contiguous().to(device=device,
dtype=torch.float32)
if self.rank else None)
self.register_buffer("WV", e["lr_b"].to(device=device, dtype=torch.float32)
if self.rank else None)
self._wqt = self.wq.t() # transposed view, hoisted out of the forward
def forward(self, x):
# THE PROLOGUE RUNS IN FP32, deliberately. What follows it is a 4-bit grid whose codes are
# ~33% apart, so a bf16 rounding of the smoothed/rotated activation moves elements across
# code boundaries: measured on the exported layers, a bf16 prologue put the layer output
# 3.8e-2 to 9.0e-2 from the fp32 one. The search that CHOSE this layer's (alpha, beta) and
# its subspace scored an fp32 prologue, so a bf16 one would deploy a different arm than the
# one that was selected. It is also cheap: the rotation is O(d*sqrt(d)) beside an FP4 GEMM.
shp = x.shape
x2 = x.reshape(-1, shp[-1]).float()
xh = x2 * self.inv_smooth
D = xh.shape[-1]
xt = (xh.reshape(-1, D // self.block, self.block)
@ _hmat(self.block, xh.device, torch.float32)).reshape(-1, D)
if self.rank:
Vf = self.V
proj = xt @ Vf # [tok, r]
xp = xt - proj @ Vf.t() # (I - VV^T) x~
else:
proj, xp = None, xt
ak, asc, _ = quantize_nvfp4(xp, block=self.a_block, two_level=False)
y = nvfp4_mm(ak, swizzle_scales(asc), self.wq, self.wsc, self.a_rglob, self.w_rglob,
out_dtype=torch.float32, b_packed_t=self._wqt)[: xp.shape[0]]
# EVERYTHING IN FP32, THEN ONE CAST AT THE END. The epilogue and the bias are fp32
# buffers, so casting before adding them promotes the result straight back to fp32 and the
# next layer_norm fails on a dtype it did not expect. One cast, last.
if self.rank:
y = y + proj @ self.WV.t()
if self.bias is not None:
y = y + self.bias
y = y.to(x.dtype)
return y.reshape(*shp[:-1], self.out_features)
def extra_repr(self):
return (f"in={self.in_features}, out={self.out_features}, NVFP4 w{self.a_block}, "
f"rank={self.rank}, H={self.block}")
def install_asp_nvfp4(model, ckpt_path=None, verbose=True) -> int:
"""Swap every exported Linear. A partial swap is not a defined arm, so it raises."""
ckpt_path = ckpt_path or os.environ.get("FW_ASP_CKPT")
blob = torch.load(str(ckpt_path), map_location="cpu", weights_only=False)
meta, layers = blob["meta"], blob["layers"]
if meta.get("lowrank_mode") != "asp_deflated":
raise RuntimeError(f"{ckpt_path}: lowrank_mode={meta.get('lowrank_mode')!r}; this runtime "
f"implements the DEFLATED ASP contract and applying it to another "
f"would give a well-formed GEMM of the wrong bilinear form.")
if verbose:
f = meta["format"]
print(f"[asp-nvfp4] {meta['scheme']}: {meta['n_layers']} Linears "
f"({meta['asp_layers']} with ASP r{meta['rank']}), {meta['bpw']} BPW", flush=True)
print(f"[asp-nvfp4] {f['element']} w{f['w_block']}/a{f['a_block']}, {f['scale']} scales, "
f"{f['weight_scale_levels']}-level weights, {f['scale_layout']}", flush=True)
named = dict(model.named_modules())
done, missing = 0, []
for name, e in layers.items():
mod = named.get(name)
if not isinstance(mod, nn.Linear):
missing.append(name)
continue
new = ASPNVFP4Linear(e, mod.weight.dtype, mod.weight.device)
parent = model.get_submodule(name.rsplit(".", 1)[0])
setattr(parent, name.rsplit(".", 1)[-1], new)
named[name] = None
del mod, new
done += 1
if missing:
raise RuntimeError(f"{ckpt_path}: {len(missing)} layers did not resolve, e.g. {missing[:3]}")
del blob, layers, named
import gc
gc.collect()
torch.cuda.empty_cache()
if verbose:
free, tot = torch.cuda.mem_get_info()
print(f"[asp-nvfp4] replaced {done} Linears | GPU {(tot-free)/2**30:.1f}/"
f"{tot/2**30:.0f} GiB", flush=True)
return done
def load_quantized_asp_model(ckpt_path, build_model, verbose=True):
"""Build the model straight from the self-contained NVFP4 checkpoint.
`build_model` is your own bf16 FastWAM constructor and must return `(model, cfg)`; the
checkpoint carries every tensor the quantised model needs, so it is only used for the
module graph and the non-Linear submodules. Nothing is read from the bf16 weights.
"""
blob = torch.load(str(ckpt_path), map_location="cpu", weights_only=False)
if "mot_rest" not in blob:
raise RuntimeError(f"{ckpt_path} is not self-contained")
model, cfg = build_model()
missing, unexpected = model.mot.load_state_dict(blob["mot_rest"], strict=False)
if unexpected:
raise RuntimeError(f"{len(unexpected)} unexpected mot tensors, e.g. {list(unexpected)[:3]}")
if model.proprio_encoder is not None:
model.proprio_encoder.load_state_dict(blob["proprio_encoder"], strict=True)
del blob
install_asp_nvfp4(model, ckpt_path, verbose=verbose)
return model, cfg
|