Studio diffusion (Phase 1): cross-platform device policy, fp16 guard, lock split, validate-before-evict (#6670)

---------

Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com>
This commit is contained in:
Daniel Han 2026-06-30 12:33:47 -07:00 committed by GitHub
commit ecf028780d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1897 additions and 199 deletions

486
scripts/diffusion_bench.py Normal file
View file

@ -0,0 +1,486 @@
# 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 _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"),
)
_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,
},
}
# ── 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(
"--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())

View file

@ -16,7 +16,7 @@ from __future__ import annotations
import inspect
import threading
import time
from dataclasses import dataclass
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Any, Optional
@ -29,6 +29,10 @@ from .diffusion_families import (
resolve_base_repo,
resolve_local_gguf_child,
)
from .diffusion_device import (
DiffusionDeviceTarget,
resolve_diffusion_device_target,
)
logger = get_logger(__name__)
@ -79,15 +83,28 @@ def _estimate_eta(total_steps: int, step: int, first_step_at: float, now: float)
return max(0.0, (total_steps - step) * per_step)
def _resolve_diffusion_compute_dtype(fam: Optional[DiffusionFamily], dtype: Any) -> Any:
"""Promote float16 -> float32 for fp16-incompatible families (e.g. Z-Image),
whose activations overflow float16's finite range and render a black image.
Every other dtype/family passes through unchanged."""
if fam is None or not getattr(fam, "fp16_incompatible", False):
return dtype
import torch
return torch.float32 if dtype == torch.float16 else dtype
class DiffusionBackend:
"""Holds at most one loaded diffusers pipeline. All mutations are serialised."""
def __init__(self) -> None:
# One lock serialises load / generate / unload — a generate must not run
# while the pipeline is being swapped out from under it. status() and
# load_progress() read the single state references without the lock, so
# polling never blocks a slow load.
# _lock serialises the small state mutations (the load swap, _loading,
# _load_token, _gen). status() / load_progress() / generate_progress()
# read those references WITHOUT it, so polling never blocks a slow load.
self._lock = threading.Lock()
# _generate_lock serialises generations and is the ONLY lock the denoise
# holds, so a long generation never blocks status()/unload()/a new load.
self._generate_lock = threading.Lock()
self._state: Optional[_LoadState] = None
self._loading: Optional[_LoadingState] = None
# Bumped on every begin_load and unload so a worker whose load was
@ -98,32 +115,34 @@ class DiffusionBackend:
# lock, like the chat backend), so an eviction/unload can preempt a slow
# load instead of blocking on the lock for the whole download.
self._cancel_event = threading.Event()
# The callback mutates this and generate_progress() reads it, both without
# the lock (generate holds it for the whole call), so polling stays live.
# The cancel Event of the generation currently in flight (or None). Set
# under _lock by unload() / a superseding load to abort that specific
# denoise (its step callback flips pipe._interrupt). Per-generation rather
# than one shared flag the next generate would clear, so a cancel can't be
# lost to a racing generate nor leak onto the wrong one.
self._active_generate_cancel: Optional[threading.Event] = None
# The callback mutates _gen and generate_progress() reads it, both lock-free,
# so per-step progress polling stays live during a generation.
self._gen: Optional[_GenState] = None
@property
def is_loaded(self) -> bool:
return self._state is not None
def _pick_device_and_dtype(self) -> tuple[str, Any]:
import torch
if torch.cuda.is_available():
# BF16 needs Ampere+ (compute capability >= 8); pre-Ampere cards
# (Turing/Volta/Pascal) only emulate it, so use FP16 there. (Checked by
# capability, not torch.cuda.is_bf16_supported(), which returns True via
# emulation on those cards and would still pick BF16.)
dtype = torch.bfloat16 if torch.cuda.get_device_capability()[0] >= 8 else torch.float16
return "cuda", dtype
# Intel XPU enables the Images page (CHAT_ONLY=False in hardware.py), so
# without this branch an Arc user would silently run diffusion on CPU.
if hasattr(torch, "xpu") and torch.xpu.is_available():
return "xpu", torch.bfloat16
mps = getattr(torch.backends, "mps", None)
if mps is not None and mps.is_available():
return "mps", torch.float16
return "cpu", torch.float32
def _resolve_device_target(self, fam: Optional[DiffusionFamily]) -> DiffusionDeviceTarget:
"""The device target for the current host with the family fp16 guard applied:
promote float16 -> float32 for fp16-incompatible families (Z-Image), whose
activations overflow float16 and render a black image. The capability flags
carry through unchanged; only the dtype moves."""
target = resolve_diffusion_device_target()
effective = _resolve_diffusion_compute_dtype(fam, target.dtype)
if effective is target.dtype:
return target
logger.warning(
"diffusion.dtype_promoted: family=%s float16 -> float32 (fp16-incompatible)",
getattr(fam, "name", None),
)
return replace(target, dtype = effective)
def _resolve_gguf_path(self, repo_id: str, gguf_filename: str, hf_token: Optional[str]) -> str:
local_root = Path(repo_id).expanduser()
@ -160,30 +179,62 @@ class DiffusionBackend:
base, rfilename, hf_token, cancel_event = self._cancel_event
)
# ── Background load + progress ─────────────────────────────────────────
@staticmethod
def _detect_family_for_pick(
repo_id: str, gguf_filename: Optional[str], family_override: Optional[str]
) -> Optional[DiffusionFamily]:
"""Detect the family from the repo id, falling back to the combined
path/filename for a direct local .gguf pick. The frontend splits such a
pick into (parent dir, basename), so the family keyword can live only in
the filename (e.g. /models/z-image-turbo-Q4_K_M.gguf) while the parent
directory carries none; scan it too when the directory alone is
undetectable. Only used as a fallback, so remote 'org/name' picks and
explicit overrides behave exactly as before."""
fam = detect_family(repo_id, family_override)
if fam is None and gguf_filename and not family_override:
fam = detect_family(f"{repo_id}/{gguf_filename}", family_override)
return fam
def validate_load(
def validate_load_request(
self,
repo_id: str,
*,
gguf_filename: Optional[str] = None,
family_override: Optional[str] = None,
) -> DiffusionFamily:
"""Cheap, pure-synchronous pre-flight: returns the resolved family or
raises ValueError. No I/O, so a caller can reject a bad request before
taking the GPU (begin_load and load_pipeline re-check on the load path)."""
"""Cheap, network-free validation shared by the route (before it evicts the
chat model) and both load paths, so an unloadable pick fails BEFORE the GPU
handoff. Raises ValueError for a missing gguf_filename or undetectable
family, and ValueError/FileNotFoundError for a bad local GGUF path. Touches
no GPU, network, or state."""
if not gguf_filename:
raise ValueError(
"gguf_filename is required: this backend loads single-file GGUF checkpoints only."
)
fam = detect_family(repo_id, family_override)
fam = self._detect_family_for_pick(repo_id, gguf_filename, family_override)
if fam is None:
raise ValueError(
f"'{repo_id}' isn't a supported image-generation model. "
f"Supported: Z-Image, Qwen-Image, FLUX.1, FLUX.2-klein."
f"Could not infer a diffusion family for '{repo_id}'. Pass family_override (z-image)."
)
# Reject a bad LOCAL pick now (the same checks the load would hit later), so
# the route never evicts a working chat model for a request that can't load.
# A path-shaped repo_id (absolute / ~ / ./ / ..) is meant to be on disk, so a
# missing one is an error here; a bare "org/name" id is a remote HF repo and
# is left for the background load to resolve.
local_root = Path(repo_id).expanduser()
if local_root.exists():
resolve_local_gguf_child(local_root, gguf_filename)
elif (
# POSIX path-shaped, a "."/".." prefix (covers ./ ../ and their Windows .\ ..\
# forms), a Windows separator anywhere (never present in a bare "org/name" HF
# id), or an absolute path on this OS.
repo_id.startswith(("/", "\\", "~", ".")) or "\\" in repo_id or local_root.is_absolute()
):
raise FileNotFoundError(f"Local model path does not exist: {repo_id}")
return fam
# ── Background load + progress ─────────────────────────────────────────
def begin_load(
self,
repo_id: str,
@ -195,7 +246,7 @@ class DiffusionBackend:
cpu_offload: bool = False,
) -> dict[str, Any]:
"""Validate, then run the (slow) load on a daemon thread. Returns at once."""
fam = self.validate_load(
fam = self.validate_load_request(
repo_id, gguf_filename = gguf_filename, family_override = family_override
)
@ -233,7 +284,9 @@ class DiffusionBackend:
# Resolve the base repo and estimate sizes on this thread (both network
# calls) so begin_load returns instantly; the bar shows raw bytes until
# the total lands. This is the only writer of _loading's fields here.
fam = detect_family(kwargs["repo_id"], kwargs.get("family_override"))
fam = self._detect_family_for_pick(
kwargs["repo_id"], kwargs.get("gguf_filename"), kwargs.get("family_override")
)
base = _resolve_base_repo(
kwargs["repo_id"], kwargs.get("base_repo"), fam, kwargs.get("hf_token")
)
@ -306,7 +359,11 @@ class DiffusionBackend:
total = 0
base_files: list[str] = []
try:
if gguf_filename:
# Skip the Hub size lookup for a LOCAL gguf path: model_info(repo_id) would
# raise on a filesystem path and (caught below) skip the base-repo lookup too,
# so the companion VAE/text-encoder files would never be prefetched and would
# instead download synchronously under the load lock.
if gguf_filename and not Path(repo_id).expanduser().exists():
info = api.model_info(repo_id, files_metadata = True, token = hf_token)
total += sum(s.size or 0 for s in info.siblings if s.rfilename == gguf_filename)
base_info = api.model_info(base_repo, files_metadata = True, token = hf_token)
@ -347,61 +404,78 @@ class DiffusionBackend:
cpu_offload: bool = False,
_load_token: Optional[int] = None,
) -> dict[str, Any]:
import diffusers
fam = self.validate_load(
# Validate first (cheap, no torch/diffusers) so a direct call with a bad
# family fails with ValueError even in a no-diffusers runtime.
fam = self.validate_load_request(
repo_id, gguf_filename = gguf_filename, family_override = family_override
)
base = _resolve_base_repo(repo_id, base_repo, fam, hf_token)
device, dtype = self._pick_device_and_dtype()
target = self._resolve_device_target(fam)
device, dtype = target.device, target.dtype
import diffusers
# Signal an in-flight denoise to abort, then take _generate_lock to WAIT for
# it to actually exit before allocating the replacement: a load is about to
# claim VRAM, so unlike unload() it must not overlap a still-live pipeline.
# The cancel makes that wait ~one step (or the rest of the denoise for a
# pipeline that ignores the step callback).
with self._lock:
# Bail before the (slow, VRAM-heavy) build if an unload/eviction or a
# newer load superseded this one while we were resolving/downloading —
# otherwise an evicted load would resurrect a pipeline into VRAM.
# Check the token BEFORE cancelling: a superseded/cancelled worker (its
# token already bumped by unload or a newer load) must not abort the
# current model's unrelated in-flight generation on its way out.
if _load_token is not None and _load_token != self._load_token:
raise RuntimeError("Diffusion load was cancelled.")
if self._active_generate_cancel is not None:
self._active_generate_cancel.set()
with self._generate_lock:
with self._lock:
# Re-check under the lock (we dropped it to wait on _generate_lock): an
# unload/eviction or a newer load may have superseded this one while we
# waited, and the slow VRAM-heavy build below must not run if so.
if _load_token is not None and _load_token != self._load_token:
raise RuntimeError("Diffusion load was cancelled.")
# Free the old pipeline before allocating the new one so two
# checkpoints never sit in VRAM at once.
self._unload_locked()
# Free the old pipeline before allocating the new one so two
# checkpoints never sit in VRAM at once.
self._unload_locked()
# Dequantise the GGUF transformer on-device; the VAE / text-encoder /
# scheduler come from the base diffusers repo (the GGUF is transformer-only).
gguf_path = self._resolve_gguf_path(repo_id, gguf_filename, hf_token)
transformer_cls = getattr(diffusers, fam.transformer_class)
transformer = transformer_cls.from_single_file(
gguf_path,
quantization_config = diffusers.GGUFQuantizationConfig(compute_dtype = dtype),
torch_dtype = dtype,
config = base,
subfolder = "transformer",
# Forward the token: the config is fetched from the (possibly gated)
# base repo before from_pretrained gets a chance to authenticate.
token = hf_token,
)
# Dequantise the GGUF transformer on-device; the VAE / text-encoder /
# scheduler come from the base diffusers repo (GGUF is transformer-only).
gguf_path = self._resolve_gguf_path(repo_id, gguf_filename, hf_token)
transformer_cls = getattr(diffusers, fam.transformer_class)
transformer = transformer_cls.from_single_file(
gguf_path,
quantization_config = diffusers.GGUFQuantizationConfig(compute_dtype = dtype),
torch_dtype = dtype,
config = base,
subfolder = "transformer",
# Forward the token: the config is fetched from the (possibly gated)
# base repo before from_pretrained gets a chance to authenticate.
token = hf_token,
)
pipe_kwargs: dict[str, Any] = {"torch_dtype": dtype, "transformer": transformer}
if hf_token:
pipe_kwargs["token"] = hf_token
pipeline_cls = getattr(diffusers, fam.pipeline_class)
pipe = pipeline_cls.from_pretrained(base, **pipe_kwargs)
pipe_kwargs: dict[str, Any] = {"torch_dtype": dtype, "transformer": transformer}
if hf_token:
pipe_kwargs["token"] = hf_token
pipeline_cls = getattr(diffusers, fam.pipeline_class)
pipe = pipeline_cls.from_pretrained(base, **pipe_kwargs)
use_offload = bool(cpu_offload and device == "cuda")
if use_offload:
pipe.enable_model_cpu_offload()
else:
pipe.to(device)
use_offload = bool(cpu_offload and target.supports_model_cpu_offload)
if use_offload:
pipe.enable_model_cpu_offload()
else:
pipe.to(device)
self._state = _LoadState(
pipe = pipe,
family = fam,
repo_id = repo_id,
base_repo = base,
device = device,
dtype = str(dtype).replace("torch.", ""),
cpu_offload = use_offload,
)
self._state = _LoadState(
pipe = pipe,
family = fam,
repo_id = repo_id,
base_repo = base,
device = device,
dtype = str(dtype).replace("torch.", ""),
cpu_offload = use_offload,
)
logger.info("diffusion.loaded: repo=%s base=%s device=%s", repo_id, base, device)
return self.status()
@ -422,63 +496,92 @@ class DiffusionBackend:
batch_size: int = 1,
) -> dict[str, Any]:
import torch
with self._lock:
state = self._state
if state is None:
raise RuntimeError("No diffusion model is loaded.")
generator = torch.Generator(device = state.device)
if seed is None:
# Draw a fresh random seed but keep it within JS's safe-integer
# range (< 2**53), so the reported seed round-trips through JSON
# and actually reproduces the image (a raw 64-bit seed would lose
# precision in the browser and the recipe couldn't be replayed).
seed = generator.seed() & ((1 << 53) - 1)
else:
seed = int(seed)
generator.manual_seed(seed)
kwargs: dict[str, Any] = {
"prompt": prompt,
"width": width,
"height": height,
"num_inference_steps": steps,
# Most pipelines take guidance via "guidance_scale"; Qwen-Image
# uses "true_cfg_scale" (its distilled guidance is off).
state.family.cfg_kwarg: guidance,
"generator": generator,
# Generate the whole batch in one forward pass (VRAM-heavy). All
# share this call's seed, drawn sequentially from one generator.
"num_images_per_prompt": batch_size,
}
# Pipelines vary in which kwargs they accept (a distilled pipeline may
# take neither a negative prompt nor a step callback), so only pass
# those where the signature has them.
call_params = inspect.signature(state.pipe.__call__).parameters
if negative_prompt and "negative_prompt" in call_params:
kwargs["negative_prompt"] = negative_prompt
gen = _GenState(total_steps = steps)
def _on_step(pipe, step_index, timestep, callback_kwargs):
now = time.time()
gen.step = step_index + 1
if gen.first_step_at == 0.0:
gen.first_step_at = now
gen.eta_seconds = _estimate_eta(gen.total_steps, gen.step, gen.first_step_at, now)
return callback_kwargs
if "callback_on_step_end" in call_params:
kwargs["callback_on_step_end"] = _on_step
self._gen = gen
# A per-generation cancel Event: unload()/a superseding load set THIS event
# (registered under _lock below) to abort just this denoise. _generate_lock
# serialises generations and is the only lock the denoise holds, so a slow
# generation never blocks status()/unload()/a new load.
cancel = threading.Event()
with self._generate_lock:
with self._lock:
state = self._state
if state is None:
raise RuntimeError("No diffusion model is loaded.")
# Register under _lock so unload()/a load can signal THIS generation.
# A cancel that arrived before now either nulled _state (we raised
# above) or targets an older generation, so nothing is lost.
self._active_generate_cancel = cancel
try:
images = state.pipe(**kwargs).images
# Snapshot taken: the local `state` ref keeps the pipe alive even if
# unload() nulls _state mid-denoise, so the call below needs no _lock.
generator = torch.Generator(device = state.device)
if seed is None:
# Draw a fresh random seed but keep it within JS's safe-integer
# range (< 2**53), so the reported seed round-trips through JSON
# and actually reproduces the image (a raw 64-bit seed would lose
# precision in the browser and the recipe couldn't be replayed).
seed = generator.seed() & ((1 << 53) - 1)
else:
seed = int(seed)
generator.manual_seed(seed)
kwargs: dict[str, Any] = {
"prompt": prompt,
"width": width,
"height": height,
"num_inference_steps": steps,
# Most pipelines take guidance via "guidance_scale"; Qwen-Image
# uses "true_cfg_scale" (its distilled guidance is off).
state.family.cfg_kwarg: guidance,
"generator": generator,
# Generate the whole batch in one forward pass (VRAM-heavy). All
# share this call's seed, drawn sequentially from one generator.
"num_images_per_prompt": batch_size,
}
# Pipelines vary in which kwargs they accept (a distilled pipeline may
# take neither a negative prompt nor a step callback), so only pass
# those where the signature has them.
call_params = inspect.signature(state.pipe.__call__).parameters
if negative_prompt and "negative_prompt" in call_params:
kwargs["negative_prompt"] = negative_prompt
gen = _GenState(total_steps = steps)
def _on_step(pipe, step_index, timestep, callback_kwargs):
now = time.time()
gen.step = step_index + 1
if gen.first_step_at == 0.0:
gen.first_step_at = now
gen.eta_seconds = _estimate_eta(
gen.total_steps, gen.step, gen.first_step_at, now
)
# Preempt a long denoise on unload/eviction or a superseding load:
# diffusers checks pipe._interrupt and stops after the current step.
if cancel.is_set():
pipe._interrupt = True
return callback_kwargs
if "callback_on_step_end" in call_params:
kwargs["callback_on_step_end"] = _on_step
self._gen = gen
try:
images = state.pipe(**kwargs).images
finally:
self._gen = None
# A cancelled denoise returns early with a partial/garbage image;
# don't hand it back to be persisted.
if cancel.is_set():
raise RuntimeError("Diffusion generation was cancelled.")
# Return the PIL images (not yet encoded): the route embeds each
# image's recipe and persists it via the gallery.
return {"images": list(images), "seed": int(seed), "repo_id": state.repo_id}
finally:
self._gen = None
# Return the PIL images (not yet encoded): the route embeds each
# image's recipe and persists it via the gallery.
return {"images": list(images), "seed": int(seed), "repo_id": state.repo_id}
# Deregister so a later unload/load can't poke a finished generation
# (only if still ours — a newer generation may have replaced it).
with self._lock:
if self._active_generate_cancel is cancel:
self._active_generate_cancel = None
def generate_progress(self) -> dict[str, Any]:
"""Live per-step progress for an in-flight generation (lock-free read)."""
@ -500,15 +603,25 @@ class DiffusionBackend:
}
def unload(self) -> dict[str, Any]:
# Abort an in-flight download (it runs without the lock and checks this),
# so unload/an eviction returns promptly instead of waiting it out.
# Abort an in-flight download so unload/an eviction returns promptly instead
# of waiting it out (the download runs without _lock and checks this event).
self._cancel_event.set()
# Signal an in-flight denoise, then WAIT on _generate_lock for it to exit
# before freeing _state. The GPU arbiter releases diffusion ownership and
# hands the card to chat the instant this returns, so chat must not start
# allocating VRAM while the old pipeline is still resident — that transient
# overlap is an OOM risk. The cancel bounds the wait to ~one step (the same
# bound the replacement-load path in load_pipeline already relies on).
with self._lock:
self._unload_locked()
# Cancel any in-flight load (its worker checks this token before
# committing) and drop the marker so the next load starts clean.
self._load_token += 1
self._loading = None
if self._active_generate_cancel is not None:
self._active_generate_cancel.set()
with self._generate_lock:
with self._lock:
self._unload_locked()
# Cancel any in-flight load (its worker checks this token before
# committing) and drop the marker so the next load starts clean.
self._load_token += 1
self._loading = None
return self.status()
def _unload_locked(self) -> None:

