Studio diffusion (Phase 16): route no-GPU loads to the native sd.cpp engine

When no CUDA/ROCm/XPU GPU is available, route diffusion load/generate to the
native stable-diffusion.cpp engine instead of diffusers, with diffusers as the
guaranteed fallback. On CPU sd.cpp is 1.4-2.8x faster and uses 1.5-2.2x less RAM.

- diffusion_engine_router: centralised engine selection (built on the existing
  select_diffusion_engine), env opt-outs, MPS gating, recorded fallback reason.
- sd_cpp_backend (SdCppDiffusionBackend): the diffusers backend method surface
  backed by sd-cli, with lazy binary install, registry-driven asset fetch,
  step-progress parsing, and cancellation.
- diffusion_families: per-family single-file VAE + text-encoder asset mapping.
- sd_cpp_engine: cancellation support (process-group kill + SdCppCancelled).
- routes/inference + gpu_arbiter: drive the active engine via the router; the
  API now reports the active engine and any fallback reason.
- tests for the backend, router, route selection, and cancellation.
This commit is contained in:
Daniel Han 2026-06-28 04:30:21 +00:00
commit 7f3c206fa1
11 changed files with 1241 additions and 27 deletions

View file

@ -0,0 +1,139 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Selects the diffusion engine (diffusers vs native sd.cpp) for the live route.
The image routes drive one engine at a time. On a CUDA/ROCm/XPU GPU that engine is
the diffusers ``DiffusionBackend`` (the default, and the only path with the torchao
fast-quant / compile stack). With no usable GPU (CPU, or Apple MPS when explicitly
enabled) it is the native ``SdCppDiffusionBackend``, which is faster and far lighter
on RAM there. The choice is made once at load time and remembered, so ``generate`` /
``unload`` / ``status`` / progress all act on the same engine the load committed to.
Selection is centralised here and built on the existing pure ``select_diffusion_engine``
decision; this module adds the policy around it (env opt-out, MPS gating, per-family
native-asset support, lazy binary availability) and records why a fallback happened.
Env knobs (one canonical interpretation each):
UNSLOTH_DIFFUSION_ENGINE=auto|diffusers|sd_cpp force an engine (auto = decide)
UNSLOTH_DIFFUSION_SD_CPP=auto|0|1 enable/disable the native route
UNSLOTH_DIFFUSION_SD_CPP_MPS=0|1 allow native on Apple MPS (default off)
UNSLOTH_DIFFUSION_SD_CPP_INSTALL=auto|0|1 allow lazy binary install (in sd_cpp_backend)
"""
from __future__ import annotations
import os
import threading
from typing import Any, Optional
from core.inference.diffusion_device import resolve_diffusion_device_target
from core.inference.diffusion_families import DiffusionFamily, family_sd_cpp_supported
from core.inference.sd_cpp_backend import _install_allowed, ensure_sd_cpp_binary
from core.inference.sd_cpp_engine import ENGINE_DIFFUSERS, ENGINE_SD_CPP, select_diffusion_engine
from loggers import get_logger
logger = get_logger(__name__)
_DISABLE_TOKENS = frozenset({"0", "off", "false", "no"})
_ENABLE_TOKENS = frozenset({"1", "on", "true", "yes"})
# The engine the current (or most recent) load committed to, and why a non-native
# choice was made. Mutated only under _lock during selection.
_lock = threading.Lock()
_active_engine_name: str = ENGINE_DIFFUSERS
_fallback_reason: Optional[str] = None
def _engine_config() -> tuple[str, str, bool]:
forced = os.environ.get("UNSLOTH_DIFFUSION_ENGINE", "auto").strip().lower()
sd_cpp = os.environ.get("UNSLOTH_DIFFUSION_SD_CPP", "auto").strip().lower()
mps = os.environ.get("UNSLOTH_DIFFUSION_SD_CPP_MPS", "0").strip().lower() in _ENABLE_TOKENS
return forced, sd_cpp, mps
def get_active_diffusion_engine() -> Any:
"""The engine object the active selection points at (defaults to diffusers)."""
if _active_engine_name == ENGINE_SD_CPP:
from core.inference.sd_cpp_backend import get_sd_cpp_backend
return get_sd_cpp_backend()
from core.inference.diffusion import get_diffusion_backend
return get_diffusion_backend()
def active_engine_name() -> str:
return _active_engine_name
def _activate(name: str, reason: Optional[str]) -> Any:
global _active_engine_name, _fallback_reason
with _lock:
_active_engine_name = name
_fallback_reason = reason if name == ENGINE_DIFFUSERS else None
if name == ENGINE_SD_CPP:
logger.info("diffusion engine: sd_cpp")
else:
logger.info("diffusion engine: diffusers (%s)", reason or "selected")
return get_active_diffusion_engine()
def select_and_activate_engine(fam: DiffusionFamily, *, hf_token: Optional[str] = None) -> Any:
"""Pick + activate the engine for loading ``fam`` on this host; return the engine.
Falls back to diffusers (recording a reason) whenever the native route is
disabled, the device has a usable GPU, MPS is not enabled, the family has no
native asset mapping, or the sd-cli binary is unavailable -- always BEFORE the
slow load begins, so a fallback never strands a half-native load.
"""
forced, sd_cpp_pref, mps_enabled = _engine_config()
if forced == ENGINE_DIFFUSERS:
return _activate(ENGINE_DIFFUSERS, "forced (UNSLOTH_DIFFUSION_ENGINE=diffusers)")
prefer_native = forced == ENGINE_SD_CPP
if sd_cpp_pref in _DISABLE_TOKENS and not prefer_native:
return _activate(ENGINE_DIFFUSERS, "native engine disabled (UNSLOTH_DIFFUSION_SD_CPP=0)")
target = resolve_diffusion_device_target()
backend = target.backend
# Policy: CPU is always native-eligible; MPS only when explicitly enabled; a GPU
# backend (cuda/rocm/xpu) never is, unless the user force-selects sd_cpp.
policy_eligible = backend == "cpu" or (backend == "mps" and mps_enabled) or prefer_native
fam_ok = family_sd_cpp_supported(fam)
binary = None
if policy_eligible and fam_ok:
binary = ensure_sd_cpp_binary(allow_install = _install_allowed())
native_available = bool(binary) and policy_eligible and fam_ok
choice = select_diffusion_engine(
backend, native_available = native_available, prefer_native = prefer_native
)
if choice == ENGINE_SD_CPP:
return _activate(ENGINE_SD_CPP, None)
# Explain the diffusers choice for status/telemetry.
if not policy_eligible:
reason = f"GPU backend '{backend}' uses diffusers"
elif not fam_ok:
reason = f"family '{fam.name}' has no native sd.cpp asset mapping"
elif not binary:
reason = "sd-cli binary unavailable"
else:
reason = "diffusers selected"
return _activate(ENGINE_DIFFUSERS, reason)
def annotate_status(status: dict[str, Any]) -> dict[str, Any]:
"""Tag a backend status dict with the active engine + any fallback reason."""
out = dict(status)
out["engine"] = _active_engine_name
out["fallback_reason"] = _fallback_reason
return out
def active_status() -> dict[str, Any]:
"""The active engine's status, annotated with which engine + any fallback reason."""
return annotate_status(get_active_diffusion_engine().status())

View file

