unsloth/scripts/quant_accuracy_sweep.py
2026-07-12 10:59:41 +00:00

659 lines
26 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
"""Decoded-image accuracy sweep for the auto VAE (and end-to-end) quantisation.
The VAE turns the DiT latent into RGB, so a coarse fp8 grid on its convs can BAND the
output. Loads a family's VAE, decodes a FIXED seeded latent set through the dense bf16 VAE
(reference) and through the same VAE quantised by the repo's own ``quantize_vae``, then
reports decoded-image LPIPS(AlexNet) / PSNR / SSIM. A (family, scheme) over the bar
(LPIPS <= 0.05, SSIM >= 0.95) belongs in ``_VAE_FAMILY_SCHEME_DENY``.
``--mode e2e`` instead runs a full pipeline dense-bf16 vs everything-auto (transformer +
text encoder + VAE) and reports mean LPIPS, the "nearly indistinguishable" (~0.1) check.
The LPIPS net is kept off the measured GPU. torch / torchao / diffusers / lpips imported
lazily so ``--help`` works without them.
Examples:
python scripts/quant_accuracy_sweep.py --family sdxl flux.1 qwen-image
python scripts/quant_accuracy_sweep.py --family ltx-2 --latent-t 3 --latent-hw 32
python scripts/quant_accuracy_sweep.py --mode e2e --family flux.1 --e2e-model \\
black-forest-labs/FLUX.1-schnell
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from pathlib import Path
from typing import Any, Optional
# ── env: the ancient bitsandbytes in this venv cannot build for CUDA 13 and hard-raises when
# We never use bnb here (quant is torchao / layerwise), so mark it unavailable BEFORE any VAE
# import and silence the welcome.
os.environ.setdefault("BITSANDBYTES_NOWELCOME", "1")
_REPO_ROOT = Path(__file__).resolve().parent.parent
_BACKEND_ROOT = _REPO_ROOT / "studio" / "backend"
for _p in (str(_BACKEND_ROOT), str(_REPO_ROOT / "scripts")):
if _p not in sys.path:
sys.path.insert(0, _p)
# ── VAE-only accuracy bars (decoder is the most sensitive stage) ──────────────
LPIPS_BAR = 0.05
SSIM_BAR = 0.95
# End-to-end composed-defaults bar (PyTorch blog "nearly indistinguishable").
E2E_LPIPS_BAR = 0.10
# family -> the diffusers base repo whose ``vae`` subfolder we decode with (only the VAE
# subfolder is fetched; the class is resolved from its config by AutoModel).
_VAE_FAMILIES: dict[str, dict[str, Any]] = {
"sdxl": {"repo": "stabilityai/stable-diffusion-xl-base-1.0"},
"flux.1": {"repo": "black-forest-labs/FLUX.1-schnell"},
"qwen-image": {"repo": "Qwen/Qwen-Image"},
"flux.2-klein": {"repo": "black-forest-labs/FLUX.2-klein-4B"},
"ltx-2": {"repo": "Lightricks/LTX-2"},
"hunyuanvideo-1.5": {"repo": "hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v"},
}
def _check_deps() -> None:
import importlib.util as ilu
missing = [
m
for m in ("torch", "torchao", "diffusers", "lpips", "numpy", "PIL")
if not ilu.find_spec(m)
]
if missing:
print(
"missing deps: " + ", ".join(missing) + "\n"
" uv pip install torch torchao diffusers lpips numpy pillow",
file = sys.stderr,
flush = True,
)
raise SystemExit(2)
def _import_diffusers():
"""Import diffusers with the bnb quantiser disabled (see env note at top)."""
import torch # noqa: F401 (torch/torchao first so their extensions register)
import torchao # noqa: F401
import diffusers.utils.import_utils as iu
iu._bitsandbytes_available = False
import diffusers
return diffusers
# ── VAE loading + latent shape introspection ─────────────────────────────────
def _load_vae(repo: str, subfolder: str, device: str):
import torch
diffusers = _import_diffusers()
vae = diffusers.AutoModel.from_pretrained(repo, subfolder = subfolder, torch_dtype = torch.bfloat16)
vae = vae.to(device).eval()
return vae
def _first_decoder_conv(vae: Any):
"""Return the decoder's input conv (its in_channels == latent channels, its ndim tells
2D vs 3D). Falls back to the first conv anywhere."""
from torch import nn
dec = getattr(vae, "decoder", None)
for mod in (dec, vae):
if mod is None:
continue
for m in mod.modules():
if isinstance(m, (nn.Conv2d, nn.Conv3d)):
return m
return None
def _latent_spec(vae: Any) -> tuple[int, bool]:
"""(latent_channels, is_3d) for a VAE, from its decoder input conv (robust across
AutoencoderKL / QwenImage / Flux2 / LTX2 / HunyuanVideo naming)."""
from torch import nn
conv = _first_decoder_conv(vae)
is_3d = isinstance(conv, nn.Conv3d)
channels = None
for key in ("latent_channels", "z_dim", "in_channels"):
v = getattr(getattr(vae, "config", object()), key, None)
if isinstance(v, int):
channels = v
break
if conv is not None:
channels = conv.in_channels # authoritative: what decode actually consumes
return int(channels), bool(is_3d)
def _ref_images(args: argparse.Namespace, size: int) -> list:
"""Natural reference photos (resized to size x size) for the encode round-trip."""
import glob
import numpy as np
from PIL import Image
ref_dir = Path(args.ref_image_dir)
files = sorted(glob.glob(str(ref_dir / "*.jpg")) + glob.glob(str(ref_dir / "*.png")))[
: args.num_samples
]
imgs = []
for f in files:
im = Image.open(f).convert("RGB").resize((size, size), Image.BICUBIC)
imgs.append(np.asarray(im, dtype = np.uint8))
return imgs
def _encode_latent(vae: Any, x: Any):
"""Encode a preprocessed pixel tensor to a deterministic latent (mode of the posterior
when the VAE exposes one), robust across AutoencoderKL / QwenImage / Flux2 / LTX2 / HV15."""
import torch
with torch.no_grad():
enc = vae.encode(x)
dist = getattr(enc, "latent_dist", None)
if dist is not None:
return dist.mode() if hasattr(dist, "mode") else dist.sample()
if hasattr(enc, "latent"):
return enc.latent
if isinstance(enc, (tuple, list)):
first = enc[0]
return first.mode() if hasattr(first, "mode") else first
return enc
def _make_latents(vae: Any, args: argparse.Namespace, device: str):
"""A fixed latent batch at the family's latent shape (one per sample). Default: encode
natural photos through the dense VAE (in-distribution, natural decoded content -- the
regime the LPIPS/SSIM bars are calibrated for). ``--latents random`` uses seeded N(0,1)."""
import torch
channels, is_3d = _latent_spec(vae)
lat = []
if args.latents == "encode":
size = args.enc_hw_3d if is_3d else args.enc_hw
imgs = _ref_images(args, size)
for arr in imgs:
x = torch.from_numpy(arr).float().permute(2, 0, 1).unsqueeze(0).div(127.5).sub(1.0)
if is_3d:
x = x.unsqueeze(2).repeat(1, 1, args.enc_frames, 1, 1) # static clip [1,3,T,H,W]
x = x.to(device = device, dtype = torch.bfloat16)
lat.append(_encode_latent(vae, x))
if lat:
return lat, is_3d
print(" (no ref images found; falling back to random latents)", flush = True)
for seed in range(args.num_samples):
g = torch.Generator().manual_seed(1000 + seed)
shape = (
(1, channels, args.latent_t, args.latent_hw_3d, args.latent_hw_3d)
if is_3d
else (1, channels, args.latent_hw, args.latent_hw)
)
z = torch.randn(shape, generator = g, dtype = torch.float32)
lat.append(z.to(device = device, dtype = torch.bfloat16))
return lat, is_3d
def _decode(vae: Any, z: Any):
"""Decode one latent, returning a list of HxWx3 uint8 numpy frames (>1 for a video VAE)."""
import numpy as np
import torch
with torch.no_grad():
try:
out = vae.decode(z)
except TypeError:
out = vae.decode(z, return_dict = True)
sample = out.sample if hasattr(out, "sample") else out[0]
sample = sample.float().clamp(-1, 1)
# [B,C,H,W] (image) or [B,C,T,H,W] (video). Emit one frame per temporal slot.
frames = []
if sample.dim() == 5:
b, c, t, h, w = sample.shape
for ti in range(t):
frames.append(sample[0, :, ti])
else:
frames.append(sample[0])
imgs = []
for f in frames:
arr = (
((f.permute(1, 2, 0).cpu().numpy() + 1.0) * 127.5).round().clip(0, 255).astype(np.uint8)
)
if arr.shape[2] == 1:
arr = np.repeat(arr, 3, axis = 2)
imgs.append(arr)
return imgs # list of HxWx3 uint8
# ── metrics ──────────────────────────────────────────────────────────────────
class _Lpips:
"""AlexNet LPIPS kept off the measured GPU. Inputs are HxWx3 uint8 arrays mapped to [-1,1]."""
def __init__(self, device: str = "cpu") -> None:
import lpips
import torch
self.torch = torch
self.device = device
self.fn = lpips.LPIPS(net = "alex", verbose = False).to(device).eval()
def __call__(self, a: Any, b: Any) -> float:
t = self.torch
def to_t(x):
return (
t.from_numpy(x)
.float()
.permute(2, 0, 1)
.unsqueeze(0)
.div(127.5)
.sub(1.0)
.to(self.device)
)
with t.no_grad():
return float(self.fn(to_t(a), to_t(b)).item())
def _metrics(ref_frames: list, q_frames: list, lp: "_Lpips") -> dict[str, float]:
from diffusion_quality import psnr, ssim # pure-numpy PSNR/SSIM
from PIL import Image
ls, ps, ss = [], [], []
for a, b in zip(ref_frames, q_frames):
ls.append(lp(a, b))
ps.append(psnr(Image.fromarray(a), Image.fromarray(b)))
ss.append(ssim(Image.fromarray(a), Image.fromarray(b)))
def _m(xs):
fin = [x for x in xs if x != float("inf")]
base = fin if fin else xs
return round(sum(base) / len(base), 4) if base else None
return {"lpips": _m(ls), "psnr": _m(ps), "ssim": _m(ss)}
# ── VAE isolation sweep ──────────────────────────────────────────────────────
def _apply_fp8_dynamic_no1x1(vae_q: Any) -> None:
"""Diagnostic caster: the shipped PerTensor fp8_dynamic config, but the conv filter
ALSO excludes pointwise (1x1 / 1x1x1) convs. torchao 0.17's f8f8bf16_conv kernel
rejects pointwise convs ("Activation and filter channels must match"), so the shipped
fp8_dynamic caster crashes at decode on any VAE that has a 1x1 conv with %16 channels.
This variant isolates the fp8_dynamic MATH accuracy on the convs that DO run, to show
whether excluding 1x1 (a recommended caster fix) keeps fp8_dynamic in-bar."""
from torch import nn
from torchao.quantization import Float8DynamicActivationFloat8WeightConfig, PerTensor, quantize_
from core.inference.diffusion_vae_quant import _VAE_KEEP_DENSE_TOKENS
def filter_fn(module: Any, fqn: str = "") -> bool:
if not isinstance(module, (nn.Linear, nn.Conv2d, nn.Conv3d)):
return False
w = getattr(module, "weight", None)
if w is None or w.dim() < 2 or w.shape[0] % 16 or w.shape[1] % 16:
return False
ks = getattr(module, "kernel_size", None)
if isinstance(ks, tuple) and all(
k == 1 for k in ks
): # pointwise conv -> torchao kernel fails
return False
name = fqn.lower() if fqn else ""
return not any(tok in name for tok in _VAE_KEEP_DENSE_TOKENS)
quantize_(
vae_q,
Float8DynamicActivationFloat8WeightConfig(granularity = PerTensor()),
filter_fn = filter_fn,
)
def _sweep_vae(args: argparse.Namespace, lp: "_Lpips", out_dir: Path) -> list[dict]:
import copy
import numpy as np
from PIL import Image
from core.inference import diffusion_vae_quant as vq
class _Target:
def __init__(self):
import torch
self.device = "cuda"
self.dtype = torch.bfloat16
target = _Target()
rows: list[dict] = []
for family in args.family:
repo = _VAE_FAMILIES[family]["repo"]
print(f"\n=== VAE {family} ({repo}) ===", flush = True)
t0 = time.time()
try:
vae = _load_vae(repo, "vae", "cuda")
except Exception as exc: # noqa: BLE001
print(f" load FAILED: {type(exc).__name__}: {str(exc)[:200]}", flush = True)
rows.append({"family": family, "scheme": "-", "error": f"load: {exc}"})
continue
ch, is_3d = _latent_spec(vae)
cls = type(vae).__name__
print(
f" {cls} latent_ch={ch} {'3D' if is_3d else '2D'} loaded {time.time()-t0:.0f}s",
flush = True,
)
latents, _ = _make_latents(vae, args, "cuda")
ref_by_sample = [_decode(vae, z) for z in latents]
fam_dir = out_dir / family
fam_dir.mkdir(parents = True, exist_ok = True)
Image.fromarray(ref_by_sample[0][0]).save(fam_dir / "dense_s0.png")
for scheme in args.scheme:
try:
vae_q = copy.deepcopy(vae)
except Exception as exc: # noqa: BLE001
print(f" [{scheme}] deepcopy FAILED: {exc}", flush = True)
continue
pipe = type("P", (), {"vae": vae_q})()
# "fp8_dynamic_no1x1" is a diagnostic that bypasses quantize_vae to apply the
# PerTensor fp8 config with pointwise convs excluded (torchao 0.17 kernel gap).
if scheme == "fp8_dynamic_no1x1":
try:
_apply_fp8_dynamic_no1x1(vae_q)
engaged = scheme
except Exception as exc: # noqa: BLE001
print(f" [{scheme}] apply FAILED: {str(exc)[:120]}", flush = True)
del vae_q
_empty_cache()
continue
else:
engaged = vq.quantize_vae(
pipe, target, mode = scheme, family = family, offload_active = False, force_fp32 = False
)
if engaged != scheme:
print(f" [{scheme}] NOT engaged (returned {engaged}); skipping", flush = True)
rows.append(
{"family": family, "vae_class": cls, "scheme": scheme, "verdict": "NOT_ENGAGED"}
)
del vae_q
_empty_cache()
continue
try:
q_by_sample = [_decode(vae_q, z) for z in latents]
except Exception as exc: # noqa: BLE001 — the shipped caster produced a VAE that crashes at decode
emsg = f"{type(exc).__name__}: {str(exc)[:100]}"
print(f" [{scheme}] DECODE CRASH: {emsg}", flush = True)
rows.append(
{
"family": family,
"vae_class": cls,
"scheme": scheme,
"verdict": "CRASH",
"error": emsg,
}
)
del vae_q
_empty_cache()
continue
all_ref = [f for frames in ref_by_sample for f in frames]
all_q = [f for frames in q_by_sample for f in frames]
m = _metrics(all_ref, all_q, lp)
Image.fromarray(q_by_sample[0][0]).save(fam_dir / f"{scheme}_s0.png")
lp_pass = m["lpips"] is not None and m["lpips"] <= LPIPS_BAR
ss_pass = m["ssim"] is not None and m["ssim"] >= SSIM_BAR
verdict = "PASS" if (lp_pass and ss_pass) else "FAIL"
row = {
"family": family,
"vae_class": cls,
"scheme": scheme,
"n_frames": len(all_ref),
**m,
"verdict": verdict,
}
rows.append(row)
print(
f" [{scheme}] LPIPS={m['lpips']} PSNR={m['psnr']} SSIM={m['ssim']} -> {verdict}",
flush = True,
)
del vae_q
_empty_cache()
del vae
_empty_cache()
return rows
def _empty_cache() -> None:
try:
import torch
torch.cuda.empty_cache()
except Exception:
pass
# ── end-to-end (dense bf16 vs everything-auto) ───────────────────────────────
_E2E_PROMPTS = [
"A cozy reading nook by a rain-streaked window, warm lamplight, a cat asleep on a stack of books",
"A lone lighthouse on a rocky cliff at sunset, dramatic clouds, crashing waves, highly detailed",
"A bustling night market street in the rain, neon signs reflected in puddles, cinematic",
"A close-up portrait of an elderly fisherman, weathered skin, soft window light, film grain",
"A red fox trotting through a snowy pine forest at dawn, volumetric light",
"A steaming bowl of ramen on a wooden table, chopsticks, shallow depth of field",
]
def _apply_auto(pipe: Any, family: str, components: list[str]) -> dict[str, Optional[str]]:
"""Apply the shipped auto stack in place for the selected components (subset of
{transformer, text_encoder, vae}), so the VAE's end-to-end contribution can be isolated."""
import torch
from core.inference import diffusion_precision as dp
from core.inference import diffusion_transformer_quant as tq
from core.inference import diffusion_vae_quant as vq
class _Target:
device = "cuda"
dtype = torch.bfloat16
tgt = _Target()
engaged: dict[str, Optional[str]] = {}
if "transformer" in components:
engaged["transformer"] = tq.quantize_transformer(pipe, tgt, mode = "auto", family = family)
if "text_encoder" in components:
try:
engaged["text_encoder"] = dp.quantize_text_encoders(
pipe, tgt, mode = "auto", family = family
)
except Exception as exc: # noqa: BLE001
engaged["text_encoder"] = f"err:{type(exc).__name__}"
if "vae" in components:
engaged["vae"] = vq.quantize_vae(pipe, tgt, mode = "auto", family = family)
return engaged
def _sweep_e2e(args: argparse.Namespace, lp: "_Lpips", out_dir: Path) -> list[dict]:
import torch
diffusers = _import_diffusers()
rows: list[dict] = []
for family in args.family:
model = args.e2e_model or _VAE_FAMILIES.get(family, {}).get("repo")
print(f"\n=== E2E {family} ({model}) ===", flush = True)
prompts = args.prompts or _E2E_PROMPTS
seeds = args.seeds
def _gen(pipe):
imgs = []
for pi, prompt in enumerate(prompts):
for seed in seeds:
g = torch.Generator(device = "cuda").manual_seed(seed)
kw = dict(
prompt = prompt,
num_inference_steps = args.steps,
generator = g,
height = args.height,
width = args.width,
)
if args.guidance is not None:
kw["guidance_scale"] = args.guidance
out = pipe(**kw)
imgs.append((pi, seed, out.images[0]))
return imgs
try:
pipe = diffusers.AutoPipelineForText2Image.from_pretrained(
model, torch_dtype = torch.bfloat16
).to("cuda")
except Exception as exc: # noqa: BLE001
print(f" pipe load FAILED: {type(exc).__name__}: {str(exc)[:200]}", flush = True)
rows.append({"family": family, "error": f"load: {exc}"})
continue
ref = _gen(pipe)
del pipe
_empty_cache()
pipe2 = diffusers.AutoPipelineForText2Image.from_pretrained(
model, torch_dtype = torch.bfloat16
).to("cuda")
engaged = _apply_auto(pipe2, family, args.e2e_components)
print(f" engaged: {engaged}", flush = True)
q = _gen(pipe2)
del pipe2
_empty_cache()
import numpy as np
ls = []
fam_dir = out_dir / f"e2e_{family}"
fam_dir.mkdir(parents = True, exist_ok = True)
for (pi, seed, a), (_, _, b) in zip(ref, q):
aa, bb = np.asarray(a.convert("RGB")), np.asarray(b.convert("RGB"))
ls.append(lp(aa, bb))
a.save(fam_dir / f"dense_p{pi}_s{seed}.png")
b.save(fam_dir / f"auto_p{pi}_s{seed}.png")
mean_l = round(sum(ls) / len(ls), 4) if ls else None
verdict = "PASS" if (mean_l is not None and mean_l <= E2E_LPIPS_BAR) else "FAIL"
rows.append(
{"family": family, "engaged": engaged, "mean_lpips": mean_l, "verdict": verdict}
)
print(f" mean LPIPS={mean_l} -> {verdict}", flush = True)
return rows
# ── output ───────────────────────────────────────────────────────────────────
def _write(out_dir: Path, mode: str, rows: list[dict]) -> None:
(out_dir / f"{mode}_results.json").write_text(json.dumps(rows, indent = 2))
print(f"\nwrote {out_dir / f'{mode}_results.json'}", flush = True)
print(f"\n=== {mode.upper()} RESULTS ===", flush = True)
if mode == "vae":
print(f" bars: LPIPS <= {LPIPS_BAR}, SSIM >= {SSIM_BAR}", flush = True)
print(
f" {'family':<20}{'scheme':<14}{'LPIPS':>9}{'PSNR':>9}{'SSIM':>9} verdict", flush = True
)
for r in rows:
if "error" in r:
print(f" {r['family']:<20}{'(error)':<14} {r['error'][:60]}", flush = True)
continue
print(
f" {r['family']:<20}{r['scheme']:<14}{_f(r.get('lpips')):>9}"
f"{_f(r.get('psnr')):>9}{_f(r.get('ssim')):>9} {r.get('verdict')}",
flush = True,
)
else:
print(f" bar: mean LPIPS <= {E2E_LPIPS_BAR}", flush = True)
for r in rows:
if "error" in r:
print(f" {r['family']}: (error) {r['error'][:80]}", flush = True)
continue
print(
f" {r['family']:<20} mean_lpips={r.get('mean_lpips')} {r.get('verdict')} {r.get('engaged')}",
flush = True,
)
def _f(v: Any) -> str:
return f"{v:.4f}" if isinstance(v, (int, float)) else "-"
# ── cli ──────────────────────────────────────────────────────────────────────
def _build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
description = "Decoded-image accuracy sweep for the auto VAE / end-to-end quantisation.",
formatter_class = argparse.ArgumentDefaultsHelpFormatter,
)
p.add_argument("--mode", choices = ["vae", "e2e"], default = "vae")
p.add_argument("--family", nargs = "+", default = list(_VAE_FAMILIES.keys()))
p.add_argument("--scheme", nargs = "+", default = ["fp8_dynamic", "fp8_dynamic_no1x1", "fp8"])
p.add_argument("--num-samples", type = int, default = 5, help = "latents to average over")
p.add_argument(
"--latents",
choices = ["encode", "random"],
default = "encode",
help = "encode natural photos (in-distribution) or seeded N(0,1) latents",
)
p.add_argument(
"--ref-image-dir",
default = "outputs/quant_accuracy/_refs",
help = "natural photos to encode for the round-trip",
)
p.add_argument("--enc-hw", type = int, default = 512, help = "2D encode pixel H=W")
p.add_argument("--enc-hw-3d", type = int, default = 256, help = "3D encode pixel H=W")
p.add_argument("--enc-frames", type = int, default = 9, help = "3D encode pixel frame count")
p.add_argument("--latent-hw", type = int, default = 64, help = "2D random-latent H=W (x8 -> 512px)")
p.add_argument("--latent-hw-3d", type = int, default = 32, help = "3D random-latent H=W")
p.add_argument("--latent-t", type = int, default = 3, help = "3D random-latent temporal length")
p.add_argument(
"--lpips-device", default = "cpu", help = "device for the LPIPS net (keep off the measured GPU)"
)
p.add_argument("--out-dir", default = "outputs/quant_accuracy")
# e2e-only
p.add_argument("--e2e-model", default = None, help = "full model repo for --mode e2e")
p.add_argument(
"--e2e-components",
nargs = "+",
default = ["transformer", "text_encoder", "vae"],
choices = ["transformer", "text_encoder", "vae"],
help = "which components to auto-quantise for the e2e (isolate the VAE with: --e2e-components vae)",
)
p.add_argument("--prompts", nargs = "*", default = None)
p.add_argument("--seeds", nargs = "*", type = int, default = [12345])
p.add_argument("--steps", type = int, default = 8)
p.add_argument("--guidance", type = float, default = None)
p.add_argument("--height", type = int, default = 1024)
p.add_argument("--width", type = int, default = 1024)
return p
def main(argv: Optional[list[str]] = None) -> int:
args = _build_parser().parse_args(argv)
_check_deps()
out_dir = Path(args.out_dir).resolve()
out_dir.mkdir(parents = True, exist_ok = True)
lp = _Lpips(args.lpips_device)
if args.mode == "vae":
rows = _sweep_vae(args, lp, out_dir)
else:
rows = _sweep_e2e(args, lp, out_dir)
_write(out_dir, args.mode, rows)
return 0
if __name__ == "__main__":
raise SystemExit(main())