View file

@ -0,0 +1,187 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Device + dtype policy for the local diffusion backend.
torch is imported lazily inside each function so this module stays importable in
a no-torch runtime (mirrors ``diffusion.py`` / ``diffusion_families.py``).
Studio's hardware layer reports product backends (CUDA, XPU, MLX, CPU); diffusers
runs on PyTorch devices, so Apple Silicon maps to MPS and ROCm maps to PyTorch's
``cuda`` device type. This module centralises that mapping plus the per-backend
dtype choice and the capability flags the backend keys optimisation paths off.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Optional
@dataclass(frozen = True)
class DiffusionDeviceTarget:
"""Resolved torch device + compute dtype + per-backend capability flags."""
device: str
dtype: Any
backend: str
vendor: Optional[str]
supports_model_cpu_offload: bool
supports_default_torch_compile: bool
supports_pinned_transfer: bool
def _studio_device_is(studio_device: Any, device_type: Any, name: str) -> bool:
"""True if ``studio_device`` equals ``DeviceType.<name>`` (when that member exists)."""
member = getattr(device_type, name, None)
return member is not None and studio_device == member
def resolve_diffusion_device_target() -> DiffusionDeviceTarget:
"""Resolve the torch device + dtype + capability flags for diffusion.
Prefers Studio's hardware layer when importable, else probes torch directly
(CUDA -> XPU -> MPS -> CPU). On Apple Silicon Studio reports MLX/CPU when its
product backend is gated on the ``mlx`` package, but diffusers runs on
PyTorch's MPS backend, so those cases still fall through to the MPS probe.
"""
import torch
try:
from utils.hardware import DeviceType, get_device
from utils.hardware import hardware as hardware_mod
studio_device = get_device()
is_rocm = bool(getattr(hardware_mod, "IS_ROCM", False))
except Exception:
DeviceType = None
studio_device = None
is_rocm = bool(getattr(getattr(torch, "version", None), "hip", None))
if DeviceType is not None and studio_device is not None:
if _studio_device_is(studio_device, DeviceType, "CUDA"):
if torch.cuda.is_available():
return _cuda_or_rocm_target(torch, is_rocm = is_rocm)
return _cpu_target(torch)
if _studio_device_is(studio_device, DeviceType, "XPU"):
return _xpu_target(torch)
# MLX / CPU / anything else: diffusers uses MPS (not MLX), so fall
# through to the torch probe below, which prefers MPS over CPU.
if torch.cuda.is_available():
return _cuda_or_rocm_target(torch, is_rocm = is_rocm)
xpu = getattr(torch, "xpu", None)
if xpu is not None and callable(getattr(xpu, "is_available", None)):
try:
if xpu.is_available():
return _xpu_target(torch)
except Exception:
pass
return _mps_or_cpu_target(torch)
def _cuda_or_rocm_target(torch: Any, *, is_rocm: bool) -> DiffusionDeviceTarget:
if is_rocm:
# ROCm (AMD) does not have NVIDIA's pre-Ampere bf16-emulation quirk, so
# is_bf16_supported() is trustworthy here; bf16 only when it proves it.
try:
bf16_ok = bool(torch.cuda.is_bf16_supported())
except Exception:
bf16_ok = False
dtype = torch.bfloat16 if bf16_ok else torch.float16
else:
# NVIDIA: bf16 needs Ampere+ (capability major >= 8). Checked by
# capability, NOT is_bf16_supported() -- pre-Ampere cards emulate bf16
# and report it supported, but it is slow / unwanted (the #6658 fix).
try:
major = torch.cuda.get_device_capability()[0]
except Exception:
major = 0
dtype = torch.bfloat16 if major >= 8 else torch.float16
return DiffusionDeviceTarget(
device = "cuda",
dtype = dtype,
backend = "rocm" if is_rocm else "cuda",
vendor = "amd" if is_rocm else "nvidia",
supports_model_cpu_offload = True,
supports_default_torch_compile = not is_rocm,
supports_pinned_transfer = True,
)
def _xpu_target(torch: Any) -> DiffusionDeviceTarget:
bf16_ok = False
xpu = getattr(torch, "xpu", None)
try:
bf16_ok = bool(xpu.is_bf16_supported()) if xpu is not None else False
except Exception:
bf16_ok = False
return DiffusionDeviceTarget(
device = "xpu",
dtype = torch.bfloat16 if bf16_ok else torch.float16,
backend = "xpu",
vendor = "intel",
supports_model_cpu_offload = True,
supports_default_torch_compile = False,
supports_pinned_transfer = False,
)
def _mps_supports_bfloat16(torch: Any) -> bool:
"""Runtime probe for usable MPS bfloat16.
PyTorch only supports bfloat16 on MPS on macOS 14+; on older macOS a bfloat16
op raises. Probe with a tiny compute forced to evaluate (device->host sync)
rather than guessing from the macOS / chip version.
"""
try:
x = torch.ones(2, dtype = torch.bfloat16, device = "mps")
return bool(torch.isfinite((x + x).float()).all().item())
except Exception:
return False
def _mps_or_cpu_target(torch: Any) -> DiffusionDeviceTarget:
mps_available = False
try:
mps_backend = getattr(getattr(torch, "backends", None), "mps", None)
mps_available = bool(
mps_backend is not None
and callable(getattr(mps_backend, "is_available", None))
and mps_backend.is_available()
)
except Exception:
mps_available = False
if mps_available:
# Prefer bfloat16; otherwise fall back to float32, NEVER silent float16.
# Modern diffusion transformers (Z-Image, FLUX.2, ...) produce activations
# far outside float16's finite range (~6.5e4) -- Z-Image's MLP
# down-projections peak near 9e5, overflowing to inf -> NaN -> a black
# image. bfloat16 (macOS 14+) shares float32's exponent range; on older
# macOS the probe fails and float32 keeps output correct (if slower).
dtype = torch.bfloat16 if _mps_supports_bfloat16(torch) else torch.float32
return DiffusionDeviceTarget(
device = "mps",
dtype = dtype,
backend = "mps",
vendor = "apple",
supports_model_cpu_offload = False,
supports_default_torch_compile = False,
supports_pinned_transfer = False,
)
return _cpu_target(torch)
def _cpu_target(torch: Any) -> DiffusionDeviceTarget:
return DiffusionDeviceTarget(
device = "cpu",
dtype = torch.float32,
backend = "cpu",
vendor = None,
supports_model_cpu_offload = False,
supports_default_torch_compile = False,
supports_pinned_transfer = False,
)

