minimax-h3-nvfp4-lambda-modality / scripts /calib_smooth_lowrank.py
yitongl's picture
Upload folder using huggingface_hub
0692312 verified
Raw
History Blame Contribute Delete
12.2 kB
#!/usr/bin/env python
"""Phase 2b pass B: choose lambda per layer, then fit the rank-32 low-rank branch.
Both stages follow `deepcompressor`'s recipe rather than the paper's prose, because the two
differ in ways that change the result.
**The lambda grid is 39 candidates, not 20.** `calib/config/smooth.py:152-161` with the recipe's
`alpha: 0.5, beta: -2, num_grids: 20`:
choices = [i/20 for i in range(1, 20)] # 0.05 .. 0.95
beta == -2 -> [(0,0)] + [(a, 0) for a] + [(a, 1-a) for a] # 1 + 19 + 19
`num_grids` sets the granularity of alpha; `beta: -2` unions two families. The scale itself is
`calib/smooth.py:117` -- `lambda = alpha_base^alpha / beta_base^beta`, with `alpha_base` the
activation AbsMax span and `beta_base` the weight AbsMax span, both per input channel. So the
family with `beta = 0` is purely activation-driven and the family with `beta = 1-alpha` is the
SmoothQuant form; the search picks between them per layer instead of assuming one.
**The low-rank branch is not a truncated SVD.** `configs/svdquant/__default__.yaml` sets
`low_rank: {rank: 32, num_iters: 100, objective: OutputsError, degree: 2, early_stop: true}`. The
SVD gives the initial factors; the iterations then alternate between refitting the low-rank
branch to the *quantization residual* and re-quantizing what is left. Truncating the SVD once and
stopping -- which is what the paper's Eq. 5 reads like on its own -- leaves the branch fitted to
the wrong target, because what it has to absorb is the part the 4-bit grid cannot represent, not
the part with the largest singular values.
Every candidate and every iteration is scored by output error on the sampled rows:
err(lambda) = || Q(X diag(lambda)^-1) Q(diag(lambda) W^T) - X W^T ||_2
with the quantizer matching the NVFP4 recipe (`configs/svdquant/nvfp4.yaml`): E2M1 elements,
block-16 groups along the input channels, and an FP8 E4M3 scale per group under a per-tensor
scale. Activations are dynamic (`static: false`), so they are quantized per call, not calibrated
to a fixed range.
Layers are independent, so this shards by layer: each worker holds one layer's weight and sample
rather than the whole model.
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
import torch
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO / "scripts"))
sys.path.insert(0, str(REPO / "src"))
FP4_E2M1_LEVELS = (0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0)
FP4_MAX = 6.0
GROUP = 16 # configs/svdquant/nvfp4.yaml: group_shapes [[-1,-1], [1,16,1,1,1]]
def _e2m1(x: torch.Tensor) -> torch.Tensor:
"""Round to the nearest representable E2M1 magnitude, keeping sign."""
levels = torch.tensor(FP4_E2M1_LEVELS, dtype=x.dtype, device=x.device)
mag = x.abs().clamp(max=FP4_MAX)
idx = torch.bucketize(mag, (levels[1:] + levels[:-1]) / 2)
return torch.sign(x) * levels[idx]
def quantize_nvfp4(t: torch.Tensor, group: int = GROUP) -> torch.Tensor:
"""NVFP4 round-trip along the last dim: per-tensor scale, then FP8-E4M3 per-group scale."""
*lead, n = t.shape
pad = (-n) % group
if pad:
t = torch.nn.functional.pad(t, (0, pad))
blocks = t.reshape(*lead, -1, group).float()
global_scale = blocks.abs().amax().clamp_min(1e-12) / (FP4_MAX * 448.0)
scales = blocks.abs().amax(dim=-1, keepdim=True) / FP4_MAX / global_scale
# The second-level scale is stored as FP8 E4M3, so it must be rounded to that grid or the
# dequantization here would be more accurate than the hardware's.
scales = scales.clamp(1e-8, 448.0).to(torch.float8_e4m3fn).float().clamp_min(1e-8)
eff = (scales * global_scale).clamp_min(1e-12)
out = (_e2m1(blocks / eff) * eff).reshape(*lead, -1)
return out[..., :n] if pad else out
def output_error(x: torch.Tensor, w: torch.Tensor, lam: torch.Tensor | None) -> float:
"""Relative L2 error of the quantized layer against the bf16 layer, on sampled rows."""
ref = x @ w.T
if lam is None:
xq, wq = quantize_nvfp4(x), quantize_nvfp4(w)
else:
xq = quantize_nvfp4(x / lam)
wq = quantize_nvfp4(w * lam)
err = (xq @ wq.T) - ref
return (err.norm() / ref.norm().clamp_min(1e-12)).item()
def alpha_beta_pairs(num_grids: int = 20) -> list[tuple[float, float]]:
choices = [i / num_grids for i in range(1, num_grids)]
return [(0.0, 0.0)] + [(a, 0.0) for a in choices] + [(a, 1.0 - a) for a in choices]
def smooth_scale(x_span: torch.Tensor, w_span: torch.Tensor, alpha: float, beta: float) -> torch.Tensor:
if alpha <= 0 and beta <= 0:
return torch.ones_like(x_span)
scale = x_span.clamp_min(1e-8).pow(alpha) if alpha > 0 else torch.ones_like(x_span)
if beta > 0:
scale = scale / w_span.clamp_min(1e-8).pow(beta)
return scale.clamp_min(1e-8)
def fit_low_rank(x: torch.Tensor, w: torch.Tensor, rank: int, num_iters: int,
early_stop: bool = True) -> tuple[torch.Tensor, torch.Tensor, float, int]:
"""Alternate: fit rank-r to the current residual, re-quantize what is left, repeat.
Returns `(l1, l2, best_error, best_iter)` with `w ~= l1 @ l2 + Q(w - l1 @ l2)`.
"""
ref = x @ w.T
ref_norm = ref.norm().clamp_min(1e-12)
best = (None, None, float("inf"), -1)
target = w
for it in range(num_iters):
u, s, vh = torch.linalg.svd(target.float(), full_matrices=False)
l1 = u[:, :rank] * s[:rank]
l2 = vh[:rank]
residual = w - (l1 @ l2)
err = ((quantize_nvfp4(x) @ quantize_nvfp4(residual).T + x @ (l1 @ l2).T) - ref)
rel = (err.norm() / ref_norm).item()
if rel < best[2] - 1e-6:
best = (l1.clone(), l2.clone(), rel, it)
elif early_stop and it - best[3] >= 5:
break
# Next iteration fits the low-rank branch to what quantization could not represent.
target = w - quantize_nvfp4(w - (l1 @ l2))
return best
def main() -> int:
ap = argparse.ArgumentParser(description="Lambda grid search + rank-32 low-rank fit")
ap.add_argument("--stats", required=True)
ap.add_argument("--samples", required=True, help="directory of sample_*.pt shards")
ap.add_argument("--out", required=True)
ap.add_argument("--model-path", default=None)
ap.add_argument("--shard", default="0/1", help="i/n over LAYERS")
ap.add_argument("--rank", type=int, default=32)
ap.add_argument("--num-iters", type=int, default=100)
ap.add_argument("--num-grids", type=int, default=20)
ap.add_argument("--max-rows", type=int, default=8192)
ap.add_argument("--span", default="all", choices=["all", "video", "text", "audio"],
help="which modality's absmax defines lambda. `all` reproduces the first\n calibration; `video` excludes the 1.36%% of rows that are text/audio\n but set 79.8%% of channel spans in block 0. Needs a stats file from\n calib_stats_modal.py for anything but `all`.")
ap.add_argument("--no-smooth", action="store_true",
help="skip the lambda search entirely and fit the low-rank branch to the raw\n weight. Not the same as passing an identity lambda to the existing\n factors: L1/L2 were fitted to W*lambda, so they have to be refitted to\n W or the two halves describe different matrices.")
ap.add_argument("--eval-span", default="video", choices=["all", "video"],
help="which rows the OutputsError sample is drawn from")
ap.add_argument("--device", default="cuda:0")
args = ap.parse_args()
import bench
from h3opt.svdquant_rules import target_linears
device = torch.device(args.device)
stats = torch.load(args.stats, map_location="cpu", weights_only=False)["stats"]
probe = stats[next(iter(stats))]
per_modality = "video" in probe and isinstance(probe.get("video"), dict)
if args.span != "all" and not args.no_smooth and not per_modality:
raise SystemExit(f"--span {args.span} needs per-modality stats (calib_stats_modal.py)")
def span_of(name: str) -> torch.Tensor:
"""The absmax that defines lambda for this layer."""
e = stats[name]
if not per_modality:
return e["absmax"]
if args.span == "all":
return e["all"]["absmax"]
v = e[args.span]["absmax"]
# The token refiner never sees video or audio rows, so a video span there is all zeros;
# fall back to its text span rather than emitting a degenerate lambda.
return v if float(v.max()) > 0 else e["text"]["absmax"]
shard_i, shard_n = (int(x) for x in args.shard.split("/"))
sample_files = sorted(Path(args.samples).glob("sample_*.pt"))
names = sorted(stats)[shard_i::shard_n]
print(f"shard {shard_i}/{shard_n}: {len(names)} layers, {len(sample_files)} sample shards",
flush=True)
# Only the weights are needed here; load on CPU and move one layer at a time.
from diffusers import ModularPipeline
pipe = ModularPipeline.from_pretrained(args.model_path or bench.DEFAULT_MODEL)
pipe.load_components(names=["transformer"], dtype=torch.bfloat16)
# `load_components` may place the denoiser on the accelerator. Only one layer's weight is
# needed on the GPU at a time here, and leaving the other 62 GB resident is what turns a
# comfortable per-layer working set into an OOM two layers in.
pipe.transformer.to("cpu")
weights = {n: m.weight.detach().to("cpu") for n, m in target_linears(pipe.transformer).items()}
del pipe
torch.cuda.empty_cache()
print(f"weights on cpu: {sum(w.numel() for w in weights.values())/1e9:.2f} B params", flush=True)
samples: dict[str, list[torch.Tensor]] = {n: [] for n in names}
for path in sample_files:
blob = torch.load(path, map_location="cpu", weights_only=False)
for n in names:
if n in blob and blob[n].numel():
samples[n].append(blob[n])
pairs = alpha_beta_pairs(args.num_grids)
results = {}
t0 = time.perf_counter()
for k, name in enumerate(names):
x = torch.cat(samples[name])[: args.max_rows].to(device, torch.float32)
w = weights[name].to(device, torch.float32)
w_span = w.abs().amax(dim=0).clamp_min(1e-8)
x_span = span_of(name).to(device).clamp_min(1e-8)
baseline = output_error(x, w, None)
if args.no_smooth:
alpha, beta = 0.0, 0.0
lam = torch.ones_like(x_span)
err_s = baseline
else:
best = (float("inf"), None, None)
for alpha, beta in pairs:
lam = smooth_scale(x_span, w_span, alpha, beta)
err = output_error(x, w, lam)
if err < best[0]:
best = (err, alpha, beta)
err_s, alpha, beta = best
lam = smooth_scale(x_span, w_span, alpha, beta)
l1, l2, err_lr, it = fit_low_rank(x / lam, w * lam, args.rank, args.num_iters)
lam_cpu, l1_cpu, l2_cpu = lam.half().cpu(), l1.half().cpu(), l2.half().cpu()
del l1, l2, lam, x, w, w_span, x_span
torch.cuda.empty_cache()
results[name] = {
"span": args.span,
"alpha": alpha, "beta": beta, "lambda": lam_cpu,
"l1": l1_cpu, "l2": l2_cpu,
"err_no_smooth": baseline, "err_smooth": err_s, "err_smooth_lowrank": err_lr,
"lowrank_iter": it, "rows": args.max_rows,
}
print(f"[{k+1}/{len(names)}] {name} a={alpha:.2f} b={beta:.2f} "
f"err {baseline:.4f} -> {err_s:.4f} -> {err_lr:.4f} (iter {it})", flush=True)
out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
torch.save({"rank": args.rank, "num_grids": args.num_grids, "shard": args.shard,
"wall_s": round(time.perf_counter() - t0, 1), "layers": results}, out)
print(f"wrote {out} in {time.perf_counter()-t0:.0f}s", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())