@ -44,6 +44,20 @@ class DiffusionFamily:
# materialising the dense bf16 transformer on the GPU (much lower load VRAM + a
# smaller download). Empty until checkpoints are hosted -> behaviour is unchanged.
prequant_repos: tuple[tuple[str, str], ...] = field(default_factory = tuple)
# Native (stable-diffusion.cpp) single-file assets, used only when the no-GPU
# sd.cpp engine is selected (CPU / Apple). The transformer GGUF is shared with
# the diffusers path; sd-cli additionally needs a single-file VAE and text
# encoder(s), because the diffusers base repo ships those sharded and sd-cli
# cannot read that layout. Each asset is a hashable (repo_id, filename) the
# backend fetches with hf_hub_download. ``sd_cpp_text_encoders`` carries a
# trailing SdCppModelFiles field name (clip_l / t5xxl / llm / qwen2vl / clip_g)
# so the backend maps each file onto the right sd-cli flag. Empty -> the family
# has no native mapping and the sd.cpp route falls back to diffusers.
sd_cpp_vae: Optional[tuple[str, str]] = None
# VAE latent-format override for sd-cli (--vae-format): "flux2" for the FLUX.2
# autoencoder, None (auto) otherwise.
sd_cpp_vae_format: Optional[str] = None
sd_cpp_text_encoders: tuple[tuple[str, str, str], ...] = field(default_factory = tuple)
# Keyed by architecture, not per model variant: a checkpoint's specific base repo
@ -59,6 +73,11 @@ _FAMILIES: tuple[DiffusionFamily, ...] = (
transformer_class = "FluxTransformer2DModel",
base_repo = "black-forest-labs/FLUX.1-schnell",
aliases = ("flux1", "flux-1"),
sd_cpp_vae = ("black-forest-labs/FLUX.1-schnell", "ae.safetensors"),
sd_cpp_text_encoders = (
("comfyanonymous/flux_text_encoders", "clip_l.safetensors", "clip_l"),
("comfyanonymous/flux_text_encoders", "t5xxl_fp16.safetensors", "t5xxl"),
),
),
# FLUX.2-klein is a distinct pipeline (Flux2KleinPipeline) with a Qwen3 text
# encoder, not the Mistral-based Flux2Pipeline; it must precede a generic
@ -69,6 +88,14 @@ _FAMILIES: tuple[DiffusionFamily, ...] = (
transformer_class = "Flux2Transformer2DModel",
base_repo = "black-forest-labs/FLUX.2-klein-4B",
aliases = ("flux2-klein",),
# FLUX.2 uses a distinct 32-channel autoencoder; sd-cli needs the latent
# format override. The single-file VAE ships in Comfy-Org/flux2-dev (the
# klein-4B repo only has a sharded diffusers VAE). Shares Qwen3-4B with z-image.
sd_cpp_vae = ("Comfy-Org/flux2-dev", "split_files/vae/flux2-vae.safetensors"),
sd_cpp_vae_format = "flux2",
sd_cpp_text_encoders = (
("Comfy-Org/z_image_turbo", "split_files/text_encoders/qwen_3_4b.safetensors", "llm"),
),
),
DiffusionFamily(
name = "qwen-image",
@ -77,6 +104,12 @@ _FAMILIES: tuple[DiffusionFamily, ...] = (
base_repo = "Qwen/Qwen-Image",
cfg_kwarg = "true_cfg_scale",
aliases = ("qwen_image", "qwenimage"),
sd_cpp_vae = ("Comfy-Org/Qwen-Image_ComfyUI", "split_files/vae/qwen_image_vae.safetensors"),
# The Qwen2.5-VL text encoder as a Q4_K_M GGUF keeps the CPU RAM win (the
# bf16 safetensors encoder is ~15 GB). sd-cli's --qwen2vl is an alias of --llm.
sd_cpp_text_encoders = (
("unsloth/Qwen2.5-VL-7B-Instruct-GGUF", "Qwen2.5-VL-7B-Instruct-Q4_K_M.gguf", "qwen2vl"),
),
),
DiffusionFamily(
name = "z-image",
@ -86,6 +119,10 @@ _FAMILIES: tuple[DiffusionFamily, ...] = (
aliases = ("zimage", "z_image"),
# Z-Image's MLP down-projections peak near 9e5, which overflows float16.
fp16_incompatible = True,
sd_cpp_vae = ("Comfy-Org/z_image_turbo", "split_files/vae/ae.safetensors"),
sd_cpp_text_encoders = (
("Comfy-Org/z_image_turbo", "split_files/text_encoders/qwen_3_4b.safetensors", "llm"),
),
),
)
@ -130,6 +167,13 @@ def family_prequant_repo(fam: DiffusionFamily, scheme: str) -> Optional[str]:
return None
def family_sd_cpp_supported(fam: DiffusionFamily) -> bool:
"""True when the family has the single-file VAE + text-encoder mapping the
native sd.cpp engine needs. A family without it can only run on diffusers, so
the no-GPU route falls back rather than routing to sd-cli."""
return bool(fam.sd_cpp_vae and fam.sd_cpp_text_encoders)
def resolve_local_gguf_child(repo_root: Path, gguf_filename: str) -> Path:
"""Resolve ``gguf_filename`` to a file under ``repo_root``, rejecting escapes.

View file

@ -51,8 +51,10 @@ def _evict_chat() -> None:
def _evict_diffusion() -> None:
from core.inference.diffusion import get_diffusion_backend
get_diffusion_backend().unload()
# Unload whichever engine the router has active (diffusers or native sd.cpp), so a
# chat acquire frees the right one.
from core.inference.diffusion_engine_router import get_active_diffusion_engine
get_active_diffusion_engine().unload()
# Patchable in tests via monkeypatch.setitem.

View file

@ -0,0 +1,553 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Native stable-diffusion.cpp diffusion backend (the no-GPU tier).
``SdCppDiffusionBackend`` presents the SAME public surface the image routes use on
the diffusers ``DiffusionBackend`` (``begin_load`` / ``load_progress`` / ``generate``
/ ``generate_progress`` / ``unload`` / ``status``), but is backed by the ``sd-cli``
subprocess (``SdCppEngine``) instead of an in-process diffusers pipeline. The engine
router (``diffusion_engine_router.py``) selects this backend only when no usable
CUDA/ROCm/XPU GPU is present, where it is measurably faster and far lighter on RAM
than diffusers (see outputs/sdcpp_cpu).
It reuses the transformer GGUF the diffusers path already downloads and additionally
fetches the per-family single-file VAE + text encoders declared in
``diffusion_families`` (sd-cli cannot read the sharded diffusers components). The
binary is installed lazily on first use; if it is unavailable or the family has no
native mapping, the router falls back to diffusers, so this backend is only ever
asked to run requests it can serve.
Import-light on purpose: no torch / diffusers here, so selecting it on a CPU box
does not drag the heavy GPU stack into the process.
"""
from __future__ import annotations
import logging
import os
import re
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional
from core.inference.diffusion_device import resolve_diffusion_device_target
from core.inference.diffusion_families import (
DiffusionFamily,
detect_family,
family_sd_cpp_supported,
resolve_base_repo,
resolve_local_gguf_child,
)
from core.inference.sd_cpp_args import SdCppGenParams, SdCppModelFiles
from core.inference.sd_cpp_engine import (
SdCppCancelled,
SdCppEngine,
find_sd_cpp_binary,
)
from loggers import get_logger
logger = get_logger(__name__)
# A sampling-progress line like " 4/4" / "[ 12/ 28]" / "sampling: 50%|...| 14/28".
# We only trust a match whose denominator equals the requested step count, so an
# unrelated "1/100" elsewhere in the log can't move the bar.
_STEP_RE = re.compile(r"(\d+)\s*/\s*(\d+)")
# Serialises the one-time binary install so concurrent first-loads don't race on the
# download / extract / chmod.
_install_lock = threading.Lock()
def ensure_sd_cpp_binary(*, allow_install: bool = True, accelerator: str = "cpu") -> Optional[str]:
"""Path to a usable ``sd-cli`` binary, installing the prebuilt once if needed.
Returns the binary path, or None when it is absent and cannot be installed
(install disabled, no network, unsupported platform). Never raises -- a None
return is the router's signal to fall back to diffusers.
"""
found = find_sd_cpp_binary()
if found:
return found
if not allow_install:
return None
with _install_lock:
# Re-check inside the lock: a concurrent first-load may have installed it.
found = find_sd_cpp_binary()
if found:
return found
try:
import sys
studio_dir = Path(__file__).resolve().parents[3] # .../studio
if str(studio_dir) not in sys.path:
sys.path.insert(0, str(studio_dir))
from install_sd_cpp_prebuilt import install as _install
except Exception as exc: # noqa: BLE001 -- import path / module issues are non-fatal
logger.warning("sd-cli installer import failed: %s", exc)
return None
try:
path = _install(accelerator = accelerator)
logger.info("sd-cli installed at %s", path)
return str(path)
except Exception as exc: # noqa: BLE001 -- download/extract failure -> fall back
logger.warning("sd-cli auto-install failed: %s", exc)
return None
@dataclass(frozen = True)
class _SdState:
"""The loaded native checkpoint: resolved asset paths + run settings."""
repo_id: str
base_repo: str
family: DiffusionFamily
device: str
files: SdCppModelFiles
vae_format: Optional[str] = None
native_speed: str = "off"
offload_flags: tuple[str, ...] = ()
threads: Optional[int] = None
@dataclass
class _SdLoading:
"""An in-flight asset download, polled for progress."""
repo_id: str
base_repo: str
expected_bytes: int = 0
downloaded_bytes: int = 0
error: Optional[str] = None
@dataclass
class _SdGen:
"""An in-flight generation, updated from parsed sd-cli progress lines."""
total_steps: int
step: int = 0
first_step_at: float = 0.0
eta_seconds: Optional[float] = None
def _estimate_eta(total_steps: int, step: int, first_step_at: float, now: float) -> Optional[float]:
steps_since_first = step - 1
if not first_step_at or steps_since_first <= 0:
return None
per_step = (now - first_step_at) / steps_since_first
return max(0.0, (total_steps - step) * per_step)
def _map_guidance(fam: DiffusionFamily, guidance: Optional[float]) -> tuple[Optional[float], Optional[float]]:
"""(cfg_scale, guidance) for sd-cli from the single diffusers ``guidance`` value.
FLUX families take a distilled embedded ``--guidance``; everyone else uses real
classifier-free ``--cfg-scale``. A distilled 0/1 means CFG off (sd-cli's 1.0); a
value > 1 is real CFG. Mirrors the engine mapping validated in the CPU benchmark.
"""
if fam.name in ("flux.1", "flux.2-klein"):
return None, (float(guidance) if guidance is not None else None)
cfg = float(guidance) if (guidance is not None and guidance > 1.0) else 1.0
return cfg, None
class SdCppDiffusionBackend:
"""Native sd.cpp backend with the diffusers ``DiffusionBackend`` method surface."""
def __init__(self, engine: Optional[SdCppEngine] = None) -> None:
self._lock = threading.Lock()
self._generate_lock = threading.Lock()
self._engine = engine # resolved lazily on first load so import stays cheap
self._state: Optional[_SdState] = None
self._loading: Optional[_SdLoading] = None
self._load_token = 0
self._cancel_event = threading.Event()
self._active_generate_cancel: Optional[threading.Event] = None
self._gen: Optional[_SdGen] = None
@property
def is_loaded(self) -> bool:
return self._state is not None
def _resolve_engine(self) -> SdCppEngine:
"""The SdCppEngine, installing the binary on first use. Raises if unusable."""
if self._engine is not None and self._engine.is_available():
return self._engine
binary = ensure_sd_cpp_binary(allow_install = _install_allowed())
if not binary:
raise RuntimeError("sd-cli (stable-diffusion.cpp) binary is unavailable.")
self._engine = SdCppEngine(binary = binary)
return self._engine
# ── Background load + progress ─────────────────────────────────────────
def begin_load(
self,
repo_id: str,
*,
gguf_filename: Optional[str] = None,
base_repo: Optional[str] = None,
family_override: Optional[str] = None,
hf_token: Optional[str] = None,
cpu_offload: bool = False,
memory_mode: Optional[str] = None,
speed_mode: Optional[str] = None,
# diffusers-only knobs accepted (so the route calls both engines uniformly)
# and ignored -- sd.cpp has no torchao quant / SDPA dispatcher / fbcache.
text_encoder_quant: Optional[str] = None,
transformer_quant: Optional[str] = None,
transformer_quant_fast_accum: Optional[bool] = None,
transformer_prequant_path: Optional[str] = None,
attention_backend: Optional[str] = None,
transformer_cache: Optional[str] = None,
transformer_cache_threshold: Optional[float] = None,
) -> dict[str, Any]:
"""Validate, then fetch assets on a daemon thread. Returns at once."""
if not gguf_filename:
raise ValueError(
"gguf_filename is required: the native engine loads single-file GGUF checkpoints only."
)
fam = detect_family(repo_id, family_override)
if fam is None:
raise ValueError(f"Could not infer a diffusion family for '{repo_id}'.")
if not family_sd_cpp_supported(fam):
raise ValueError(f"Family '{fam.name}' has no native sd.cpp asset mapping.")
base = resolve_base_repo(fam, base_repo)
with self._lock:
if self._loading is not None and self._loading.error is None:
raise RuntimeError("A diffusion load is already in progress.")
self._load_token += 1
token = self._load_token
self._cancel_event.clear()
self._loading = _SdLoading(repo_id = repo_id, base_repo = base)
threading.Thread(
target = self._run_load,
kwargs = dict(
repo_id = repo_id,
gguf_filename = gguf_filename,
base = base,
fam = fam,
hf_token = hf_token,
_load_token = token,
),
daemon = True,
).start()
return self.status()
def _run_load(
self,
*,
repo_id: str,
gguf_filename: str,
base: str,
fam: DiffusionFamily,
hf_token: Optional[str],
_load_token: int,
) -> None:
try:
# Ensure the binary up front so an install failure surfaces before the
# multi-GB asset pull (the router also pre-checks, but a forced reload here
# must not silently download then fail at generate).
engine = self._resolve_engine()
assets = self._asset_specs(repo_id, gguf_filename, fam)
self._set_expected_bytes(assets, hf_token)
paths = self._fetch_assets(assets, hf_token)
files = SdCppModelFiles(
diffusion_model = paths["diffusion_model"],
vae = paths.get("vae"),
clip_l = paths.get("clip_l"),
clip_g = paths.get("clip_g"),
t5xxl = paths.get("t5xxl"),
llm = paths.get("llm"),
qwen2vl = paths.get("qwen2vl"),
)
device = resolve_diffusion_device_target().device
state = _SdState(
repo_id = repo_id,
base_repo = base,
family = fam,
device = device,
files = files,
vae_format = fam.sd_cpp_vae_format,
native_speed = "off",
offload_flags = (),
threads = None,
)
# Probe the binary version (cheap) so a broken install fails the load.
engine.version()
with self._lock:
if self._load_token != _load_token:
return # superseded / cancelled
self._state = state
self._loading = None
except SdCppCancelled:
return
except Exception as exc: # noqa: BLE001 -- surfaced via load_progress
if self._load_token != _load_token:
return
logger.error("sd_cpp.load_failed: %s", exc)
with self._lock:
if self._load_token == _load_token and self._loading is not None:
self._loading.error = str(exc)
def _asset_specs(
self, repo_id: str, gguf_filename: str, fam: DiffusionFamily
) -> list[tuple[str, str, str]]:
"""(repo, filename, kind) for every file sd-cli needs. ``kind`` is the
SdCppModelFiles field; the transformer reuses the diffusers GGUF."""
specs: list[tuple[str, str, str]] = [(repo_id, gguf_filename, "diffusion_model")]
if fam.sd_cpp_vae:
specs.append((fam.sd_cpp_vae[0], fam.sd_cpp_vae[1], "vae"))
for terepo, tefile, kind in fam.sd_cpp_text_encoders:
specs.append((terepo, tefile, kind))
return specs
def _set_expected_bytes(self, assets: list[tuple[str, str, str]], hf_token: Optional[str]) -> None:
"""Best-effort total download size for the progress bar (0 if unknown)."""
total = 0
try:
from huggingface_hub import HfApi
api = HfApi(token = hf_token)
for repo, fn, _ in assets:
if Path(repo).expanduser().exists():
continue
try:
info = api.get_paths_info(repo, paths = [fn], expand = False)
for it in info:
total += int(getattr(it, "size", 0) or 0)
except Exception: # noqa: BLE001 -- one missing size is non-fatal
continue
except Exception: # noqa: BLE001 -- estimate is best-effort
total = 0
loading = self._loading
if loading is not None:
loading.expected_bytes = total
def _fetch_assets(
self, assets: list[tuple[str, str, str]], hf_token: Optional[str]
) -> dict[str, str]:
"""Download every asset (cancellable), returning kind -> local path."""
from utils.hf_xet_fallback import hf_hub_download_with_xet_fallback
paths: dict[str, str] = {}
for repo, fn, kind in assets:
if self._cancel_event.is_set():
raise SdCppCancelled("load cancelled")
local_root = Path(repo).expanduser()
if kind == "diffusion_model" and local_root.exists():
path = str(resolve_local_gguf_child(local_root, fn))
else:
path = hf_hub_download_with_xet_fallback(
repo, fn, hf_token, cancel_event = self._cancel_event
)
paths[kind] = path
with self._lock:
if self._loading is not None:
try:
self._loading.downloaded_bytes += os.path.getsize(path)
except OSError:
pass
return paths
def load_progress(self) -> dict[str, Any]:
loading = self._loading
if loading is not None and loading.error:
return _progress("error", error = loading.error)
if loading is None:
return _progress("ready" if self._state is not None else None)
downloaded = loading.downloaded_bytes
expected = loading.expected_bytes
if expected > 0 and downloaded >= expected * 0.999:
return _progress("finalizing", min(downloaded, expected), expected, 1.0)
fraction = min(downloaded / expected, 1.0) if expected > 0 else 0.0
return _progress("downloading", downloaded, expected, fraction)
# ── Generate ───────────────────────────────────────────────────────────
def generate(
self,
*,
prompt: str,
negative_prompt: Optional[str] = None,
width: int = 1024,
height: int = 1024,
steps: int = 9,
guidance: float = 0.0,
seed: Optional[int] = None,
batch_size: int = 1,
) -> dict[str, Any]:
import tempfile
from PIL import Image
cancel = threading.Event()
with self._generate_lock:
with self._lock:
state = self._state
if state is None:
raise RuntimeError("No diffusion model is loaded.")
self._active_generate_cancel = cancel
engine = self._resolve_engine()
try:
if seed is None:
seed = int.from_bytes(os.urandom(6), "big") & ((1 << 53) - 1)
else:
seed = int(seed)
cfg_scale, flux_guidance = _map_guidance(state.family, guidance)
vae_extra = (
["--vae-format", state.vae_format] if state.vae_format else None
)
self._gen = _SdGen(total_steps = int(steps))
images = []
with tempfile.TemporaryDirectory(prefix = "sdcpp_gen_") as tmpdir:
for index in range(max(1, int(batch_size))):
if cancel.is_set():
raise RuntimeError("Diffusion generation was cancelled.")
# Distinct seed per batch image (sd-cli is one image/run here),
# so a batch is reproducible image-by-image from the base seed.
seed_i = (seed + index) & ((1 << 53) - 1)
out_path = str(Path(tmpdir) / f"img_{index}.png")
params = SdCppGenParams(
prompt = prompt,
negative_prompt = negative_prompt or None,
width = int(width),
height = int(height),
steps = int(steps),
cfg_scale = cfg_scale,
guidance = flux_guidance,
seed = seed_i,
batch_count = 1,
)
engine.generate(
state.files,
params,
output_path = out_path,
offload = list(state.offload_flags) or None,
native_speed = state.native_speed,
threads = state.threads,
extra_args = vae_extra,
on_log = self._on_log,
cancel_event = cancel,
)
with Image.open(out_path) as im:
images.append(im.copy())
if cancel.is_set():
raise RuntimeError("Diffusion generation was cancelled.")
return {"images": images, "seed": int(seed), "repo_id": state.repo_id}
except SdCppCancelled as exc:
raise RuntimeError("Diffusion generation was cancelled.") from exc
finally:
self._gen = None
with self._lock:
if self._active_generate_cancel is cancel:
self._active_generate_cancel = None
def _on_log(self, line: str) -> None:
gen = self._gen
if gen is None or gen.total_steps <= 0:
return
for a, b in _STEP_RE.findall(line):
if int(b) == gen.total_steps:
now = time.time()
gen.step = min(int(a), gen.total_steps)
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
)
def generate_progress(self) -> dict[str, Any]:
gen = self._gen
if gen is None or gen.total_steps <= 0:
return {"active": False, "step": 0, "total_steps": 0, "fraction": 0.0, "eta_seconds": None}
return {
"active": True,
"step": gen.step,
"total_steps": gen.total_steps,
"fraction": min(gen.step / gen.total_steps, 1.0),
"eta_seconds": gen.eta_seconds,
}
# ── Unload / status ──────────────────────────────────────────────────────
def unload(self) -> dict[str, Any]:
self._cancel_event.set()
with self._lock:
if self._active_generate_cancel is not None:
self._active_generate_cancel.set()
self._state = None
self._load_token += 1
self._loading = None
return self.status()
def status(self) -> dict[str, Any]:
state = self._state
if state is None:
return {
"loaded": False, "repo_id": None, "family": None, "base_repo": None,
"device": None, "dtype": None, "cpu_offload": False, "offload_policy": None,
"vae_tiling": False, "memory_mode": None, "speed_mode": None, "speed_optims": [],
"text_encoder_quant": None, "transformer_quant": None, "attention_backend": None,
"transformer_cache": None, "engine": "sd_cpp",
}
return {
"loaded": True,
"repo_id": state.repo_id,
"family": state.family.name,
"base_repo": state.base_repo,
"device": state.device,
"dtype": "gguf",
"cpu_offload": False,
"offload_policy": "none",
"vae_tiling": False,
"memory_mode": None,
"speed_mode": state.native_speed,
"speed_optims": [],
"text_encoder_quant": None,
"transformer_quant": None,
"attention_backend": None,
"transformer_cache": None,
"engine": "sd_cpp",
}
def _install_allowed() -> bool:
"""Whether lazy binary install is permitted (UNSLOTH_DIFFUSION_SD_CPP_INSTALL)."""
val = os.environ.get("UNSLOTH_DIFFUSION_SD_CPP_INSTALL", "auto").strip().lower()
return val not in ("0", "off", "false", "no")
def _progress(
phase: Optional[str],
bytes_downloaded: int = 0,
bytes_total: int = 0,
fraction: float = 0.0,
*,
error: Optional[str] = None,
) -> dict[str, Any]:
return {
"phase": phase,
"bytes_downloaded": bytes_downloaded,
"bytes_total": bytes_total,
"fraction": fraction,
"error": error,
}
_sd_cpp_backend: Optional[SdCppDiffusionBackend] = None
def get_sd_cpp_backend() -> SdCppDiffusionBackend:
global _sd_cpp_backend
if _sd_cpp_backend is None:
_sd_cpp_backend = SdCppDiffusionBackend()
return _sd_cpp_backend