View file

@ -31,6 +31,10 @@ class DiffusionFamily:
cfg_kwarg: str = "guidance_scale"
# Extra lowercased substrings (besides ``name``) that map a repo id here.
aliases: tuple[str, ...] = field(default_factory = tuple)
# True for families whose activations overflow float16's finite range
# (~6.5e4) and produce inf -> NaN latents -> a black image. The backend
# promotes a resolved float16 to float32 for these at load time.
fp16_incompatible: bool = False
# Keyed by architecture, not per model variant: a checkpoint's specific base repo
@ -71,6 +75,8 @@ _FAMILIES: tuple[DiffusionFamily, ...] = (
transformer_class = "ZImageTransformer2DModel",
base_repo = "Tongyi-MAI/Z-Image-Turbo",
aliases = ("zimage", "z_image"),
# Z-Image's MLP down-projections peak near 9e5, which overflows float16.
fp16_incompatible = True,
),
)
@ -135,6 +141,6 @@ def resolve_local_gguf_child(repo_root: Path, gguf_filename: str) -> Path:
child = repo_root.joinpath(*rel.parts).resolve()
if child != repo_real and repo_real not in child.parents:
raise ValueError("gguf_filename must resolve to a file inside the repo.")
if not child.exists():
raise FileNotFoundError(f"'{gguf_filename}' not found under {repo_root}.")
if not child.is_file():
raise FileNotFoundError(f"'{gguf_filename}' is not a file under {repo_root}.")
return child

View file

