Merge remote-tracking branch 'origin/diffusion-lora-ux' into diffusion-lora-training

This commit is contained in:
Daniel Han 2026-07-01 23:45:29 +00:00
commit b4b66907fe
12 changed files with 260 additions and 160 deletions

View file

@ -29,8 +29,10 @@ from loggers import get_logger
from utils.hardware import clear_gpu_cache
from .diffusion_families import (
DIFFUSION_CANCELLED_MSG,
DIFFUSION_NOT_LOADED_MSG,
DiffusionFamily,
detect_family,
detect_family_for_pick,
resolve_base_repo,
resolve_local_gguf_child,
supported_family_names,
@ -43,11 +45,10 @@ from .diffusion_device import (
from .diffusion_memory import (
OFFLOAD_NONE,
apply_memory_plan,
estimate_gguf_dense_mib,
estimate_gguf_resident_mib,
estimate_image_runtime_mib,
estimate_safetensors_dense_mib,
file_size_mib,
infer_gguf_quant_label,
plan_diffusion_memory,
snapshot_device_memory,
)
@ -410,22 +411,6 @@ class DiffusionBackend:
base, rfilename, hf_token, cancel_event = self._cancel_event
)
@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_request(
self,
repo_id: str,
@ -441,7 +426,7 @@ class DiffusionBackend:
undetectable family, and ValueError/FileNotFoundError for a bad local path.
Touches no GPU, network, or state."""
kind = resolve_model_kind(gguf_filename, model_kind)
fam = self._detect_family_for_pick(repo_id, gguf_filename, family_override)
fam = detect_family_for_pick(repo_id, gguf_filename, family_override)
if fam is None:
raise ValueError(
f"'{repo_id}' is not a supported diffusion image model. Supported families: "
@ -564,7 +549,7 @@ 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 = self._detect_family_for_pick(
fam = detect_family_for_pick(
kwargs["repo_id"], kwargs.get("gguf_filename"), kwargs.get("family_override")
)
kind = resolve_model_kind(kwargs.get("gguf_filename"), kwargs.get("model_kind"))
@ -780,7 +765,6 @@ class DiffusionBackend:
plan = self._plan_memory(
target,
single_file_path,
gguf_filename,
base,
fam,
memory_mode,
@ -973,7 +957,11 @@ class DiffusionBackend:
quant = transformer_quant_engaged,
attention_backend = attention_engaged,
compile_kwargs = {
"fullgraph": cache_engaged is None,
# Mirrors apply_speed_optims' fullgraph decision: an active
# step cache OR a planned offload graph-breaks, so the cached
# bundle must be keyed on the same fullgraph setting.
"fullgraph": cache_engaged is None
and plan.offload_policy == OFFLOAD_NONE,
"dynamic": effective_speed != SPEED_MAX,
"mode": "max-autotune-no-cudagraphs"
if effective_speed == SPEED_MAX
@ -989,8 +977,21 @@ class DiffusionBackend:
family = fam,
speed_mode = effective_speed,
cache_active = cache_engaged is not None,
# The planned offload policy: group/model/sequential offload installs
# compiler-disabled onload hooks, so compile must drop fullgraph.
offload_active = plan.offload_policy != OFFLOAD_NONE,
logger = logger,
)
if transformer_quant_engaged is not None and not speed_applied.get("compiled"):
# Promotion above could not engage compile (e.g. the family is not
# compile-friendly, or compile_repeated_blocks failed): the quantized
# transformer is now running eager, which is far slower than the GGUF
# path it replaced. Surface it loudly rather than hiding the regression.
logger.warning(
"diffusion.transformer_quant: %s engaged but the transformer is NOT "
"compiled; eager torchao quant is ~30x slower than GGUF here",
transformer_quant_engaged,
)
# Quantise the dense companion text encoder(s) (opt-in fp8 / nvfp4),
# also before placement so the offload hooks move the smaller weights.
te_quant = quantize_text_encoders(
@ -1046,6 +1047,9 @@ class DiffusionBackend:
if eager_patched:
uninstall_patches()
uninstall_arch_patches()
# Also free the half-built pipe's VRAM: the failed load never
# commits _state, so nothing else reclaims it until the next unload.
clear_gpu_cache()
logger.info(
"diffusion.loaded: repo=%s base=%s device=%s offload=%s tiling=%s reasons=%s",
@ -1144,7 +1148,6 @@ class DiffusionBackend:
self,
target: DiffusionDeviceTarget,
single_file_path: Optional[str],
gguf_filename: Optional[str],
base: str,
fam: DiffusionFamily,
memory_mode: Optional[str],
@ -1158,9 +1161,10 @@ class DiffusionBackend:
offload policy + VAE memory savers. Kept on the backend so the cached base
repo (companion text-encoder / VAE) feeds the size estimate.
The size estimate is per-kind: a GGUF dequantises (a 4-bit file ~4x), a
safetensors single-file loads near its on-disk size, and a full pipeline is
one cached download (transformer + companions) that is already compressed."""
The size estimate is per-kind: diffusers keeps GGUF weights packed (per-matmul
transient dequant), so a GGUF loads near its on-disk size; a safetensors
single-file loads near its on-disk size (it carries its dtype); and a full
pipeline is one cached download (transformer + companions), already compressed."""
device_memory = snapshot_device_memory(target)
if kind == "pipeline":
# The whole repo (transformer + companions) is one cached download; the
@ -1172,18 +1176,18 @@ class DiffusionBackend:
else:
if kind == "single_file":
# Safetensors single-file: no dequant expansion (it carries its dtype).
transformer_dense = estimate_safetensors_dense_mib(file_size_mib(single_file_path))
else:
transformer_dense = estimate_gguf_dense_mib(
file_size_mib(single_file_path), infer_gguf_quant_label(gguf_filename)
transformer_resident = estimate_safetensors_dense_mib(
file_size_mib(single_file_path)
)
else:
transformer_resident = estimate_gguf_resident_mib(file_size_mib(single_file_path))
# The companion components (VAE + text encoders) load near their on-disk
# size; sum whatever the prefetch already placed in the base-repo cache.
companion = self._cache_bytes(base)
companion_mib = int(companion // (1024 * 1024)) if companion else None
model_dense_mib = None
if transformer_dense is not None:
model_dense_mib = transformer_dense + (companion_mib or 0)
if transformer_resident is not None:
model_dense_mib = transformer_resident + (companion_mib or 0)
runtime_headroom = estimate_image_runtime_mib(width = None, height = None, family = fam.name)
return plan_diffusion_memory(
target = target,
@ -1392,7 +1396,7 @@ class DiffusionBackend:
with self._lock:
state = self._state
if state is None:
raise RuntimeError("No diffusion model is loaded.")
raise RuntimeError(DIFFUSION_NOT_LOADED_MSG)
# 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.
@ -1633,7 +1637,7 @@ class DiffusionBackend:
# 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.")
raise RuntimeError(DIFFUSION_CANCELLED_MSG)
# The first compiled generation just paid the compile cost; persist the
# warm torch.compile cache bundle when saving is enabled (distributor /
# first-run warm). Idempotent + best-effort -- never fails a generation.

View file

@ -20,6 +20,14 @@ from pathlib import Path, PurePosixPath
from typing import Optional
# Runtime->route contract: the RuntimeError messages a backend raises for
# client-recoverable generate states. The /images/generate route matches these
# EXACTLY to return 409 (vs a sanitized 500 for real failures), so both engines
# must raise them verbatim -- keep them named here, not as scattered literals.
DIFFUSION_NOT_LOADED_MSG = "No diffusion model is loaded."
DIFFUSION_CANCELLED_MSG = "Diffusion generation was cancelled."
@dataclass(frozen = True)
class DiffusionFamily:
name: str
@ -338,6 +346,24 @@ def supported_family_names() -> tuple[str, ...]:
return tuple(fam.name for fam in _FAMILIES)
def detect_family_for_pick(
repo_id: str,
gguf_filename: Optional[str] = None,
override: Optional[str] = None,
) -> Optional[DiffusionFamily]:
"""``detect_family``, 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 the combined string too when the directory alone is undetectable. Only a
fallback, so remote ``org/name`` picks and explicit overrides behave exactly as
``detect_family``. Shared by both engines so validation and load can't diverge."""
fam = detect_family(repo_id, override)
if fam is None and gguf_filename and not override:
fam = detect_family(f"{repo_id}/{gguf_filename}", override)
return fam
def resolve_base_repo(fam: DiffusionFamily, base_repo: Optional[str]) -> str:
"""The companion diffusers repo: caller-supplied if given, else the family fallback."""
base = (base_repo or "").strip()

View file

@ -218,51 +218,22 @@ def file_size_mib(path: Any) -> Optional[int]:
return None
def infer_gguf_quant_label(filename: Optional[str]) -> Optional[str]:
"""Pull a quant tag (Q4_K_M, Q8_0, BF16, ...) out of a GGUF filename."""
if not filename:
return None
from pathlib import Path
def estimate_gguf_resident_mib(storage_mib: Optional[int]) -> Optional[int]:
"""Approximate the RESIDENT device size of a GGUF transformer loaded through
diffusers' ``GGUFQuantizationConfig``.
stem = Path(filename).name
if stem.lower().endswith(".gguf"):
stem = stem[:-5]
parts = [p.upper() for p in stem.replace("-", "_").split("_") if p]
for index, part in enumerate(parts):
if part in ("BF16", "F16", "FP16", "FP8", "Q8", "Q6", "Q5", "Q4", "Q3", "Q2"):
suffix = parts[index + 1 :]
# Quant names carry either a K-family suffix (Q4_K_M) or a legacy
# numeric one (Q8_0, Q5_1); keep up to two suffix tokens.
if suffix and suffix[0] in ("K", "M", "S", "L", "XS", "XXS", "0", "1"):
return "_".join([part] + suffix[:2])
return part
if part.startswith("IQ") or part.startswith("UD"):
return "_".join(parts[index : index + 3])
return None
The weights stay PACKED on the device as quantised bytes (``GGUFParameter`` /
uint8); ``GGUFLinear.forward`` dequantises each weight to the bf16 compute dtype
transiently for its matmul and frees it immediately, so the persistent footprint
is ~= the on-disk tensor size, NOT the unpacked bf16 size. Measured on
Z-Image-Turbo: Q2_K 3.64 GiB -> 3.68 GiB, Q8_0 7.22 GiB -> 7.25 GiB resident.
The transient per-op dequant is covered by the separate runtime headroom.
def estimate_gguf_dense_mib(storage_mib: Optional[int], quant: Optional[str]) -> Optional[int]:
"""Approximate the dequantised (device) size of a GGUF from its on-disk size
and quant label. The compute dtype is bf16/fp16, so a 4-bit file roughly
quadruples once unpacked; higher-bit quants expand less."""
(The prior per-quant expansion assumed a full unpack that never happens on this
path; it over-estimated e.g. Q2 ~7.6x, forcing needless offload.)"""
if storage_mib is None:
return None
q = (quant or "").upper()
if any(t in q for t in ("BF16", "F16", "FP16")):
return storage_mib
if "FP8" in q or "Q8" in q:
return int(storage_mib * 2.0)
if "Q6" in q:
return int(storage_mib * 2.8)
if "Q5" in q:
return int(storage_mib * 3.3)
if "Q4" in q or "IQ4" in q or "UD" in q:
return int(storage_mib * 4.0)
if "Q3" in q or "IQ3" in q:
return int(storage_mib * 5.3)
if "Q2" in q or "Q1" in q or "IQ2" in q or "IQ1" in q:
return int(storage_mib * 8.0)
return int(storage_mib * 4.0) # unknown: assume 4-bit-ish
return int(storage_mib * 1.05) # small margin for allocator + bf16 norms/biases
def estimate_safetensors_dense_mib(storage_mib: Optional[int]) -> Optional[int]:

View file

@ -164,11 +164,17 @@ def apply_speed_optims(
family: Any,
speed_mode: str = SPEED_OFF,
cache_active: bool = False,
offload_active: bool = False,
logger: Any = None,
) -> dict[str, bool]:
"""Apply the opt-in speed optimisations for ``speed_mode`` to a built pipeline,
BEFORE placement / offload. Returns which optimisations actually engaged. Every
step is best-effort: a pipeline that doesn't support one is simply skipped."""
step is best-effort: a pipeline that doesn't support one is simply skipped.
``offload_active`` is the planned offload policy != none: group/model/sequential
offloading installs ``@torch.compiler.disable``d onload hooks, so the compile must
drop ``fullgraph`` (same reason as an active step cache) or it crashes at the first
denoise step."""
applied = {
"channels_last": False,
"cudnn_benchmark": False,
@ -178,12 +184,11 @@ def apply_speed_optims(
"compiled_dequant": False,
}
mode = normalize_speed_mode(speed_mode)
# TF32 is the one PROCESS-GLOBAL flag we flip (on max). Restore it whenever this
# load isn't max, so a later default/off diffusion load -- or chat inference in the
# same long-lived process -- doesn't silently inherit a prior max load's TF32 and
# lose the bit-identical default the regression harness checks.
if mode != SPEED_MAX:
_restore_tf32(logger)
# TF32 and cudnn.benchmark are the process-global flags this may flip (TF32 on max,
# cudnn.benchmark on any non-off CUDA load). The caller snapshots them before this
# call and restores on unload / failed load via snapshot_backend_flags /
# restore_backend_flags, so a later `off` load -- or chat inference in the same
# process -- never inherits them. We keep no separate bookkeeping here.
if mode == SPEED_OFF:
return applied
@ -212,11 +217,19 @@ def apply_speed_optims(
applied["compiled_dequant"] = gguf_compile.install_compiled_dequant(logger)
elif compile_eligible(target, is_gguf = is_gguf, family = family):
applied["compiled"] = _compile_repeated_blocks(
pipe, logger, max_autotune = False, cache_active = cache_active
pipe,
logger,
max_autotune = False,
cache_active = cache_active,
offload_active = offload_active,
)
elif mode == SPEED_MAX and compile_eligible(target, is_gguf = is_gguf, family = family):
applied["compiled"] = _compile_repeated_blocks(
pipe, logger, max_autotune = True, cache_active = cache_active
pipe,
logger,
max_autotune = True,
cache_active = cache_active,
offload_active = offload_active,
)
if mode == SPEED_MAX:
@ -247,6 +260,7 @@ def _compile_repeated_blocks(
*,
max_autotune: bool = False,
cache_active: bool = False,
offload_active: bool = False,
) -> bool:
transformer = getattr(pipe, "transformer", None)
fn = getattr(transformer, "compile_repeated_blocks", None)
@ -259,14 +273,34 @@ def _compile_repeated_blocks(
# / max-autotune) are deliberately NOT used: they crash on the regionally-compiled
# block because its static output buffer is overwritten across denoise steps.
#
# fullgraph drops to False when a step cache is engaged: FBCache's per-step decision is
# ``@torch.compiler.disable``d, i.e. a graph break, which fullgraph=True rejects ("Skip
# inlining torch.compiler.disable()d function"). The break is cheap and the rest of the
# block still compiles.
kwargs: dict[str, Any] = {"fullgraph": not cache_active, "dynamic": not max_autotune}
# fullgraph drops to False when a step cache OR CPU offloading is engaged: both insert
# an ``@torch.compiler.disable``d function into the forward -- FBCache's per-step
# decision, and group/model/sequential offload's ``ModuleGroup.onload_`` streaming hook
# -- i.e. a graph break, which fullgraph=True rejects ("Skip inlining
# torch.compiler.disable()d function"). The break is cheap and the rest of the block
# still compiles.
kwargs: dict[str, Any] = {
"fullgraph": not (cache_active or offload_active),
"dynamic": not max_autotune,
}
if max_autotune:
kwargs["mode"] = "max-autotune-no-cudagraphs"
try:
import torch
# Heterogeneous-block DiTs (e.g. Z-Image) compile ~one graph per distinct block
# shape through compile_repeated_blocks; Z-Image needs ~11, above dynamo's default
# recompile_limit of 8. Once the limit is hit a resident load hard-errors under
# fullgraph (and an offload/cache load silently drops the overflow blocks to eager),
# so raise it well past that (64) for headroom on larger heterogeneous DiTs. This is
# diffusers' own documented fix for regional-compile recompilation (their guide bumps
# cache_size_limit). Deliberately NOT force_parameter_static_shapes=False: it doesn't
# cut the variant count here and makes each compile ~6x slower (24s -> 143s cold).
dynamo_cfg = getattr(getattr(torch, "_dynamo", None), "config", None)
if dynamo_cfg is not None:
for _limit_attr in ("recompile_limit", "cache_size_limit"): # name varies by torch ver
if hasattr(dynamo_cfg, _limit_attr):
setattr(dynamo_cfg, _limit_attr, max(getattr(dynamo_cfg, _limit_attr) or 0, 64))
fn(**kwargs)
return True
except Exception as exc: # noqa: BLE001 — optimisation only
@ -284,22 +318,10 @@ def _enable_cudnn_benchmark(logger: Any) -> bool:
return False
# The TF32 flag values from before the first max load flipped them, so a later
# non-max load / unload can put the process back exactly as it found it (rather than
# forcing a hardcoded default that might clobber another component's choice).
_tf32_prev: Optional[tuple[bool, bool]] = None
def _enable_tf32(logger: Any) -> bool:
global _tf32_prev
try:
import torch
if _tf32_prev is None:
_tf32_prev = (
torch.backends.cuda.matmul.allow_tf32,
torch.backends.cudnn.allow_tf32,
)
torch.backends.cuda.matmul.allow_tf32 = True
torch.backends.cudnn.allow_tf32 = True
return True
@ -308,26 +330,6 @@ def _enable_tf32(logger: Any) -> bool:
return False
def restore_tf32(logger: Any = None) -> None:
"""Put the process-global TF32 flags back to their pre-max-load values. No-op if
a max load never set them. Called on a non-max load and on unload."""
_restore_tf32(logger)
def _restore_tf32(logger: Any) -> None:
global _tf32_prev
if _tf32_prev is None:
return
try:
import torch
torch.backends.cuda.matmul.allow_tf32 = _tf32_prev[0]
torch.backends.cudnn.allow_tf32 = _tf32_prev[1]
except Exception as exc: # noqa: BLE001 — best-effort restore
_warn(logger, "tf32_restore", exc)
finally:
_tf32_prev = None
def _fuse_qkv(pipe: Any, logger: Any) -> bool:
for owner in (pipe, getattr(pipe, "transformer", None)):
fn = getattr(owner, "fuse_qkv_projections", None)

View file

@ -35,8 +35,10 @@ from typing import Any, Optional
from core.inference.diffusion_device import resolve_diffusion_device_target
from core.inference.diffusion_families import (
DIFFUSION_CANCELLED_MSG,
DIFFUSION_NOT_LOADED_MSG,
DiffusionFamily,
detect_family,
detect_family_for_pick,
family_sd_cpp_supported,
resolve_base_repo,
resolve_local_gguf_child,
@ -248,7 +250,10 @@ class SdCppDiffusionBackend:
raise ValueError(
"gguf_filename is required: the native engine loads single-file GGUF checkpoints only."
)
fam = detect_family(repo_id, family_override)
# Use the filename-fallback detector the route validated with, so a local
# .gguf pick whose family keyword lives only in the basename doesn't pass
# validation and then dead-end here on a no-GPU (native-routed) host.
fam = detect_family_for_pick(repo_id, gguf_filename, family_override)
if fam is None:
raise ValueError(
f"'{repo_id}' is not a supported diffusion image model. Supported families: "
@ -505,7 +510,7 @@ class SdCppDiffusionBackend:
with self._lock:
state = self._state
if state is None:
raise RuntimeError("No diffusion model is loaded.")
raise RuntimeError(DIFFUSION_NOT_LOADED_MSG)
self._active_generate_cancel = cancel
engine = self._resolve_engine()
try:
@ -554,7 +559,7 @@ class SdCppDiffusionBackend:
lora_dir = str(Path(tmpdir) / "loras")
for index in range(max(1, int(batch_size))):
if cancel.is_set():
raise RuntimeError("Diffusion generation was cancelled.")
raise RuntimeError(DIFFUSION_CANCELLED_MSG)
# Distinct seed per batch image (sd-cli is one image/run here),
# so a batch is reproducible image-by-image from the base seed.
# Mask to sd-cli's int64 range, NOT 53 bits: the request model and
@ -593,7 +598,7 @@ class SdCppDiffusionBackend:
images.append(im.copy())
seeds.append(seed_i)
if cancel.is_set():
raise RuntimeError("Diffusion generation was cancelled.")
raise RuntimeError(DIFFUSION_CANCELLED_MSG)
# ``seeds`` is the per-image seed (each sd-cli run used seed+index), so
# the route can persist the real seed for every image in the batch.
return {
@ -603,7 +608,7 @@ class SdCppDiffusionBackend:
"repo_id": state.repo_id,
}
except SdCppCancelled as exc:
raise RuntimeError("Diffusion generation was cancelled.") from exc
raise RuntimeError(DIFFUSION_CANCELLED_MSG) from exc
finally:
self._gen = None
with self._lock:

View file

@ -34,6 +34,7 @@ import time
from pathlib import Path
from typing import Callable, Optional
from utils.process_lifetime import child_popen_kwargs
from core.inference.sd_cpp_args import (
SdCppGenParams,
SdCppModelFiles,
@ -352,6 +353,10 @@ class SdCppEngine:
# Own session/process group so cancellation/timeout can kill the whole
# tree, not just the parent (POSIX only; harmless flag elsewhere).
start_new_session = (os.name == "posix"),
# Bind the child to the parent's lifetime (Linux PR_SET_PDEATHSIG), so a
# hard parent crash mid-generation can't orphan sd-cli holding VRAM/RAM --
# matching every llama.cpp Popen site. Composes with start_new_session.
**child_popen_kwargs(),
)
# Drain stdout on a reader thread so the timeout is enforced even when the
# child hangs WITHOUT printing (e.g. stuck in model load / GPU init): a plain

View file

@ -10377,6 +10377,10 @@ async def generate_diffusion_image(
):
from core.inference import image_gallery
from core.inference.diffusion_engine_router import get_active_diffusion_engine
from core.inference.diffusion_families import (
DIFFUSION_CANCELLED_MSG,
DIFFUSION_NOT_LOADED_MSG,
)
backend = get_active_diffusion_engine()
try:
@ -10414,11 +10418,15 @@ async def generate_diffusion_image(
# doesn't support) — a 400 with the reason, not a generic 500.
raise HTTPException(status_code = 400, detail = str(exc))
except RuntimeError as exc:
# Only "no model loaded" / cancelled are client-state (409). The native
# sd.cpp engine also raises RuntimeError for execution failures (nonzero
# exit, timeout, missing output), which are server errors (500).
# Only "no model loaded" / user-cancelled are client-state (409); both engines
# raise these two EXACT messages. The native sd.cpp engine also raises
# RuntimeError for execution failures (nonzero exit, timeout, missing output)
# whose text can embed the raw sd-cli tail (local paths / argv) -- those are
# server errors (500) returned as a fixed literal, never echoed. Match the
# sentinels exactly, not as a substring, so an sd-cli failure that merely
# contains "cancelled" can't misroute to 409 and leak that output.
msg = str(exc)
if "No diffusion model is loaded" in msg or "cancelled" in msg.lower():
if msg in (DIFFUSION_NOT_LOADED_MSG, DIFFUSION_CANCELLED_MSG):
raise HTTPException(status_code = 409, detail = msg)
logger.error("diffusion.generate_failed: %s", exc, exc_info = True)
raise HTTPException(status_code = 500, detail = "Image generation failed.")

View file

@ -472,7 +472,8 @@ def test_generate_img2img_unsupported_family_raises(fake_runtime, tmp_path, monk
base_repo = "base/repo",
)
monkeypatch.setattr(
"core.inference.diffusion.detect_family", lambda repo_id, override = None: plain
"core.inference.diffusion.detect_family_for_pick",
lambda repo_id, gguf_filename = None, override = None: plain,
)
(tmp_path / "model.gguf").write_bytes(b"x")
backend = DiffusionBackend()
@ -987,6 +988,38 @@ def test_generate_without_load_raises(fake_runtime):
backend.generate(prompt = "x")
def test_failed_load_restores_backend_flags(fake_runtime, tmp_path, monkeypatch):
# A failure AFTER apply_speed_optims (here an OOM in apply_memory_plan) must go
# through the load's try/finally and restore the process-global TF32 / cudnn flags,
# so a later `off` load is still bit-identical, and must not commit a partial state.
# Regression: a refactor dropped this guard, leaking the flags on a failed load.
(tmp_path / "model.gguf").write_bytes(b"x")
backend = DiffusionBackend()
restored: list = []
cleared: list = []
monkeypatch.setattr(
"core.inference.diffusion.restore_backend_flags", lambda snap: restored.append(snap)
)
monkeypatch.setattr("core.inference.diffusion.clear_gpu_cache", lambda: cleared.append(True))
monkeypatch.setattr(
"core.inference.diffusion.apply_memory_plan",
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("CUDA out of memory")),
)
with pytest.raises(RuntimeError, match = "out of memory"):
backend.load_pipeline(
str(tmp_path),
gguf_filename = "model.gguf",
family_override = "z-image",
base_repo = "base/repo",
speed_mode = "max",
)
assert restored, "restore_backend_flags was not called on the failed-load path"
assert cleared, "clear_gpu_cache was not called on the failed-load path (VRAM leak)"
assert backend._state is None and backend.is_loaded is False
def test_resolve_base_repo_prefers_caller_then_hf_tag_then_fallback(monkeypatch):
from core.inference import diffusion
from core.inference.diffusion_families import detect_family

View file

@ -27,9 +27,8 @@ from core.inference.diffusion_memory import (
DeviceMemory,
MemoryPlan,
apply_memory_plan,
estimate_gguf_dense_mib,
estimate_gguf_resident_mib,
estimate_image_runtime_mib,
infer_gguf_quant_label,
normalize_memory_mode,
plan_diffusion_memory,
snapshot_device_memory,
@ -70,29 +69,15 @@ def test_normalize_memory_mode_accepts_and_rejects():
# ── filename / size estimates ─────────────────────────────────────────────────
@pytest.mark.parametrize(
"filename,expected",
[
("z-image-turbo-Q4_K_M.gguf", "Q4_K_M"),
("flux1-dev-Q8_0.gguf", "Q8_0"),
("model-BF16.gguf", "BF16"),
("qwen-image-IQ4_XS.gguf", "IQ4_XS"),
("no-quant-here.gguf", None),
(None, None),
],
)
def test_infer_gguf_quant_label(filename, expected):
assert infer_gguf_quant_label(filename) == expected
def test_estimate_gguf_dense_mib_expansion():
# 4-bit roughly quadruples once dequantised to bf16; F16 is already dense.
assert estimate_gguf_dense_mib(1000, "Q4_K_M") == 4000
assert estimate_gguf_dense_mib(1000, "Q8_0") == 2000
assert estimate_gguf_dense_mib(1000, "BF16") == 1000
assert estimate_gguf_dense_mib(None, "Q4_K_M") is None
# Unknown quant falls back to the conservative 4-bit-ish factor.
assert estimate_gguf_dense_mib(1000, None) == 4000
def test_estimate_gguf_resident_mib_matches_packed_size():
# GGUF weights stay packed (uint8) on-device; diffusers dequantises per-matmul
# transiently, so the resident footprint ~= the on-disk size regardless of quant
# level (measured on Z-Image-Turbo: Q2_K 3.64->3.68 GiB, Q8_0 7.22->7.25 GiB). A
# small margin covers allocator overhead. The prior per-quant expansion over-
# estimated (Q2 ~7.6x) and forced needless offload on a roomy card.
assert estimate_gguf_resident_mib(1000) == 1050
assert estimate_gguf_resident_mib(7220) == 7581
assert estimate_gguf_resident_mib(None) is None
def test_estimate_image_runtime_scales_with_pixels_and_family():

View file

@ -303,6 +303,37 @@ def test_generate_pipeline_error_returns_sanitized_500(client, monkeypatch):
assert "CUDA" not in resp.json()["detail"]
def test_generate_execution_error_with_cancelled_substring_is_sanitized_500(client, monkeypatch):
# A native sd-cli execution failure whose raw tail merely CONTAINS "cancelled"
# must stay a sanitized 500, not misroute to 409 and echo that output (path/arg
# leak). Regression: the handler matched "cancelled" as a substring.
backend = diffusion_module.get_diffusion_backend()
backend.loaded = True
def _fail(**kwargs):
raise RuntimeError("sd-cli exited 1. Last output:\nop cancelled at /home/u/models/x.gguf")
monkeypatch.setattr(backend, "generate", _fail)
resp = client.post("/api/inference/images/generate", json = {"prompt": "p"})
assert resp.status_code == 500
assert resp.json()["detail"] == "Image generation failed."
assert "cancelled" not in resp.json()["detail"] and "models" not in resp.json()["detail"]
def test_generate_user_cancellation_returns_409(client, monkeypatch):
# The exact cancellation sentinel both engines raise is client-state (409).
backend = diffusion_module.get_diffusion_backend()
backend.loaded = True
def _cancel(**kwargs):
raise RuntimeError("Diffusion generation was cancelled.")
monkeypatch.setattr(backend, "generate", _cancel)
resp = client.post("/api/inference/images/generate", json = {"prompt": "p"})
assert resp.status_code == 409
assert resp.json()["detail"] == "Diffusion generation was cancelled."
def test_load_unknown_family_returns_400(client, monkeypatch):
def _raise(*a, **k):
raise ValueError("'x/y' isn't a supported image-generation model. Supported: Z-Image.")

View file

@ -239,6 +239,25 @@ def test_speed_default_dense_falls_back_to_regional_compile(monkeypatch):
assert called == {"compiled_dequant": 0}
def test_offload_active_drops_fullgraph(monkeypatch):
# Group/model/sequential offload installs a torch.compiler.disable'd onload hook;
# compiling with fullgraph=True then crashes at the first denoise step. Same reason
# as an active step cache -> fullgraph must drop to False when offload is planned.
# (Dense model: on this branch GGUF `default` takes the compiled-dequant path.)
_stub_torch(monkeypatch)
pipe = _Pipe(with_compile = True)
applied = apply_speed_optims(
pipe,
_target(),
is_gguf = False,
family = _family(),
speed_mode = SPEED_DEFAULT,
offload_active = True,
)
assert applied["compiled"] is True
assert pipe.compile_kwargs["fullgraph"] is False
def test_speed_default_gguf_compiles_only_dequant(monkeypatch):
# GGUF `default` is the LIGHT path: compile ONLY the dequant op chain, NOT the
# regional block compile.

View file

@ -219,6 +219,17 @@ def test_begin_load_requires_gguf_filename():
b.begin_load("unsloth/Z-Image-Turbo-GGUF")
def test_begin_load_resolves_family_from_filename_only(monkeypatch):
# A local .gguf pick whose family keyword lives only in the basename (parent dir
# carries none) must resolve via the same filename fallback the route validated
# with -- not dead-end with "Could not infer" on a native (no-GPU) host.
b = SdCppDiffusionBackend(engine = _FakeEngine())
monkeypatch.setattr(b, "_run_load", lambda **kwargs: None) # skip the download thread
b.begin_load("/models/gguf-store", gguf_filename = "Z-Image-Turbo-Q4_K_M.gguf")
# Validation passed (no ValueError) and the family was inferred from the filename.
assert b._loading is not None and b._loading.repo_id == "/models/gguf-store"
def test_ensure_binary_returns_found(monkeypatch):
monkeypatch.setattr(bk, "find_sd_cpp_binary", lambda: "/usr/bin/sd-cli")
assert ensure_sd_cpp_binary() == "/usr/bin/sd-cli"