View file

@ -26,6 +26,7 @@ import logging
import os
import queue
import shutil
import signal
import subprocess
import sys
import threading
@ -50,6 +51,30 @@ _BINARY_STEM = "sd-cli"
_LEGACY_STEM = "sd"
class SdCppCancelled(RuntimeError):
"""A generation was cancelled via its ``cancel_event`` (unload / superseding load
/ arbiter eviction). Distinct from a generation *failure* so the caller can keep
cancellation semantics (no diffusers fallback, no error surfaced as a crash)."""
def _terminate(proc: "subprocess.Popen") -> None:
"""Hard-stop an sd-cli process (and any children). On POSIX the process is its
own session leader (``start_new_session``), so kill the whole group; otherwise
fall back to killing just the process."""
if proc.poll() is not None:
return
try:
if os.name == "posix":
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
else:
proc.kill()
except Exception: # noqa: BLE001 -- killpg can miss (no pgid / already gone); fall back
try:
proc.kill()
except Exception: # noqa: BLE001 -- best-effort teardown
pass
def _binary_name(stem: str) -> str:
return f"{stem}.exe" if sys.platform == "win32" else stem
@ -199,6 +224,7 @@ class SdCppEngine:
timeout: Optional[float] = 1800.0,
env: Optional[dict[str, str]] = None,
on_log: Optional[Callable[[str], None]] = None,
cancel_event: Optional[threading.Event] = None,
) -> Path:
"""Run one ``sd-cli`` generation; return the written image path.
@ -206,7 +232,9 @@ class SdCppEngine:
(``--diffusion-fa`` etc.), de-duplicated against the offload flags that may
already include them. Raises ``RuntimeError`` if the binary is missing, the
process exits nonzero, or no output file is produced. ``on_log`` (if given)
receives each line of sd-cli's progress output as it arrives.
receives each line of sd-cli's progress output as it arrives. ``cancel_event``
(if given) is polled while the child runs; when set, the process tree is
killed and ``SdCppCancelled`` is raised.
"""
offload = list(offload or [])
speed = [f for f in native_speed_flags(native_speed) if f not in offload]
@ -221,7 +249,10 @@ class SdCppEngine:
verbose = verbose,
extra_args = merged_extra,
)
return self._run(cmd, output_path, timeout = timeout, env = env, on_log = on_log)
return self._run(
cmd, output_path, timeout = timeout, env = env, on_log = on_log,
cancel_event = cancel_event,
)
def upscale(
self,
@ -233,6 +264,7 @@ class SdCppEngine:
timeout: Optional[float] = 1800.0,
env: Optional[dict[str, str]] = None,
on_log: Optional[Callable[[str], None]] = None,
cancel_event: Optional[threading.Event] = None,
) -> Path:
"""Upscale an image with an ESRGAN model; return the written path."""
cmd = build_sd_cpp_upscale_command(
@ -242,7 +274,10 @@ class SdCppEngine:
verbose = verbose,
extra_args = extra_args,
)
return self._run(cmd, output_path, timeout = timeout, env = env, on_log = on_log)
return self._run(
cmd, output_path, timeout = timeout, env = env, on_log = on_log,
cancel_event = cancel_event,
)
# ── internals ─────────────────────────────────────────────────────────────
@ -268,11 +303,13 @@ class SdCppEngine:
timeout: Optional[float],
env: Optional[dict[str, str]],
on_log: Optional[Callable[[str], None]],
cancel_event: Optional[threading.Event] = None,
) -> Path:
"""Run an sd-cli argv, stream output, and return the produced image path.
Raises ``RuntimeError`` on nonzero exit, timeout, or a missing output.
Shared by ``generate`` and ``upscale``.
Raises ``RuntimeError`` on nonzero exit, timeout, or a missing output, and
``SdCppCancelled`` when ``cancel_event`` fires. Shared by ``generate`` and
``upscale``.
"""
out = Path(output_path)
base = dict(os.environ)
@ -289,6 +326,9 @@ class SdCppEngine:
text = True,
errors = "replace",
env = run_env,
# 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"),
)
# 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
@ -313,8 +353,13 @@ class SdCppEngine:
stdout_done = False
try:
while True:
# Cancellation (unload / superseding load / arbiter eviction): kill the
# process tree and signal the caller it was cancelled, not a failure.
if cancel_event is not None and cancel_event.is_set() and proc.poll() is None:
_terminate(proc)
raise SdCppCancelled("sd-cli generation was cancelled.")
if deadline is not None and time.monotonic() >= deadline and proc.poll() is None:
proc.kill()
_terminate(proc)
raise RuntimeError(f"sd-cli timed out after {timeout}s")
try:
line = line_q.get(timeout = 0.1)
@ -335,7 +380,7 @@ class SdCppEngine:
ret = proc.wait(timeout = 5.0)
finally:
if proc.poll() is None:
proc.kill()
_terminate(proc)
if ret != 0:
raise RuntimeError(f"sd-cli exited {ret}. Last output:\n" + "\n".join(tail[-12:]))