@ -143,14 +143,24 @@ def list_images(limit: Optional[int] = None, offset: int = 0) -> list[dict[str,
except OSError:
return []
paths.sort(key = _mtime, reverse = True)
window = paths[offset:] if limit is None else paths[offset : offset + limit]
# Page over READABLE records, not raw files: filtering a foreign/corrupt PNG out of an
# already-sliced window would drop valid images that sort after it and make the route's
# has_more wrong. Read only as far as needed to fill the requested window.
# Known Phase-1 limit: this re-reads headers from the newest down to `offset+limit` on
# every page, so a deep infinite-scroll over a very large gallery (thousands of images,
# e.g. a long uncapped batch) is O(offset) header-opens per page. PIL opens are lazy
# (header only) and this runs off the event loop, so it's not a freeze; a later phase can
# switch to cursor-based paging (resume after the last-seen record) if it starts to bite.
want = None if limit is None else offset + limit
records = []
for path in window:
for path in paths:
meta = _read_meta(path)
if meta is None: # not one of ours (no recipe chunk) — skip
continue
records.append(_record(path.stem, meta))
return records
if want is not None and len(records) >= want:
break
return records[offset:] if limit is None else records[offset : offset + limit]
def delete(image_id: str) -> bool:

View file

@ -1746,6 +1746,9 @@ class GalleryImage(BaseModel):
guidance: float = Field(..., description = "Guidance scale")
seed: int = Field(..., description = "Seed used")
batch_index: int = Field(0, description = "Position within its batch (0-based)")
batch_size: int = Field(
1, description = "Batch size used; with batch_index it lets restore replay this image"
)
model: Optional[str] = Field(None, description = "Model repo id that produced it")
created_at: float = Field(..., description = "Creation time (epoch seconds)")

View file

@ -10211,10 +10211,11 @@ async def load_diffusion_model(
backend = get_diffusion_backend()
try:
# Reject an unsupported/malformed request BEFORE taking the GPU, so a
# bad image-model pick doesn't evict the user's loaded chat model only
# to 400. validate_load is pure (no I/O), so it runs inline.
backend.validate_load(
# Validate cheaply BEFORE touching the GPU: an unloadable pick (bad family,
# missing local GGUF) must not evict a working chat model and then 400.
# validate_load_request does local-path I/O, so run it off the event loop.
await asyncio.to_thread(
backend.validate_load_request,
request.model_path,
gguf_filename = request.gguf_filename,
family_override = request.family_override,
@ -10223,7 +10224,7 @@ async def load_diffusion_model(
# compete with the training subprocess for VRAM. The chat path does the
# same via _guard_chat_load_against_training; this is its image sibling.
_guard_diffusion_load_against_training()
# Take the GPU from the chat backend, then kick the (slow) load onto a
# Now take the GPU from the chat backend, then kick the (slow) load onto a
# background thread and return at once — the client polls images/load-progress.
await asyncio.to_thread(acquire_for, DIFFUSION)
status_dict = await asyncio.to_thread(
@ -10236,7 +10237,7 @@ async def load_diffusion_model(
cpu_offload = request.cpu_offload,
)
return DiffusionStatusResponse(**status_dict)
except ValueError as exc:
except (ValueError, FileNotFoundError) as exc:
raise HTTPException(status_code = 400, detail = redact_native_paths(str(exc)))
except RuntimeError as exc:
# A load is already in progress.
@ -10298,6 +10299,9 @@ async def generate_diffusion_image(
# Position within the batch: images here share a seed + timestamp,
# so the export filename needs this to stay unique.
"batch_index": index,
# The batch shares one seed, so reproducing image batch_index>0
# needs the original batch_size: persist it so restore can replay.
"batch_size": request.batch_size,
"model": result.get("repo_id"),
"created_at": created_at,
},

View file

@ -3081,23 +3081,45 @@ def _repo_gguf_last_modified(repo_info) -> float:
# image GGUFs in its On Device list.
_DIFFUSION_GGUF_ARCHS = frozenset(
{
"flux",
"flux2",
"sd1",
"sd2",
"sd3",
"sdxl",
"stable_diffusion",
"lumina2",
"qwen_image",
# ONLY the families the diffusion backend can actually assemble (see
# diffusion_families._FAMILIES). Other on-device diffusion archs (SD1/2/3,
# SDXL, PixArt, Lumina2, AuraFlow, Wan, HunyuanVideo, ...) would pass this
# Images-picker filter and then fail validate_load with a 400, so they are
# deliberately excluded until the backend supports them.
"flux", # flux.1
"flux2", # flux.2-klein
"qwen_image", # qwen-image
"qwenimage",
"auraflow",
"pixart",
"hunyuan_video",
"wan",
"z_image", # z-image
"zimage",
}
)
# Known diffusion / image-video GGUF archs the backend can NOT assemble yet. These
# are the GGUF general.architecture values llama.cpp also has no architecture for,
# kept in sync with core.inference.llama_cpp.LlamaCppBackend._DIFFUSION_ARCHES
# (minus the loadable set above). Tagging them with a dedicated, non-loadable task
# keeps them OUT of the chat picker -- loading one as a chat model dies with
# "unknown model architecture" -- while also keeping them out of the Images picker
# (the task is not an IMAGE_GEN_TASK), where they would 400 in validate_load.
_UNSUPPORTED_DIFFUSION_GGUF_ARCHS = frozenset(
{
"sd1",
"sd3",
"sdxl",
"aura",
"hidream",
"cosmos",
"ltxv",
"hyvid",
"wan",
"lumina2",
}
)
# Task tag for the archs above; mirrored by the frontend NON_CHAT_TASKS gate.
_UNSUPPORTED_DIFFUSION_TASK = "image-diffusion-unsupported"
def _gguf_architecture(path: str) -> Optional[str]:
"""The GGUF ``general.architecture``, or None. Delegates to the shared,
@ -3111,12 +3133,21 @@ def _gguf_architecture(path: str) -> Optional[str]:
def _arch_to_task(arch: Optional[str]) -> Optional[str]:
if arch is None:
return None
return "text-to-image" if arch.lower() in _DIFFUSION_GGUF_ARCHS else "text-generation"
a = arch.lower()
if a in _DIFFUSION_GGUF_ARCHS:
return "text-to-image"
# A diffusion arch the backend can't assemble: hide it from chat (it would die
# in llama.cpp) without surfacing it in Images (it would 400 in validate_load).
if a in _UNSUPPORTED_DIFFUSION_GGUF_ARCHS:
return _UNSUPPORTED_DIFFUSION_TASK
return "text-generation"
def _repo_gguf_task(repo_info) -> Optional[str]:
"""HF pipeline task of a cached GGUF repo, from its architecture:
'text-to-image' for diffusion archs, else 'text-generation' (None if unreadable)."""
'text-to-image' for a loadable diffusion arch, the non-loadable diffusion tag
for a recognized-but-unsupported image arch, else 'text-generation' (None if
unreadable)."""
try:
for path in _iter_gguf_paths(Path(repo_info.repo_path)):
if _is_mmproj_filename(path.name):

View file

@ -708,6 +708,31 @@ def test_gguf_download_progress_counts_quant_subdir(monkeypatch, tmp_path):
assert result["progress"] == 1.0
def test_arch_to_task_hides_unsupported_diffusion_from_chat():
# Loadable diffusion archs -> the Images-picker task.
assert models_route._arch_to_task("flux") == "text-to-image"
assert models_route._arch_to_task("z_image") == "text-to-image"
assert models_route._arch_to_task("qwen_image") == "text-to-image"
# A real LLM arch stays a chat model; None passes through.
assert models_route._arch_to_task("llama") == "text-generation"
assert models_route._arch_to_task(None) is None
# Known-but-unsupported diffusion archs get a task that is NEITHER chat
# ("text-generation") NOR a loadable image task ("text-to-image"), so the chat
# picker hides them (they'd die in llama.cpp) and the Images picker leaves them
# out (they'd 400 in validate_load).
for arch in ("sdxl", "sd1", "sd3", "wan", "lumina2", "hidream", "cosmos"):
task = models_route._arch_to_task(arch)
assert task == models_route._UNSUPPORTED_DIFFUSION_TASK
assert task not in ("text-generation", "text-to-image")
# Drift guard: every diffusion arch llama.cpp rejects as a chat model must be
# classified here as some image task (loadable OR unsupported), never chat.
from core.inference.llama_cpp import LlamaCppBackend
classified = models_route._DIFFUSION_GGUF_ARCHS | models_route._UNSUPPORTED_DIFFUSION_GGUF_ARCHS
missing = {a for a in LlamaCppBackend._DIFFUSION_ARCHES if a.lower() not in classified}
assert not missing, f"diffusion archs would still show in chat: {missing}"
def test_delete_cached_refuses_diffusion_loaded_repo(monkeypatch):
# The cached-delete guard refuses deleting a repo the diffusion (Images)
# backend has loaded, mirroring the chat guard, so its GGUF can't be removed

View file

@ -17,7 +17,9 @@ import pytest
from core.inference.diffusion import (
DiffusionBackend,
_LoadState,
_base_file_downloaded,
_resolve_diffusion_compute_dtype,
)
from core.inference.diffusion_families import (
detect_family,
@ -81,6 +83,11 @@ def test_resolve_local_gguf_child(tmp_path):
resolve_local_gguf_child(tmp_path, "..\\secret.gguf")
with pytest.raises(FileNotFoundError):
resolve_local_gguf_child(tmp_path, "missing.gguf")
# A directory named like the gguf exists but isn't loadable; reject it here so
# the preflight doesn't pass and evict chat for a pick from_single_file can't load.
(tmp_path / "dir.gguf").mkdir()
with pytest.raises(FileNotFoundError):
resolve_local_gguf_child(tmp_path, "dir.gguf")
def test_resolve_local_gguf_child_blocks_symlink_escape(tmp_path):
@ -206,6 +213,12 @@ def fake_runtime(monkeypatch):
# The backend imports clear_gpu_cache by reference; no-op it so unload doesn't
# run real hardware detection against the stubbed torch.
monkeypatch.setattr("core.inference.diffusion.clear_gpu_cache", lambda: None)
# Fake the hardware layer too: resolve_diffusion_device_target() consults
# utils.hardware.get_device(), so on a real XPU/ROCm box the host would leak
# through the stubbed torch and the device tests would resolve a non-CUDA
# target. None -> fall through to the (stubbed, monkeypatchable) torch probe.
monkeypatch.setattr("utils.hardware.get_device", lambda: None, raising = False)
monkeypatch.setattr("utils.hardware.hardware.IS_ROCM", False, raising = False)
_FakePipeline.last = {}
_FakeTransformer.last = {}
yield
@ -312,8 +325,9 @@ def test_load_without_gguf_raises():
def test_load_unknown_family_raises():
backend = DiffusionBackend()
# User-facing message: names the supported models, no internal-API jargon.
with pytest.raises(ValueError, match = "isn't a supported image-generation model"):
# load_pipeline validates via validate_load_request, which rejects an
# undetectable family before any GPU/network work.
with pytest.raises(ValueError, match = "family"):
backend.load_pipeline("some/unrecognised-repo", gguf_filename = "x.gguf")
@ -447,9 +461,11 @@ def test_pick_dtype_bf16_only_on_ampere(fake_runtime, monkeypatch):
backend = DiffusionBackend()
monkeypatch.setattr(torch.cuda, "is_available", lambda: True, raising = False)
monkeypatch.setattr(torch.cuda, "get_device_capability", lambda: (8, 0), raising = False)
assert backend._pick_device_and_dtype() == ("cuda", torch.bfloat16)
t = backend._resolve_device_target(None)
assert (t.device, t.dtype) == ("cuda", torch.bfloat16)
monkeypatch.setattr(torch.cuda, "get_device_capability", lambda: (7, 5), raising = False)
assert backend._pick_device_and_dtype() == ("cuda", torch.float16)
t = backend._resolve_device_target(None)
assert (t.device, t.dtype) == ("cuda", torch.float16)
def test_unload_sets_cancel_event(fake_runtime):
@ -504,6 +520,361 @@ def test_prefetch_downloads_gguf_and_base(monkeypatch, tmp_path):
assert ("base/repo", "vae/x.safetensors") in calls
# fp16-incompatible guard + dtype promotion
def test_zimage_is_fp16_incompatible():
# Only Z-Image-class families carry the guard (their activations overflow fp16).
assert detect_family("unsloth/Z-Image-Turbo-GGUF").fp16_incompatible is True
assert detect_family("unsloth/Z-Image-GGUF").fp16_incompatible is True
assert detect_family("unsloth/Qwen-Image-2512-GGUF").fp16_incompatible is False
assert detect_family("unsloth/FLUX.1-schnell-GGUF").fp16_incompatible is False
assert detect_family("unsloth/FLUX.2-klein-4B-GGUF").fp16_incompatible is False
def test_resolve_compute_dtype_promotes_fp16_for_zimage(fake_runtime):
torch = sys.modules["torch"]
z = detect_family("unsloth/Z-Image-GGUF")
q = detect_family("unsloth/Qwen-Image-GGUF")
# Z-Image: fp16 -> fp32; bf16 / fp32 pass through unchanged.
assert _resolve_diffusion_compute_dtype(z, torch.float16) is torch.float32
assert _resolve_diffusion_compute_dtype(z, torch.bfloat16) is torch.bfloat16
assert _resolve_diffusion_compute_dtype(z, torch.float32) is torch.float32
# An fp16-compatible family (and None) keep fp16.
assert _resolve_diffusion_compute_dtype(q, torch.float16) is torch.float16
assert _resolve_diffusion_compute_dtype(None, torch.float16) is torch.float16
def test_load_promotes_fp16_to_fp32_for_zimage_only(fake_runtime, monkeypatch, tmp_path):
torch = sys.modules["torch"]
# Pre-Ampere CUDA -> the resolver picks fp16; the guard must promote Z-Image
# (and only Z-Image) to fp32 so it doesn't render a black image.
monkeypatch.setattr(torch.cuda, "is_available", lambda: True, raising = False)
monkeypatch.setattr(torch.cuda, "get_device_capability", lambda: (7, 5), raising = False)
(tmp_path / "m.gguf").write_bytes(b"x")
z = DiffusionBackend().load_pipeline(
str(tmp_path), gguf_filename = "m.gguf", family_override = "z-image"
)
assert z["device"] == "cuda" and z["dtype"] == "float32"
# The promoted dtype reaches the transformer build (and thus the quant config).
assert str(_FakeTransformer.last["torch_dtype"]) == "torch.float32"
q = DiffusionBackend().load_pipeline(
str(tmp_path), gguf_filename = "m.gguf", family_override = "qwen-image"
)
assert q["dtype"] == "float16" # fp16-compatible family keeps fp16 on pre-Ampere
# Lock split + mid-denoise cancellation
def test_generate_lock_split_keeps_status_responsive_and_unload_waits(fake_runtime):
import threading
backend = DiffusionBackend()
started = threading.Event()
release = threading.Event()
class _BlockingPipe:
def __call__(self, **kwargs):
started.set()
release.wait(5)
return types.SimpleNamespace(images = [_FakeImage()])
fam = detect_family("unsloth/Z-Image-GGUF")
backend._state = _LoadState(
pipe = _BlockingPipe(),
family = fam,
repo_id = "r",
base_repo = "b",
device = "cpu",
dtype = "float32",
cpu_offload = False,
)
out: dict = {}
def _run():
try:
out["res"] = backend.generate(prompt = "p", steps = 4)
except Exception as exc: # noqa: BLE001
out["exc"] = exc
t = threading.Thread(target = _run)
t.start()
assert started.wait(5) # the denoise is in flight, holding _generate_lock
# status() / generate_progress() read _state lock-free, so they must NOT block
# behind the denoise.
assert backend.status()["loaded"] is True
assert backend.generate_progress()["active"] is True
# unload() signals THIS generation's cancel, then WAITS on _generate_lock for it
# to exit before freeing _state -- so when the GPU arbiter hands the card to chat
# the moment unload returns, the old pipeline is already gone (no dual-allocation
# OOM). Run it on a thread to observe that it blocks behind the live denoise.
unload_done = threading.Event()
threading.Thread(target = lambda: (backend.unload(), unload_done.set()), daemon = True).start()
assert not unload_done.wait(0.5) # blocked behind the live denoise
assert backend._state is not None # not freed while the pipeline is still live
assert backend._active_generate_cancel is not None
assert backend._active_generate_cancel.is_set() # cancel was signalled up front
release.set() # denoise returns and releases _generate_lock
t.join(5)
assert unload_done.wait(5) # only now does unload free _state and return
assert backend.status()["loaded"] is False
# The cancelled generation raised rather than returning a now-evicted image.
assert "exc" in out and "cancelled" in str(out["exc"]).lower()
def test_callback_cancellation_interrupts_denoise(fake_runtime):
import threading
backend = DiffusionBackend()
at_step0 = threading.Event()
resume = threading.Event()
class _SteppingPipe:
def __init__(self) -> None:
self._interrupt = False
self.steps_run = 0
def __call__(
self,
*,
callback_on_step_end = None,
num_inference_steps = 8,
**kwargs,
):
for i in range(num_inference_steps):
if self._interrupt: # diffusers' interrupt protocol
break
if callback_on_step_end is not None:
callback_on_step_end(self, i, 0.0, {})
self.steps_run = i + 1
if i == 0:
at_step0.set()
resume.wait(5)
return types.SimpleNamespace(images = [_FakeImage()])
pipe = _SteppingPipe()
fam = detect_family("unsloth/Z-Image-GGUF")
backend._state = _LoadState(
pipe = pipe,
family = fam,
repo_id = "r",
base_repo = "b",
device = "cpu",
dtype = "float32",
cpu_offload = False,
)
out: dict = {}
def _run():
try:
out["res"] = backend.generate(prompt = "p", steps = 8)
except Exception as exc: # noqa: BLE001
out["exc"] = exc
t = threading.Thread(target = _run)
t.start()
assert at_step0.wait(5) # step 0's callback ran with no cancel pending
# Simulate an eviction / superseding load signalling THIS generation's cancel.
assert backend._active_generate_cancel is not None
backend._active_generate_cancel.set()
resume.set()
t.join(5)
# The next step's callback saw the cancel, flipped pipe._interrupt, and the loop
# broke early, so the generation raised instead of returning a partial image.
assert pipe._interrupt is True
assert pipe.steps_run < 8
assert "exc" in out and "cancelled" in str(out["exc"]).lower()
def test_validate_load_request(tmp_path):
backend = DiffusionBackend()
with pytest.raises(ValueError, match = "gguf_filename"):
backend.validate_load_request("unsloth/Z-Image-Turbo-GGUF")
with pytest.raises(ValueError, match = "family"):
backend.validate_load_request("meta/Llama-3", gguf_filename = "q.gguf")
assert (
backend.validate_load_request("unsloth/Z-Image-Turbo-GGUF", gguf_filename = "q.gguf").name
== "z-image"
)
# A local path with a missing child fails here (before any GPU/network work).
with pytest.raises(FileNotFoundError):
backend.validate_load_request(
str(tmp_path), gguf_filename = "missing.gguf", family_override = "z-image"
)
(tmp_path / "m.gguf").write_bytes(b"x")
assert (
backend.validate_load_request(
str(tmp_path), gguf_filename = "m.gguf", family_override = "z-image"
).name
== "z-image"
)
# A path-shaped repo_id that does not exist is rejected here (it would otherwise
# be treated as remote, evict chat, and only fail in the background load).
with pytest.raises(FileNotFoundError):
backend.validate_load_request(
"/tmp/unsloth-definitely-missing-model",
gguf_filename = "m.gguf",
family_override = "z-image",
)
# Windows-shaped local paths (backslash separator / .\ ..\ prefixes) are also caught
# here, so a mistyped local pick on Windows can't be mistaken for a remote HF repo.
for missing in (r".\models\z-image", r"..\z-image", r"models\z-image", r"C:\models\z"):
with pytest.raises(FileNotFoundError):
backend.validate_load_request(
missing, gguf_filename = "m.gguf", family_override = "z-image"
)
# A bare "org/name" HF id is still treated as remote (not rejected as a local path).
assert (
backend.validate_load_request("unsloth/Z-Image-Turbo-GGUF", gguf_filename = "q.gguf").name
== "z-image"
)
def test_replacement_load_waits_for_inflight_generation(fake_runtime, tmp_path):
# A superseding load must signal the in-flight generation's cancel AND wait for
# it to release _generate_lock before allocating, so two pipelines never sit in
# VRAM at once (unlike unload(), which returns promptly without waiting).
import threading
backend = DiffusionBackend()
started = threading.Event()
release = threading.Event()
class _BlockingPipe:
def __call__(self, **kwargs):
started.set()
release.wait(5)
return types.SimpleNamespace(images = [_FakeImage()])
fam = detect_family("unsloth/Z-Image-GGUF")
backend._state = _LoadState(
pipe = _BlockingPipe(),
family = fam,
repo_id = "r",
base_repo = "b",
device = "cpu",
dtype = "float32",
cpu_offload = False,
)
gen_out: dict = {}
def _gen():
try:
backend.generate(prompt = "p", steps = 4)
except Exception as exc: # noqa: BLE001
gen_out["exc"] = exc
gt = threading.Thread(target = _gen)
gt.start()
assert started.wait(5) # generation in flight, holding _generate_lock
(tmp_path / "m.gguf").write_bytes(b"x")
load_done = threading.Event()
def _load():
backend.load_pipeline(str(tmp_path), gguf_filename = "m.gguf", family_override = "z-image")
load_done.set()
lt = threading.Thread(target = _load)
lt.start()
# The load must NOT finish while the generation still holds _generate_lock; it
# has signalled the generation's cancel and is waiting to allocate.
assert not load_done.wait(0.5)
assert backend._active_generate_cancel is not None
assert backend._active_generate_cancel.is_set()
release.set() # the blocked denoise returns; generate() sees cancel and raises
gt.join(5)
assert load_done.wait(5) # only now does the replacement allocate
assert "exc" in gen_out and "cancelled" in str(gen_out["exc"]).lower()
assert backend.status()["loaded"] is True
assert backend.status()["repo_id"] == str(tmp_path)
def test_superseded_load_does_not_cancel_unrelated_generation(fake_runtime, tmp_path):
# A stale worker (its _load_token already bumped by unload/a newer load) must bail on
# the token check BEFORE touching the current model's in-flight generation -- otherwise
# it would abort an unrelated denoise on its way out.
import threading
backend = DiffusionBackend()
started = threading.Event()
release = threading.Event()
class _BlockingPipe:
def __call__(self, **kwargs):
started.set()
release.wait(5)
return types.SimpleNamespace(images = [_FakeImage()])
fam = detect_family("unsloth/Z-Image-GGUF")
backend._state = _LoadState(
pipe = _BlockingPipe(),
family = fam,
repo_id = "current",
base_repo = "b",
device = "cpu",
dtype = "float32",
cpu_offload = False,
)
backend._load_token = 7 # the "current" token
gen_out: dict = {}
def _gen():
try:
backend.generate(prompt = "p", steps = 4)
except Exception as exc: # noqa: BLE001
gen_out["exc"] = exc
gt = threading.Thread(target = _gen)
gt.start()
assert started.wait(5) # generation in flight
cancel_event = backend._active_generate_cancel
assert cancel_event is not None
# A superseded load arrives with a STALE token -> must raise without cancelling.
(tmp_path / "m.gguf").write_bytes(b"x")
with pytest.raises(RuntimeError, match = "cancelled"):
backend.load_pipeline(
str(tmp_path), gguf_filename = "m.gguf", family_override = "z-image", _load_token = 1
)
# The in-flight generation's cancel must NOT have been signalled by the stale worker.
assert not cancel_event.is_set()
release.set()
gt.join(5)
# The generation completed normally (not cancelled).
assert "exc" not in gen_out
# The stale load did not replace the current model.
assert backend.status()["repo_id"] == "current"
def test_detect_family_from_local_gguf_filename(tmp_path):
# A direct local .gguf pick splits into (parent dir, basename); the family
# keyword can live only in the filename, so validate must scan it too.
(tmp_path / "z-image-turbo-Q4_K_M.gguf").write_bytes(b"w")
backend = DiffusionBackend()
fam = backend.validate_load_request(str(tmp_path), gguf_filename = "z-image-turbo-Q4_K_M.gguf")
assert fam.name == "z-image"
# An edit-checkpoint filename is still rejected even via the filename path.
(tmp_path / "FLUX.1-Kontext-dev-Q4.gguf").write_bytes(b"w")
with pytest.raises(ValueError):
backend.validate_load_request(str(tmp_path), gguf_filename = "FLUX.1-Kontext-dev-Q4.gguf")
# A bare parent dir whose name DOES carry the keyword still works (unchanged).
assert backend._detect_family_for_pick("unsloth/Z-Image-GGUF", "x.gguf", None).name == "z-image"
def test_run_load_does_not_stamp_superseded_progress(fake_runtime, monkeypatch):
# A worker whose load is superseded mid-resolve must not stamp its progress
# (base_repo / expected_bytes) onto the new load's _LoadingState.

View file

@ -0,0 +1,285 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Hermetic, CPU-only tests for the diffusion device/dtype resolver.
`torch` is stubbed via a fake module so no GPU/torch is needed, and
`utils.hardware` is either stubbed (studio-layer path) or forced to fail
(torch-probe fallback path). Both paths are asserted.
"""
from __future__ import annotations
import sys
import types
from core.inference import diffusion_device as dd
# ── Fakes ─────────────────────────────────────────────────────────────
class _FakeDtype:
def __init__(self, name: str) -> None:
self.name = name
def __eq__(self, other: object) -> bool:
return isinstance(other, _FakeDtype) and other.name == self.name
def __hash__(self) -> int:
return hash(self.name)
def __repr__(self) -> str: # str(dtype) -> "torch.bfloat16"
return f"torch.{self.name}"
BF16 = _FakeDtype("bfloat16")
FP16 = _FakeDtype("float16")
FP32 = _FakeDtype("float32")
class _FiniteResult:
def __init__(self, finite: bool) -> None:
self._finite = finite
def all(self) -> "_FiniteResult":
return self
def item(self) -> bool:
return self._finite
class _FakeTensor:
def __init__(self, finite: bool = True) -> None:
self._finite = finite
def __add__(self, other: object) -> "_FakeTensor":
return self
def float(self) -> "_FakeTensor":
return self
def _make_torch(
*,
cuda_available: bool = False,
capability = (8, 0),
capability_raises: bool = False,
bf16_supported: bool = False,
hip = None,
mps_available: bool = False,
mps_probe: str = "pass", # "pass" | "raise" | "nonfinite"
xpu_available = None, # None -> no xpu attr; True/False -> present
xpu_bf16: bool = False,
) -> types.ModuleType:
torch = types.ModuleType("torch")
torch.bfloat16 = BF16
torch.float16 = FP16
torch.float32 = FP32
torch.version = types.SimpleNamespace(hip = hip)
def _get_cap():
if capability_raises:
raise RuntimeError("no capability")
return capability
torch.cuda = types.SimpleNamespace(
is_available = lambda: cuda_available,
get_device_capability = _get_cap,
is_bf16_supported = lambda: bf16_supported,
)
mps_ns = types.SimpleNamespace(is_available = lambda: mps_available)
torch.backends = types.SimpleNamespace(mps = mps_ns)
def _ones(*_a, **_k):
if mps_probe == "raise":
raise RuntimeError("bf16 unsupported on this MPS")
return _FakeTensor(finite = (mps_probe == "pass"))
torch.ones = _ones
torch.isfinite = lambda t: _FiniteResult(getattr(t, "_finite", True))
if xpu_available is not None:
torch.xpu = types.SimpleNamespace(
is_available = lambda: xpu_available,
is_bf16_supported = lambda: xpu_bf16,
)
return torch
def _install(
monkeypatch,
torch,
*,
studio_device = None,
is_rocm = False,
hardware_fails = False,
):
"""Install the fake torch and either a fake or failing `utils.hardware`."""
monkeypatch.setitem(sys.modules, "torch", torch)
if hardware_fails:
# Force `from utils.hardware import ...` to raise -> torch-probe fallback.
monkeypatch.setitem(sys.modules, "utils.hardware", None)
return
class _DT:
CUDA = "cuda"
XPU = "xpu"
MLX = "mlx"
CPU = "cpu"
fake_uh = types.ModuleType("utils.hardware")
fake_uh.DeviceType = _DT
fake_uh.get_device = lambda: studio_device
fake_uh.hardware = types.SimpleNamespace(IS_ROCM = is_rocm)
monkeypatch.setitem(sys.modules, "utils.hardware", fake_uh)
# ── Studio-layer path ─────────────────────────────────────────────────
def test_cuda_ampere_bf16(monkeypatch):
torch = _make_torch(cuda_available = True, capability = (8, 0))
_install(monkeypatch, torch, studio_device = "cuda")
t = dd.resolve_diffusion_device_target()
assert (t.device, t.dtype, t.backend, t.vendor) == ("cuda", BF16, "cuda", "nvidia")
assert (
t.supports_model_cpu_offload
and t.supports_default_torch_compile
and t.supports_pinned_transfer
)
def test_cuda_pre_ampere_fp16(monkeypatch):
torch = _make_torch(cuda_available = True, capability = (7, 5), bf16_supported = True)
_install(monkeypatch, torch, studio_device = "cuda")
t = dd.resolve_diffusion_device_target()
# is_bf16_supported() is True (emulated) but capability < 8 -> fp16.
assert t.dtype == FP16 and t.backend == "cuda"
def test_cuda_capability_raises_falls_back_fp16(monkeypatch):
torch = _make_torch(cuda_available = True, capability_raises = True)
_install(monkeypatch, torch, studio_device = "cuda")
t = dd.resolve_diffusion_device_target()
assert t.dtype == FP16 and t.device == "cuda"
def test_cuda_studio_says_cuda_but_unavailable_is_cpu(monkeypatch):
torch = _make_torch(cuda_available = False)
_install(monkeypatch, torch, studio_device = "cuda")
t = dd.resolve_diffusion_device_target()
assert t.device == "cpu" and t.dtype == FP32
def test_rocm_target(monkeypatch):
torch = _make_torch(cuda_available = True, bf16_supported = True)
_install(monkeypatch, torch, studio_device = "cuda", is_rocm = True)
t = dd.resolve_diffusion_device_target()
assert (t.device, t.backend, t.vendor) == ("cuda", "rocm", "amd")
assert t.dtype == BF16
assert t.supports_default_torch_compile is False # ROCm disables default compile
def test_rocm_without_bf16_uses_fp16(monkeypatch):
torch = _make_torch(cuda_available = True, bf16_supported = False)
_install(monkeypatch, torch, studio_device = "cuda", is_rocm = True)
t = dd.resolve_diffusion_device_target()
assert t.dtype == FP16 and t.backend == "rocm"
def test_xpu_bf16(monkeypatch):
torch = _make_torch(xpu_available = True, xpu_bf16 = True)
_install(monkeypatch, torch, studio_device = "xpu")
t = dd.resolve_diffusion_device_target()
assert (t.device, t.backend, t.vendor, t.dtype) == ("xpu", "xpu", "intel", BF16)
assert (
t.supports_model_cpu_offload
and not t.supports_default_torch_compile
and not t.supports_pinned_transfer
)
def test_xpu_without_bf16_fp16(monkeypatch):
torch = _make_torch(xpu_available = True, xpu_bf16 = False)
_install(monkeypatch, torch, studio_device = "xpu")
t = dd.resolve_diffusion_device_target()
assert t.device == "xpu" and t.dtype == FP16
def test_mps_probe_pass_bf16(monkeypatch):
torch = _make_torch(mps_available = True, mps_probe = "pass")
_install(monkeypatch, torch, studio_device = "mlx")
t = dd.resolve_diffusion_device_target()
assert (t.device, t.backend, t.vendor, t.dtype) == ("mps", "mps", "apple", BF16)
assert not t.supports_model_cpu_offload
def test_mps_probe_raises_uses_fp32_not_fp16(monkeypatch):
torch = _make_torch(mps_available = True, mps_probe = "raise")
_install(monkeypatch, torch, studio_device = "mlx")
t = dd.resolve_diffusion_device_target()
assert t.device == "mps" and t.dtype == FP32 # strict: never silent fp16
def test_mps_probe_nonfinite_uses_fp32(monkeypatch):
torch = _make_torch(mps_available = True, mps_probe = "nonfinite")
_install(monkeypatch, torch, studio_device = "mlx")
t = dd.resolve_diffusion_device_target()
assert t.device == "mps" and t.dtype == FP32
def test_studio_cpu_on_apple_prefers_mps(monkeypatch):
torch = _make_torch(mps_available = True, mps_probe = "pass")
_install(monkeypatch, torch, studio_device = "cpu") # Studio reports CPU (no mlx pkg)
t = dd.resolve_diffusion_device_target()
assert t.device == "mps" and t.dtype == BF16
def test_cpu_when_nothing_available(monkeypatch):
torch = _make_torch(mps_available = False)
_install(monkeypatch, torch, studio_device = "cpu")
t = dd.resolve_diffusion_device_target()
assert (t.device, t.backend, t.vendor, t.dtype) == ("cpu", "cpu", None, FP32)
assert not any(
(t.supports_model_cpu_offload, t.supports_default_torch_compile, t.supports_pinned_transfer)
)
# ── torch-probe fallback path (utils.hardware import fails) ────────────
def test_fallback_cuda(monkeypatch):
torch = _make_torch(cuda_available = True, capability = (9, 0))
_install(monkeypatch, torch, hardware_fails = True)
t = dd.resolve_diffusion_device_target()
assert t.device == "cuda" and t.dtype == BF16 and t.backend == "cuda"
def test_fallback_rocm_via_torch_hip(monkeypatch):
torch = _make_torch(cuda_available = True, bf16_supported = True, hip = "6.2")
_install(monkeypatch, torch, hardware_fails = True)
t = dd.resolve_diffusion_device_target()
assert t.backend == "rocm" and t.vendor == "amd"
def test_fallback_xpu(monkeypatch):
torch = _make_torch(cuda_available = False, xpu_available = True, xpu_bf16 = True)
_install(monkeypatch, torch, hardware_fails = True)
t = dd.resolve_diffusion_device_target()
assert t.device == "xpu" and t.dtype == BF16
def test_fallback_mps(monkeypatch):
torch = _make_torch(cuda_available = False, mps_available = True, mps_probe = "pass")
_install(monkeypatch, torch, hardware_fails = True)
t = dd.resolve_diffusion_device_target()
assert t.device == "mps" and t.dtype == BF16
def test_fallback_cpu(monkeypatch):
torch = _make_torch(cuda_available = False, mps_available = False)
_install(monkeypatch, torch, hardware_fails = True)
t = dd.resolve_diffusion_device_target()
assert t.device == "cpu" and t.dtype == FP32

View file

@ -29,10 +29,23 @@ class _FakeBackend:
def is_loaded(self) -> bool:
return self.loaded
def validate_load(self, model_path, **kwargs):
# The route runs this cheap pre-flight before taking the GPU. The fake
# accepts anything; tests that exercise rejection patch it to raise.
return None
def validate_load_request(
self,
model_path,
*,
gguf_filename = None,
family_override = None,
):
# Mirror the real backend's cheap validation so the route's
# validate-before-evict ordering is exercised.
from core.inference.diffusion_families import detect_family
if not gguf_filename:
raise ValueError("gguf_filename is required.")
fam = detect_family(model_path, family_override)
if fam is None:
raise ValueError(f"Could not infer a diffusion family for '{model_path}'.")
return fam
def begin_load(self, model_path, **kwargs):
# The real backend loads on a thread; the fake completes instantly.
@ -251,7 +264,7 @@ def test_load_unknown_family_returns_400(client, monkeypatch):
backend = _FakeBackend()
# Validation runs in the pre-flight (before the GPU is taken), so that is
# where an unsupported model is rejected now.
backend.validate_load = _raise
backend.validate_load_request = _raise
monkeypatch.setattr(diffusion_module, "get_diffusion_backend", lambda: backend)
resp = client.post(
"/api/inference/images/load", json = {"model_path": "x/y", "gguf_filename": "q.gguf"}
@ -272,7 +285,7 @@ def test_load_validation_failure_does_not_evict_chat(client, monkeypatch):
def _raise(*a, **k):
raise ValueError("'x/y' isn't a supported image-generation model.")
backend.validate_load = _raise
backend.validate_load_request = _raise
monkeypatch.setattr(diffusion_module, "get_diffusion_backend", lambda: backend)
resp = client.post(
"/api/inference/images/load", json = {"model_path": "x/y", "gguf_filename": "q.gguf"}
@ -325,3 +338,44 @@ def test_routes_require_auth():
app.include_router(studio_router, prefix = "/api/inference")
unauth = TestClient(app)
assert unauth.get("/api/inference/images/status").status_code in (401, 403)
def test_invalid_family_returns_400_without_evicting_chat(client):
# An undetectable family fails validation BEFORE the GPU handoff, so the
# arbiter is never acquired and a loaded chat model would not be evicted.
resp = client.post(
"/api/inference/images/load", json = {"model_path": "x/y", "gguf_filename": "q.gguf"}
)
assert resp.status_code == 400
assert "family" in resp.json()["detail"]
assert gpu_arbiter._owner is None
def test_validate_filenotfound_maps_to_400_without_eviction(client, monkeypatch):
def _raise_fnf(*a, **k):
raise FileNotFoundError("'q.gguf' not found under /models/x.")
backend = _FakeBackend()
backend.validate_load_request = _raise_fnf
monkeypatch.setattr(diffusion_module, "get_diffusion_backend", lambda: backend)
resp = client.post(
"/api/inference/images/load", json = {"model_path": "/models/x", "gguf_filename": "q.gguf"}
)
assert resp.status_code == 400
assert gpu_arbiter._owner is None
def test_in_progress_returns_409_after_validation_passes(client, monkeypatch):
def _busy(*a, **k):
raise RuntimeError("A diffusion load is already in progress.")
backend = _FakeBackend()
backend.begin_load = _busy
monkeypatch.setattr(diffusion_module, "get_diffusion_backend", lambda: backend)
resp = client.post(
"/api/inference/images/load",
json = {"model_path": "unsloth/Z-Image-Turbo-GGUF", "gguf_filename": "q.gguf"},
)
assert resp.status_code == 409
# Validation passed first, so the GPU WAS acquired before begin_load reported busy.
assert gpu_arbiter._owner == gpu_arbiter.DIFFUSION

View file

@ -119,6 +119,19 @@ def test_list_skips_foreign_pngs(tmp_path):
assert [r["prompt"] for r in listed] == ["ours"]
def test_foreign_png_in_window_does_not_drop_valid_images():
# A foreign PNG sorting INTO the requested page must not consume a window slot and
# drop a valid image that sorts after it: paging is over readable records, not files.
_save_with_mtime("p2", 100.0)
foreign = gallery.gallery_dir() / "zzz_foreign.png"
_img().save(foreign, format = "PNG") # newest by mtime (set below), sorts first
os.utime(foreign, (300.0, 300.0))
_save_with_mtime("p1", 200.0)
# First page of 2 must still return both real images, not [p1] (foreign eating a slot).
page1 = gallery.list_images(limit = 2, offset = 0)
assert [r["prompt"] for r in page1] == ["p1", "p2"]
def test_list_skips_recipe_missing_required_fields(tmp_path):
# A PNG carrying our chunk but an incomplete/older-schema recipe (no seed etc.)
# must be skipped, not crash the whole listing when the route builds GalleryImage.

View file

@ -32,6 +32,7 @@ import {
} from "@tanstack/react-router";
import { AnimatePresence, motion } from "motion/react";
import {
lazy,
Suspense,
useEffect,
useLayoutEffect,
@ -57,6 +58,13 @@ function RouteFallback() {
);
}
// ImagesPage is mounted persistently below (not via the /images route) so an
// in-flight image batch survives leaving the tab, mirroring ChatPage. Kept lazy
// so its bundle still loads only on the first /images visit.
const ImagesPage = lazy(() =>
import("@/features/images").then((m) => ({ default: m.ImagesPage })),
);
function PersonalizationSyncMount() {
usePersonalizationSync(hasAuthToken());
return null;
@ -141,6 +149,17 @@ function RootLayout() {
const chatSearch = isChatRoute ? liveChatSearch : frozenChatSearch;
const shouldMountChat = isChatRoute || chatMounted;
// Same persistent-mount treatment for /images so a long image batch keeps
// generating when the user flips to another tab (ImagesPage reads no URL
// search, so it needs no freeze dance -- just the mount latch). Mounts lazily
// on first /images visit, then stays mounted, hidden+inert while off-route.
const isImagesRoute = pathname === "/images";
const [imagesMounted, setImagesMounted] = useState(isImagesRoute);
if (isImagesRoute && !imagesMounted) {
setImagesMounted(true);
}
const shouldMountImages = isImagesRoute || imagesMounted;
useTrainingUnloadGuard();
// Global export driver: streams worker logs and tracks status from any route
// so an export keeps running and stays visible while training / chatting.
@ -253,12 +272,30 @@ function RootLayout() {
<ChatPage search={chatSearch} active={isChatRoute} />
</div>
)}
{/* Same keep-alive treatment for Images so a long batch keeps
generating off-tab; `active` force-closes its body-portaled
overlays (model selector, recipe popover, aspect dropdown) so
none can bleed over another tab while hidden. */}
{shouldMountImages && (
<div
className={
isImagesRoute
? "flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-visible"
: "hidden"
}
inert={!isImagesRoute || undefined}
>
<Suspense fallback={<RouteFallback />}>
<ImagesPage active={isImagesRoute} />
</Suspense>
</div>
)}
{/* Use mode="popLayout" instead of "wait" to prevent UI freezes when
switching from heavy pages (like Export with many checkpoints).
"popLayout" allows the new route to mount immediately while the
old one animates out, avoiding blocking on expensive exit renders.
See issue #5850. */}
{!isChatRoute && (
{!isChatRoute && !isImagesRoute && (
<AnimatePresence initial={false} mode="popLayout">
<motion.div
key={pathname}

View file

@ -2,20 +2,15 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { createRoute } from "@tanstack/react-router";
import { lazy } from "react";
import { requireAuth } from "../auth-guards";
import { Route as rootRoute } from "./__root";
const ImagesPage = lazy(() =>
import("@/features/images").then((m) => ({
default: m.ImagesPage,
})),
);
// RootLayout renders ImagesPage persistently (so an in-flight image batch is not
// cancelled when leaving the tab); this route only owns the URL + auth gate.
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/images",
staticData: { title: "Images" },
beforeLoad: () => requireAuth(),
component: ImagesPage,
component: () => null,
});

View file

@ -985,6 +985,16 @@ export const IMAGE_GEN_TASKS = [
"image-text-to-image",
] as const;
// Diffusion GGUF archs the Images backend can't assemble yet (SD/SDXL/PixArt/Wan/
// ...). The backend tags them with this task so the chat picker hides them -- they
// die with "unknown model architecture" in llama.cpp -- while the Images picker,
// which filters on IMAGE_GEN_TASKS, also leaves them out (they'd 400 on load).
const UNSUPPORTED_DIFFUSION_TASK = "image-diffusion-unsupported";
// Tasks that must never appear as a loadable chat model: the Images-handled
// generation tasks plus the non-loadable diffusion tag above.
const NON_CHAT_TASKS: readonly string[] = [...IMAGE_GEN_TASKS, UNSUPPORTED_DIFFUSION_TASK];
// Editing/inpaint checkpoints are tagged image-to-image but need an input image,
// which the text-to-image backend rejects (mirrors its _EDIT_KEYWORDS). Hidden by
// id so they don't show in the Images picker only to 400 on load. Keeping the
@ -1010,7 +1020,7 @@ function passesTaskGate(
filter: HfTaskFilter,
): boolean {
if (filter) return taskMatchesFilter(repoTask, filter) && !isImageEditModel(repoId);
return !(repoTask != null && (IMAGE_GEN_TASKS as readonly string[]).includes(repoTask));
return !(repoTask != null && NON_CHAT_TASKS.includes(repoTask));
}
// Module-level caches so re-mounting the popover shows results instantly

View file

@ -62,6 +62,7 @@ export interface GalleryImage {
guidance: number;
seed: number;
batch_index: number;
batch_size: number;
model: string | null;
created_at: number;
}

View file

@ -348,12 +348,17 @@ function RecipeRow({
function RecipePopover({
image,
onRestore,
active,
}: {
image: GalleryImage;
onRestore: (image: GalleryImage) => void;
active: boolean;
}) {
// Controlled + force-closed off-tab: PopoverContent portals to body, so the
// hidden/inert page wrapper can't contain it when the page is kept mounted.
const [open, setOpen] = useState(false);
return (
<Popover>
<Popover open={active && open} onOpenChange={(o) => setOpen(active && o)}>
<PopoverTrigger asChild>
<Button size="sm" variant="ghost" className="gap-1.5">
<HugeiconsIcon icon={InformationCircleIcon} className="size-4" />
@ -389,7 +394,7 @@ function RecipePopover({
type Busy = "loading" | "unloading" | "generating" | null;
export function ImagesPage() {
export function ImagesPage({ active = true }: { active?: boolean }) {
const [quant, setQuant] = useState<string | null>(galleryCache.quant);
const [prompt, setPrompt] = useState(
"a tiny ginger sloth coding in a sunlit treehouse, photorealistic",
@ -419,6 +424,11 @@ export function ImagesPage() {
const [genStep, setGenStep] = useState<DiffusionGenerateProgress | null>(null);
const genPollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const [status, setStatus] = useState<DiffusionStatus | null>(null);
// Controlled so the body-portaled overlays force-close when this page is mounted
// but off-tab (a hidden/inert parent can't contain a body portal): the model
// selector and the aspect-ratio dropdown.
const [selectorOpen, setSelectorOpen] = useState(false);
const [aspectOpen, setAspectOpen] = useState(false);
// Records come from the backend (durable); srcById maps each id to its object
// URL (loaded images) or data URL (the one just generated).
const [images, setImages] = useState<GalleryImage[]>(() => galleryCache.images);
@ -429,6 +439,10 @@ export function ImagesPage() {
);
// Guards a "load more" so a fast scroll can't fire several at once.
const loadingMore = useRef(false);
// False once the page truly unmounts (app close / chat-only eject). The page now
// stays mounted across tab switches, so a switch does NOT flip this -- a batch keeps
// generating off-tab; the multi-run loop only stops on a real unmount.
const isMounted = useRef(true);
const pollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// The persistent load toast's id, so each poll updates it in place (chat-style).
const loadToastId = useRef<string | number | null>(null);
@ -538,6 +552,9 @@ export function ImagesPage() {
setSeed(String(image.seed));
setWidth(image.width);
setHeight(image.height);
// The batch shared one seed, so image batch_index>0 only reproduces by replaying the
// whole batch: restore the batch size too (older recipes without it default to 1).
setBatchSize(image.batch_size ?? 1);
const m = matchAspect(image.width, image.height);
setAspect(m.key);
setPortrait(m.portrait);
@ -580,6 +597,29 @@ export function ImagesPage() {
}
}, []);
// Track mount so a long generate run stops issuing GPU work when the page is
// truly unmounted (app close / chat-only eject). The page now stays mounted
// across tab switches (RootLayout keeps it alive like chat), so a switch no
// longer breaks the loop -- a batch keeps generating off-tab. The mount-time
// refreshStatus and timer/toast cleanup live in the load-resume effect below,
// so this one carries only the mount flag.
useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);
// Re-sync model status when the tab becomes active again: while off-tab the
// diffusion model may have been evicted (e.g. a chat load claimed the GPU),
// and the page no longer remounts on return to refresh it on its own.
useEffect(() => {
if (!active) return;
void (async () => {
await refreshStatus();
})();
}, [active, refreshStatus]);
// Poll load-progress until the background load reaches "ready" or "error",
// updating the persistent toast in place each tick.
const pollLoadProgress = useCallback(async () => {
@ -692,12 +732,28 @@ export function ImagesPage() {
// replacement load now would tear down the live poll/toast and reset
// busy, while the backend rejects the second load with a 409.
if (busy !== null) return;
if (!meta.ggufVariant || !meta.ggufFilename) return; // not a quant pick
setQuant(meta.ggufVariant);
const d = defaultsFor(id);
setSteps(d.steps);
setGuidance(d.guidance);
void handleLoad(id, meta.ggufFilename);
if (meta.ggufVariant && meta.ggufFilename) {
setQuant(meta.ggufVariant);
const d = defaultsFor(id);
setSteps(d.steps);
setGuidance(d.guidance);
void handleLoad(id, meta.ggufFilename);
return;
}
// A direct single-file local .gguf pick has no variant/filename (custom folder /
// LM Studio). Load it by splitting the path into (parent dir, basename) the backend
// resolves, instead of silently doing nothing.
if (meta.isGguf) {
const norm = id.replace(/\\/g, "/");
const slash = norm.lastIndexOf("/");
const filename = slash >= 0 ? norm.slice(slash + 1) : norm;
const dir = slash >= 0 ? norm.slice(0, slash) : ".";
if (!filename.toLowerCase().endsWith(".gguf")) return;
const d = defaultsFor(id);
setSteps(d.steps);
setGuidance(d.guidance);
void handleLoad(dir, filename);
}
},
[busy, handleLoad],
);
@ -773,6 +829,9 @@ export function ImagesPage() {
}, 300);
try {
for (let i = 0; i < runs; i++) {
// The page truly unmounted mid-run (app close / chat-only eject): stop
// issuing more GPU generations. A plain tab switch keeps it mounted.
if (!isMounted.current) break;
const res = await generateDiffusionImage({
prompt: prompt.trim(),
// Only send a negative prompt when guidance uses it, so the recipe
@ -785,6 +844,7 @@ export function ImagesPage() {
seed: baseSeed + i,
batch_size: batchSize,
});
if (!isMounted.current) break;
// Prepend this run's records (newest first) and load their blobs.
setImages((prev) => [...res.images, ...prev]);
if (res.images[0]) setSelectedId(res.images[0].id);
@ -817,6 +877,8 @@ export function ImagesPage() {
variant="ghost"
className="!h-[34px]"
task={IMAGE_GEN_TASKS}
open={active && selectorOpen}
onOpenChange={(o) => setSelectorOpen(active && o)}
/>
</div>
@ -850,7 +912,12 @@ export function ImagesPage() {
hint="Pick a ratio to lock the proportions, then set the size with the sliders. Flip swaps width and height. Sizes run from 256 to 2048 in steps of 16. Z-Image is trained around 1 megapixel, so much larger sizes can look worse."
>
<div className="flex items-center gap-2">
<Select value={aspect} onValueChange={changeAspect}>
<Select
value={aspect}
onValueChange={changeAspect}
open={active && aspectOpen}
onOpenChange={(o) => setAspectOpen(active && o)}
>
<SelectTrigger className="flex-1">
<SelectValue />
</SelectTrigger>
@ -942,7 +1009,7 @@ export function ImagesPage() {
any image and read as a unit instead of blending into the canvas.
Size/seed live in the Recipe popover, so no separate chip here. */}
<div className="absolute bottom-4 right-4 flex items-center gap-0.5 rounded-xl bg-background/80 p-1 shadow-lg ring-1 ring-border backdrop-blur">
<RecipePopover image={selected} onRestore={restoreSettings} />
<RecipePopover image={selected} onRestore={restoreSettings} active={active} />
<Button
size="sm"
variant="ghost"