unsloth/scripts/diffusion_bench.py
Daniel Han 96940d87b7
Studio diffusion (Phase 4): native stable-diffusion.cpp engine for CPU/Mac (#6679)
* Studio diffusion: cross-platform device policy, fp16 guard, lock split, validate-before-evict

Phase 1 of porting the richer diffusion stack onto the image-generation backend.

- Add a compartmentalized device/dtype policy module (diffusion_device.py)
  resolving CUDA/ROCm/XPU/MPS/CPU with capability flags. Keeps the NVIDIA
  capability-based bf16 choice; ROCm and XPU are isolated; MPS uses bf16 or
  fp32, never a silent fp16 that renders a black image.
- Add a per-family fp16_incompatible flag (Z-Image) and promote a resolved
  float16 to float32 for those families so they do not produce black images.
- Split the backend locks: a generation holds only _generate_lock, so status,
  unload, and a new load are never blocked by a long denoise. Add per-generation
  cancellation via callback_on_step_end so an eviction or a superseding load
  preempts a running generation; a replacement load waits for it to stop before
  allocating, so two pipelines never sit in VRAM at once.
- Validate a load request before the GPU handoff so an unloadable pick never
  evicts a working chat model, and reject missing local paths up front.
- Add CPU-only tests for the device policy, dtype guard, lock split and
  cancellation, and validate-before-evict, plus a GPU benchmark/regression
  script (scripts/diffusion_bench.py) measuring latency, peak VRAM, and PSNR
  against a saved reference.

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

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

* Studio diffusion (Phase 2A): measured-budget memory planner + offload/VAE policy

Add a lean, backend-agnostic memory policy that picks a CPU-offload policy and
VAE tiling/slicing from measured free device memory vs the model's estimated
resident footprint, then applies it to the built pipeline. auto stays resident
when the model fits (byte-identical to the prior resident path), and falls to
whole-module offload when tight; fast/balanced/low_vram are explicit overrides.
Sequential submodule offload is unreliable for GGUF transformers on diffusers
0.38, so it falls back to whole-module offload and status reports the policy
actually engaged.

Verified on Z-Image-Turbo Q4_K_M (B200): auto reproduces the resident image with
no VRAM/latency regression (PSNR inf); balanced/low_vram cut generation peak VRAM
47.9% (15951 -> 8318 MB) with byte-identical output, at the expected latency cost.

73 prior + 35 new CPU tests pass.

* Studio diffusion (Phase 2D): streamed block-level offload + functional VAE tiling

Add a streamed 'group' offload tier (diffusers apply_group_offloading, block_level,
use_stream) that keeps the transformer flowing through the GPU a few blocks at a
time while the text encoder / VAE stay resident, and fix VAE tiling to drive the
VAE submodule (pipelines like Z-Image expose enable_tiling on pipe.vae, not the
pipeline). apply_memory_plan now returns the (policy, tiling) actually engaged so
status never overstates either, and group falls back to whole-module offload when
the transformer can't be streamed.

Measured on Z-Image (B200), all lossless (PSNR inf vs resident): balanced/group
cuts generation peak VRAM 32% (15951 -> 10840 MB) at near-resident speed (2.07 ->
2.99s); low_vram/model cuts it 48% (-> 8318 MB) but is slower (7.99s). Mode names
now match that tradeoff: balanced = stream the transformer, low_vram = offload
every component. auto picks group when the companions fit resident, else model.

112 CPU tests pass.

* Studio diffusion (Phase 5): image quality-vs-quant accuracy harness

Add scripts/diffusion_quality.py, the accuracy analogue of the KLD workflow: hold
prompt + seed fixed, render a grid with a reference quant (default BF16), then render
each candidate quant and measure drift from the reference. Records mean PSNR + SSIM
(pure-numpy, no skimage/scipy) and optional CLIP text-alignment + image-similarity
(transformers, --clip), plus file size, latency, and peak VRAM, then prints a
quality-vs-cost table and recommends the smallest quant within a quality budget.
--selftest validates the metrics on synthetic images with no GPU or model.

Verified on Z-Image (B200): the table degrades monotonically with quant size
(Q8 -> Q4 -> Q2: PSNR 21.7 -> 15.5, SSIM 0.82 -> 0.61), while CLIP-text stays flat
(~0.34) -- quantization erodes fine detail far more than prompt adherence.

* Studio diffusion (Phase 3): opt-in speed layer (channels_last / compile / TF32)

Add a speed_mode knob (off by default, so the render path stays bit-identical):
default applies channels_last VAE + regional torch.compile of the denoiser's
repeated block where eligible; max also enables TF32 matmul and fused QKV. Regional
compile is gated off for the GGUF transformer (dequantises per-op) and for families
flagged not compile-friendly (a new supports_torch_compile flag, False for Z-Image),
so it activates automatically only once a non-GGUF bf16 transformer is loaded. Speed
optims run before placement/offload, per the diffusers composition order. status now
reports speed_mode + the optims actually engaged.

Verified on Z-Image (B200): default -> ['channels_last'], max -> ['channels_last',
'tf32'], compile correctly skipped for GGUF; generation works in every mode.

121 CPU tests pass.

* Studio diffusion (Phase 2B): opt-in fp8 text-encoder layerwise casting

Add a text_encoder_fp8 knob that casts the companion text encoder(s) to fp8 (e4m3)
storage via diffusers apply_layerwise_casting, upcasting per layer to the bf16
compute dtype while normalisations and embeddings stay full precision. Applied
before placement, gated to CUDA + bf16, best-effort (a failure leaves the encoder
dense). status reports which encoders were cast.

Verified on Z-Image (B200, balanced/group mode where the encoder stays resident):
generation peak VRAM dropped 37% (10840 -> 6791 MB, below the lowest-VRAM offload)
at near-resident speed. It is a memory-vs-quality tradeoff, not free -- ~20 dB PSNR
vs the bf16 encoder, a larger shift than one transformer quant step -- so it is off
by default and documented as such, with the Phase 5 harness to size the cost.

127 CPU tests pass.

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

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

* Studio diffusion (Phase 2C): NVFP4 text-encoder quant (+ generalise fp8 knob)

Generalise the text-encoder precision knob from a fp8 bool to text_encoder_quant
(fp8 | nvfp4). nvfp4 quantises the companion text encoder to 4-bit via torchao
NVFP4 weight-only (two-level microscaling) on Blackwell's FP4 tensor cores; fp8
stays the broader-hardware path (cc>=8.9). Both are gated, best-effort, and run
before placement; status reports the mode actually engaged. This is the lean
realisation of GGUF-native text-encoder quant: 4-bit on the encoder without the
3045-line port.

Verified on Z-Image (B200, balanced/group where the encoder stays resident), vs the
bf16 encoder: nvfp4 cut generation peak VRAM 48% (10840 -> 5593 MB, the lowest TE
option, below whole-model offload) at near-fp8 quality (16.4 vs 17.1 dB PSNR), and
both quants ran faster than bf16. A memory-vs-quality tradeoff (off by default);
size it per model with the Phase 5 quality harness. diffusion_bench gains
--text-encoder-quant.

129 CPU tests pass.

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

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

* Studio diffusion (Phase 4): native stable-diffusion.cpp engine for CPU/Mac

Adds the CPU / Apple-Silicon tier of the two-engine strategy, mirroring the
chat backend's llama.cpp shell-out. Diffusers stays the default on CUDA / ROCm
/ XPU; this covers the hardware diffusers serves poorly, consuming the same
split GGUF assets Studio already curates.

- sd_cpp_args.py: pure sd-cli command builder. Maps the family to its
  text-encoder flag (Z-Image Qwen3 to --llm, Qwen-Image to --qwen2vl, FLUX.1
  CLIP-L + T5), and the diffusers memory policy (none/group/model/sequential)
  to sd.cpp's offload flags (--offload-to-cpu / --clip-on-cpu / --vae-on-cpu /
  --vae-tiling / --diffusion-fa), so one user knob drives both engines.