View file

@ -1904,3 +1904,10 @@ class DiffusionStatusResponse(BaseModel):
"_native_cudnn), or null for the default SDPA",
)
transformer_cache: Optional[str] = Field(None, description = "Step cache engaged: fbcache | null")
engine: Optional[str] = Field(
None, description = "Active diffusion engine: diffusers | sd_cpp"
)
fallback_reason: Optional[str] = Field(
None,
description = "Why diffusers was chosen over the native sd.cpp engine (null when none)",
)

View file

@ -10058,24 +10058,32 @@ async def load_diffusion_model(
request: DiffusionLoadRequest, current_subject: str = Depends(get_current_subject)
):
from core.inference.diffusion import get_diffusion_backend
from core.inference.diffusion_engine_router import annotate_status, select_and_activate_engine
from core.inference.gpu_arbiter import acquire_for, DIFFUSION
from utils.native_path_leases import redact_native_paths
backend = get_diffusion_backend()
try:
# Validate cheaply BEFORE touching the GPU: an unloadable pick (bad family,
# missing local GGUF) must not evict a working chat model and then 400.
await asyncio.to_thread(
# missing local GGUF) must not evict a working chat model and then 400. The
# validated family also drives engine selection below.
fam = await asyncio.to_thread(
backend.validate_load_request,
request.model_path,
gguf_filename = request.gguf_filename,
family_override = request.family_override,
)
# Pick the engine for this host (diffusers on GPU, native sd.cpp with no GPU),
# installing the sd-cli binary if needed -- all BEFORE evicting chat, so a
# native fallback never strands a half-loaded state.
engine = await asyncio.to_thread(
select_and_activate_engine, fam, hf_token = request.hf_token
)
# 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(
backend.begin_load,
engine.begin_load,
request.model_path,
gguf_filename = request.gguf_filename,
base_repo = request.base_repo,
@ -10092,7 +10100,7 @@ async def load_diffusion_model(
transformer_cache = request.transformer_cache,
transformer_cache_threshold = request.transformer_cache_threshold,
)
return DiffusionStatusResponse(**status_dict)
return DiffusionStatusResponse(**annotate_status(status_dict))
except (ValueError, FileNotFoundError) as exc:
raise HTTPException(status_code = 400, detail = redact_native_paths(str(exc)))
except RuntimeError as exc:
@ -10105,9 +10113,9 @@ async def generate_diffusion_image(
request: DiffusionGenerateRequest, current_subject: str = Depends(get_current_subject)
):
from core.inference import image_gallery
from core.inference.diffusion import get_diffusion_backend
from core.inference.diffusion_engine_router import get_active_diffusion_engine
backend = get_diffusion_backend()
backend = get_active_diffusion_engine()
try:
result = await asyncio.to_thread(
backend.generate,
@ -10221,27 +10229,27 @@ async def clear_gallery_images(current_subject: str = Depends(get_current_subjec
@studio_router.post("/images/unload", response_model = DiffusionStatusResponse)
async def unload_diffusion_model(current_subject: str = Depends(get_current_subject)):
from core.inference.diffusion import get_diffusion_backend
from core.inference.diffusion_engine_router import annotate_status, get_active_diffusion_engine
from core.inference.gpu_arbiter import release, DIFFUSION
status_dict = await asyncio.to_thread(get_diffusion_backend().unload)
status_dict = await asyncio.to_thread(get_active_diffusion_engine().unload)
release(DIFFUSION)
return DiffusionStatusResponse(**status_dict)
return DiffusionStatusResponse(**annotate_status(status_dict))
@studio_router.get("/images/status", response_model = DiffusionStatusResponse)
async def diffusion_status(current_subject: str = Depends(get_current_subject)):
from core.inference.diffusion import get_diffusion_backend
return DiffusionStatusResponse(**get_diffusion_backend().status())
from core.inference.diffusion_engine_router import active_status
return DiffusionStatusResponse(**active_status())
@studio_router.get("/images/load-progress", response_model = DiffusionLoadProgressResponse)
async def diffusion_load_progress(current_subject: str = Depends(get_current_subject)):
from core.inference.diffusion import get_diffusion_backend
return DiffusionLoadProgressResponse(**get_diffusion_backend().load_progress())
from core.inference.diffusion_engine_router import get_active_diffusion_engine
return DiffusionLoadProgressResponse(**get_active_diffusion_engine().load_progress())
@studio_router.get("/images/generate-progress", response_model = DiffusionGenerateProgressResponse)
async def diffusion_generate_progress(current_subject: str = Depends(get_current_subject)):
from core.inference.diffusion import get_diffusion_backend
return DiffusionGenerateProgressResponse(**get_diffusion_backend().generate_progress())
from core.inference.diffusion_engine_router import get_active_diffusion_engine
return DiffusionGenerateProgressResponse(**get_active_diffusion_engine().generate_progress())

View file

@ -0,0 +1,136 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Tests for the diffusion engine router (diffusers vs native sd.cpp selection)."""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from core.inference import diffusion_engine_router as r
from core.inference.diffusion_families import detect_family
from core.inference.sd_cpp_engine import ENGINE_DIFFUSERS, ENGINE_SD_CPP
_ENVS = (
"UNSLOTH_DIFFUSION_ENGINE",
"UNSLOTH_DIFFUSION_SD_CPP",
"UNSLOTH_DIFFUSION_SD_CPP_MPS",
"UNSLOTH_DIFFUSION_SD_CPP_INSTALL",
)
@pytest.fixture(autouse = True)
def _clean_env_and_state(monkeypatch):
for e in _ENVS:
monkeypatch.delenv(e, raising = False)
# A light status-capable stub so neither selection nor active_status() imports the
# heavy diffusers/sd.cpp backends; the active engine NAME comes from module state.
monkeypatch.setattr(
r, "get_active_diffusion_engine",
lambda: SimpleNamespace(status = lambda: {"loaded": False, "repo_id": None}),
)
yield
def _set_device(monkeypatch, backend):
monkeypatch.setattr(
r, "resolve_diffusion_device_target",
lambda: SimpleNamespace(backend = backend, device = backend),
)
def _set_binary(monkeypatch, path):
monkeypatch.setattr(r, "ensure_sd_cpp_binary", lambda **_: path)
def _select(fam_name = "z-image"):
"""Activate the engine for a family and return which engine was chosen."""
r.select_and_activate_engine(detect_family(fam_name))
return r.active_engine_name()
# ── core selection matrix ─────────────────────────────────────────────────────
def test_cpu_with_binary_and_supported_family_picks_sd_cpp(monkeypatch):
_set_device(monkeypatch, "cpu")
_set_binary(monkeypatch, "/usr/bin/sd-cli")
assert _select() == ENGINE_SD_CPP
assert r.active_engine_name() == ENGINE_SD_CPP
@pytest.mark.parametrize("gpu", ["cuda", "rocm", "xpu"])
def test_gpu_backends_use_diffusers(monkeypatch, gpu):
_set_device(monkeypatch, gpu)
_set_binary(monkeypatch, "/usr/bin/sd-cli") # even with a binary, GPU stays diffusers
assert _select() == ENGINE_DIFFUSERS
assert "uses diffusers" in (r.active_status()["fallback_reason"] or "")
def test_forced_diffusers_overrides_cpu(monkeypatch):
_set_device(monkeypatch, "cpu")
_set_binary(monkeypatch, "/usr/bin/sd-cli")
monkeypatch.setenv("UNSLOTH_DIFFUSION_ENGINE", "diffusers")
assert _select() == ENGINE_DIFFUSERS
assert "forced" in (r.active_status()["fallback_reason"] or "")
def test_sd_cpp_disabled_uses_diffusers(monkeypatch):
_set_device(monkeypatch, "cpu")
_set_binary(monkeypatch, "/usr/bin/sd-cli")
monkeypatch.setenv("UNSLOTH_DIFFUSION_SD_CPP", "0")
assert _select() == ENGINE_DIFFUSERS
assert "disabled" in (r.active_status()["fallback_reason"] or "")
def test_mps_default_diffusers_but_optin_sd_cpp(monkeypatch):
_set_device(monkeypatch, "mps")
_set_binary(monkeypatch, "/usr/bin/sd-cli")
# Default: MPS is not native-eligible -> diffusers.
assert _select() == ENGINE_DIFFUSERS
# Opt in: MPS routes to sd.cpp.
monkeypatch.setenv("UNSLOTH_DIFFUSION_SD_CPP_MPS", "1")
assert _select() == ENGINE_SD_CPP
def test_unsupported_family_falls_back(monkeypatch):
_set_device(monkeypatch, "cpu")
_set_binary(monkeypatch, "/usr/bin/sd-cli")
monkeypatch.setattr(r, "family_sd_cpp_supported", lambda fam: False)
assert _select() == ENGINE_DIFFUSERS
assert "no native sd.cpp asset mapping" in (r.active_status()["fallback_reason"] or "")
def test_missing_binary_falls_back(monkeypatch):
_set_device(monkeypatch, "cpu")
_set_binary(monkeypatch, None) # install unavailable
assert _select() == ENGINE_DIFFUSERS
assert "binary unavailable" in (r.active_status()["fallback_reason"] or "")
def test_force_sd_cpp_on_gpu_when_binary_present(monkeypatch):
_set_device(monkeypatch, "cuda")
_set_binary(monkeypatch, "/usr/bin/sd-cli")
monkeypatch.setenv("UNSLOTH_DIFFUSION_ENGINE", "sd_cpp")
assert _select() == ENGINE_SD_CPP
def test_force_sd_cpp_without_binary_falls_back(monkeypatch):
_set_device(monkeypatch, "cuda")
_set_binary(monkeypatch, None)
monkeypatch.setenv("UNSLOTH_DIFFUSION_ENGINE", "sd_cpp")
assert _select() == ENGINE_DIFFUSERS
# ── active_status annotation ──────────────────────────────────────────────────
def test_active_status_injects_engine_and_reason(monkeypatch):
_set_device(monkeypatch, "cpu")
_set_binary(monkeypatch, None)
_select() # -> diffusers fallback (no binary)
st = r.active_status()
assert st["engine"] == ENGINE_DIFFUSERS
assert st["fallback_reason"] and "binary unavailable" in st["fallback_reason"]

View file

@ -114,6 +114,24 @@ def _unloaded_status():
def client(monkeypatch, tmp_path):
backend = _FakeBackend()
monkeypatch.setattr(diffusion_module, "get_diffusion_backend", lambda: backend)
# Neutralise the engine router so the routes deterministically drive this fake
# (diffusers) backend regardless of the host's real device, and never attempt a
# native sd.cpp install/download. The router's selection logic is covered in
# test_diffusion_engine_router.py; one route-level sd_cpp test lives below.
import core.inference.diffusion_engine_router as engine_router
# Delegate to whatever get_diffusion_backend currently returns, so per-test
# re-patches of the backend still flow through the routes.
monkeypatch.setattr(
engine_router, "select_and_activate_engine",
lambda fam, **kw: diffusion_module.get_diffusion_backend(),
)
monkeypatch.setattr(
engine_router, "get_active_diffusion_engine",
lambda: diffusion_module.get_diffusion_backend(),
)
monkeypatch.setattr(engine_router, "_active_engine_name", "diffusers")
monkeypatch.setattr(engine_router, "_fallback_reason", None)
# Isolate from the real GPU arbiter: reset ownership and stub the evictors so
# the load route's acquire_for() never touches live backend singletons.
monkeypatch.setattr(gpu_arbiter, "_owner", None)
@ -423,6 +441,54 @@ def test_out_of_range_cache_threshold_returns_422(client):
assert resp.status_code == 422
def test_load_routes_to_sd_cpp_on_cpu(monkeypatch, tmp_path):
"""End-to-end through the REAL router: a CPU host with an available binary routes
the load to the native sd.cpp engine and the response reports engine=sd_cpp."""
from types import SimpleNamespace
import core.inference.diffusion_engine_router as engine_router
import core.inference.sd_cpp_backend as sd_backend
for e in (
"UNSLOTH_DIFFUSION_ENGINE", "UNSLOTH_DIFFUSION_SD_CPP",
"UNSLOTH_DIFFUSION_SD_CPP_MPS", "UNSLOTH_DIFFUSION_SD_CPP_INSTALL",
):
monkeypatch.delenv(e, raising = False)
validator = _FakeBackend() # supplies validate_load_request (and is the diffusers fallback)
monkeypatch.setattr(diffusion_module, "get_diffusion_backend", lambda: validator)
# Force the router's decision inputs: CPU device + an available binary.
monkeypatch.setattr(
engine_router, "resolve_diffusion_device_target",
lambda: SimpleNamespace(backend = "cpu", device = "cpu"),
)
monkeypatch.setattr(engine_router, "ensure_sd_cpp_binary", lambda **_: "/x/sd-cli")
monkeypatch.setattr(engine_router, "_active_engine_name", "diffusers")
monkeypatch.setattr(engine_router, "_fallback_reason", None)
# The native backend the router will activate.
sd_fake = _FakeBackend()
monkeypatch.setattr(sd_backend, "get_sd_cpp_backend", lambda: sd_fake)
monkeypatch.setattr(gpu_arbiter, "_owner", None)
monkeypatch.setitem(gpu_arbiter._EVICTORS, gpu_arbiter.CHAT, lambda: None)
monkeypatch.setitem(gpu_arbiter._EVICTORS, gpu_arbiter.DIFFUSION, lambda: None)
app = FastAPI()
app.include_router(studio_router, prefix = "/api/inference")
app.dependency_overrides[get_current_subject] = lambda: "test-user"
client = TestClient(app)
resp = client.post(
"/api/inference/images/load",
json = {"model_path": "unsloth/Z-Image-Turbo-GGUF", "gguf_filename": "z.gguf"},
)
assert resp.status_code == 200
body = resp.json()
assert body["engine"] == "sd_cpp"
assert body["fallback_reason"] is None
assert sd_fake.loaded is True # the native engine actually received the load
def test_invalid_transformer_quant_returns_422_without_eviction(client):
# An unsupported transformer_quant is rejected by the request schema (Literal), so
# the GPU is never acquired and no chat model is evicted.

View file

@ -0,0 +1,210 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Tests for the native sd.cpp diffusion backend (the no-GPU engine)."""
from __future__ import annotations
import threading
import pytest
from PIL import Image
from core.inference import sd_cpp_backend as bk
from core.inference.diffusion_families import detect_family
from core.inference.sd_cpp_args import SdCppGenParams, SdCppModelFiles
from core.inference.sd_cpp_backend import (
SdCppDiffusionBackend,
_map_guidance,
ensure_sd_cpp_binary,
)
from core.inference.sd_cpp_engine import SdCppCancelled
class _FakeEngine:
"""Stands in for SdCppEngine: writes a 1x1 PNG and records the args."""
def __init__(self, *, fail=None, cancel_on_call=False):
self.calls = []
self.fail = fail
self.cancel_on_call = cancel_on_call
def is_available(self):
return True
def version(self, **_):
return "fake sd-cli"
def generate(self, files, params, *, output_path, cancel_event=None, **kw):
self.calls.append((files, params, output_path, kw))
if self.cancel_on_call and cancel_event is not None:
cancel_event.set()
if self.fail is not None:
raise self.fail
if cancel_event is not None and cancel_event.is_set():
raise SdCppCancelled("cancelled")
Image.new("RGB", (1, 1), (10, 20, 30)).save(output_path)
from pathlib import Path
return Path(output_path)
def _loaded_backend(fam_name="z-image", engine=None):
b = SdCppDiffusionBackend(engine = engine or _FakeEngine())
fam = detect_family(fam_name)
b._state = bk._SdState(
repo_id = "unsloth/Z-Image-Turbo-GGUF",
base_repo = fam.base_repo,
family = fam,
device = "cpu",
files = SdCppModelFiles(diffusion_model = "/m/z.gguf", vae = "/m/vae.safetensors", llm = "/m/llm.safetensors"),
vae_format = fam.sd_cpp_vae_format,
)
return b
# ── asset resolution ──────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"fam_name,expect_kinds",
[
("flux.1", {"diffusion_model", "vae", "clip_l", "t5xxl"}),
("z-image", {"diffusion_model", "vae", "llm"}),
("qwen-image", {"diffusion_model", "vae", "qwen2vl"}),
("flux.2-klein", {"diffusion_model", "vae", "llm"}),
],
)
def test_asset_specs_cover_required_files(fam_name, expect_kinds):
b = SdCppDiffusionBackend(engine = _FakeEngine())
fam = detect_family(fam_name)
specs = b._asset_specs("unsloth/x-GGUF", "x-Q4_K_M.gguf", fam)
kinds = {kind for _, _, kind in specs}
assert kinds == expect_kinds
# Every spec has a non-empty repo + filename.
assert all(repo and fn for repo, fn, _ in specs)
# The transformer reuses the requested GGUF, not a registry file.
tr = [s for s in specs if s[2] == "diffusion_model"][0]
assert tr[0] == "unsloth/x-GGUF" and tr[1] == "x-Q4_K_M.gguf"
# ── guidance mapping ──────────────────────────────────────────────────────────
def test_map_guidance_flux_uses_distilled_guidance():
cfg, g = _map_guidance(detect_family("flux.1"), 3.5)
assert cfg is None and g == 3.5
def test_map_guidance_cfg_family_off_when_distilled():
# qwen-image uses real CFG; a distilled 0 -> CFG off (1.0), a >1 value passes through.
assert _map_guidance(detect_family("qwen-image"), 0.0) == (1.0, None)
assert _map_guidance(detect_family("qwen-image"), 4.0) == (4.0, None)
# ── status ────────────────────────────────────────────────────────────────────
def test_status_unloaded_reports_sd_cpp_engine():
b = SdCppDiffusionBackend(engine = _FakeEngine())
st = b.status()
assert st["loaded"] is False and st["engine"] == "sd_cpp"
def test_status_loaded_shape():
b = _loaded_backend()
st = b.status()
assert st["loaded"] is True
assert st["engine"] == "sd_cpp"
assert st["family"] == "z-image"
assert st["device"] == "cpu"
# diffusers-only fields are present (route response parity) but null.
for k in ("transformer_quant", "attention_backend", "transformer_cache", "text_encoder_quant"):
assert st[k] is None
# ── generate ──────────────────────────────────────────────────────────────────
def test_generate_returns_images_and_seed():
eng = _FakeEngine()
b = _loaded_backend(engine = eng)
out = b.generate(prompt = "a fox", width = 64, height = 64, steps = 8, seed = 123, batch_size = 2)
assert out["seed"] == 123
assert out["repo_id"] == "unsloth/Z-Image-Turbo-GGUF"
assert len(out["images"]) == 2
assert all(isinstance(im, Image.Image) for im in out["images"])
# One sd-cli run per batch image, each a distinct seed from the base.
assert len(eng.calls) == 2
seeds = [params.seed for _, params, _, _ in eng.calls]
assert seeds == [123, 124]
def test_generate_raises_when_not_loaded():
b = SdCppDiffusionBackend(engine = _FakeEngine())
with pytest.raises(RuntimeError, match = "No diffusion model is loaded"):
b.generate(prompt = "x")
def test_generate_passes_vae_format_for_flux2():
eng = _FakeEngine()
b = _loaded_backend(fam_name = "flux.2-klein", engine = eng)
b.generate(prompt = "x", steps = 4, seed = 1)
_, _, _, kw = eng.calls[0]
assert kw.get("extra_args") == ["--vae-format", "flux2"]
def test_generate_cancellation_raises_cancelled_not_failure():
# The engine cancels mid-run; the backend surfaces a cancellation, not a crash.
eng = _FakeEngine(cancel_on_call = True)
b = _loaded_backend(engine = eng)
with pytest.raises(RuntimeError, match = "cancelled"):
b.generate(prompt = "x", steps = 8, seed = 5)
def test_generate_progress_tracks_parsed_steps():
b = _loaded_backend()
b._gen = bk._SdGen(total_steps = 8)
b._on_log(" sampling 4/8 done")
p = b.generate_progress()
assert p["active"] is True and p["step"] == 4 and p["total_steps"] == 8
# A fraction with a different denominator must not move the bar.
b._on_log("loaded 1/3 tensors")
assert b.generate_progress()["step"] == 4
# ── load validation + binary install ──────────────────────────────────────────
def test_begin_load_rejects_unsupported_family(monkeypatch):
b = SdCppDiffusionBackend(engine = _FakeEngine())
# A family with no native asset mapping must be rejected (router falls back).
monkeypatch.setattr(bk, "family_sd_cpp_supported", lambda fam: False)
with pytest.raises(ValueError, match = "no native sd.cpp asset mapping"):
b.begin_load("unsloth/Z-Image-Turbo-GGUF", gguf_filename = "z.gguf")
def test_begin_load_requires_gguf_filename():
b = SdCppDiffusionBackend(engine = _FakeEngine())
with pytest.raises(ValueError, match = "gguf_filename is required"):
b.begin_load("unsloth/Z-Image-Turbo-GGUF")
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"
def test_ensure_binary_install_disabled_returns_none(monkeypatch):
monkeypatch.setattr(bk, "find_sd_cpp_binary", lambda: None)
assert ensure_sd_cpp_binary(allow_install = False) is None
def test_unload_clears_state_and_signals_cancel():
cancel = threading.Event()
b = _loaded_backend()
b._active_generate_cancel = cancel
st = b.unload()
assert st["loaded"] is False
assert cancel.is_set()
assert b._cancel_event.is_set()

View file

@ -78,7 +78,10 @@ def test_find_returns_none_when_absent(tmp_path, monkeypatch):
# ── availability / version ──────────────────────────────────────────────────
def test_engine_unavailable_when_no_binary():
def test_engine_unavailable_when_no_binary(monkeypatch):
# Force the "no binary anywhere" condition so the test is hermetic even on a host
# that happens to have sd-cli installed.
monkeypatch.setattr(eng, "find_sd_cpp_binary", lambda: None)
e = SdCppEngine(binary = None)
assert e.is_available() is False
assert e.version() is None
@ -350,12 +353,13 @@ def test_upscale_runs_and_returns_path(tmp_path, monkeypatch):
assert "--upscale-model" in _FakePopen.captured_cmd
def test_upscale_raises_when_binary_missing():
def test_upscale_raises_when_binary_missing(monkeypatch, tmp_path):
monkeypatch.setattr(eng, "find_sd_cpp_binary", lambda: None)
e = SdCppEngine(binary = None)
with pytest.raises(RuntimeError, match = "not found"):
e.upscale(
SdCppUpscaleParams(input_image = "/i.png", upscale_model = "/m/e.pth"),
output_path = "/tmp/x.png",
output_path = str(tmp_path / "x.png"),
)