unsloth/scripts/verify_prequant_backend.py
Daniel Han 8ecfd30923
Studio: whole-model NVFP4 for the video families with hosted pre-quantized denoisers (#10729)
* studio: whole-model NVFP4 for the video families with hosted pre-quantized denoisers

Wan2.2-TI2V-5B, Wan2.2-T2V-A14B (both experts), HunyuanVideo-1.5 480p and 720p run NVFP4 on the
torchao backend from hosted pre-quantized denoisers, so a load never quantises 5 to 28 GB of dense
weights on the fly. Every admitted linear (min 512 features, in and out divisible by 16) is NVFP4;
nothing is per-layer here.

- one shared auto walk with a per-family head in front of it, and divisible_for_scheme
- ZeroRowSafeLinear, a live bug: torchao's nvfp4 activation path raises on HunyuanVideo-1.5's
  empty image stream on every text-to-video render
- loader gates on the packed-weight fingerprint, the component and require_divisible
- conventional video pre-quant seeding, all components or none, with the memory plan taking the
  artifact's resident size rather than the dense build peak
- registry rows for the four hosted NVFP4 repos

nvfp4 stays an explicit opt-in here; no family gets an auto row on this branch.

The tooling that builds and fingerprints these artifacts is not in this repo. It lives in the
private scripts repo with the rest of the quant generation tooling.

* Tighten the comments and docstrings on the video prequant path

Collapse the long single-line comments this branch added to the repo's
wrap width, shorten the prose docstrings to the parts that say why, and
drop the test docstrings that only restate the test name. Comments,
docstrings and whitespace only: no code changes.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Credit the resident video pipeline's memory in the seed plan

* Pin the residency check in the two-expert download plan test

* Credit only the target card's resident bytes and fingerprint built checkpoints

The residency plan counted every accelerator storage of the old pipeline as
reclaimable on the card the new load targets, so switching GPUs could skip the
dense denoiser shards on a card that never held them. Count only the target's.

The builder never wrote metadata["fingerprint"], so the loader's packed-weight
check had nothing to compare against for artifacts it produced.

* Fingerprint torchao 0.18 Int8Tensor prequant weights

torchao >= 0.18 int8 quantization produces Int8Tensor, which the packed
fingerprint payload map did not list, so every int8 weight was skipped
and the recorded fingerprint verified nothing. Hash its qdata, scale and
optional zero point / activation slots, with a stub test and a real
torchao round trip that catches a flipped byte.

* Apply the GEMM alignment floor in the offline prequant builder and record it

The runtime quantize_ skips Linears whose in/out features miss the scheme's
tiling floor, but build_prequant_checkpoint.py did not, so a ragged layer
could be baked as a quantized GEMM the runtime leaves dense. The builder now
passes divisible_for_scheme(scheme) to make_filter_fn and stamps
require_divisible so the loader can verify it.

* Trim comments

* Studio: NVFP4 kill switch for image and video, default off

The hosted unsloth/*-NVFP4 checkpoints are still private, so NVFP4 is
disabled in general until UNSLOTH_NVFP4_DIFFUSION is set to a truthy
value (1, true, yes, on). The default lives in one constant,
NVFP4_DIFFUSION_DEFAULT in core/inference/diffusion_nvfp4_flag.py, so
turning it on for everyone later is a one-line change. With the switch
on, behaviour is unchanged.

While it is off:
- auto never picks nvfp4 for the dense transformer (ladder and family
  prefer rows), and no smoke probe is spent on it;
- an explicit transformer_quant or text_encoder_quant of nvfp4 gets a
  400 "NVFP4 is disabled in this build" from every image and video
  load and download-plan route, ahead of the precision fallback;
- no hosted NVFP4 denoiser or encoder is resolved, seeded, planned or
  sized, and the Hub helpers never ask about a *-NVFP4 repo;
- /images/info estimates and the /api/system dense ladder omit nvfp4,
  and /api/system reports nvfp4_diffusion so the Precision and Text
  encoder selects hide NVFP4.

The offline builder does not consult the switch. Existing NVFP4 test
modules run with it on through a conftest fixture; the default-off
behaviour, and that the enabled path is still reachable end to end,
are covered in test_nvfp4_diffusion_flag.py.

* Studio: keep the diffusion bench NVFP4-capable under the kill switch

An explicit --transformer-quant or --text-encoder-quant nvfp4 turns the
switch on for the bench itself, like the other developer tools. The
switch tests ask auto the way a load does, with a hosted checkpoint
where the selector requires one, so the enabled-path check stays
meaningful on the branches that add that requirement.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: reset a hidden NVFP4 pick to Auto when the backend switch is off

A Precision or Text encoder select reseeded to nvfp4 by an earlier load kept
that value after a restart turned UNSLOTH_NVFP4_DIFFUSION off: the option was
filtered out, so the select rendered blank and the next load or Reapply sent
nvfp4 and got a 400. Once /api/system reports the switch off, a held nvfp4
now falls back to auto on the image and video pages. Nothing is reset before
system info arrives, since the switch reads off by default until then.

* Studio: keep the hosted video denoiser seed for an explicit scheme under Speed off

Only an auto precision is rewritten to off under Speed off; an explicit
scheme is still quantized (and upgrades the speed), so suppressing the seed
staged the dense denoiser for a runtime quantize. Matches diffusion.py.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: update the Speed off video plan test for the kept explicit seed

* Studio: refuse an NVFP4 checkpoint loaded directly as the model while NVFP4 is off

With UNSLOTH_NVFP4_DIFFUSION off, a hosted NVFP4 repo (or any repo or local
path whose pre-quant metadata records scheme nvfp4) picked as the model
itself named no precision, so none of the scheme gates saw it. The image and
video load and download-plan routes now 400 such a pick with the same
disabled message style. Detection reads the recorded scheme of a local file,
a local directory or a cached Hub snapshot, then the family NVFP4 repo tables,
then the -NVFP4 name. Switched on, the pick proceeds as before.

* Studio: format the NVFP4 checkpoint refusal tests

* Studio: skip pipeline roots and the thread hop in the NVFP4 checkpoint refusal

A diffusers pipeline loads from its component folders, so a stray root
artifact no longer makes its directory read as an NVFP4 checkpoint. With the
switch on the route helper returns before handing anything to a thread.

* Studio: stamp the built denoiser component and drop an unused metadata reader

* Studio: keep the NVFP4 flag and speed-off video plan tests off the runner's own GPU

* Studio: trim comments in the NVFP4 video layer

* Studio: check the hosted video seed rows before spending a quant smoke probe

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: let the prequant builder target the second Wan A14B expert

Add --component (default transformer) for the subfolder, metadata and upload name,
and resolve video families so the Wan and HunyuanVideo denoisers can be built.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-09-25 16:22:17 -07:00

196 lines
7.2 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""GPU verification of the Phase 9 pre-quantized load path through the real backend code.
Exercises the actual product functions (``load_prequantized_transformer`` and the runtime
``quantize_transformer``), not a reimplementation:
prequant -- load the checkpoint built by build_prequant_checkpoint.py via the real
``load_prequantized_transformer`` (meta-init + assign), measure GPU load peak,
generate.
runtime -- the existing path: from_pretrained dense bf16 -> ``quantize_transformer`` on
device, measure GPU load peak, generate (the LPIPS reference).
Asserts the prequant load peak is far below the dense one and the images match (LPIPS ~0).
Run each mode in its own process for a clean peak. One CUDA GPU."""
from __future__ import annotations
import argparse
import logging
import os
import sys
import time
from pathlib import Path
import numpy as np
_REPO = Path(__file__).resolve().parent.parent
_RESEARCH = _REPO / "outputs" / "quant_research"
BACKEND = _REPO / "studio" / "backend"
BASE = "Tongyi-MAI/Z-Image-Turbo"
CKPT = os.environ.get("PREQUANT_CKPT", str(_RESEARCH / "prequant_fp8" / "transformer_fp8.pt"))
PROMPT = "A cinematic photograph of a red fox in a snowy forest at dawn, highly detailed"
OUT = Path(os.environ.get("PREQUANT_OUT_DIR", str(_RESEARCH / "prequant_verify_images")))
logging.basicConfig(level = logging.INFO, format = "%(message)s")
LOGGER = logging.getLogger("verify_prequant")
# Prequant must match runtime numerics (LPIPS) and beat the dense load peak by this fraction.
LPIPS_MAX = 0.02
PREQUANT_PEAK_MAX_FRACTION = 0.75
_RUNTIME_PEAK_FILE = OUT / "runtime_peak.txt"
def _target(dtype):
import types
return types.SimpleNamespace(device = "cuda", dtype = dtype)
def _gen(pipe, steps, seed, res):
import torch
g = torch.Generator(device = "cuda").manual_seed(seed)
torch.cuda.synchronize()
t0 = time.time()
img = pipe(
prompt = PROMPT,
width = res,
height = res,
num_inference_steps = steps,
guidance_scale = 0.0,
generator = g,
).images[0]
torch.cuda.synchronize()
return img, time.time() - t0
def _lpips(ref, arr):
try:
import lpips, torch
fn = lpips.LPIPS(net = "alex", verbose = False).cuda().eval()
def t(x):
return (torch.from_numpy(x).float().permute(2, 0, 1).unsqueeze(0) / 127.5 - 1.0).cuda()
with torch.no_grad():
return float(fn(t(ref), t(arr)).item())
except Exception as exc: # noqa: BLE001
print(f" (lpips: {type(exc).__name__})", flush = True)
return None
def run(mode, steps, seed, res):
sys.path.insert(0, str(BACKEND))
os.environ["UNSLOTH_NVFP4_DIFFUSION"] = "1"
import torch
import diffusers
from core.inference.diffusion_prequant import (
ALLOW_LOCAL_PREQUANT_PATH_ENV,
PrequantSource,
load_prequantized_transformer,
)
from core.inference.diffusion_transformer_quant import quantize_transformer
OUT.mkdir(parents = True, exist_ok = True)
transformer_cls = diffusers.ZImageTransformer2DModel
torch.cuda.reset_peak_memory_stats()
torch.cuda.empty_cache()
if mode == "prequant":
# Local checkpoints are refused unless allowlisted;
# CKPT is operator-supplied and trusted.
ckpt_dir = os.path.dirname(os.path.realpath(CKPT))
existing = os.environ.get(ALLOW_LOCAL_PREQUANT_PATH_ENV, "")
os.environ[ALLOW_LOCAL_PREQUANT_PATH_ENV] = (
ckpt_dir if not existing else existing + os.pathsep + ckpt_dir
)
source = PrequantSource(kind = "path", location = CKPT, filename = None)
transformer = load_prequantized_transformer(
transformer_cls,
BASE,
source,
device = "cuda",
dtype = torch.bfloat16,
hf_token = None,
scheme = "fp8",
logger = LOGGER,
)
if transformer is None:
print("prequant load FAILED (returned None)", flush = True)
return 1
pipe = diffusers.ZImagePipeline.from_pretrained(
BASE, torch_dtype = torch.bfloat16, transformer = transformer
)
pipe.to("cuda")
load_peak = torch.cuda.max_memory_allocated() / 1e9
marker = getattr(transformer, "_unsloth_runtime_quant", None)
print(f"[prequant] load_gpu_peak={load_peak:.1f} GB marker={marker}", flush = True)
else: # runtime
transformer = transformer_cls.from_pretrained(
BASE, subfolder = "transformer", torch_dtype = torch.bfloat16
).to("cuda")
pipe = diffusers.ZImagePipeline.from_pretrained(
BASE, torch_dtype = torch.bfloat16, transformer = transformer
)
pipe.to("cuda")
scheme = quantize_transformer(pipe, _target(torch.bfloat16), mode = "fp8", logger = LOGGER)
load_peak = torch.cuda.max_memory_allocated() / 1e9
print(f"[runtime] engaged={scheme} load_gpu_peak={load_peak:.1f} GB", flush = True)
# Persist the dense reference peak so a later prequant run can enforce its VRAM win.
_RUNTIME_PEAK_FILE.write_text(f"{load_peak:.6f}")
img, dt = _gen(pipe, steps, seed, res)
img, dt = _gen(pipe, steps, seed, res)
img.save(OUT / f"{mode}.png")
print(f"[{mode}] gen={dt:.3f}s saved {mode}.png", flush = True)
if mode != "prequant":
return 0
# Enforce both invariants so a broken checkpoint fails loudly instead of merely completing.
ref_path = OUT / "runtime.png"
if not ref_path.exists():
print("FAIL: runtime reference image missing; run --mode runtime first", flush = True)
return 1
from PIL import Image
lp = _lpips(np.array(Image.open(ref_path).convert("RGB")), np.array(img))
print(f"[prequant] LPIPS_vs_runtime={lp}", flush = True)
if lp is None:
print("FAIL: LPIPS could not be computed (install lpips)", flush = True)
return 1
if lp > LPIPS_MAX:
print(f"FAIL: LPIPS {lp:.4f} > {LPIPS_MAX} (prequant diverged from runtime)", flush = True)
return 1
if _RUNTIME_PEAK_FILE.exists():
try:
runtime_peak = float(_RUNTIME_PEAK_FILE.read_text().strip())
except ValueError:
runtime_peak = 0.0
if runtime_peak > 0.0 and load_peak > runtime_peak * PREQUANT_PEAK_MAX_FRACTION:
print(
f"FAIL: prequant load peak {load_peak:.1f} GB not below "
f"{PREQUANT_PEAK_MAX_FRACTION:.0%} of dense {runtime_peak:.1f} GB",
flush = True,
)
return 1
return 0
def main(argv = None) -> int:
p = argparse.ArgumentParser()
p.add_argument("--mode", choices = ["prequant", "runtime"], required = True)
p.add_argument("--steps", type = int, default = 8)
p.add_argument("--res", type = int, default = 1024)
p.add_argument("--seed", type = int, default = 42)
args = p.parse_args(argv)
rc = run(args.mode, args.steps, args.seed, args.res)
print("VERIFY-PREQUANT-DONE", flush = True)
return rc
if __name__ == "__main__":
sys.exit(main())