- sd_cpp_engine.py: SdCppEngine over a located sd-cli. find_sd_cpp_binary()
  with the same precedence as the llama finder (env override, then the Studio
  install root, then in-tree, then PATH), an is_available/version probe, and a
  one-shot subprocess generate that streams progress and returns the PNG.
  runtime_env() prepends the binary's directory to the platform library path
  so a prebuilt's bundled libstable-diffusion.so resolves.
  select_diffusion_engine() is the pure routing decision (GPU backends to
  diffusers, CPU/MPS to native when present).
- install_sd_cpp_prebuilt.py: resolve + download the per-host prebuilt
  (macOS-arm64/Metal, Linux x86_64 CPU, Vulkan/ROCm/Windows variants) into the
  Studio install root. resolve_release_asset() is a pure, unit-tested
  host-to-asset matrix.
- scripts/sd_cpp_smoke.py: end-to-end native generation harness.

Tests (CPU-only, subprocess/filesystem stubbed): 49 new across args, engine,
routing, runtime env, and the installer resolver. Full diffusion suite 166
passing.

Verified on a B200 box: built sd-cli (CUDA) and the prebuilt (CPU) both
generate Z-Image-Turbo Q4_K end to end through SdCppEngine: balanced (group
offload, 5.0s gen), low_vram (full CPU offload + VAE tiling, 13.4s), and the
dynamically-linked CPU prebuilt (50.4s on CPU), all producing coherent images.

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

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

* Studio diffusion (Phase 4): enforce the sd-cli timeout while reading output

Iterating proc.stdout directly blocks until the stream closes, so a sd-cli that hangs
without producing output (or without closing stdout) would never reach proc.wait and the
wall-clock timeout was silently bypassed. Drain stdout on a daemon thread and wait on the
PROCESS, so the main thread always enforces the timeout and kills a hung process (which
closes the pipe and ends the reader). Add a test that times out even when stdout blocks,
and make the no-binary test hermetic so a host-installed sd-cli can't leak in.

* Studio diffusion (Phase 4) review fixes: sd.cpp installer + engine hardening

- install_sd_cpp_prebuilt: download the release archive with urlopen + an explicit
  timeout + copyfileobj (urlretrieve has no timeout and hangs on a stalled socket);
  extract through a per-member containment check (Zip-Slip guard); expanduser the
  --install-dir so a tilde path is not taken literally; and on Windows CUDA also fetch
  the separately-published cudart runtime DLL archive so sd-cli.exe can start.
- sd_cpp_engine: find_sd_cpp_binary honors UNSLOTH_STUDIO_HOME / STUDIO_HOME like the
  installer, so a custom-root install is discovered without UNSLOTH_SD_CPP_PATH; start
  sd-cli with the parent-death child_popen_kwargs so it is not orphaned on a backend
  crash; reap the SIGKILLed child (proc.wait) so a cancel/timeout does not leave a zombie.
- tests: Zip-Slip rejection, normal extraction, studio-home discovery.

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

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

* Studio diffusion (Phase 4) review round 2: collect sd-cli batch outputs

Codex review: when batch_count > 1, stable-diffusion.cpp's save_results() writes
the numbered files <stem>_<idx><suffix> (base_0.png, base_1.png, ...) instead of
the literal --output path. SdCppEngine.generate checked only the literal path, so
a batch generation would exit 0 and then raise 'no image' (or return a stale
file). generate now returns the literal path when present and otherwise falls
back to the numbered siblings; single-image behavior is unchanged.

Test: a fake sd-cli that writes img_0.png/img_1.png (not img.png) is collected
without error.

---------

Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com>
2026-07-01 15:03:53 -03:00

511 lines
19 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
"""Standalone GPU benchmark + regression harness for the Studio diffusion backend.
Drives ``DiffusionBackend`` directly (no HTTP server) to measure load time, peak
VRAM, and generation latency for a single GGUF image model, plus an accuracy
guard: a fixed-seed image is rendered and compared (PSNR) against a stored
reference so a precision/dtype/guard regression that silently changes output is
caught, not just speed/memory.
Two modes:
--write-baseline PATH run once, save metrics JSON + reference.png next to it.
--compare PATH run again, diff against the baseline, exit nonzero if a
latency / VRAM / PSNR threshold is exceeded.
torch / diffusers are imported lazily (only after argument parsing and only
inside functions) so ``--help`` works on a host without them. Not part of CPU CI;
this needs a real GPU and a downloadable model.
Example:
python scripts/diffusion_bench.py --write-baseline outputs/diffusion_bench/baseline.json \\
--model unsloth/Z-Image-Turbo-GGUF --gguf z-image-turbo-Q4_K_M.gguf
# ... make changes ...
python scripts/diffusion_bench.py --compare outputs/diffusion_bench/baseline.json \\
--model unsloth/Z-Image-Turbo-GGUF --gguf z-image-turbo-Q4_K_M.gguf
"""
from __future__ import annotations
import argparse
import json
import math
import os
import platform
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
# The backend package lives at unsloth/studio/backend; this file is at
# unsloth/scripts/diffusion_bench.py. Put the backend root on sys.path so
# ``core.inference.diffusion`` imports the same way the server does. (The actual
# backend import is deferred into main() so --help never triggers torch.)
_BACKEND_ROOT = Path(__file__).resolve().parent.parent / "studio" / "backend"
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))
# ── small helpers ──────────────────────────────────────────────────────────
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _git_commit() -> Optional[str]:
try:
out = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd = str(Path(__file__).resolve().parent),
capture_output = True,
text = True,
timeout = 10,
)
return out.stdout.strip() or None if out.returncode == 0 else None
except Exception:
return None
def _percentile(values: list[float], pct: float) -> float:
"""Nearest-rank percentile over a small sample (no numpy)."""
if not values:
return 0.0
ordered = sorted(values)
rank = int(math.ceil(pct / 100.0 * len(ordered))) - 1
rank = max(0, min(rank, len(ordered) - 1))
return ordered[rank]
def _is_cuda(device: Optional[str]) -> bool:
return bool(device) and device.split(":", 1)[0] == "cuda"
def _cuda_reset_peak() -> None:
import torch
if torch.cuda.is_available():
torch.cuda.reset_peak_memory_stats()
def _cuda_sync() -> None:
import torch
if torch.cuda.is_available():
torch.cuda.synchronize()
def _cuda_peak_alloc() -> Optional[int]:
import torch
return int(torch.cuda.max_memory_allocated()) if torch.cuda.is_available() else None
def _cuda_peak_reserved() -> Optional[int]:
import torch
return int(torch.cuda.max_memory_reserved()) if torch.cuda.is_available() else None
def _cuda_alloc() -> Optional[int]:
import torch
return int(torch.cuda.memory_allocated()) if torch.cuda.is_available() else None
def _gpu_name() -> Optional[str]:
try:
import torch
if torch.cuda.is_available():
return torch.cuda.get_device_name(0)
except Exception:
pass
return None
def _versions() -> dict[str, Optional[str]]:
out: dict[str, Optional[str]] = {"torch": None, "diffusers": None}
try:
import torch
out["torch"] = torch.__version__
except Exception:
pass
try:
import diffusers
out["diffusers"] = diffusers.__version__
except Exception:
pass
return out
def _psnr(ref_png: Path, cand_png: Path) -> float:
"""PSNR (dB) between two PNGs; inf when identical."""
import numpy as np
from PIL import Image
with Image.open(ref_png) as im_a:
a = np.asarray(im_a.convert("RGB"), dtype = np.float64)
with Image.open(cand_png) as im_b:
b = np.asarray(im_b.convert("RGB"), dtype = np.float64)
if a.shape != b.shape:
# Different geometry means the comparison is meaningless; report worst case.
return 0.0
mse = float(((a - b) ** 2).mean())
if mse == 0.0:
return math.inf
return 20.0 * math.log10(255.0) - 10.0 * math.log10(mse)
# ── load + generate ────────────────────────────────────────────────────────
def _wait_for_load(backend: Any, timeout_s: int = 2400) -> None:
deadline = time.time() + timeout_s
last = None
while time.time() < deadline:
p = backend.load_progress()
phase = p.get("phase")
if phase != last:
last = phase
frac = p.get("fraction") or 0.0
bt = (p.get("bytes_total") or 0) / 1e9
print(f" load phase={phase} frac={frac:.3f} total={bt:.2f}GB", flush = True)
if phase == "ready":
return
if phase == "error":
raise RuntimeError(f"load error: {p.get('error')}")
time.sleep(2)
raise TimeoutError(f"model load did not reach ready within {timeout_s}s")
def _generate_once(backend: Any, args: argparse.Namespace) -> Any:
"""One generation at the fixed seed; returns the first PIL image."""
result = backend.generate(
prompt = args.prompt,
width = args.width,
height = args.height,
steps = args.steps,
guidance = args.guidance,
seed = args.seed,
batch_size = args.batch_size,
)
images = result["images"]
return images[0]
def _run(args: argparse.Namespace) -> dict[str, Any]:
"""Load the model, measure load + generation, render the fixed-seed image.
Returns the metrics dict; writes the rendered image to ``args._image_out``.
"""
from core.inference.diffusion import get_diffusion_backend
backend = get_diffusion_backend()
status: dict[str, Any] = {}
load_metrics: dict[str, Any] = {}
gen_metrics: dict[str, Any] = {}
try:
# ── load ──
_cuda_reset_peak()
t0 = time.time()
backend.begin_load(
args.model,
gguf_filename = args.gguf,
base_repo = args.base_repo,
family_override = args.family_override,
hf_token = os.environ.get("HF_TOKEN"),
cpu_offload = args.cpu_offload,
memory_mode = args.memory_mode,
text_encoder_quant = args.text_encoder_quant,
)
_wait_for_load(backend)
_cuda_sync()
load_metrics = {
"wall_seconds": round(time.time() - t0, 2),
"peak_vram_bytes": _cuda_peak_alloc(),
"peak_reserved_bytes": _cuda_peak_reserved(),
"final_vram_bytes": _cuda_alloc(),
}
status = backend.status()
print(f" loaded: {status}", flush = True)
# ── warmup (discarded) ──
for _ in range(max(0, args.warmup)):
_generate_once(backend, args)
# ── measured generations (fixed seed -> deterministic) ──
_cuda_reset_peak()
latencies: list[float] = []
first_image = None
for i in range(max(1, args.iters)):
_cuda_sync()
g0 = time.time()
image = _generate_once(backend, args)
_cuda_sync()
latencies.append(time.time() - g0)
if first_image is None:
first_image = image
print(f" gen[{i}] {latencies[-1]:.3f}s", flush = True)
total = sum(latencies)
gen_metrics = {
"iters": len(latencies),
"warmup": max(0, args.warmup),
"latencies_s": [round(x, 4) for x in latencies],
"median_latency_s": round(_percentile(latencies, 50), 4),
"p90_latency_s": round(_percentile(latencies, 90), 4),
"images_per_sec": round((args.batch_size * len(latencies)) / total, 4)
if total > 0
else None,
"peak_vram_bytes": _cuda_peak_alloc(),
}
# The fixed-seed image is the accuracy anchor.
args._image_out.parent.mkdir(parents = True, exist_ok = True)
first_image.save(args._image_out)
print(f" saved image -> {args._image_out}", flush = True)
finally:
try:
backend.unload()
except Exception as exc: # noqa: BLE001 — best-effort cleanup
print(f" warn: unload failed: {exc}", flush = True)
return {
"env": {
"timestamp": _now_iso(),
"git_commit": _git_commit(),
"python": platform.python_version(),
"platform": platform.platform(),
"versions": _versions(),
"gpu_name": _gpu_name(),
"status": status,
},
"load": load_metrics,
"generate": gen_metrics,
"config": {
"model": args.model,
"gguf": args.gguf,
"base_repo": args.base_repo,
"family_override": args.family_override,
"prompt": args.prompt,
"width": args.width,
"height": args.height,
"steps": args.steps,
"guidance": args.guidance,
"seed": args.seed,
"batch_size": args.batch_size,
"memory_mode": args.memory_mode,
"cpu_offload": args.cpu_offload,
"text_encoder_quant": args.text_encoder_quant,
},
}
# ── modes ──────────────────────────────────────────────────────────────────
def _write_baseline(args: argparse.Namespace) -> int:
baseline_path = Path(args.write_baseline).resolve()
ref_png = baseline_path.parent / "reference.png"
args._image_out = ref_png
metrics = _run(args)
metrics["accuracy"] = {
"reference_png": str(ref_png),
"width": args.width,
"height": args.height,
"steps": args.steps,
"seed": args.seed,
"dtype": (metrics["env"]["status"] or {}).get("dtype"),
}
baseline_path.parent.mkdir(parents = True, exist_ok = True)
baseline_path.write_text(json.dumps(metrics, indent = 2))
print("\n=== BASELINE WRITTEN ===", flush = True)
print(f" json: {baseline_path}", flush = True)
print(f" reference: {ref_png}", flush = True)
print(f" load: {metrics['load']}", flush = True)
print(
f" generate: median={metrics['generate'].get('median_latency_s')}s "
f"p90={metrics['generate'].get('p90_latency_s')}s "
f"img/s={metrics['generate'].get('images_per_sec')} "
f"peak_vram={metrics['generate'].get('peak_vram_bytes')}",
flush = True,
)
return 0
def _compare(args: argparse.Namespace) -> int:
baseline_path = Path(args.compare).resolve()
baseline = json.loads(baseline_path.read_text())
out_dir = Path(args.out_dir).resolve()
args._image_out = out_dir / "compare.png"
# Refuse a noisy cross-hardware / cross-dtype comparison unless forced.
base_env = baseline.get("env", {})
base_status = base_env.get("status") or {}
cur_gpu = _gpu_name()
base_gpu = base_env.get("gpu_name")
metrics = _run(args)
cur_status = metrics["env"]["status"] or {}
mismatch = []
if base_gpu != cur_gpu:
mismatch.append(f"gpu {base_gpu!r} -> {cur_gpu!r}")
if base_status.get("device") != cur_status.get("device"):
mismatch.append(f"device {base_status.get('device')!r} -> {cur_status.get('device')!r}")
if base_status.get("dtype") != cur_status.get("dtype"):
mismatch.append(f"dtype {base_status.get('dtype')!r} -> {cur_status.get('dtype')!r}")
if mismatch:
print("\n!! environment mismatch vs baseline: " + "; ".join(mismatch), flush = True)
if not args.force_compare:
print(" refusing noisy comparison (pass --force-compare to override).", flush = True)
return 2
# PSNR vs the stored reference image. The baseline stores an absolute reference_png,
# which breaks if the baseline directory was copied/moved, so fall back to reference.png
# next to the baseline JSON. A still-missing reference is a failure below, not a silent
# pass -- otherwise the benchmark would report PASS having done no image comparison.
ref_png = Path(baseline.get("accuracy", {}).get("reference_png", ""))
if not ref_png.is_file():
ref_png = baseline_path.parent / "reference.png"
psnr = _psnr(ref_png, args._image_out) if ref_png.is_file() else float("nan")
base_gen = baseline.get("generate", {})
cur_gen = metrics["generate"]
base_median = base_gen.get("median_latency_s") or 0.0
cur_median = cur_gen.get("median_latency_s") or 0.0
latency_reg = (cur_median - base_median) / base_median if base_median > 0 else 0.0
base_peak = base_gen.get("peak_vram_bytes")
cur_peak = cur_gen.get("peak_vram_bytes")
vram_reg = ((cur_peak - base_peak) / base_peak) if (base_peak and cur_peak) else 0.0
print("\n=== REGRESSION REPORT ===", flush = True)
print(f" {'metric':<22}{'baseline':>16}{'current':>16}{'delta':>12}", flush = True)
print(
f" {'median_latency_s':<22}{base_median:>16.4f}{cur_median:>16.4f}{latency_reg * 100:>11.1f}%",
flush = True,
)
if base_peak and cur_peak:
print(
f" {'peak_vram_MB':<22}{base_peak / 1e6:>16.1f}{cur_peak / 1e6:>16.1f}{vram_reg * 100:>11.1f}%",
flush = True,
)
print(f" {'psnr_dB(vs ref)':<22}{'-':>16}{psnr:>16.2f}{'':>12}", flush = True)
failures = []
if latency_reg > args.max_latency_regression:
failures.append(
f"latency +{latency_reg * 100:.1f}% > {args.max_latency_regression * 100:.0f}%"
)
if base_peak and cur_peak and vram_reg > args.max_vram_regression:
failures.append(f"peak VRAM +{vram_reg * 100:.1f}% > {args.max_vram_regression * 100:.0f}%")
if math.isnan(psnr):
failures.append("PSNR reference image missing; cannot verify output quality")
elif psnr < args.min_psnr:
failures.append(f"PSNR {psnr:.2f}dB < {args.min_psnr:.1f}dB (output changed)")
if failures:
print("\n FAIL: " + "; ".join(failures), flush = True)
return 1
print("\n PASS: no regression beyond thresholds.", flush = True)
return 0
# ── cli ────────────────────────────────────────────────────────────────────
def _build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
description = "Benchmark + regression guard for the Studio diffusion backend.",
formatter_class = argparse.ArgumentDefaultsHelpFormatter,
)
p.add_argument(
"--model", default = "unsloth/Z-Image-Turbo-GGUF", help = "GGUF repo id or local path"
)
p.add_argument(
"--gguf",
default = "z-image-turbo-Q4_K_M.gguf",
help = "transformer GGUF filename inside --model",
)
p.add_argument("--base-repo", default = None, help = "override the diffusers base repo")
p.add_argument("--family-override", default = None, help = "force a diffusion family")
p.add_argument(
"--prompt",
default = "A cozy reading nook by a rain-streaked window, warm lamplight, "
"a cat asleep on a stack of books, highly detailed",
)
p.add_argument("--width", type = int, default = 1024)
p.add_argument("--height", type = int, default = 1024)
p.add_argument("--steps", type = int, default = 9)
p.add_argument("--guidance", type = float, default = 0.0)
p.add_argument("--seed", type = int, default = 12345, help = "fixed seed -> deterministic image")
p.add_argument("--batch-size", type = int, default = 1)
p.add_argument("--warmup", type = int, default = 1, help = "discarded warmup generations")
p.add_argument("--iters", type = int, default = 3, help = "measured generations")
p.add_argument(
"--memory-mode",
default = None,
choices = ["auto", "fast", "balanced", "low_vram"],
help = "memory policy (default: backend auto)",
)
p.add_argument(
"--text-encoder-quant",
default = None,
choices = ["fp8", "nvfp4"],
help = "quantise the companion text encoder (fp8 or nvfp4)",
)
p.add_argument(
"--cpu-offload", action = "store_true", help = "legacy: force whole-module CPU offload"
)
p.add_argument(
"--write-baseline",
metavar = "PATH",
default = None,
help = "run once and save metrics JSON + reference.png",
)
p.add_argument(
"--compare", metavar = "PATH", default = None, help = "run again and diff against a baseline JSON"
)
p.add_argument(
"--max-latency-regression",
type = float,
default = 0.10,
help = "fail if median latency rises by more than this fraction",
)
p.add_argument(
"--max-vram-regression",
type = float,
default = 0.10,
help = "fail if peak generation VRAM rises by more than this fraction",
)
p.add_argument(
"--min-psnr",
type = float,
default = 35.0,
help = "fail if the fixed-seed image PSNR vs reference drops below this",
)
p.add_argument(
"--force-compare",
action = "store_true",
help = "compare even when GPU/device/dtype differ from the baseline",
)
p.add_argument(
"--out-dir", default = "outputs/diffusion_bench", help = "where compare.png is written"
)
return p
def main(argv: Optional[list[str]] = None) -> int:
args = _build_parser().parse_args(argv)
if bool(args.write_baseline) == bool(args.compare):
print("error: pass exactly one of --write-baseline / --compare", file = sys.stderr)
return 2
if args.write_baseline:
return _write_baseline(args)
return _compare(args)
if __name__ == "__main__":
raise SystemExit(main())