Collapse the multi-line comment blocks across the image, video, sd.cpp and diffusion-training code to one or two lines each, and drop comments that only restate the statement below them. Comments only, no code or behaviour changes.
3918 lines
166 KiB
Python
3918 lines
166 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""CPU-only unit tests for the diffusion backend.
|
|
|
|
The family helpers are pure functions, tested directly. The backend lifecycle is
|
|
exercised with ``torch`` / ``diffusers`` stubbed via ``sys.modules`` so no real
|
|
GPU, weights, or network access is needed (sub-second, CI-friendly).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import sys
|
|
import threading
|
|
import types
|
|
|
|
import pytest
|
|
|
|
from core.inference.diffusion import (
|
|
DiffusionBackend,
|
|
_LoadState,
|
|
_base_file_downloaded,
|
|
_clamp_max_side,
|
|
_resolve_base_repo,
|
|
_resolve_diffusion_compute_dtype,
|
|
)
|
|
|
|
# diffusion.py imports the compile/arch patch modules LAZILY (they pull torch at module level), so import them at collection time under the real torch, cached in sys.modules before the fake-torch fixtures swap it out.
|
|
import core.inference.diffusion_eager_patches # noqa: E402,F401
|
|
import core.inference.diffusion_arch_patches # noqa: E402,F401
|
|
from core.inference.diffusion_families import (
|
|
detect_family,
|
|
resolve_base_repo,
|
|
resolve_local_gguf_child,
|
|
supported_family_names,
|
|
)
|
|
|
|
|
|
# Pure family helpers
|
|
|
|
|
|
def test_clamp_max_side_bounds_oversized_init():
|
|
# img2img / inpaint derive OUTPUT size from the uploaded image, and an oversized upload (up to the 4096/side decode cap) drives an OOM-scale latent. _clamp_max_side bounds the longest side to 2048, preserving aspect ratio.
|
|
from PIL import Image
|
|
|
|
# A 12MP landscape photo: longest side clamped to 2048, 4:3 aspect preserved.
|
|
out = _clamp_max_side(Image.new("RGB", (4096, 3072)), 2048)
|
|
assert out.size == (2048, 1536)
|
|
# A portrait upload clamps on its longest (height) side.
|
|
assert _clamp_max_side(Image.new("RGB", (1000, 4000)), 2048).size == (512, 2048)
|
|
# An image already within bound is returned unchanged (no needless resample).
|
|
small = Image.new("RGB", (768, 512))
|
|
assert _clamp_max_side(small, 2048) is small
|
|
|
|
|
|
def test_detect_family_from_repo_id():
|
|
# Detection is by architecture; Turbo/full and schnell/dev map to one family.
|
|
assert detect_family("unsloth/Z-Image-Turbo-GGUF").name == "z-image"
|
|
assert detect_family("unsloth/Z-Image-GGUF").name == "z-image"
|
|
assert detect_family("unsloth/Qwen-Image-2512-GGUF").name == "qwen-image"
|
|
assert detect_family("unsloth/FLUX.1-schnell-GGUF").name == "flux.1"
|
|
# FLUX.2-klein is its own pipeline (Qwen3 TE), distinct from FLUX.1.
|
|
klein = detect_family("unsloth/FLUX.2-klein-4B-GGUF")
|
|
assert klein.name == "flux.2-klein"
|
|
assert klein.pipeline_class == "Flux2KleinPipeline"
|
|
assert klein.cfg_kwarg == "guidance_scale"
|
|
# Both klein sizes share the one family (base repo resolved per-variant).
|
|
assert detect_family("unsloth/FLUX.2-klein-9B-GGUF").name == "flux.2-klein"
|
|
# FLUX.2-dev is the Mistral-based Flux2Pipeline, a distinct family from klein, and must not collide with it.
|
|
dev = detect_family("unsloth/FLUX.2-dev-GGUF")
|
|
assert dev.name == "flux.2-dev"
|
|
assert dev.pipeline_class == "Flux2Pipeline"
|
|
assert dev.base_repo == "black-forest-labs/FLUX.2-dev"
|
|
assert detect_family("black-forest-labs/FLUX.2-dev").name == "flux.2-dev"
|
|
# Qwen-Image guides via true_cfg_scale, not guidance_scale.
|
|
assert detect_family("unsloth/Qwen-Image-2512-GGUF").cfg_kwarg == "true_cfg_scale"
|
|
assert detect_family("unsloth/Z-Image-GGUF").cfg_kwarg == "guidance_scale"
|
|
# Qwen-Image-Edit is a SUPPORTED instruction-editing family; the most-specific match wins so it does not fall back to the generic qwen-image.
|
|
edit = detect_family("unsloth/Qwen-Image-Edit-2511-GGUF")
|
|
assert edit.name == "qwen-image-edit"
|
|
assert edit.pipeline_class == "QwenImageEditPlusPipeline"
|
|
assert edit.edit is True
|
|
assert detect_family("unsloth/Qwen-Image-Edit-2509-GGUF").name == "qwen-image-edit"
|
|
# FLUX Kontext is a SUPPORTED editing family: the "kontext" keyword is un-rejected for it, and it must win over the generic "flux.1" match.
|
|
kontext = detect_family("unsloth/FLUX.1-Kontext-dev-GGUF")
|
|
assert kontext.name == "flux.1-kontext"
|
|
assert kontext.pipeline_class == "FluxKontextPipeline"
|
|
assert kontext.edit is True
|
|
assert kontext.cfg_kwarg == "guidance_scale"
|
|
# A plain FLUX.1 checkpoint must still resolve to the base flux.1 family, not kontext.
|
|
assert detect_family("unsloth/FLUX.1-dev-GGUF").name == "flux.1"
|
|
# A plain Qwen-Image checkpoint must still resolve to the base family, not edit.
|
|
assert detect_family("unsloth/Qwen-Image-2512-GGUF").name == "qwen-image"
|
|
# Krea 2 (diffusers >= 0.39): bf16-only single-stream DiT, no GGUF/sd.cpp mapping.
|
|
krea2 = detect_family("krea/Krea-2-Turbo")
|
|
assert krea2.name == "krea-2"
|
|
assert krea2.pipeline_class == "Krea2Pipeline"
|
|
assert krea2.transformer_class == "Krea2Transformer2DModel"
|
|
assert krea2.cfg_kwarg == "guidance_scale"
|
|
assert krea2.fp16_incompatible is True
|
|
assert krea2.sd_cpp_text_encoders == ()
|
|
assert detect_family("meta-llama/Llama-3-8B") is None
|
|
|
|
|
|
def test_detect_family_matches_reject_and_alias_by_segment():
|
|
# Reject keywords and short aliases must match whole path/name segments, not raw substrings, so an unrelated word containing one does not misroute a valid base model.
|
|
assert detect_family("/models/edited/z-image-turbo-Q4_K_M.gguf").name == "z-image"
|
|
assert detect_family("unsloth/Z-Image-Edition-GGUF").name == "z-image"
|
|
assert detect_family("/models/kontextual/z-image-turbo-Q4_K_M.gguf").name == "z-image"
|
|
# Supported edit families still resolve (edit / kontext are whole tokens there).
|
|
assert detect_family("unsloth/Qwen-Image-Edit-2511-GGUF").name == "qwen-image-edit"
|
|
assert detect_family("unsloth/FLUX.1-Kontext-dev-GGUF").name == "flux.1-kontext"
|
|
# Unsupported variants sharing only a base arch keyword are still rejected.
|
|
assert detect_family("unsloth/Qwen-Image-Layered-GGUF") is None
|
|
assert detect_family("unsloth/Qwen-Image-2512-Inpaint") is None
|
|
|
|
|
|
def test_detect_family_edit_keyword_scoped_to_basename():
|
|
from core.inference.diffusion_families import detect_family_for_pick
|
|
|
|
# A parent directory named `edit`/`inpaint` must NOT poison a valid pick: only the model id / filename basename is scanned for reject keywords.
|
|
assert detect_family("/models/edit") is None # the dir alone is ambiguous
|
|
assert detect_family_for_pick("/models/edit", "Z-Image-Turbo-Q4.gguf").name == "z-image"
|
|
assert detect_family_for_pick("/models/inpaint", "qwen-image-2512-Q4.gguf").name == "qwen-image"
|
|
# A genuinely unsupported variant keyword in the FILENAME still rejects.
|
|
assert detect_family_for_pick("/models/misc", "Qwen-Image-Layered-Q4.gguf") is None
|
|
|
|
|
|
def test_detect_family_override():
|
|
assert detect_family("local/path", override = "z-image").name == "z-image"
|
|
assert detect_family("local/path", override = "zimage").name == "z-image"
|
|
assert detect_family("local/path", override = "not-a-family") is None
|
|
|
|
|
|
def test_supported_family_names():
|
|
names = supported_family_names()
|
|
# The unknown-model error lists these, so the key families must be present.
|
|
for expected in ("flux.1", "flux.2-klein", "flux.2-dev", "qwen-image", "z-image", "krea-2"):
|
|
assert expected in names
|
|
# Every listed name is a valid family_override (round-trips through detect_family).
|
|
for name in names:
|
|
assert detect_family("some/unknown-repo", override = name) is not None
|
|
|
|
|
|
def test_resolve_base_repo():
|
|
fam = detect_family("x", override = "z-image")
|
|
assert resolve_base_repo(fam, None) == fam.base_repo
|
|
assert resolve_base_repo(fam, " ") == fam.base_repo
|
|
assert resolve_base_repo(fam, "custom/base") == "custom/base"
|
|
|
|
|
|
def test_resolve_local_gguf_child(tmp_path):
|
|
(tmp_path / "model.gguf").write_bytes(b"x")
|
|
assert resolve_local_gguf_child(tmp_path, "model.gguf") == (tmp_path / "model.gguf").resolve()
|
|
with pytest.raises(ValueError):
|
|
resolve_local_gguf_child(tmp_path, "/etc/passwd")
|
|
with pytest.raises(ValueError):
|
|
resolve_local_gguf_child(tmp_path, "../secret.gguf")
|
|
with pytest.raises(ValueError):
|
|
resolve_local_gguf_child(tmp_path, "..\\secret.gguf")
|
|
with pytest.raises(FileNotFoundError):
|
|
resolve_local_gguf_child(tmp_path, "missing.gguf")
|
|
|
|
|
|
def test_resolve_local_gguf_child_blocks_symlink_escape(tmp_path):
|
|
outside = tmp_path / "outside.gguf"
|
|
outside.write_bytes(b"secret")
|
|
repo = tmp_path / "repo"
|
|
repo.mkdir()
|
|
try:
|
|
(repo / "model.gguf").symlink_to(outside)
|
|
except (OSError, NotImplementedError):
|
|
pytest.skip("symlinks not supported on this platform")
|
|
with pytest.raises(ValueError):
|
|
resolve_local_gguf_child(repo, "model.gguf")
|
|
|
|
|
|
# Stubbed runtime for backend lifecycle
|
|
|
|
|
|
class _FakeDtype:
|
|
def __init__(self, name: str) -> None:
|
|
self._name = name
|
|
|
|
def __repr__(self) -> str:
|
|
return f"torch.{self._name}"
|
|
|
|
__str__ = __repr__
|
|
|
|
|
|
class _FakeGenerator:
|
|
def __init__(self, device = None) -> None:
|
|
self.device = device
|
|
self.manual = None
|
|
|
|
def seed(self) -> int:
|
|
return 4242
|
|
|
|
def manual_seed(self, value: int):
|
|
self.manual = value
|
|
return self
|
|
|
|
|
|
class _FakeImage:
|
|
"""Stand-in for a generated PIL image (the route persists it; here we only
|
|
count how many come back)."""
|
|
|
|
|
|
class _FakePipe:
|
|
def __init__(self) -> None:
|
|
self.moved_to = None
|
|
self.offloaded = False
|
|
self.sequential_offloaded = False
|
|
self.vae_tiled = False
|
|
self.vae_sliced = False
|
|
self.last_kwargs = None
|
|
|
|
def to(self, device):
|
|
self.moved_to = device
|
|
return self
|
|
|
|
def enable_model_cpu_offload(self, device = None) -> None:
|
|
self.offloaded = True
|
|
self.offload_device = device
|
|
|
|
def enable_sequential_cpu_offload(self, device = None) -> None:
|
|
self.sequential_offloaded = True
|
|
self.offload_device = device
|
|
|
|
def enable_vae_tiling(self) -> None:
|
|
self.vae_tiled = True
|
|
|
|
def enable_vae_slicing(self) -> None:
|
|
self.vae_sliced = True
|
|
|
|
# Explicit signature (not just **kwargs) so generate() signature-gated guards take effect: a **kwargs-only fake would make `"negative_prompt" in signature` always False.
|
|
def __call__(
|
|
self,
|
|
*,
|
|
prompt = None,
|
|
negative_prompt = None,
|
|
callback_on_step_end = None,
|
|
guidance_scale = None,
|
|
true_cfg_scale = None,
|
|
cfg_trunc_ratio = None,
|
|
**kwargs,
|
|
):
|
|
self.last_kwargs = {
|
|
"prompt": prompt,
|
|
"negative_prompt": negative_prompt,
|
|
"callback_on_step_end": callback_on_step_end,
|
|
"guidance_scale": guidance_scale,
|
|
"true_cfg_scale": true_cfg_scale,
|
|
"cfg_trunc_ratio": cfg_trunc_ratio,
|
|
**kwargs,
|
|
}
|
|
# Mirror diffusers batching: a prompt LIST yields one image per prompt, and num_images_per_prompt fans each prompt out.
|
|
n = kwargs.get("num_images_per_prompt", 1)
|
|
if isinstance(prompt, list):
|
|
n *= len(prompt)
|
|
return types.SimpleNamespace(images = [_FakeImage() for _ in range(n)])
|
|
|
|
|
|
class _FakePipeline:
|
|
last: dict = {}
|
|
last_single_file: dict = {}
|
|
|
|
@classmethod
|
|
def from_pretrained(cls, base, **kwargs):
|
|
_FakePipeline.last = {"base": base, **kwargs}
|
|
return _FakePipe()
|
|
|
|
@classmethod
|
|
def from_single_file(cls, path, **kwargs):
|
|
# SDXL-style single-file: the WHOLE pipeline comes from one .safetensors file.
|
|
_FakePipeline.last_single_file = {"path": path, **kwargs}
|
|
return _FakePipe()
|
|
|
|
|
|
class _FakeTransformer:
|
|
last: dict = {}
|
|
|
|
@classmethod
|
|
def from_single_file(cls, path, **kwargs):
|
|
_FakeTransformer.last = {"path": path, **kwargs}
|
|
return object()
|
|
|
|
|
|
class _FakeImg2ImgPipe:
|
|
"""An img2img pipeline call: records the image-conditioned kwargs. Its signature
|
|
declares image/strength but NOT width/height, mirroring real img2img pipelines
|
|
(which derive the output size from the input image)."""
|
|
|
|
last_kwargs: dict = {}
|
|
|
|
def __call__(
|
|
self,
|
|
*,
|
|
prompt = None,
|
|
image = None,
|
|
strength = None,
|
|
negative_prompt = None,
|
|
callback_on_step_end = None,
|
|
guidance_scale = None,
|
|
true_cfg_scale = None,
|
|
**kwargs,
|
|
):
|
|
_FakeImg2ImgPipe.last_kwargs = {
|
|
"prompt": prompt,
|
|
"image": image,
|
|
"strength": strength,
|
|
**kwargs,
|
|
}
|
|
n = kwargs.get("num_images_per_prompt", 1)
|
|
return types.SimpleNamespace(images = [_FakeImage() for _ in range(n)])
|
|
|
|
|
|
class _FakeImg2ImgPipeline:
|
|
built_from: object = None
|
|
from_pipe_kwargs: dict = {}
|
|
|
|
@classmethod
|
|
def from_pipe(cls, base_pipe, **kwargs):
|
|
_FakeImg2ImgPipeline.built_from = base_pipe
|
|
_FakeImg2ImgPipeline.from_pipe_kwargs = kwargs
|
|
return _FakeImg2ImgPipe()
|
|
|
|
|
|
class _FakeInpaintPipe:
|
|
"""An inpaint pipeline call: records image + mask_image + strength. Real inpaint
|
|
pipelines take both an init image and a grayscale mask and derive output size from
|
|
the input, so width/height are not in its signature."""
|
|
|
|
last_kwargs: dict = {}
|
|
|
|
def __call__(
|
|
self,
|
|
*,
|
|
prompt = None,
|
|
image = None,
|
|
mask_image = None,
|
|
strength = None,
|
|
negative_prompt = None,
|
|
callback_on_step_end = None,
|
|
guidance_scale = None,
|
|
true_cfg_scale = None,
|
|
**kwargs,
|
|
):
|
|
_FakeInpaintPipe.last_kwargs = {
|
|
"prompt": prompt,
|
|
"image": image,
|
|
"mask_image": mask_image,
|
|
"strength": strength,
|
|
**kwargs,
|
|
}
|
|
n = kwargs.get("num_images_per_prompt", 1)
|
|
return types.SimpleNamespace(images = [_FakeImage() for _ in range(n)])
|
|
|
|
|
|
class _FakeInpaintPipeline:
|
|
built_from: object = None
|
|
|
|
@classmethod
|
|
def from_pipe(cls, base_pipe, **kwargs):
|
|
_FakeInpaintPipeline.built_from = base_pipe
|
|
return _FakeInpaintPipe()
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_runtime(monkeypatch):
|
|
torch = types.ModuleType("torch")
|
|
torch.bfloat16 = _FakeDtype("bfloat16")
|
|
torch.float16 = _FakeDtype("float16")
|
|
torch.float32 = _FakeDtype("float32")
|
|
torch.Generator = _FakeGenerator
|
|
torch.cuda = types.SimpleNamespace(is_available = lambda: False)
|
|
torch.backends = types.SimpleNamespace(mps = None)
|
|
# generate() wraps the pipe call in torch.inference_mode(); a no-op CM here.
|
|
torch.inference_mode = lambda: contextlib.nullcontext()
|
|
|
|
diffusers = types.ModuleType("diffusers")
|
|
diffusers.GGUFQuantizationConfig = lambda compute_dtype = None: ("quant", compute_dtype)
|
|
diffusers.ZImagePipeline = _FakePipeline
|
|
diffusers.ZImageTransformer2DModel = _FakeTransformer
|
|
diffusers.ZImageImg2ImgPipeline = _FakeImg2ImgPipeline
|
|
diffusers.ZImageInpaintPipeline = _FakeInpaintPipeline
|
|
# Qwen-Image too, so the true_cfg_scale cfg-kwarg path is exercisable.
|
|
diffusers.QwenImagePipeline = _FakePipeline
|
|
diffusers.QwenImageTransformer2DModel = _FakeTransformer
|
|
diffusers.QwenImageImg2ImgPipeline = _FakeImg2ImgPipeline
|
|
diffusers.QwenImageInpaintPipeline = _FakeInpaintPipeline
|
|
# Instruction-editing pipeline (Qwen-Image-Edit): its own pipeline IS the loaded one.
|
|
diffusers.QwenImageEditPlusPipeline = _FakePipeline
|
|
# Ideogram 4, so its guidance_scale/guidance_schedule pairing is exercisable. It loads only as a full pipeline (two DiTs), so stub the assembly to a fake pipe.
|
|
diffusers.Ideogram4Pipeline = _FakePipeline
|
|
diffusers.Ideogram4Transformer2DModel = _FakeTransformer
|
|
# Lumina 2, so the cfg_trunc_ratio special case is exercisable (the fake pipe signature carries the kwarg, mirroring the real Lumina2Pipeline).
|
|
diffusers.Lumina2Pipeline = _FakePipeline
|
|
diffusers.Lumina2Transformer2DModel = _FakeTransformer
|
|
# SDXL: a U-Net family whose single-file checkpoint is the whole pipeline, so the pipeline class carries from_single_file and UNet2DConditionModel is the denoiser class.
|
|
diffusers.StableDiffusionXLPipeline = _FakePipeline
|
|
diffusers.UNet2DConditionModel = _FakeTransformer
|
|
diffusers.StableDiffusionXLImg2ImgPipeline = _FakeImg2ImgPipeline
|
|
diffusers.StableDiffusionXLInpaintPipeline = _FakeInpaintPipeline
|
|
|
|
monkeypatch.setattr(
|
|
"core.inference.diffusion.load_ideogram4_pipeline",
|
|
lambda repo_id, dtype, hf_token = None: _FakePipe(),
|
|
)
|
|
|
|
monkeypatch.setitem(sys.modules, "torch", torch)
|
|
monkeypatch.setitem(sys.modules, "diffusers", diffusers)
|
|
# The backend imports clear_gpu_cache by reference; no-op it so unload does not run real hardware detection against the stubbed torch.
|
|
monkeypatch.setattr("core.inference.diffusion.clear_gpu_cache", lambda: None)
|
|
_FakePipeline.last = {}
|
|
_FakePipeline.last_single_file = {}
|
|
_FakeTransformer.last = {}
|
|
_FakeImg2ImgPipeline.built_from = None
|
|
_FakeImg2ImgPipe.last_kwargs = {}
|
|
_FakeInpaintPipeline.built_from = None
|
|
_FakeInpaintPipe.last_kwargs = {}
|
|
yield
|
|
|
|
|
|
def test_load_generate_unload_gguf(fake_runtime, tmp_path):
|
|
(tmp_path / "model.gguf").write_bytes(b"weights")
|
|
backend = DiffusionBackend()
|
|
|
|
status = backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "model.gguf",
|
|
base_repo = "base/repo",
|
|
family_override = "z-image",
|
|
hf_token = "hf_secret",
|
|
)
|
|
assert status["loaded"] is True
|
|
assert status["family"] == "z-image"
|
|
assert status["base_repo"] == "base/repo"
|
|
assert status["device"] == "cpu"
|
|
assert status["dtype"] == "float32"
|
|
assert status["cpu_offload"] is False
|
|
# Transformer built from the local GGUF, pipeline assembled from the base repo.
|
|
assert _FakeTransformer.last["path"] == str((tmp_path / "model.gguf").resolve())
|
|
assert _FakeTransformer.last["subfolder"] == "transformer"
|
|
# The token reaches the (possibly gated) base config fetch and the pipeline.
|
|
assert _FakeTransformer.last["token"] == "hf_secret"
|
|
assert _FakePipeline.last["base"] == "base/repo"
|
|
assert "transformer" in _FakePipeline.last
|
|
|
|
gen = backend.generate(
|
|
prompt = "a sloth", negative_prompt = "blurry", width = 512, height = 512, steps = 4, guidance = 3.0
|
|
)
|
|
assert gen["seed"] == 4242 # random seed reported back
|
|
assert gen["repo_id"] == str(tmp_path) # echoed so the route can record the model
|
|
assert len(gen["images"]) == 1 # PIL images handed to the route for persistence
|
|
# z-image guides via guidance_scale (not true_cfg_scale); the signature-gated negative_prompt and per-step callback both reach the pipeline call.
|
|
call = backend._state.pipe.last_kwargs
|
|
assert call["guidance_scale"] == 3.0 and call["true_cfg_scale"] is None
|
|
assert call["negative_prompt"] == "blurry"
|
|
assert callable(call["callback_on_step_end"])
|
|
|
|
gen2 = backend.generate(prompt = "again", seed = 99)
|
|
assert gen2["seed"] == 99
|
|
|
|
# batch_size produces that many images in one call, seeded base..base+2 per image (matching the native engine) so each batch member replays alone from its recipe.
|
|
batch = backend.generate(prompt = "batch", seed = 7, batch_size = 3)
|
|
assert len(batch["images"]) == 3 and batch["seed"] == 7
|
|
assert batch["seeds"] == [7, 8, 9]
|
|
|
|
assert backend.unload()["loaded"] is False
|
|
assert backend.is_loaded is False
|
|
|
|
|
|
def test_generate_progress_active_during_setup(fake_runtime, tmp_path, monkeypatch):
|
|
# A generation must report active from the moment it holds the lock, before the slow pre-denoise setup. _apply_loras runs inside that window, so probe generate_progress() from there.
|
|
(tmp_path / "model.gguf").write_bytes(b"weights")
|
|
backend = DiffusionBackend()
|
|
backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "model.gguf",
|
|
base_repo = "base/repo",
|
|
family_override = "z-image",
|
|
hf_token = "hf_secret",
|
|
)
|
|
|
|
seen = {}
|
|
|
|
def fake_apply(self, state, loras, cancel):
|
|
seen["progress"] = self.generate_progress()
|
|
|
|
monkeypatch.setattr(DiffusionBackend, "_apply_loras", fake_apply)
|
|
|
|
assert backend.generate_progress()["active"] is False
|
|
|
|
gen = backend.generate(prompt = "a sloth", steps = 4)
|
|
assert len(gen["images"]) == 1
|
|
|
|
# Active was published during setup, with the requested step total and step 0.
|
|
assert seen["progress"]["active"] is True
|
|
assert seen["progress"]["total_steps"] == 4
|
|
assert seen["progress"]["step"] == 0
|
|
|
|
assert backend.generate_progress()["active"] is False
|
|
|
|
|
|
def test_generate_progress_cleared_on_setup_error(fake_runtime, tmp_path, monkeypatch):
|
|
# A setup-time failure skips the inner finally that nulls _gen, so the outer finally must clear the published progress; otherwise a crashed generation leaves the UI stuck "active".
|
|
(tmp_path / "model.gguf").write_bytes(b"weights")
|
|
backend = DiffusionBackend()
|
|
backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "model.gguf",
|
|
base_repo = "base/repo",
|
|
family_override = "z-image",
|
|
hf_token = "hf_secret",
|
|
)
|
|
|
|
def boom(self, state, loras, cancel):
|
|
raise RuntimeError("setup failed")
|
|
|
|
monkeypatch.setattr(DiffusionBackend, "_apply_loras", boom)
|
|
|
|
with pytest.raises(RuntimeError, match = "setup failed"):
|
|
backend.generate(prompt = "a sloth", steps = 4)
|
|
|
|
assert backend.generate_progress()["active"] is False
|
|
|
|
|
|
def test_generate_progress_active_through_compile_cache_save(fake_runtime, tmp_path, monkeypatch):
|
|
# Post-denoise work (the compile-cache save) still runs before the route persists the image, so progress must stay active through it, else a reload mount probe reads idle and refreshes the gallery before the result exists.
|
|
from core.inference import diffusion as dmod
|
|
|
|
(tmp_path / "model.gguf").write_bytes(b"weights")
|
|
backend = DiffusionBackend()
|
|
backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "model.gguf",
|
|
base_repo = "base/repo",
|
|
family_override = "z-image",
|
|
hf_token = "hf_secret",
|
|
)
|
|
|
|
seen = {}
|
|
|
|
def fake_save(ctx, *, logger = None):
|
|
seen["progress"] = backend.generate_progress()
|
|
return True
|
|
|
|
monkeypatch.setattr(dmod.compile_cache, "register_shape", lambda *a, **k: None)
|
|
monkeypatch.setattr(dmod.compile_cache, "save", fake_save)
|
|
|
|
gen = backend.generate(prompt = "a sloth", steps = 4)
|
|
assert len(gen["images"]) == 1
|
|
# Still active while the compile-cache save ran.
|
|
assert seen["progress"]["active"] is True
|
|
assert seen["progress"]["total_steps"] == 4
|
|
assert backend.generate_progress()["active"] is False
|
|
|
|
|
|
def test_dense_speed_auto_defers_compile_to_third_generation(fake_runtime, tmp_path, monkeypatch):
|
|
# Dense models with speed unset stay bit-identical eager for the first two generations; the 3rd engages the `default` profile mid-session, upgrading attention alongside it.
|
|
from core.inference import diffusion as dmod
|
|
|
|
monkeypatch.setattr(dmod, "compile_eligible", lambda *a, **k: True)
|
|
monkeypatch.setattr(
|
|
dmod,
|
|
"apply_speed_optims",
|
|
lambda pipe, target, **k: {"compiled": k.get("speed_mode") == "default"},
|
|
)
|
|
monkeypatch.setattr(dmod, "apply_attention_backend", lambda pipe, backend, logger = None: backend)
|
|
monkeypatch.setattr(
|
|
dmod,
|
|
"select_attention_backend",
|
|
lambda target, requested, speed_active = False: ("_native_cudnn" if speed_active else None),
|
|
)
|
|
monkeypatch.setattr(dmod.compile_cache, "begin", lambda **k: None)
|
|
|
|
(tmp_path / "model.safetensors").write_bytes(b"weights")
|
|
backend = DiffusionBackend()
|
|
status = backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "model.safetensors",
|
|
base_repo = "base/repo",
|
|
family_override = "qwen-image",
|
|
)
|
|
assert status["speed_mode"] == "off"
|
|
assert status["resolved"]["speed_mode"]["value"] == "deferred"
|
|
assert status["resolved"]["speed_mode"]["source"] == "auto"
|
|
|
|
backend.generate(prompt = "one")
|
|
backend.generate(prompt = "two")
|
|
assert backend.status()["speed_mode"] == "off" # first two stay exact eager
|
|
backend.generate(prompt = "three")
|
|
status3 = backend.status()
|
|
assert status3["speed_mode"] == "default"
|
|
assert "compiled" in status3["speed_optims"]
|
|
assert status3["attention_backend"] == "_native_cudnn"
|
|
assert status3["resolved"]["speed_mode"]["value"] == "default"
|
|
|
|
# An explicit "off" is pinned: no deferral, still eager after 3 generations.
|
|
backend.unload()
|
|
status_off = backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "model.safetensors",
|
|
base_repo = "base/repo",
|
|
family_override = "qwen-image",
|
|
speed_mode = "off",
|
|
)
|
|
assert status_off["resolved"]["speed_mode"]["value"] == "off"
|
|
for p in ("a", "b", "c"):
|
|
backend.generate(prompt = p)
|
|
assert backend.status()["speed_mode"] == "off"
|
|
backend.unload()
|
|
|
|
|
|
def test_deferred_speed_skips_when_lora_requested(fake_runtime, tmp_path, monkeypatch):
|
|
# A compiled transformer rejects LoRA, and _apply_loras raises before its unchanged-selection no-op, so engaging the deferred compile on a LoRA generation would permanently break every LoRA generation on this load. The deferral must skip while a LoRA is requested.
|
|
from core.inference import diffusion as dmod
|
|
|
|
monkeypatch.setattr(dmod, "compile_eligible", lambda *a, **k: True)
|
|
engaged: list = []
|
|
|
|
def fake_engage(self, state):
|
|
engaged.append(state.generation_count)
|
|
state.speed_deferred = False # mirror the real helper: engage once, then clear
|
|
|
|
monkeypatch.setattr(DiffusionBackend, "_engage_deferred_speed", fake_engage)
|
|
# LoRA loading is covered elsewhere; stub it so this test needs no adapter file.
|
|
monkeypatch.setattr(DiffusionBackend, "_apply_loras", lambda self, state, loras, cancel: None)
|
|
|
|
(tmp_path / "model.safetensors").write_bytes(b"weights")
|
|
backend = DiffusionBackend()
|
|
backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "model.safetensors",
|
|
base_repo = "base/repo",
|
|
family_override = "qwen-image",
|
|
)
|
|
backend.generate(prompt = "one")
|
|
backend.generate(prompt = "two")
|
|
# 3rd generation requests a LoRA: the deferral must be skipped (pipe stays LoRA-capable).
|
|
backend.generate(prompt = "three", loras = [("adapter", 1.0)])
|
|
assert engaged == []
|
|
# 4th generation without a LoRA: the deferral now engages (the guard is LoRA-specific).
|
|
backend.generate(prompt = "four")
|
|
assert len(engaged) == 1
|
|
|
|
|
|
def test_deferred_speed_skips_while_adapter_attached(fake_runtime, tmp_path, monkeypatch):
|
|
# Even a NO-LoRA generation must defer while an adapter from a PRIOR generation is still attached: _apply_loras runs AFTER the engage, so compiling here would bake the resident adapter into the graph and the later (swallowed) unload would leave it active forever.
|
|
from core.inference import diffusion as dmod
|
|
|
|
monkeypatch.setattr(dmod, "compile_eligible", lambda *a, **k: True)
|
|
engaged: list = []
|
|
|
|
def fake_engage(self, state):
|
|
engaged.append(state.generation_count)
|
|
state.speed_deferred = False
|
|
|
|
monkeypatch.setattr(DiffusionBackend, "_engage_deferred_speed", fake_engage)
|
|
|
|
# Track the attached set on the pipe, mirroring the real _apply_loras marker.
|
|
def fake_apply(self, state, loras, cancel):
|
|
specs = [(i, w) for (i, w) in (loras or []) if w != 0]
|
|
state.pipe._unsloth_loras = tuple(specs)
|
|
|
|
monkeypatch.setattr(DiffusionBackend, "_apply_loras", fake_apply)
|
|
|
|
(tmp_path / "model.safetensors").write_bytes(b"weights")
|
|
backend = DiffusionBackend()
|
|
backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "model.safetensors",
|
|
base_repo = "base/repo",
|
|
family_override = "qwen-image",
|
|
)
|
|
# Gens 1-2 attach an adapter, so it is still resident going into gen 3.
|
|
backend.generate(prompt = "one", loras = [("adapter", 1.0)])
|
|
backend.generate(prompt = "two", loras = [("adapter", 1.0)])
|
|
# Gen 3 requests NO LoRA but the adapter is still attached, so defer.
|
|
backend.generate(prompt = "three")
|
|
assert engaged == []
|
|
# Gen 3's _apply_loras([]) cleared the adapter; gen 4 is genuinely LoRA-free, so engage.
|
|
backend.generate(prompt = "four")
|
|
assert len(engaged) == 1
|
|
|
|
|
|
def test_deferred_speed_preserves_explicit_attention(fake_runtime, tmp_path, monkeypatch):
|
|
# A dense model loaded with Speed on Auto but Attention explicitly pinned must KEEP that choice when the 3rd generation engages the deferred profile: the auto cuDNN upgrade applies only when attention was left on auto.
|
|
from core.inference import diffusion as dmod
|
|
|
|
monkeypatch.setattr(dmod, "compile_eligible", lambda *a, **k: True)
|
|
monkeypatch.setattr(
|
|
dmod,
|
|
"apply_speed_optims",
|
|
lambda pipe, target, **k: {"compiled": k.get("speed_mode") == "default"},
|
|
)
|
|
monkeypatch.setattr(dmod, "apply_attention_backend", lambda pipe, backend, logger = None: backend)
|
|
|
|
# A select mock that HONORS an explicit request: "native" stays on the default even under a speed profile, and only an unset request upgrades to cuDNN.
|
|
def fake_select(
|
|
target,
|
|
requested,
|
|
speed_active = False,
|
|
):
|
|
if requested in (None, "", "auto"):
|
|
return "_native_cudnn" if speed_active else None
|
|
if str(requested).lower() in ("native", "sdpa"):
|
|
return None
|
|
return requested
|
|
|
|
monkeypatch.setattr(dmod, "select_attention_backend", fake_select)
|
|
monkeypatch.setattr(dmod.compile_cache, "begin", lambda **k: None)
|
|
|
|
(tmp_path / "model.safetensors").write_bytes(b"weights")
|
|
backend = DiffusionBackend()
|
|
backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "model.safetensors",
|
|
base_repo = "base/repo",
|
|
family_override = "qwen-image",
|
|
attention_backend = "native",
|
|
)
|
|
backend.generate(prompt = "one")
|
|
backend.generate(prompt = "two")
|
|
backend.generate(prompt = "three") # deferred profile engages here
|
|
status = backend.status()
|
|
assert status["speed_mode"] == "default" # the compile profile still engaged
|
|
assert "compiled" in status["speed_optims"]
|
|
# The pinned "native" survived: NOT silently upgraded to cuDNN.
|
|
assert status["attention_backend"] is None
|
|
assert status["resolved"]["attention_backend"]["value"] == "native"
|
|
assert status["resolved"]["attention_backend"]["source"] == "explicit"
|
|
|
|
# Control: with attention left on auto the same deferral DOES upgrade to cuDNN, so the assertion above is not vacuously passing.
|
|
backend.unload()
|
|
backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "model.safetensors",
|
|
base_repo = "base/repo",
|
|
family_override = "qwen-image",
|
|
)
|
|
for p in ("a", "b", "c"):
|
|
backend.generate(prompt = p)
|
|
assert backend.status()["attention_backend"] == "_native_cudnn"
|
|
backend.unload()
|
|
|
|
|
|
def _tiny_png_b64() -> str:
|
|
import base64
|
|
import io
|
|
|
|
from PIL import Image
|
|
|
|
buf = io.BytesIO()
|
|
Image.new("RGB", (64, 64), (120, 30, 30)).save(buf, format = "PNG")
|
|
return base64.b64encode(buf.getvalue()).decode()
|
|
|
|
|
|
def test_generate_img2img_uses_from_pipe(fake_runtime, tmp_path):
|
|
"""An init_image routes generate() through the family's img2img pipeline, built via
|
|
Pipeline.from_pipe around the loaded pipe (no reload), with image + strength passed
|
|
and width/height dropped (the img2img pipe derives size from the input image)."""
|
|
(tmp_path / "model.gguf").write_bytes(b"x")
|
|
backend = DiffusionBackend()
|
|
backend.load_pipeline(
|
|
str(tmp_path), gguf_filename = "model.gguf", base_repo = "base/repo", family_override = "z-image"
|
|
)
|
|
# The loaded family advertises the image-conditioned workflows for UI gating (upscale rides the img2img pipeline, so it appears whenever img2img does).
|
|
assert backend.status()["workflows"] == ["txt2img", "img2img", "upscale", "inpaint", "outpaint"]
|
|
|
|
loaded_pipe = backend._state.pipe
|
|
out = backend.generate(
|
|
prompt = "a car at sunset",
|
|
steps = 4,
|
|
guidance = 0.0,
|
|
seed = 3,
|
|
init_image = _tiny_png_b64(),
|
|
strength = 0.5,
|
|
)
|
|
assert len(out["images"]) == 1
|
|
# from_pipe was handed the loaded text-to-image pipe (component reuse, no reload).
|
|
assert _FakeImg2ImgPipeline.built_from is loaded_pipe
|
|
# ...and with torch_dtype=None so from_pipe SKIPS its default float32 recast, which upcasts the reused bf16 modules and crashes on torchao-quantized weights.
|
|
assert _FakeImg2ImgPipeline.from_pipe_kwargs.get("torch_dtype", "MISSING") is None
|
|
call = _FakeImg2ImgPipe.last_kwargs
|
|
assert call["image"] is not None # decoded source image passed through
|
|
assert call["strength"] == 0.5
|
|
assert "width" not in call and "height" not in call # img2img derives size from image
|
|
|
|
# A txt2img call after it still uses the base pipe (no image kwarg).
|
|
backend.generate(prompt = "plain", steps = 4, seed = 1)
|
|
assert backend._state.pipe.last_kwargs.get("image") is None
|
|
|
|
|
|
def test_generate_img2img_unsupported_family_raises(fake_runtime, tmp_path, monkeypatch):
|
|
"""A family with no image-conditioning at all (no img2img/inpaint/edit/reference) rejects
|
|
an init_image with a clear error rather than failing deep in the pipeline."""
|
|
from core.inference.diffusion_families import DiffusionFamily
|
|
|
|
# A synthetic txt2img-only family: no img2img/inpaint pipeline, not edit, not reference (every shipped family now supports some image workflow).
|
|
plain = DiffusionFamily(
|
|
name = "plain-test",
|
|
pipeline_class = "ZImagePipeline",
|
|
transformer_class = "ZImageTransformer2DModel",
|
|
base_repo = "base/repo",
|
|
)
|
|
monkeypatch.setattr(
|
|
"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()
|
|
backend.load_pipeline(str(tmp_path), gguf_filename = "model.gguf", base_repo = "base/repo")
|
|
assert backend.status()["workflows"] == ["txt2img"]
|
|
with pytest.raises(ValueError, match = "img2img"):
|
|
backend.generate(prompt = "x", steps = 4, init_image = _tiny_png_b64())
|
|
|
|
|
|
def test_generate_rejects_conditioning_without_init_image(fake_runtime, tmp_path):
|
|
"""mask / upscale / reference all need an input image; without one they must raise a
|
|
clear ValueError rather than silently degrading to txt2img."""
|
|
(tmp_path / "model.gguf").write_bytes(b"x")
|
|
backend = DiffusionBackend()
|
|
backend.load_pipeline(
|
|
str(tmp_path), gguf_filename = "model.gguf", base_repo = "base/repo", family_override = "z-image"
|
|
)
|
|
with pytest.raises(ValueError, match = "mask_image requires"):
|
|
backend.generate(prompt = "x", steps = 4, mask_image = _mask_b64(64))
|
|
with pytest.raises(ValueError, match = "upscale requires"):
|
|
backend.generate(prompt = "x", steps = 4, upscale = 2.0)
|
|
with pytest.raises(ValueError, match = "reference_images require"):
|
|
backend.generate(prompt = "x", steps = 4, reference_images = [_tiny_png_b64()])
|
|
|
|
|
|
def test_generate_rejects_reference_on_unsupported_family(fake_runtime, tmp_path):
|
|
"""A non-reference family rejects reference_images instead of silently dropping them."""
|
|
(tmp_path / "model.gguf").write_bytes(b"x")
|
|
backend = DiffusionBackend()
|
|
backend.load_pipeline(
|
|
str(tmp_path), gguf_filename = "model.gguf", base_repo = "base/repo", family_override = "z-image"
|
|
)
|
|
with pytest.raises(ValueError, match = "Reference images are not supported"):
|
|
backend.generate(
|
|
prompt = "x",
|
|
steps = 4,
|
|
init_image = _tiny_png_b64(),
|
|
reference_images = [_tiny_png_b64()],
|
|
)
|
|
|
|
|
|
def test_generate_upscale_enlarges_and_low_strength(fake_runtime, tmp_path):
|
|
"""An init_image + upscale factor routes generate() through the family's img2img
|
|
pipeline (hires fix): the source is enlarged to size*factor (rounded to /16) before the
|
|
denoise, the strength defaults low, and the factor is capped so a huge value can't OOM."""
|
|
(tmp_path / "model.gguf").write_bytes(b"x")
|
|
backend = DiffusionBackend()
|
|
backend.load_pipeline(
|
|
str(tmp_path), gguf_filename = "model.gguf", base_repo = "base/repo", family_override = "z-image"
|
|
)
|
|
# Upscale rides the img2img pipeline, so it is advertised alongside img2img.
|
|
assert "upscale" in backend.status()["workflows"]
|
|
|
|
loaded_pipe = backend._state.pipe
|
|
out = backend.generate(
|
|
prompt = "a crisp photo",
|
|
steps = 4,
|
|
guidance = 0.0,
|
|
seed = 3,
|
|
init_image = _tiny_png_b64(),
|
|
upscale = 2.0, # 64 -> 128, no explicit strength
|
|
)
|
|
assert len(out["images"]) == 1
|
|
# Reuses the resident modules via from_pipe (no reload, no extra VRAM).
|
|
assert _FakeImg2ImgPipeline.built_from is loaded_pipe
|
|
call = _FakeImg2ImgPipe.last_kwargs
|
|
# The image handed to the pipe is the ENLARGED source (64 * 2 = 128, already /16).
|
|
assert call["image"].size == (128, 128)
|
|
# Strength defaults to the hires-fix value when the caller sends none.
|
|
assert call["strength"] == 0.35
|
|
|
|
# The factor is capped at 4x so a large request can't blow up the VAE/transformer.
|
|
backend.generate(
|
|
prompt = "x",
|
|
steps = 4,
|
|
seed = 1,
|
|
init_image = _tiny_png_b64(),
|
|
upscale = 99.0,
|
|
)
|
|
assert _FakeImg2ImgPipe.last_kwargs["image"].size == (256, 256) # 64 * 4 (capped)
|
|
|
|
# An explicit strength overrides the hires-fix default.
|
|
backend.generate(
|
|
prompt = "x",
|
|
steps = 4,
|
|
seed = 1,
|
|
init_image = _tiny_png_b64(),
|
|
upscale = 1.5,
|
|
strength = 0.2,
|
|
)
|
|
assert _FakeImg2ImgPipe.last_kwargs["strength"] == 0.2
|
|
# 64 * 1.5 = 96, already a multiple of 16.
|
|
assert _FakeImg2ImgPipe.last_kwargs["image"].size == (96, 96)
|
|
|
|
|
|
def _png_b64(side: int) -> str:
|
|
import base64
|
|
import io
|
|
|
|
from PIL import Image
|
|
|
|
buf = io.BytesIO()
|
|
Image.new("RGB", (side, side), (10, 20, 30)).save(buf, format = "PNG")
|
|
return base64.b64encode(buf.getvalue()).decode()
|
|
|
|
|
|
def test_decode_image_rejects_oversized(fake_runtime, tmp_path):
|
|
"""An input image larger than the per-side cap is rejected with a clear error (protects
|
|
img2img / inpaint / reference from decompression-bomb / OOM inputs), not a 500."""
|
|
(tmp_path / "model.gguf").write_bytes(b"x")
|
|
backend = DiffusionBackend()
|
|
backend.load_pipeline(
|
|
str(tmp_path), gguf_filename = "model.gguf", base_repo = "base/repo", family_override = "z-image"
|
|
)
|
|
with pytest.raises(ValueError, match = "too large"):
|
|
backend.generate(prompt = "x", steps = 4, init_image = _png_b64(4112)) # > 4096/side
|
|
|
|
|
|
def test_upscale_output_is_capped(fake_runtime, tmp_path):
|
|
"""Upscale bounds the absolute output side to 2048 even when input*factor exceeds it, so a
|
|
large upload at 4x can't OOM the VAE/transformer."""
|
|
(tmp_path / "model.gguf").write_bytes(b"x")
|
|
backend = DiffusionBackend()
|
|
backend.load_pipeline(
|
|
str(tmp_path), gguf_filename = "model.gguf", base_repo = "base/repo", family_override = "z-image"
|
|
)
|
|
backend.generate(prompt = "x", steps = 4, seed = 1, init_image = _png_b64(1024), upscale = 4.0)
|
|
# 1024 * 4 = 4096 -> clamped to 2048 (longest side), still a multiple of 16.
|
|
assert _FakeImg2ImgPipe.last_kwargs["image"].size == (2048, 2048)
|
|
|
|
|
|
def _mask_b64(side: int) -> str:
|
|
import base64
|
|
import io
|
|
|
|
from PIL import Image
|
|
|
|
buf = io.BytesIO()
|
|
img = Image.new("L", (side, side), 0)
|
|
for y in range(side // 4, 3 * side // 4):
|
|
for x in range(side // 4, 3 * side // 4):
|
|
img.putpixel((x, y), 255)
|
|
img.save(buf, format = "PNG")
|
|
return base64.b64encode(buf.getvalue()).decode()
|
|
|
|
|
|
def test_img2img_snaps_non_multiple_of_16(fake_runtime, tmp_path):
|
|
"""An odd-sized img2img upload (not divisible by 16) is auto-resized to the nearest
|
|
multiple of 16 so the pipeline's divisibility check passes instead of erroring."""
|
|
(tmp_path / "model.gguf").write_bytes(b"x")
|
|
backend = DiffusionBackend()
|
|
backend.load_pipeline(
|
|
str(tmp_path), gguf_filename = "model.gguf", base_repo = "base/repo", family_override = "z-image"
|
|
)
|
|
backend.generate(prompt = "x", steps = 4, seed = 1, init_image = _png_b64(186), strength = 0.5)
|
|
# 186 / 16 = 11.625 -> round to 12 -> 192.
|
|
assert _FakeImg2ImgPipe.last_kwargs["image"].size == (192, 192)
|
|
|
|
|
|
def test_inpaint_snaps_image_and_mask_together(fake_runtime, tmp_path):
|
|
"""Inpaint snaps the odd-sized input to /16 AND resizes the mask to match, so the image
|
|
and mask stay aligned (a mismatch would crash the inpaint pipeline)."""
|
|
(tmp_path / "model.gguf").write_bytes(b"x")
|
|
backend = DiffusionBackend()
|
|
backend.load_pipeline(
|
|
str(tmp_path), gguf_filename = "model.gguf", base_repo = "base/repo", family_override = "z-image"
|
|
)
|
|
backend.generate(
|
|
prompt = "x",
|
|
steps = 4,
|
|
seed = 1,
|
|
init_image = _png_b64(186),
|
|
mask_image = _mask_b64(186),
|
|
strength = 0.5,
|
|
)
|
|
assert _FakeInpaintPipe.last_kwargs["image"].size == (192, 192)
|
|
assert _FakeInpaintPipe.last_kwargs["mask_image"].size == (192, 192)
|
|
|
|
|
|
def test_generate_reference_uses_loaded_pipe_at_slider_size(fake_runtime, tmp_path):
|
|
"""A reference family (FLUX.2-klein) advertises txt2img + reference, and a generate with
|
|
an init_image passes it as the loaded pipe's `image` arg (no from_pipe, no strength) while
|
|
the output size stays the REQUESTED slider size (the pipe resizes the reference itself)."""
|
|
import diffusers
|
|
|
|
diffusers.Flux2KleinPipeline = _FakePipeline
|
|
diffusers.Flux2KleinInpaintPipeline = _FakeInpaintPipeline
|
|
diffusers.Flux2Transformer2DModel = _FakeTransformer
|
|
(tmp_path / "model.gguf").write_bytes(b"x")
|
|
backend = DiffusionBackend()
|
|
backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "model.gguf",
|
|
base_repo = "base/repo",
|
|
family_override = "flux.2-klein",
|
|
)
|
|
# FLUX.2-klein: txt2img + reference (own pipe) + inpaint (dedicated pipe). No img2img class, so no img2img/upscale.
|
|
assert backend.status()["workflows"] == ["txt2img", "reference", "inpaint"]
|
|
|
|
loaded_pipe = backend._state.pipe
|
|
out = backend.generate(
|
|
prompt = "a portrait in this style",
|
|
steps = 6,
|
|
guidance = 4.0,
|
|
seed = 5,
|
|
width = 768,
|
|
height = 512,
|
|
init_image = _tiny_png_b64(),
|
|
strength = 0.5,
|
|
)
|
|
assert len(out["images"]) == 1
|
|
call = loaded_pipe.last_kwargs
|
|
assert call["image"] is not None # reference handed to the loaded pipe
|
|
assert call["width"] == 768 and call["height"] == 512 # OUTPUT size = sliders, not input
|
|
assert "strength" not in call # reference conditioning has no strength
|
|
assert "mask_image" not in call
|
|
# Guidance flows via guidance_scale (FLUX.2 default behaviour).
|
|
assert call["guidance_scale"] == 4.0
|
|
|
|
# Multi-reference: extra reference_images are combined with init_image into a LIST so the model can blend several references.
|
|
backend.generate(
|
|
prompt = "combine these",
|
|
steps = 6,
|
|
seed = 9,
|
|
width = 1024,
|
|
height = 1024,
|
|
init_image = _tiny_png_b64(),
|
|
reference_images = [_tiny_png_b64(), _tiny_png_b64()],
|
|
)
|
|
img_arg = loaded_pipe.last_kwargs["image"]
|
|
assert isinstance(img_arg, list) and len(img_arg) == 3 # primary + 2 extras
|
|
|
|
# Branch ordering: an init image + MASK on a reference family must route to inpaint, NOT be swallowed by the reference branch (which ignores the mask).
|
|
backend.generate(
|
|
prompt = "repaint here",
|
|
steps = 6,
|
|
seed = 2,
|
|
init_image = _tiny_png_b64(),
|
|
mask_image = _tiny_mask_b64(),
|
|
strength = 0.8,
|
|
)
|
|
assert _FakeInpaintPipeline.built_from is loaded_pipe # built via from_pipe off the load
|
|
assert _FakeInpaintPipe.last_kwargs["mask_image"] is not None
|
|
assert _FakeInpaintPipe.last_kwargs["strength"] == 0.8
|
|
|
|
# Without an init image the same family does plain txt2img (no image arg).
|
|
backend.generate(prompt = "just text", steps = 6, seed = 1)
|
|
assert backend._state.pipe.last_kwargs.get("image") is None
|
|
|
|
|
|
def _tiny_mask_b64() -> str:
|
|
import base64
|
|
import io
|
|
|
|
from PIL import Image
|
|
|
|
buf = io.BytesIO()
|
|
# A grayscale mask: white square (repaint) on black (keep).
|
|
img = Image.new("L", (64, 64), 0)
|
|
for y in range(16, 48):
|
|
for x in range(16, 48):
|
|
img.putpixel((x, y), 255)
|
|
img.save(buf, format = "PNG")
|
|
return base64.b64encode(buf.getvalue()).decode()
|
|
|
|
|
|
def test_generate_inpaint_uses_from_pipe(fake_runtime, tmp_path):
|
|
"""An init_image + mask_image routes generate() through the family's inpaint pipeline,
|
|
built via Pipeline.from_pipe around the loaded pipe (no reload), with the decoded image
|
|
+ mask + strength passed through and width/height dropped (size derives from the input)."""
|
|
(tmp_path / "model.gguf").write_bytes(b"x")
|
|
backend = DiffusionBackend()
|
|
backend.load_pipeline(
|
|
str(tmp_path), gguf_filename = "model.gguf", base_repo = "base/repo", family_override = "z-image"
|
|
)
|
|
loaded_pipe = backend._state.pipe
|
|
out = backend.generate(
|
|
prompt = "a red door",
|
|
steps = 4,
|
|
guidance = 0.0,
|
|
seed = 5,
|
|
init_image = _tiny_png_b64(),
|
|
mask_image = _tiny_mask_b64(),
|
|
strength = 0.7,
|
|
)
|
|
assert len(out["images"]) == 1
|
|
# The inpaint pipe (not img2img) was selected and built from the loaded pipe.
|
|
assert _FakeInpaintPipeline.built_from is loaded_pipe
|
|
assert _FakeImg2ImgPipeline.built_from is None
|
|
call = _FakeInpaintPipe.last_kwargs
|
|
assert call["image"] is not None and call["mask_image"] is not None
|
|
assert call["strength"] == 0.7
|
|
assert "width" not in call and "height" not in call # inpaint derives size from image
|
|
|
|
|
|
def test_image_conditioned_passes_image_size_not_slider(fake_runtime, tmp_path):
|
|
"""When the workflow pipe DOES accept width/height, an image-conditioned call must pass
|
|
the INPUT IMAGE's size, never the txt2img slider size -- otherwise a non-slider-sized
|
|
input (e.g. a 1536px outpaint canvas with a 1024 slider) mismatches the latents
|
|
("tensor a (128) must match tensor b (192)"). Covers Transform + Extend with any size."""
|
|
import base64
|
|
import io
|
|
|
|
from PIL import Image
|
|
|
|
class _SizePipe:
|
|
last: dict = {}
|
|
|
|
def __call__(
|
|
self,
|
|
*,
|
|
prompt = None,
|
|
image = None,
|
|
strength = None,
|
|
width = None,
|
|
height = None,
|
|
negative_prompt = None,
|
|
callback_on_step_end = None,
|
|
guidance_scale = None,
|
|
true_cfg_scale = None,
|
|
**kwargs,
|
|
):
|
|
_SizePipe.last = {"width": width, "height": height}
|
|
n = kwargs.get("num_images_per_prompt", 1)
|
|
return types.SimpleNamespace(images = [_FakeImage() for _ in range(n)])
|
|
|
|
class _SizePipeline:
|
|
@classmethod
|
|
def from_pipe(cls, base_pipe, **kwargs):
|
|
return _SizePipe()
|
|
|
|
import diffusers
|
|
|
|
diffusers.ZImageImg2ImgPipeline = _SizePipeline
|
|
(tmp_path / "model.gguf").write_bytes(b"x")
|
|
backend = DiffusionBackend()
|
|
backend.load_pipeline(
|
|
str(tmp_path), gguf_filename = "model.gguf", base_repo = "base/repo", family_override = "z-image"
|
|
)
|
|
buf = io.BytesIO()
|
|
Image.new("RGB", (96, 64), (10, 20, 30)).save(buf, format = "PNG") # non-square, non-slider
|
|
b64 = base64.b64encode(buf.getvalue()).decode()
|
|
backend.generate(prompt = "x", steps = 4, width = 1024, height = 1024, init_image = b64, strength = 0.5)
|
|
# The pipe got the IMAGE's 96x64, not the 1024x1024 slider.
|
|
assert _SizePipe.last == {"width": 96, "height": 64}
|
|
|
|
|
|
def test_compile_shape_dims_follow_workflow():
|
|
"""_compile_shape_dims mirrors generate()'s width/height derivation: slider size for
|
|
txt2img / reference / controlnet, the input image's size for the image-conditioned
|
|
workflows (whose forward runs at init_pil.size, whatever the sliders say)."""
|
|
from PIL import Image
|
|
|
|
from core.inference.diffusion import _compile_shape_dims
|
|
|
|
img = Image.new("RGB", (96, 64), (10, 20, 30))
|
|
assert _compile_shape_dims("txt2img", None, 1024, 512) == (1024, 512)
|
|
# reference generates at the slider size even though an init image is present.
|
|
assert _compile_shape_dims("reference", img, 1024, 512) == (1024, 512)
|
|
assert _compile_shape_dims("controlnet", None, 768, 768) == (768, 768)
|
|
for wf in ("img2img", "inpaint", "upscale", "edit"):
|
|
assert _compile_shape_dims(wf, img, 1024, 512) == (96, 64)
|
|
|
|
|
|
def test_register_shape_uses_actual_forward_dims(fake_runtime, tmp_path, monkeypatch):
|
|
"""The static compile-cache manifest must record the dims the forward ACTUALLY ran
|
|
at: an image-conditioned generate derives its output size from the input image, so
|
|
registering the slider values would mark a never-compiled shape as covered while the
|
|
truly-used shape never re-dirties/saves the bundle (warm restarts keep paying its
|
|
compile)."""
|
|
from core.inference import diffusion as diff
|
|
|
|
registered: list = []
|
|
monkeypatch.setattr(
|
|
diff.compile_cache,
|
|
"register_shape",
|
|
lambda ctx, shape, *, static: registered.append(tuple(shape)),
|
|
)
|
|
monkeypatch.setattr(diff.compile_cache, "save", lambda ctx, *, logger = None: True)
|
|
(tmp_path / "model.gguf").write_bytes(b"x")
|
|
backend = DiffusionBackend()
|
|
backend.load_pipeline(
|
|
str(tmp_path), gguf_filename = "model.gguf", base_repo = "base/repo", family_override = "z-image"
|
|
)
|
|
# txt2img registers the requested slider size.
|
|
backend.generate(prompt = "x", steps = 4, width = 1024, height = 512, seed = 1)
|
|
assert registered[-1] == (1024, 512, 1)
|
|
# img2img runs at the INPUT image's 64x64; the 1024x512 slider must not be recorded.
|
|
backend.generate(
|
|
prompt = "x",
|
|
steps = 4,
|
|
width = 1024,
|
|
height = 512,
|
|
seed = 1,
|
|
init_image = _tiny_png_b64(),
|
|
strength = 0.5,
|
|
)
|
|
assert registered[-1] == (64, 64, 1)
|
|
|
|
|
|
def test_edit_family_uses_own_pipeline_and_requires_image(fake_runtime, tmp_path):
|
|
"""An instruction-editing family (Qwen-Image-Edit) exposes only the 'edit' workflow,
|
|
runs the image through its OWN loaded pipeline (no from_pipe), and rejects a call with
|
|
no input image."""
|
|
(tmp_path / "model.gguf").write_bytes(b"x")
|
|
backend = DiffusionBackend()
|
|
backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "model.gguf",
|
|
base_repo = "Qwen/Qwen-Image-Edit-2511",
|
|
family_override = "qwen-image-edit",
|
|
)
|
|
# Edit families advertise only the edit workflow.
|
|
assert backend.status()["workflows"] == ["edit"]
|
|
loaded_pipe = backend._state.pipe
|
|
|
|
out = backend.generate(
|
|
prompt = "make it night",
|
|
steps = 8,
|
|
guidance = 4.0,
|
|
seed = 1,
|
|
init_image = _tiny_png_b64(),
|
|
)
|
|
assert len(out["images"]) == 1
|
|
# The loaded pipe handled it directly: no from_pipe img2img/inpaint was built.
|
|
assert backend._state.pipe is loaded_pipe
|
|
assert _FakeImg2ImgPipeline.built_from is None and _FakeInpaintPipeline.built_from is None
|
|
assert loaded_pipe.last_kwargs.get("image") is not None
|
|
|
|
# An edit model with no input image fails fast with a clear message.
|
|
with pytest.raises(ValueError, match = "image"):
|
|
backend.generate(prompt = "make it night", steps = 8)
|
|
|
|
|
|
def test_load_pipeline_kind_uses_from_pretrained(fake_runtime):
|
|
"""A full-pipeline (no single-file) load on an unsloth/* repo builds the pipe with
|
|
pipeline_cls.from_pretrained(repo_id) -- NO single-file transformer build, NO GGUF
|
|
quant config -- so an embedded bnb-4bit config is reloaded by diffusers itself."""
|
|
backend = DiffusionBackend()
|
|
status = backend.load_pipeline(
|
|
"unsloth/Z-Image-Turbo-unsloth-bnb-4bit", family_override = "z-image"
|
|
)
|
|
assert status["loaded"] is True
|
|
assert status["family"] == "z-image"
|
|
# from_pretrained pointed at the repo itself (it IS its own base), with no transformer.
|
|
assert _FakePipeline.last["base"] == "unsloth/Z-Image-Turbo-unsloth-bnb-4bit"
|
|
assert "transformer" not in _FakePipeline.last
|
|
# The GGUF single-file build path was never taken.
|
|
assert _FakeTransformer.last == {}
|
|
|
|
|
|
def test_load_single_file_safetensors_no_gguf_config(fake_runtime, tmp_path):
|
|
"""A single-file *.safetensors transformer is built with from_single_file WITHOUT the
|
|
GGUF dequant config (it carries its own dtype), then assembled from the base repo."""
|
|
(tmp_path / "model.safetensors").write_bytes(b"weights")
|
|
backend = DiffusionBackend()
|
|
status = backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "model.safetensors",
|
|
base_repo = "base/repo",
|
|
family_override = "qwen-image",
|
|
)
|
|
assert status["loaded"] is True
|
|
assert _FakeTransformer.last["path"] == str((tmp_path / "model.safetensors").resolve())
|
|
assert _FakeTransformer.last["subfolder"] == "transformer"
|
|
# No GGUF quant config on the safetensors path (the GGUF path sets one).
|
|
assert "quantization_config" not in _FakeTransformer.last
|
|
assert _FakePipeline.last["base"] == "base/repo"
|
|
assert "transformer" in _FakePipeline.last
|
|
|
|
|
|
def test_load_sdxl_pipeline_from_pretrained(fake_runtime):
|
|
"""SDXL as a full pipeline (no single-file name) loads via pipeline_cls.from_pretrained
|
|
on the allowlisted official base repo -- no U-Net single-file build, no GGUF config.
|
|
A U-Net family must NOT try to build a transformer from a single file."""
|
|
backend = DiffusionBackend()
|
|
status = backend.load_pipeline("stabilityai/stable-diffusion-xl-base-1.0")
|
|
assert status["loaded"] is True
|
|
assert status["family"] == "sdxl"
|
|
assert _FakePipeline.last["base"] == "stabilityai/stable-diffusion-xl-base-1.0"
|
|
assert "transformer" not in _FakePipeline.last
|
|
# Neither single-file path (transformer-only nor whole-pipeline) was taken.
|
|
assert _FakeTransformer.last == {}
|
|
assert _FakePipeline.last_single_file == {}
|
|
|
|
|
|
def test_load_sdxl_single_file_uses_pipeline_from_single_file(fake_runtime, tmp_path):
|
|
"""A single-file SDXL *.safetensors is the WHOLE pipeline: it must load via
|
|
pipeline_cls.from_single_file(path, config=base), NOT transformer_cls.from_single_file
|
|
(UNet2DConditionModel has no companion-transformer assembly here)."""
|
|
(tmp_path / "sdxl.safetensors").write_bytes(b"weights")
|
|
backend = DiffusionBackend()
|
|
status = backend.load_pipeline(
|
|
str(tmp_path), gguf_filename = "sdxl.safetensors", family_override = "sdxl"
|
|
)
|
|
assert status["loaded"] is True
|
|
assert status["family"] == "sdxl"
|
|
# The whole-pipeline single-file path was taken with the base repo as config.
|
|
assert _FakePipeline.last_single_file["path"] == str((tmp_path / "sdxl.safetensors").resolve())
|
|
assert _FakePipeline.last_single_file["config"] == "stabilityai/stable-diffusion-xl-base-1.0"
|
|
# The transformer-only single-file build was NOT taken.
|
|
assert _FakeTransformer.last == {}
|
|
|
|
|
|
def test_load_sdxl_allowlisted_turbo_repo_is_trusted(fake_runtime):
|
|
"""The official sdxl-turbo repo is on the non-GGUF allowlist, so a full-pipeline load
|
|
is permitted even though it is not under unsloth/*."""
|
|
backend = DiffusionBackend()
|
|
status = backend.load_pipeline("stabilityai/sdxl-turbo")
|
|
assert status["loaded"] is True
|
|
assert status["family"] == "sdxl"
|
|
|
|
|
|
def test_load_pipeline_rejects_non_unsloth_repo(fake_runtime):
|
|
backend = DiffusionBackend()
|
|
with pytest.raises(ValueError, match = "unsloth"):
|
|
backend.load_pipeline("randomorg/Z-Image-bnb-4bit", family_override = "z-image")
|
|
|
|
|
|
def test_load_sdxl_rejects_untrusted_repo(fake_runtime):
|
|
"""A random non-allowlisted, non-unsloth repo is still rejected for a full pipeline
|
|
load even when it detects as SDXL -- the allowlist is exact-match only."""
|
|
backend = DiffusionBackend()
|
|
with pytest.raises(ValueError, match = "unsloth"):
|
|
backend.load_pipeline("randomorg/my-sdxl-merge", family_override = "sdxl")
|
|
|
|
|
|
def test_validate_gates_untrusted_base_repo(fake_runtime, tmp_path):
|
|
# A companion base_repo also loads via from_pretrained, so a trusted GGUF model_path must not smuggle in an arbitrary remote base: base_repo clears the same trust bar, before any GPU handoff.
|
|
backend = DiffusionBackend()
|
|
with pytest.raises(ValueError, match = "base_repo"):
|
|
backend.validate_load_request(
|
|
"unsloth/Qwen-Image-2512-GGUF",
|
|
gguf_filename = "x.gguf",
|
|
model_kind = "gguf",
|
|
base_repo = "evil/companions",
|
|
)
|
|
# A local base_repo dir that is NOT a diffusers pipeline is rejected HERE, before the GPU handoff: it passes the any-existing-path trust check but from_pretrained needs model_index.json, so it would otherwise evict the resident model and only then fail.
|
|
bad_base = tmp_path / "bare-base"
|
|
bad_base.mkdir()
|
|
with pytest.raises(ValueError, match = "model_index.json"):
|
|
backend.validate_load_request(
|
|
"unsloth/Qwen-Image-2512-GGUF",
|
|
gguf_filename = "x.gguf",
|
|
model_kind = "gguf",
|
|
base_repo = str(bad_base),
|
|
)
|
|
# A local base_repo that IS a real pipeline dir passes the gate.
|
|
(tmp_path / "model_index.json").write_text("{}")
|
|
fam = backend.validate_load_request(
|
|
"unsloth/Qwen-Image-2512-GGUF",
|
|
gguf_filename = "x.gguf",
|
|
model_kind = "gguf",
|
|
base_repo = str(tmp_path),
|
|
)
|
|
assert fam is not None
|
|
|
|
|
|
def test_resolve_local_single_file(tmp_path):
|
|
# A bare single-file safetensors directory resolves to that checkpoint basename, so the images load route can reinterpret an On-Device "pipeline" pick as a single_file load.
|
|
from core.inference.diffusion import resolve_local_single_file
|
|
|
|
d = tmp_path / "solo"
|
|
d.mkdir()
|
|
(d / "model.safetensors").write_bytes(b"w")
|
|
assert resolve_local_single_file(str(d)) == "model.safetensors"
|
|
|
|
# A real diffusers pipeline dir loads as a pipeline unchanged.
|
|
(d / "model_index.json").write_text("{}")
|
|
assert resolve_local_single_file(str(d)) is None
|
|
|
|
# Ambiguous (two checkpoints) or empty dirs leave the load unchanged.
|
|
d2 = tmp_path / "shards"
|
|
d2.mkdir()
|
|
(d2 / "a.safetensors").write_bytes(b"w")
|
|
(d2 / "b.safetensors").write_bytes(b"w")
|
|
assert resolve_local_single_file(str(d2)) is None
|
|
assert resolve_local_single_file(str(tmp_path / "empty-nonexistent")) is None
|
|
# A remote repo id (not a local dir) -> None.
|
|
assert resolve_local_single_file("unsloth/Qwen-Image-2512-GGUF") is None
|
|
|
|
# A PEFT adapter folder, even with a family-token name, is NOT a base checkpoint: from_single_file would fail on the adapter weights AFTER eviction, so it must not be reinterpreted as single_file (the pipeline pick then 400s in validation, before the handoff).
|
|
adapter = tmp_path / "flux-style-lora"
|
|
adapter.mkdir()
|
|
(adapter / "adapter_config.json").write_text("{}")
|
|
(adapter / "adapter_model.safetensors").write_bytes(b"w")
|
|
assert resolve_local_single_file(str(adapter)) is None
|
|
# A bare adapter_model.safetensors is likewise not treated as the sole checkpoint.
|
|
adapter2 = tmp_path / "z-image-lora"
|
|
adapter2.mkdir()
|
|
(adapter2 / "adapter_model.safetensors").write_bytes(b"w")
|
|
assert resolve_local_single_file(str(adapter2)) is None
|
|
|
|
|
|
def test_resolve_base_repo_drops_untrusted_card_tag(monkeypatch):
|
|
# With no base_repo the base comes from the GGUF repo base_model card tag -- attacker-controlled metadata -- then loads via from_pretrained. An untrusted tag must be dropped for the curated family default.
|
|
import core.inference.diffusion as dmod
|
|
|
|
fam = detect_family("unsloth/FLUX.1-dev-GGUF")
|
|
# A malicious card tag is ignored, so the family default base is used.
|
|
monkeypatch.setattr(dmod, "_hf_base_model", lambda repo_id, hf_token: "attacker/evil-pipeline")
|
|
assert _resolve_base_repo("attacker/flux.1-evil-GGUF", None, fam, None) == fam.base_repo
|
|
# A trusted (allowlisted) card tag is still honoured, so variant resolution is not regressed.
|
|
monkeypatch.setattr(
|
|
dmod, "_hf_base_model", lambda repo_id, hf_token: "black-forest-labs/FLUX.1-dev"
|
|
)
|
|
assert (
|
|
_resolve_base_repo("unsloth/FLUX.1-dev-GGUF", None, fam, None)
|
|
== "black-forest-labs/FLUX.1-dev"
|
|
)
|
|
# An explicit trusted base_repo wins over the card tag; an untrusted one is caught earlier at validate_load_request.
|
|
assert (
|
|
_resolve_base_repo("unsloth/FLUX.1-dev-GGUF", "unsloth/custom-base", fam, None)
|
|
== "unsloth/custom-base"
|
|
)
|
|
|
|
|
|
def test_detect_family_rejects_layered():
|
|
# Qwen-Image-Layered needs a dedicated pipeline (additional_t_cond), so it must be rejected at load instead of crashing at the first denoise step.
|
|
assert detect_family("unsloth/Qwen-Image-Layered-GGUF") is None
|
|
assert detect_family("unsloth/qwen_image_layered") is None
|
|
|
|
|
|
def test_failed_load_rolls_back_eager_patches(fake_runtime, tmp_path, monkeypatch):
|
|
"""A load failure AFTER the eager patches install but BEFORE the _LoadState commit must
|
|
roll the process-wide patches back, so the next bit-identical `off` load is not
|
|
contaminated (the asymmetric-cleanup bug the reviewers flagged)."""
|
|
from core.inference import diffusion as diff_mod
|
|
from core.inference import diffusion_eager_patches as ep
|
|
|
|
(tmp_path / "model.gguf").write_bytes(b"x")
|
|
ep.uninstall_patches() # clean slate
|
|
|
|
def _boom(*_a, **_k):
|
|
raise RuntimeError("placement boom")
|
|
|
|
# apply_memory_plan runs AFTER the patches are installed, before _LoadState commits.
|
|
monkeypatch.setattr(diff_mod, "apply_memory_plan", _boom)
|
|
backend = DiffusionBackend()
|
|
with pytest.raises(RuntimeError):
|
|
backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "model.gguf",
|
|
family_override = "z-image",
|
|
base_repo = "base/repo",
|
|
speed_mode = "eager", # != off -> installs the shared patches
|
|
)
|
|
assert ep.is_installed() is False # rolled back by the load-failure finally
|
|
assert backend.is_loaded is False
|
|
|
|
|
|
def test_cpu_offload_ignored_off_cuda(fake_runtime, tmp_path):
|
|
(tmp_path / "model.gguf").write_bytes(b"x")
|
|
backend = DiffusionBackend()
|
|
status = backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "model.gguf",
|
|
family_override = "z-image",
|
|
base_repo = "base/repo",
|
|
cpu_offload = True,
|
|
)
|
|
# No CUDA in the stub, so offload is not engaged.
|
|
assert status["cpu_offload"] is False
|
|
|
|
|
|
def test_low_vram_ignored_off_cuda(fake_runtime, tmp_path):
|
|
(tmp_path / "model.gguf").write_bytes(b"x")
|
|
backend = DiffusionBackend()
|
|
status = backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "model.gguf",
|
|
family_override = "z-image",
|
|
base_repo = "base/repo",
|
|
memory_mode = "low_vram",
|
|
)
|
|
# No CUDA in the stub, so offload is not engaged regardless of the request.
|
|
assert status["cpu_offload"] is False
|
|
|
|
|
|
def test_generate_without_load_raises(fake_runtime):
|
|
backend = DiffusionBackend()
|
|
with pytest.raises(RuntimeError):
|
|
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 try/finally and restore the process-global TF32 / cudnn flags, so a later `off` load is still bit-identical, and must not commit partial state.
|
|
(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
|
|
|
|
fam = detect_family("unsloth/Qwen-Image-2512-GGUF")
|
|
monkeypatch.setattr(diffusion, "_hf_base_model", lambda repo, tok: "Qwen/Qwen-Image-2512")
|
|
# Caller's explicit base wins and the HF tag is not consulted.
|
|
assert (
|
|
diffusion._resolve_base_repo("unsloth/Qwen-Image-2512-GGUF", "my/base", fam, None)
|
|
== "my/base"
|
|
)
|
|
# No caller base: the repo's base_model tag (the variant base) is used.
|
|
assert (
|
|
diffusion._resolve_base_repo("unsloth/Qwen-Image-2512-GGUF", None, fam, None)
|
|
== "Qwen/Qwen-Image-2512"
|
|
)
|
|
# No caller base and no tag: the family fallback.
|
|
monkeypatch.setattr(diffusion, "_hf_base_model", lambda repo, tok: None)
|
|
assert (
|
|
diffusion._resolve_base_repo("unsloth/Qwen-Image-2512-GGUF", " ", fam, None)
|
|
== fam.base_repo
|
|
)
|
|
|
|
|
|
def test_load_without_gguf_raises():
|
|
backend = DiffusionBackend()
|
|
# No gguf_filename means a full-pipeline load, gated to unsloth/*; a non-unsloth repo is rejected before any GPU/network work.
|
|
with pytest.raises(ValueError, match = "unsloth"):
|
|
backend.load_pipeline("some-org/Z-Image-bnb-4bit")
|
|
|
|
|
|
def test_load_unknown_family_raises():
|
|
backend = DiffusionBackend()
|
|
with pytest.raises(ValueError):
|
|
backend.load_pipeline("some/unrecognised-repo", gguf_filename = "x.gguf")
|
|
|
|
|
|
# load_progress state machine (no threads / network / real cache)
|
|
|
|
from core.inference.diffusion import _LoadingState, _LoadState # noqa: E402
|
|
|
|
|
|
def test_load_progress_idle_and_ready():
|
|
backend = DiffusionBackend()
|
|
assert backend.load_progress()["phase"] is None
|
|
backend._state = _LoadState(object(), None, "r", "b", "cpu", "float32", False)
|
|
assert backend.load_progress()["phase"] == "ready"
|
|
|
|
|
|
def test_load_progress_error():
|
|
backend = DiffusionBackend()
|
|
backend._loading = _LoadingState(repo_id = "r", base_repo = "b", error = "boom")
|
|
p = backend.load_progress()
|
|
assert p["phase"] == "error" and p["error"] == "boom"
|
|
|
|
|
|
def test_load_progress_downloading_then_finalizing(monkeypatch):
|
|
backend = DiffusionBackend()
|
|
backend._loading = _LoadingState(repo_id = "r", base_repo = "b", expected_bytes = 1000)
|
|
|
|
monkeypatch.setattr(DiffusionBackend, "_cache_bytes", staticmethod(lambda repo: 150))
|
|
p = backend.load_progress()
|
|
assert p["phase"] == "downloading"
|
|
assert p["bytes_downloaded"] == 300 # summed across repo + base
|
|
assert abs(p["fraction"] - 0.3) < 1e-9
|
|
|
|
monkeypatch.setattr(DiffusionBackend, "_cache_bytes", staticmethod(lambda repo: 500))
|
|
assert backend.load_progress()["phase"] == "finalizing" # 1000/1000
|
|
|
|
|
|
def test_base_file_downloaded_excludes_undownloaded():
|
|
# Counted: the pipeline manifest + component subfolders from_pretrained fetches.
|
|
assert _base_file_downloaded("model_index.json")
|
|
assert _base_file_downloaded("text_encoder/model-00001-of-00003.safetensors")
|
|
assert _base_file_downloaded("vae/diffusion_pytorch_model.safetensors")
|
|
# Excluded: the GGUF supplies the transformer, and docs/assets are never downloaded, so counting them would peg the bar short of 100%.
|
|
assert not _base_file_downloaded(
|
|
"transformer/diffusion_pytorch_model-00001-of-00003.safetensors"
|
|
)
|
|
assert not _base_file_downloaded("assets/Z-Image-Gallery.pdf")
|
|
assert not _base_file_downloaded("README.md")
|
|
assert not _base_file_downloaded(".gitattributes")
|
|
|
|
|
|
def test_load_progress_fraction_clamped(monkeypatch):
|
|
# The cache scan can exceed the estimate (e.g. a second cached quant); the reported fraction must still clamp to 1.0.
|
|
backend = DiffusionBackend()
|
|
backend._loading = _LoadingState(repo_id = "r", base_repo = "b", expected_bytes = 1000)
|
|
monkeypatch.setattr(DiffusionBackend, "_cache_bytes", staticmethod(lambda repo: 900))
|
|
p = backend.load_progress() # summed 1800 > expected 1000
|
|
assert p["phase"] == "finalizing"
|
|
assert p["fraction"] == 1.0
|
|
assert p["bytes_downloaded"] == 1000 # clamped to the estimate
|
|
|
|
|
|
def test_estimate_eta():
|
|
from core.inference.diffusion import _estimate_eta
|
|
|
|
# No rate yet until a step has elapsed since the first.
|
|
assert _estimate_eta(8, 1, first_step_at = 100.0, now = 100.0) is None
|
|
assert _estimate_eta(8, 0, first_step_at = 0.0, now = 100.0) is None
|
|
# 3 steps in 3s since the first: 1s/step, 4 steps left, so ~4s.
|
|
assert _estimate_eta(8, 4, first_step_at = 100.0, now = 103.0) == 4.0
|
|
# Last step ⇒ 0 remaining.
|
|
assert _estimate_eta(8, 8, first_step_at = 100.0, now = 107.0) == 0.0
|
|
|
|
|
|
def test_generate_qwen_uses_true_cfg_scale(fake_runtime, tmp_path):
|
|
(tmp_path / "model.gguf").write_bytes(b"weights")
|
|
backend = DiffusionBackend()
|
|
backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "model.gguf",
|
|
base_repo = "Qwen/Qwen-Image",
|
|
family_override = "qwen-image",
|
|
)
|
|
backend.generate(prompt = "a sloth", guidance = 4.0)
|
|
# Qwen-Image's distilled guidance is off; the real CFG must land on true_cfg_scale.
|
|
call = backend._state.pipe.last_kwargs
|
|
assert call["true_cfg_scale"] == 4.0 and call["guidance_scale"] is None
|
|
|
|
|
|
def _load_ideogram(backend, tmp_path):
|
|
# Ideogram 4 loads only as a full pipeline (its two DiTs are assembled per-component by the stubbed loader), so a local pipeline dir is enough here.
|
|
(tmp_path / "model_index.json").write_text("{}")
|
|
backend.load_pipeline(str(tmp_path), family_override = "ideogram-4")
|
|
|
|
|
|
def test_ideogram_rejects_single_file_and_gguf_kinds(fake_runtime, tmp_path):
|
|
# Ideogram 4 needs two DiTs assembled per-component, so there is no transformer-only single-file or GGUF load: the explicit kinds must be rejected up front, not assembled into a pipeline missing its second DiT.
|
|
backend = DiffusionBackend()
|
|
(tmp_path / "model.gguf").write_bytes(b"x")
|
|
with pytest.raises(ValueError, match = "full diffusers pipeline"):
|
|
backend.load_pipeline(
|
|
str(tmp_path), gguf_filename = "model.gguf", family_override = "ideogram-4"
|
|
)
|
|
(tmp_path / "model.safetensors").write_bytes(b"x")
|
|
with pytest.raises(ValueError, match = "full diffusers pipeline"):
|
|
backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "model.safetensors",
|
|
model_kind = "single_file",
|
|
family_override = "ideogram-4",
|
|
)
|
|
|
|
|
|
def test_generate_ideogram_defaults_keep_recommended_schedule(fake_runtime, tmp_path):
|
|
# Ideogram 4 pipeline defaults to its tapered guidance_schedule (valid only at 48 steps) and REJECTS guidance_scale while the schedule is set, so at the advertised defaults the backend must drop the constant.
|
|
backend = DiffusionBackend()
|
|
_load_ideogram(backend, tmp_path)
|
|
backend.generate(prompt = "a sloth", steps = 48, guidance = 7.0)
|
|
call = backend._state.pipe.last_kwargs
|
|
assert call["guidance_scale"] is None # not passed: the pipe default engages
|
|
assert "guidance_schedule" not in call
|
|
|
|
|
|
def test_generate_ideogram_custom_guidance_nulls_schedule(fake_runtime, tmp_path):
|
|
# Any non-default request must broadcast the constant legally: guidance_scale set AND guidance_schedule explicitly nulled (the pipeline raises when both are set).
|
|
backend = DiffusionBackend()
|
|
_load_ideogram(backend, tmp_path)
|
|
backend.generate(prompt = "a sloth", steps = 20, guidance = 5.0)
|
|
call = backend._state.pipe.last_kwargs
|
|
assert call["guidance_scale"] == 5.0
|
|
assert "guidance_schedule" in call and call["guidance_schedule"] is None
|
|
|
|
|
|
def _load_lumina(backend, tmp_path):
|
|
# Lumina 2 loads through the GENERIC pipeline path, so a local pipeline dir is enough here.
|
|
(tmp_path / "model_index.json").write_text("{}")
|
|
backend.load_pipeline(str(tmp_path), family_override = "lumina-2")
|
|
|
|
|
|
def test_generate_lumina2_passes_cfg_trunc_ratio(fake_runtime, tmp_path):
|
|
# The card recipe truncates the CFG double-forward to the first quarter of the trajectory, while the pipeline default (1.0) applies it everywhere, so the backend passes the card value.
|
|
backend = DiffusionBackend()
|
|
_load_lumina(backend, tmp_path)
|
|
backend.generate(prompt = "a sloth", steps = 50, guidance = 4.0)
|
|
call = backend._state.pipe.last_kwargs
|
|
assert call["cfg_trunc_ratio"] == 0.25
|
|
assert call["guidance_scale"] == 4.0
|
|
|
|
|
|
def test_generate_other_family_never_passes_cfg_trunc_ratio(fake_runtime, tmp_path):
|
|
# The kwarg is family-gated, not just signature-gated: another family whose pipeline accepts cfg_trunc_ratio must not inherit Lumina recipe constant.
|
|
backend = DiffusionBackend()
|
|
(tmp_path / "model.gguf").write_bytes(b"weights")
|
|
backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "model.gguf",
|
|
base_repo = "base/repo",
|
|
family_override = "z-image",
|
|
)
|
|
backend.generate(prompt = "a sloth", steps = 9, guidance = 0.0)
|
|
call = backend._state.pipe.last_kwargs
|
|
assert call["cfg_trunc_ratio"] is None
|
|
|
|
|
|
def test_begin_load_rejects_concurrent(monkeypatch):
|
|
backend = DiffusionBackend()
|
|
# The worker resolves the base + downloads, both over the network; stub them so this is offline.
|
|
monkeypatch.setattr("core.inference.diffusion._hf_base_model", lambda *a, **k: None)
|
|
monkeypatch.setattr(DiffusionBackend, "_prefetch_files", lambda self, *a, **k: None)
|
|
monkeypatch.setattr(
|
|
DiffusionBackend, "_estimate_download_bytes", staticmethod(lambda *a, **k: (0, []))
|
|
)
|
|
# Block the spawned worker so the load stays "in progress".
|
|
monkeypatch.setattr(
|
|
DiffusionBackend, "load_pipeline", lambda self, **k: __import__("time").sleep(0.2)
|
|
)
|
|
backend.begin_load("unsloth/Z-Image-Turbo-GGUF", gguf_filename = "z-image-turbo-Q4_K_S.gguf")
|
|
with pytest.raises(RuntimeError):
|
|
backend.begin_load("unsloth/Z-Image-Turbo-GGUF", gguf_filename = "z-image-turbo-Q4_K_S.gguf")
|
|
|
|
|
|
def test_unload_cancels_in_flight_load(fake_runtime):
|
|
# An unload (or arbiter eviction, which calls unload) while a load worker is still resolving/downloading must cancel it: load_pipeline sees the bumped token and aborts, so the evicted load never resurrects a pipeline into VRAM.
|
|
backend = DiffusionBackend()
|
|
fam = detect_family("unsloth/Z-Image-Turbo-GGUF")
|
|
token = 7
|
|
backend._load_token = token
|
|
with pytest.raises(RuntimeError, match = "cancelled"):
|
|
# Simulate the worker reaching load_pipeline after unload bumped the token.
|
|
backend._load_token = token + 1
|
|
backend.load_pipeline(
|
|
"unsloth/Z-Image-Turbo-GGUF",
|
|
gguf_filename = "z-image-turbo-Q4_K_S.gguf",
|
|
base_repo = fam.base_repo,
|
|
_load_token = token,
|
|
)
|
|
|
|
|
|
def test_superseded_load_does_not_cancel_live_generation(fake_runtime):
|
|
# A superseded background load that finally reaches load_pipeline must bail WITHOUT signalling the current model in-flight generation: the token check must run before the cancel is set, or a stale worker aborts an unrelated, still-live denoise.
|
|
import threading as _threading
|
|
|
|
backend = DiffusionBackend()
|
|
fam = detect_family("unsloth/Z-Image-Turbo-GGUF")
|
|
live_cancel = _threading.Event()
|
|
backend._active_generate_cancel = live_cancel # a generation from the CURRENT model
|
|
token = 11
|
|
backend._load_token = token + 1 # this load has already been superseded
|
|
with pytest.raises(RuntimeError, match = "cancelled"):
|
|
backend.load_pipeline(
|
|
"unsloth/Z-Image-Turbo-GGUF",
|
|
gguf_filename = "z-image-turbo-Q4_K_S.gguf",
|
|
base_repo = fam.base_repo,
|
|
_load_token = token,
|
|
)
|
|
assert not live_cancel.is_set() # the live generation was left untouched
|
|
|
|
|
|
def test_pick_dtype_bf16_only_on_ampere(fake_runtime, monkeypatch):
|
|
# BF16 only on Ampere+ (cc >= 8); pre-Ampere cards must fall back to FP16.
|
|
torch = sys.modules["torch"]
|
|
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)
|
|
monkeypatch.setattr(torch.cuda, "get_device_capability", lambda: (7, 5), raising = False)
|
|
assert backend._pick_device_and_dtype() == ("cuda", torch.float16)
|
|
|
|
|
|
def test_unload_sets_cancel_event(fake_runtime):
|
|
# unload signals an in-flight download (which runs without the lock) to abort.
|
|
backend = DiffusionBackend()
|
|
assert not backend._cancel_event.is_set()
|
|
backend.unload()
|
|
assert backend._cancel_event.is_set()
|
|
|
|
|
|
def test_prefetch_aborts_when_cancelled(tmp_path):
|
|
# A prefetch interrupted by unload raises rather than downloading the whole base, so the load can be preempted mid-download.
|
|
backend = DiffusionBackend()
|
|
backend._cancel_event.set()
|
|
# Local gguf path so the transformer download is skipped; the base loop hits the cancel check on its first file (no network).
|
|
(tmp_path / "model.gguf").write_bytes(b"x")
|
|
with pytest.raises(RuntimeError, match = "Cancelled"):
|
|
backend._prefetch_files(
|
|
str(tmp_path),
|
|
"model.gguf",
|
|
"Tongyi-MAI/Z-Image-Turbo",
|
|
["vae/diffusion_pytorch_model.safetensors"],
|
|
None,
|
|
)
|
|
|
|
|
|
def test_prefetch_downloads_gguf_and_base(monkeypatch, tmp_path):
|
|
backend = DiffusionBackend()
|
|
calls: list = []
|
|
monkeypatch.setattr(
|
|
"utils.hf_xet_fallback.hf_hub_download_with_xet_fallback",
|
|
lambda repo, fn, tok, **k: (calls.append((repo, fn)), f"/cache/{fn}")[1],
|
|
)
|
|
# Hub repo: the GGUF transformer and each base file are fetched.
|
|
backend._prefetch_files(
|
|
"unsloth/Z-Image-Turbo-GGUF",
|
|
"model.gguf",
|
|
"base/repo",
|
|
["vae/x.safetensors", "text_encoder/y.safetensors"],
|
|
"hf_tok",
|
|
)
|
|
assert ("unsloth/Z-Image-Turbo-GGUF", "model.gguf") in calls
|
|
assert ("base/repo", "vae/x.safetensors") in calls
|
|
assert ("base/repo", "text_encoder/y.safetensors") in calls
|
|
# Local GGUF path: the transformer download is skipped, base still fetched.
|
|
calls.clear()
|
|
(tmp_path / "model.gguf").write_bytes(b"x")
|
|
backend._prefetch_files(str(tmp_path), "model.gguf", "base/repo", ["vae/x.safetensors"], None)
|
|
assert all(repo != str(tmp_path) for repo, _ in calls)
|
|
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 promotes to 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 resolves to fp16, so the guard must promote Z-Image (and only Z-Image) to fp32 or it renders 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
|
|
|
|
|
|
def test_bad_mode_strings_fail_before_eviction(fake_runtime):
|
|
# Every mode normalizer that can raise runs BEFORE the load evicts the previous pipeline, so a bad request never costs the user their working model.
|
|
backend = DiffusionBackend()
|
|
fam = detect_family("unsloth/Z-Image-GGUF")
|
|
backend._state = _LoadState(
|
|
pipe = object(),
|
|
family = fam,
|
|
repo_id = "r",
|
|
base_repo = "b",
|
|
device = "cpu",
|
|
dtype = "float32",
|
|
cpu_offload = False,
|
|
)
|
|
for kwargs in (
|
|
{"transformer_quant": "int7"},
|
|
{"speed_mode": "warp"},
|
|
{"attention_backend": "bogus"},
|
|
{"transformer_cache": "bogus"},
|
|
{"text_encoder_quant": "fp3"},
|
|
):
|
|
with pytest.raises(ValueError):
|
|
backend.load_pipeline("unsloth/Z-Image-GGUF", gguf_filename = "m.gguf", **kwargs)
|
|
assert backend._state is not None
|
|
|
|
|
|
# Lock split + mid-denoise cancellation
|
|
|
|
|
|
def test_generate_lock_split_keeps_status_and_unload_responsive(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 only _generate_lock
|
|
|
|
# status() / generate_progress() must NOT block behind the denoise.
|
|
assert backend.status()["loaded"] is True
|
|
assert backend.generate_progress()["active"] is True
|
|
|
|
cancel_ref = backend._active_generate_cancel
|
|
assert cancel_ref is not None
|
|
|
|
# unload() signals THIS generation cancel event, then waits for the denoise to exit before returning: callers treat its return as "VRAM is free". Release the pipe once the cancel lands, standing in for a real pipeline step callback.
|
|
releaser = threading.Thread(target = lambda: (cancel_ref.wait(5), release.set()))
|
|
releaser.start()
|
|
backend.unload()
|
|
releaser.join(5)
|
|
assert cancel_ref.is_set()
|
|
assert backend.status()["loaded"] is False
|
|
|
|
t.join(5)
|
|
# The cancelled generation raised rather than returning a now-evicted image, and had already exited (deregistering its cancel) before unload() returned.
|
|
assert "exc" in out and "cancelled" in str(out["exc"]).lower()
|
|
assert backend._active_generate_cancel is None
|
|
|
|
|
|
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 callback saw the cancel, flipped pipe._interrupt and broke the loop, 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()
|
|
# No filename + unsloth repo -> a full-pipeline load (allowed for unsloth/*).
|
|
assert backend.validate_load_request("unsloth/Z-Image-Turbo-unsloth-bnb-4bit").name == "z-image"
|
|
# No filename + non-unsloth repo -> a pipeline load, gated to unsloth/* -> rejected.
|
|
with pytest.raises(ValueError, match = "unsloth"):
|
|
backend.validate_load_request("some-org/Z-Image-bnb-4bit")
|
|
# An explicit gguf/single_file kind still requires a single-file name.
|
|
with pytest.raises(ValueError, match = "single-file"):
|
|
backend.validate_load_request("unsloth/Z-Image-Turbo-GGUF", model_kind = "gguf")
|
|
# A pipeline kind must NOT carry a single-file name.
|
|
with pytest.raises(ValueError, match = "pipeline"):
|
|
backend.validate_load_request(
|
|
"unsloth/Z-Image-Turbo-bnb-4bit", gguf_filename = "q.gguf", model_kind = "pipeline"
|
|
)
|
|
# A single-file safetensors load is also gated to unsloth/* repos.
|
|
with pytest.raises(ValueError, match = "unsloth"):
|
|
backend.validate_load_request("some-org/Z-Image", gguf_filename = "model.safetensors")
|
|
with pytest.raises(ValueError, match = "family"):
|
|
backend.validate_load_request("meta/Llama-3", gguf_filename = "q.gguf")
|
|
# A family-looking repo paired with a non-GGUF single-file name is rejected here, BEFORE the route evicts chat (else the background load would be the first to notice README.md is no checkpoint).
|
|
with pytest.raises(ValueError, match = r"\.gguf"):
|
|
backend.validate_load_request("unsloth/Z-Image-Turbo-GGUF", gguf_filename = "README.md")
|
|
assert (
|
|
backend.validate_load_request("unsloth/Z-Image-Turbo-GGUF", gguf_filename = "q.gguf").name
|
|
== "z-image"
|
|
)
|
|
# A kind/extension mismatch fails fast here, before the route evicts chat and grabs the GPU only to fail in the background from_single_file path.
|
|
with pytest.raises(ValueError, match = ".gguf"):
|
|
backend.validate_load_request(
|
|
"unsloth/Z-Image-Turbo-GGUF", gguf_filename = "model.safetensors", model_kind = "gguf"
|
|
)
|
|
with pytest.raises(ValueError, match = "gguf"):
|
|
backend.validate_load_request(
|
|
"unsloth/Qwen-Image-2512-FP8", gguf_filename = "q.gguf", model_kind = "single_file"
|
|
)
|
|
# A remote "*-GGUF" repo loaded as a full pipeline is a single-file GGUF repo, so from_pretrained finds no manifest and fails after chat is evicted; reject it before the GPU handoff.
|
|
with pytest.raises(ValueError, match = "GGUF"):
|
|
backend.validate_load_request("unsloth/Z-Image-Turbo-GGUF", model_kind = "pipeline")
|
|
# 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",
|
|
)
|
|
|
|
|
|
def test_replacement_load_waits_for_inflight_generation(fake_runtime, tmp_path):
|
|
# A superseding load must signal the in-flight generation 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).
|
|
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 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)
|
|
|
|
|
|
# ── Phase 2A: memory policy wiring (load -> planner -> placement) ──────────────
|
|
|
|
|
|
def test_load_reports_memory_plan_fields_on_cpu(fake_runtime, tmp_path):
|
|
# The default stub resolves to a CPU target: no offload is possible, but VAE tiling is on (no separate device pool), and status carries the new fields.
|
|
(tmp_path / "m.gguf").write_bytes(b"weights")
|
|
backend = DiffusionBackend()
|
|
status = backend.load_pipeline(str(tmp_path), gguf_filename = "m.gguf", family_override = "z-image")
|
|
assert status["offload_policy"] == "none"
|
|
assert status["cpu_offload"] is False
|
|
assert status["vae_tiling"] is True
|
|
assert status["memory_mode"] == "auto"
|
|
pipe = backend._state.pipe
|
|
assert pipe.moved_to == "cpu" and pipe.vae_tiled and pipe.vae_sliced
|
|
|
|
|
|
def _force_cuda_target(backend, monkeypatch):
|
|
"""Drive the loader down the CUDA (offload-capable) path under the stub."""
|
|
torch = sys.modules["torch"]
|
|
monkeypatch.setattr(backend, "_pick_device_and_dtype", lambda: ("cuda", torch.bfloat16))
|
|
|
|
|
|
def test_load_memory_mode_balanced_streams_or_falls_back(fake_runtime, tmp_path, monkeypatch):
|
|
# balanced requests streamed block-level (group) offload. Under the stub there is no real diffusers.hooks, so group cannot engage and the applier falls back to whole-module offload, reporting the policy actually engaged.
|
|
(tmp_path / "m.gguf").write_bytes(b"x")
|
|
backend = DiffusionBackend()
|
|
_force_cuda_target(backend, monkeypatch)
|
|
status = backend.load_pipeline(
|
|
str(tmp_path), gguf_filename = "m.gguf", family_override = "z-image", memory_mode = "balanced"
|
|
)
|
|
assert status["offload_policy"] in ("group", "model") and status["cpu_offload"] is True
|
|
assert status["memory_mode"] == "balanced"
|
|
assert backend._state.pipe.offloaded is True # model-offload fallback engaged
|
|
|
|
|
|
def test_load_memory_mode_low_vram_engages_model_offload(fake_runtime, tmp_path, monkeypatch):
|
|
# low_vram offloads every component; whole-module offload is the robust path and engages directly (no streaming, so no diffusers.hooks needed).
|
|
(tmp_path / "m.gguf").write_bytes(b"x")
|
|
backend = DiffusionBackend()
|
|
_force_cuda_target(backend, monkeypatch)
|
|
status = backend.load_pipeline(
|
|
str(tmp_path), gguf_filename = "m.gguf", family_override = "z-image", memory_mode = "low_vram"
|
|
)
|
|
assert status["offload_policy"] == "model" and status["cpu_offload"] is True
|
|
pipe = backend._state.pipe
|
|
assert pipe.offloaded is True and pipe.moved_to is None # offload owns placement
|
|
|
|
|
|
def test_load_explicit_cpu_offload_engages_model_offload_on_cuda(
|
|
fake_runtime, tmp_path, monkeypatch
|
|
):
|
|
# cpu_offload=True with no mode: auto would stay resident (budget unknown under the stub), but the explicit flag forces whole-module offload.
|
|
(tmp_path / "m.gguf").write_bytes(b"x")
|
|
backend = DiffusionBackend()
|
|
_force_cuda_target(backend, monkeypatch)
|
|
status = backend.load_pipeline(
|
|
str(tmp_path), gguf_filename = "m.gguf", family_override = "z-image", cpu_offload = True
|
|
)
|
|
assert status["offload_policy"] == "model" and status["cpu_offload"] is True
|
|
|
|
|
|
def test_load_speed_mode_gguf_auto_defaults_and_explicit(fake_runtime, tmp_path):
|
|
# No speed_mode on a GGUF model resolves to auto `default`. compile only engages on CUDA, so on this CPU stub no optim engages, but the resolved mode is `default`.
|
|
(tmp_path / "m.gguf").write_bytes(b"x")
|
|
backend = DiffusionBackend()
|
|
status = backend.load_pipeline(str(tmp_path), gguf_filename = "m.gguf", family_override = "z-image")
|
|
assert status["speed_mode"] == "default"
|
|
# An explicit "off" opts back into the bit-identical path (engages nothing).
|
|
status_off = backend.load_pipeline(
|
|
str(tmp_path), gguf_filename = "m.gguf", family_override = "z-image", speed_mode = "off"
|
|
)
|
|
assert status_off["speed_mode"] == "off" and status_off["speed_optims"] == []
|
|
# An explicit speed_mode threads through to status (engaged optims are GPU-verified).
|
|
status2 = backend.load_pipeline(
|
|
str(tmp_path), gguf_filename = "m.gguf", family_override = "z-image", speed_mode = "max"
|
|
)
|
|
assert status2["speed_mode"] == "max"
|
|
# Text-encoder quant defaults off; a requested mode threads through (actual engagement is GPU-verified, since it needs real torch/torchao).
|
|
assert status2["text_encoder_quant"] is None
|
|
status3 = backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "m.gguf",
|
|
family_override = "z-image",
|
|
text_encoder_quant = "nvfp4",
|
|
)
|
|
# Under the CPU stub nvfp4 is unsupported, so it engages nothing.
|
|
assert status3["text_encoder_quant"] is None
|
|
|
|
|
|
def test_load_fast_mode_stays_resident_on_cuda(fake_runtime, tmp_path, monkeypatch):
|
|
(tmp_path / "m.gguf").write_bytes(b"x")
|
|
backend = DiffusionBackend()
|
|
_force_cuda_target(backend, monkeypatch)
|
|
status = backend.load_pipeline(
|
|
str(tmp_path), gguf_filename = "m.gguf", family_override = "z-image", memory_mode = "fast"
|
|
)
|
|
assert status["offload_policy"] == "none" and status["cpu_offload"] is False
|
|
assert backend._state.pipe.moved_to == "cuda"
|
|
|
|
|
|
# ── transformer quant (opt-in dense fast path) ────────────────────────────────
|
|
|
|
|
|
def _stub_dense_quant(monkeypatch, *, scheme = "fp8"):
|
|
"""Force the dense+quant branch hermetically: a supported dense source, a
|
|
from_pretrained on the fake transformer, and a quantizer that engages `scheme`.
|
|
Returns a dict recording the dense-loader / quantizer calls."""
|
|
from core.inference import diffusion as dmod
|
|
|
|
calls: dict = {"from_pretrained": 0, "quantize": 0, "quant_mode": None}
|
|
|
|
@classmethod
|
|
def _from_pretrained(cls, base, **kwargs):
|
|
calls["from_pretrained"] += 1
|
|
calls["fp_kwargs"] = {"base": base, **kwargs}
|
|
return object()
|
|
|
|
monkeypatch.setattr(_FakeTransformer, "from_pretrained", _from_pretrained, raising = False)
|
|
monkeypatch.setattr(dmod, "dense_transformer_supported", lambda target: True)
|
|
# Resolve the scheme without the real GPU smoke probe, and configure no pre-quant checkpoint so the dense materialise+quantise branch is exercised.
|
|
monkeypatch.setattr(
|
|
dmod, "select_transformer_quant_scheme", lambda target, mode, family = None: scheme
|
|
)
|
|
monkeypatch.setattr(dmod, "resolve_prequant_source", lambda fam, scheme, **kw: None)
|
|
|
|
def _quantize(pipe, target, *, mode, **kw):
|
|
calls["quantize"] += 1
|
|
calls["quant_mode"] = mode
|
|
return scheme
|
|
|
|
monkeypatch.setattr(dmod, "quantize_transformer", _quantize)
|
|
return calls
|
|
|
|
|
|
def test_default_load_autos_dense_gate_and_falls_back(fake_runtime, tmp_path, monkeypatch):
|
|
# UNSET Dtype defaults to the hardware ladder: the dense gate IS consulted, and a device without dense support falls back to the GGUF build.
|
|
from core.inference import diffusion as dmod
|
|
|
|
consulted = {"n": 0}
|
|
|
|
def _supported(*a, **k):
|
|
consulted["n"] += 1
|
|
return False
|
|
|
|
monkeypatch.setattr(dmod, "dense_transformer_supported", _supported)
|
|
(tmp_path / "m.gguf").write_bytes(b"x")
|
|
backend = DiffusionBackend()
|
|
status = backend.load_pipeline(str(tmp_path), gguf_filename = "m.gguf", family_override = "z-image")
|
|
assert consulted["n"] >= 1
|
|
assert status["transformer_quant"] is None
|
|
assert _FakeTransformer.last["path"] # GGUF from_single_file was used
|
|
|
|
|
|
def test_explicit_off_load_skips_dense_quant_path(fake_runtime, tmp_path, monkeypatch):
|
|
# An EXPLICIT "none" pins running the GGUF as-is: the dense gate is never consulted, so the pinned-off contract cannot regress.
|
|
from core.inference import diffusion as dmod
|
|
|
|
monkeypatch.setattr(
|
|
dmod,
|
|
"dense_transformer_supported",
|
|
lambda *a, **k: pytest.fail("dense path must not run with an explicit off"),
|
|
)
|
|
(tmp_path / "m.gguf").write_bytes(b"x")
|
|
backend = DiffusionBackend()
|
|
status = backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "m.gguf",
|
|
family_override = "z-image",
|
|
transformer_quant = "none",
|
|
)
|
|
assert status["transformer_quant"] is None
|
|
assert _FakeTransformer.last["path"] # GGUF from_single_file was used
|
|
|
|
|
|
def test_speed_off_load_suppresses_auto_dtype_quant(fake_runtime, tmp_path, monkeypatch):
|
|
# An explicit Speed="off" load with an UNSET dtype must stay GGUF-as-is: the auto dtype default must NOT promote it to a quantized + compiled build. The dense gate must never be consulted.
|
|
from core.inference import diffusion as dmod
|
|
|
|
monkeypatch.setattr(
|
|
dmod,
|
|
"dense_transformer_supported",
|
|
lambda *a, **k: pytest.fail("dense path must not run under an explicit Speed=off"),
|
|
)
|
|
(tmp_path / "m.gguf").write_bytes(b"x")
|
|
backend = DiffusionBackend()
|
|
status = backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "m.gguf",
|
|
family_override = "z-image",
|
|
speed_mode = "off",
|
|
)
|
|
assert status["transformer_quant"] is None
|
|
assert status["speed_mode"] == "off"
|
|
assert _FakeTransformer.last["path"] # GGUF from_single_file was used, not a dense build
|
|
|
|
|
|
def test_transformer_quant_dense_path_engaged(fake_runtime, tmp_path, monkeypatch):
|
|
# transformer_quant + a CUDA resident plan: load the DENSE transformer from the base repo, place it, quantise it, and report the engaged scheme.
|
|
backend = DiffusionBackend()
|
|
_force_cuda_target(backend, monkeypatch)
|
|
calls = _stub_dense_quant(monkeypatch, scheme = "fp8")
|
|
(tmp_path / "m.gguf").write_bytes(b"x")
|
|
status = backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "m.gguf",
|
|
family_override = "z-image",
|
|
transformer_quant = "fp8",
|
|
)
|
|
assert status["transformer_quant"] == "fp8"
|
|
# No speed_mode was given, but a quantized transformer is ~30x slower eager, so the backend promotes it to `default` (regional compile) instead of the dense `off`.
|
|
assert status["speed_mode"] == "default"
|
|
assert calls["from_pretrained"] == 1 and calls["quantize"] == 1
|
|
assert calls["quant_mode"] == "fp8"
|
|
assert calls["fp_kwargs"]["subfolder"] == "transformer" # dense transformer subfolder
|
|
# The GGUF single-file path was NOT used for the transformer.
|
|
assert _FakeTransformer.last == {}
|
|
# quantize ran on-device: the dense pipe was placed on cuda (before compile).
|
|
assert backend._state.pipe.moved_to == "cuda"
|
|
assert status["offload_policy"] == "none"
|
|
|
|
|
|
def test_transformer_quant_prequant_path_engaged(fake_runtime, tmp_path, monkeypatch):
|
|
# A configured pre-quant checkpoint loads the already-quantized transformer directly; the dense from_pretrained and the on-device quantize_transformer are NOT used.
|
|
from core.inference import diffusion as dmod
|
|
|
|
backend = DiffusionBackend()
|
|
_force_cuda_target(backend, monkeypatch)
|
|
monkeypatch.setattr(dmod, "dense_transformer_supported", lambda target: True)
|
|
monkeypatch.setattr(
|
|
dmod, "select_transformer_quant_scheme", lambda target, mode, family = None: "fp8"
|
|
)
|
|
monkeypatch.setattr(dmod, "resolve_prequant_source", lambda fam, scheme, **kw: object())
|
|
prequant_obj = object()
|
|
loaded: dict = {"n": 0}
|
|
|
|
def _load_prequant(transformer_cls, base, source, **kw):
|
|
loaded["n"] += 1
|
|
loaded["scheme"] = kw.get("scheme")
|
|
return prequant_obj
|
|
|
|
monkeypatch.setattr(dmod, "load_prequantized_transformer", _load_prequant)
|
|
|
|
@classmethod
|
|
def _fp_fail(cls, *a, **k):
|
|
pytest.fail("dense from_pretrained must not run when a prequant checkpoint loads")
|
|
|
|
monkeypatch.setattr(_FakeTransformer, "from_pretrained", _fp_fail, raising = False)
|
|
monkeypatch.setattr(
|
|
dmod,
|
|
"quantize_transformer",
|
|
lambda *a, **k: pytest.fail("quantize_transformer must not run on the prequant path"),
|
|
)
|
|
(tmp_path / "m.gguf").write_bytes(b"x")
|
|
status = backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "m.gguf",
|
|
family_override = "z-image",
|
|
transformer_quant = "fp8",
|
|
transformer_prequant_path = str(tmp_path / "zimage_fp8.pt"),
|
|
)
|
|
assert status["transformer_quant"] == "fp8"
|
|
assert loaded["n"] == 1 and loaded["scheme"] == "fp8"
|
|
# The pre-quantized transformer object was assembled into the pipeline...
|
|
assert _FakePipeline.last.get("transformer") is prequant_obj
|
|
# ...and the GGUF single-file path was not used.
|
|
assert _FakeTransformer.last == {}
|
|
|
|
|
|
def test_transformer_quant_prequant_load_fails_falls_back_to_dense(
|
|
fake_runtime, tmp_path, monkeypatch
|
|
):
|
|
# A configured prequant source whose load returns None must fall back to the dense materialise+quantise path (not straight to GGUF), preserving the fast mode.
|
|
from core.inference import diffusion as dmod
|
|
|
|
backend = DiffusionBackend()
|
|
_force_cuda_target(backend, monkeypatch)
|
|
calls = _stub_dense_quant(monkeypatch, scheme = "fp8")
|
|
# Override the no-prequant default: a source resolves, but its load fails.
|
|
monkeypatch.setattr(dmod, "resolve_prequant_source", lambda fam, scheme, **kw: object())
|
|
monkeypatch.setattr(dmod, "load_prequantized_transformer", lambda *a, **k: None)
|
|
(tmp_path / "m.gguf").write_bytes(b"x")
|
|
status = backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "m.gguf",
|
|
family_override = "z-image",
|
|
transformer_quant = "fp8",
|
|
)
|
|
assert status["transformer_quant"] == "fp8"
|
|
assert calls["from_pretrained"] == 1 and calls["quantize"] == 1 # dense path ran
|
|
assert _FakeTransformer.last == {} # GGUF not used
|
|
|
|
|
|
def test_transformer_quant_falls_back_to_gguf_on_failure(fake_runtime, tmp_path, monkeypatch):
|
|
# A dense/quant failure (here quantize returns None) must fall back to the GGUF build, not error; status reports no transformer_quant engaged.
|
|
from core.inference import diffusion as dmod
|
|
|
|
backend = DiffusionBackend()
|
|
_force_cuda_target(backend, monkeypatch)
|
|
monkeypatch.setattr(dmod, "dense_transformer_supported", lambda target: True)
|
|
|
|
@classmethod
|
|
def _from_pretrained(cls, base, **kwargs):
|
|
return object()
|
|
|
|
monkeypatch.setattr(_FakeTransformer, "from_pretrained", _from_pretrained, raising = False)
|
|
monkeypatch.setattr(dmod, "quantize_transformer", lambda pipe, target, **kw: None)
|
|
(tmp_path / "m.gguf").write_bytes(b"x")
|
|
status = backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "m.gguf",
|
|
family_override = "z-image",
|
|
transformer_quant = "fp8",
|
|
)
|
|
assert status["loaded"] is True
|
|
assert status["transformer_quant"] is None # fell back
|
|
assert _FakeTransformer.last["path"] # GGUF from_single_file used
|
|
|
|
|
|
def test_transformer_quant_skipped_when_plan_offloads(fake_runtime, tmp_path, monkeypatch):
|
|
# The dense bf16 transformer only fits resident, so when the memory plan would offload (low_vram) the fast path is skipped and GGUF loads instead.
|
|
from core.inference import diffusion as dmod
|
|
|
|
backend = DiffusionBackend()
|
|
_force_cuda_target(backend, monkeypatch)
|
|
monkeypatch.setattr(dmod, "dense_transformer_supported", lambda target: True)
|
|
|
|
@classmethod
|
|
def _fp_fail(cls, *a, **k):
|
|
pytest.fail("dense transformer must not load when the plan offloads")
|
|
|
|
monkeypatch.setattr(_FakeTransformer, "from_pretrained", _fp_fail, raising = False)
|
|
(tmp_path / "m.gguf").write_bytes(b"x")
|
|
status = backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "m.gguf",
|
|
family_override = "z-image",
|
|
transformer_quant = "fp8",
|
|
memory_mode = "low_vram",
|
|
)
|
|
assert status["transformer_quant"] is None
|
|
assert status["offload_policy"] == "model"
|
|
assert _FakeTransformer.last["path"] # GGUF path used
|
|
|
|
|
|
def test_dense_quant_skipped_when_dense_transformer_does_not_fit(
|
|
fake_runtime, tmp_path, monkeypatch
|
|
):
|
|
# The GGUF fits resident (plan `none`), but the DENSE bf16 transformer the fast path materializes does not. The fast path must be skipped up front (preflighted against the dense transformer), and GGUF loads RESIDENT -- not evicted, OOMed, then offloaded.
|
|
from core.inference import diffusion as dmod
|
|
|
|
backend = DiffusionBackend()
|
|
_force_cuda_target(backend, monkeypatch)
|
|
monkeypatch.setattr(dmod, "dense_transformer_supported", lambda target: True)
|
|
# A scheme resolves and there is no prequant, so the dense bf16 is materialized and the dense-fit re-check runs against a large (will-not-fit) dense transformer.
|
|
monkeypatch.setattr(
|
|
dmod, "select_transformer_quant_scheme", lambda target, mode, family = None: "fp8"
|
|
)
|
|
monkeypatch.setattr(dmod, "resolve_prequant_source", lambda fam, scheme, **kw: None)
|
|
monkeypatch.setattr(
|
|
DiffusionBackend,
|
|
"_dense_transformer_resident_bytes",
|
|
staticmethod(lambda base: 40 * 1024**3),
|
|
)
|
|
orig_plan = DiffusionBackend._plan_memory
|
|
|
|
def plan_wrap(
|
|
self,
|
|
*a,
|
|
transformer_resident_override_mib = None,
|
|
**k,
|
|
):
|
|
# GGUF budget fits (real plan -> none); the dense-transformer preflight does not.
|
|
if transformer_resident_override_mib is not None:
|
|
return types.SimpleNamespace(offload_policy = "model")
|
|
return orig_plan(self, *a, **k)
|
|
|
|
monkeypatch.setattr(DiffusionBackend, "_plan_memory", plan_wrap)
|
|
|
|
@classmethod
|
|
def _fp_fail(cls, *a, **k):
|
|
pytest.fail("dense transformer must not load when it won't fit resident")
|
|
|
|
monkeypatch.setattr(_FakeTransformer, "from_pretrained", _fp_fail, raising = False)
|
|
(tmp_path / "m.gguf").write_bytes(b"x")
|
|
status = backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "m.gguf",
|
|
family_override = "z-image",
|
|
transformer_quant = "fp8",
|
|
)
|
|
assert status["transformer_quant"] is None # dense quant skipped
|
|
assert status["offload_policy"] == "none" # GGUF loaded resident, not offloaded
|
|
assert _FakeTransformer.last["path"] # GGUF path used
|
|
|
|
|
|
def test_dense_quant_prequant_proceeds_but_forbids_dense_fallback(
|
|
fake_runtime, tmp_path, monkeypatch
|
|
):
|
|
# With a prequant checkpoint the fast path loads the small quantized file, so a dense misfit must NOT decline it -- but the dense re-check still runs to gate the in-loader fallback: if the prequant later fails, the loader must raise to GGUF (allow_dense_fallback=False).
|
|
from core.inference import diffusion as dmod
|
|
|
|
backend = DiffusionBackend()
|
|
_force_cuda_target(backend, monkeypatch)
|
|
monkeypatch.setattr(dmod, "dense_transformer_supported", lambda target: True)
|
|
monkeypatch.setattr(
|
|
dmod, "select_transformer_quant_scheme", lambda target, mode, family = None: "fp8"
|
|
)
|
|
# usable_ (not resolve_): the re-check site only honours a source the loader would accept, so the fake must present a USABLE one.
|
|
monkeypatch.setattr(dmod, "usable_prequant_source", lambda fam, scheme, **kw: "prequant/path")
|
|
# Large dense shards cached: if the re-check ran, it would wrongly decline the fast path.
|
|
monkeypatch.setattr(
|
|
DiffusionBackend,
|
|
"_dense_transformer_resident_bytes",
|
|
staticmethod(lambda base: 999 * 1024**3),
|
|
)
|
|
dense_refit_ran = []
|
|
orig_plan = DiffusionBackend._plan_memory
|
|
|
|
def spy_plan(
|
|
self,
|
|
*a,
|
|
transformer_resident_override_mib = None,
|
|
**k,
|
|
):
|
|
if transformer_resident_override_mib is not None:
|
|
dense_refit_ran.append(True)
|
|
# GGUF budget fits (real plan -> none); the dense-transformer preflight does not.
|
|
return types.SimpleNamespace(offload_policy = "model")
|
|
return orig_plan(self, *a, **k)
|
|
|
|
monkeypatch.setattr(DiffusionBackend, "_plan_memory", spy_plan)
|
|
attempted = []
|
|
|
|
def fake_dense_load(self, *a, **k):
|
|
attempted.append(k.get("allow_dense_fallback"))
|
|
return None, None # fall through to GGUF; we only assert the path was reached
|
|
|
|
monkeypatch.setattr(DiffusionBackend, "_load_dense_quant_pipeline", fake_dense_load)
|
|
(tmp_path / "m.gguf").write_bytes(b"x")
|
|
backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "m.gguf",
|
|
family_override = "z-image",
|
|
transformer_quant = "fp8",
|
|
)
|
|
assert dense_refit_ran == [True] # the re-check runs (it gates the fallback)...
|
|
assert attempted == [False] # ...fast path still attempted, dense fallback forbidden
|
|
|
|
|
|
def test_dense_quant_replan_retries_once_on_transient_free_undercount(
|
|
fake_runtime, tmp_path, monkeypatch
|
|
):
|
|
# A transient foreign allocation at snapshot time makes an empty card look full and the candidate replan declines resident -- but the candidate FITS total capacity, so the loader must retry the replan once with a fresh settled snapshot instead of falling back to GGUF-as-is.
|
|
import dataclasses
|
|
|
|
from core.inference import diffusion as dmod
|
|
|
|
backend = DiffusionBackend()
|
|
_force_cuda_target(backend, monkeypatch)
|
|
monkeypatch.setattr(dmod, "dense_transformer_supported", lambda target: True)
|
|
monkeypatch.setattr(
|
|
dmod, "select_transformer_quant_scheme", lambda target, mode, family = None: "int8"
|
|
)
|
|
monkeypatch.setattr(
|
|
dmod,
|
|
"resolve_dense_quant_candidate",
|
|
lambda **kw: types.SimpleNamespace(
|
|
transient_transformer_mib = 33_831, companions_mib = 46_157, prequant = True
|
|
),
|
|
)
|
|
replan_calls = []
|
|
orig_plan = DiffusionBackend._plan_memory
|
|
|
|
def spy_plan(
|
|
self,
|
|
*a,
|
|
transformer_resident_override_mib = None,
|
|
**k,
|
|
):
|
|
real = orig_plan(
|
|
self, *a, transformer_resident_override_mib = transformer_resident_override_mib, **k
|
|
)
|
|
if transformer_resident_override_mib is None:
|
|
# Initial GGUF plan: force offload so the candidate replan branch is entered.
|
|
return dataclasses.replace(real, offload_policy = "model")
|
|
replan_calls.append(True)
|
|
if len(replan_calls) == 1:
|
|
# First replan: the transient undercount. Required fits total capacity, so a retry must follow.
|
|
return types.SimpleNamespace(
|
|
offload_policy = "model",
|
|
estimates = {"resident_required_mib": 90_228, "safe_device_budget_mib": 40_000},
|
|
device_memory = types.SimpleNamespace(
|
|
total_mib = 183_359, memory_kind = "discrete_vram", free_mib = 60_000
|
|
),
|
|
reasons = ("companions exceed budget",),
|
|
)
|
|
# Retry: the transient cleared; resident.
|
|
return dataclasses.replace(real, offload_policy = "none")
|
|
|
|
monkeypatch.setattr(DiffusionBackend, "_plan_memory", spy_plan)
|
|
attempted = []
|
|
|
|
def fake_dense_load(self, *a, **k):
|
|
attempted.append(k.get("allow_dense_fallback"))
|
|
raise RuntimeError("test: stop after reaching the fast path")
|
|
|
|
monkeypatch.setattr(DiffusionBackend, "_load_dense_quant_pipeline", fake_dense_load)
|
|
(tmp_path / "m.gguf").write_bytes(b"x")
|
|
backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "m.gguf",
|
|
family_override = "z-image",
|
|
transformer_quant = "int8",
|
|
)
|
|
assert replan_calls == [True, True] # declined once, retried once
|
|
assert attempted == [False] # fast path attempted; prequant-sized plan forbids dense fallback
|
|
|
|
|
|
def test_dense_quant_replan_no_retry_when_capacity_truly_short(fake_runtime, tmp_path, monkeypatch):
|
|
# When the candidate does NOT fit total capacity, the decline is real: no retry.
|
|
import dataclasses
|
|
|
|
from core.inference import diffusion as dmod
|
|
|
|
backend = DiffusionBackend()
|
|
_force_cuda_target(backend, monkeypatch)
|
|
monkeypatch.setattr(dmod, "dense_transformer_supported", lambda target: True)
|
|
monkeypatch.setattr(
|
|
dmod, "select_transformer_quant_scheme", lambda target, mode, family = None: "int8"
|
|
)
|
|
monkeypatch.setattr(
|
|
dmod,
|
|
"resolve_dense_quant_candidate",
|
|
lambda **kw: types.SimpleNamespace(
|
|
transient_transformer_mib = 33_831, companions_mib = 46_157, prequant = True
|
|
),
|
|
)
|
|
replan_calls = []
|
|
orig_plan = DiffusionBackend._plan_memory
|
|
|
|
def spy_plan(
|
|
self,
|
|
*a,
|
|
transformer_resident_override_mib = None,
|
|
**k,
|
|
):
|
|
real = orig_plan(
|
|
self, *a, transformer_resident_override_mib = transformer_resident_override_mib, **k
|
|
)
|
|
if transformer_resident_override_mib is None:
|
|
return dataclasses.replace(real, offload_policy = "model")
|
|
replan_calls.append(True)
|
|
return types.SimpleNamespace(
|
|
offload_policy = "model",
|
|
estimates = {"resident_required_mib": 150_000, "safe_device_budget_mib": 40_000},
|
|
device_memory = types.SimpleNamespace(
|
|
total_mib = 183_359, memory_kind = "discrete_vram", free_mib = 60_000
|
|
),
|
|
reasons = ("companions exceed budget",),
|
|
)
|
|
|
|
monkeypatch.setattr(DiffusionBackend, "_plan_memory", spy_plan)
|
|
(tmp_path / "m.gguf").write_bytes(b"x")
|
|
backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "m.gguf",
|
|
family_override = "z-image",
|
|
transformer_quant = "int8",
|
|
)
|
|
assert replan_calls == [True] # genuine capacity shortfall: declined without a retry
|
|
|
|
|
|
def _decline_dense_quant(backend, monkeypatch, tmp_path):
|
|
"""Configure the harness so the dense-quant fast path is declined for capacity
|
|
(mirrors test_dense_quant_replan_no_retry_when_capacity_truly_short)."""
|
|
import dataclasses
|
|
|
|
from core.inference import diffusion as dmod
|
|
|
|
_force_cuda_target(backend, monkeypatch)
|
|
monkeypatch.setattr(dmod, "dense_transformer_supported", lambda target: True)
|
|
monkeypatch.setattr(
|
|
dmod, "select_transformer_quant_scheme", lambda target, mode, family = None: "int8"
|
|
)
|
|
monkeypatch.setattr(
|
|
dmod,
|
|
"resolve_dense_quant_candidate",
|
|
lambda **kw: types.SimpleNamespace(
|
|
transient_transformer_mib = 33_831, companions_mib = 46_157, prequant = False
|
|
),
|
|
)
|
|
orig_plan = DiffusionBackend._plan_memory
|
|
|
|
def spy_plan(
|
|
self,
|
|
*a,
|
|
transformer_resident_override_mib = None,
|
|
**k,
|
|
):
|
|
real = orig_plan(
|
|
self, *a, transformer_resident_override_mib = transformer_resident_override_mib, **k
|
|
)
|
|
if transformer_resident_override_mib is None:
|
|
return dataclasses.replace(real, offload_policy = "model")
|
|
return types.SimpleNamespace(
|
|
offload_policy = "model",
|
|
estimates = {"resident_required_mib": 150_000, "safe_device_budget_mib": 40_000},
|
|
device_memory = types.SimpleNamespace(
|
|
total_mib = 183_359, memory_kind = "discrete_vram", free_mib = 60_000
|
|
),
|
|
reasons = ("companions exceed budget",),
|
|
)
|
|
|
|
monkeypatch.setattr(DiffusionBackend, "_plan_memory", spy_plan)
|
|
(tmp_path / "m.gguf").write_bytes(b"x")
|
|
|
|
|
|
def test_declined_dense_with_baked_loras_fails_instead_of_silent_drop(
|
|
fake_runtime, tmp_path, monkeypatch
|
|
):
|
|
# transformer_quant + adapters, dense build declined for capacity: the GGUF fallback cannot bake the adapters, so completing it would silently generate WITHOUT them behind an HTTP success.
|
|
backend = DiffusionBackend()
|
|
_decline_dense_quant(backend, monkeypatch, tmp_path)
|
|
with pytest.raises(RuntimeError, match = "LoRA adapters could not be applied"):
|
|
backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "m.gguf",
|
|
family_override = "z-image",
|
|
transformer_quant = "int8",
|
|
loras = [("adapter", 1.0)],
|
|
)
|
|
|
|
|
|
def test_declined_dense_without_loras_still_falls_back_to_gguf(fake_runtime, tmp_path, monkeypatch):
|
|
# The plain decline (no adapters requested) keeps the silent GGUF fallback: weight-0 adapters count as "none".
|
|
backend = DiffusionBackend()
|
|
_decline_dense_quant(backend, monkeypatch, tmp_path)
|
|
result = backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "m.gguf",
|
|
family_override = "z-image",
|
|
transformer_quant = "int8",
|
|
loras = [("adapter", 0.0)],
|
|
)
|
|
assert result is not None
|
|
assert backend.status()["transformer_quant"] is None # GGUF-as-is fallback
|
|
|
|
|
|
class _BakePipe:
|
|
def __init__(self):
|
|
self.calls: list = []
|
|
|
|
def load_lora_weights(
|
|
self,
|
|
path,
|
|
adapter_name = None,
|
|
):
|
|
self.calls.append(("load", path, adapter_name))
|
|
|
|
def set_adapters(
|
|
self,
|
|
names,
|
|
adapter_weights = None,
|
|
):
|
|
self.calls.append(("set", tuple(names), tuple(adapter_weights)))
|
|
|
|
|
|
def test_dense_quant_lora_bake_attaches_before_quantize(fake_runtime, monkeypatch):
|
|
# A LoRA bake must (a) skip the prequant shortcut (adapters need the DENSE transformer), (b) attach the adapters BEFORE quantize_transformer (peft post-quant torchao dispatch TypeErrors on a manually quantized module), and (c) mark the pipe as baked.
|
|
from core.inference import diffusion as dmod
|
|
|
|
backend = DiffusionBackend()
|
|
monkeypatch.setattr(
|
|
dmod, "select_transformer_quant_scheme", lambda target, mode, family = None: "int8"
|
|
)
|
|
prequant_consulted = []
|
|
monkeypatch.setattr(
|
|
dmod,
|
|
"resolve_prequant_source",
|
|
lambda *a, **k: prequant_consulted.append(True) or None,
|
|
)
|
|
order: list = []
|
|
|
|
class FakeTransformerCls:
|
|
@staticmethod
|
|
def from_pretrained(*a, **k):
|
|
order.append("dense_load")
|
|
return object()
|
|
|
|
pipe = _BakePipe()
|
|
monkeypatch.setattr(DiffusionBackend, "_assemble_pipe", staticmethod(lambda *a, **k: pipe))
|
|
monkeypatch.setattr(
|
|
DiffusionBackend,
|
|
"_resolve_lora_set",
|
|
staticmethod(lambda specs, **k: (("sloth", "/adapters/sloth.safetensors", 0.8),)),
|
|
)
|
|
|
|
def fake_quantize(p, target, **k):
|
|
order.append("quantize")
|
|
assert any(c[0] == "load" for c in p.calls), "adapters must attach before quantize"
|
|
return "int8"
|
|
|
|
monkeypatch.setattr(dmod, "quantize_transformer", fake_quantize)
|
|
got_pipe, scheme = backend._load_dense_quant_pipeline(
|
|
FakeTransformerCls,
|
|
object,
|
|
"base/repo",
|
|
"cuda",
|
|
"bf16",
|
|
None,
|
|
types.SimpleNamespace(device = "cuda", dtype = "bf16"),
|
|
"int8",
|
|
fam = types.SimpleNamespace(name = "z-image"),
|
|
lora_specs = [("sloth", 0.8)],
|
|
)
|
|
assert scheme == "int8"
|
|
assert prequant_consulted == [] # prequant shortcut skipped for the bake
|
|
assert order == ["dense_load", "quantize"]
|
|
assert pipe.calls[0] == ("load", "/adapters/sloth.safetensors", "sloth")
|
|
assert pipe.calls[1] == ("set", ("sloth",), (0.8,))
|
|
assert pipe._unsloth_loras == (("sloth", "/adapters/sloth.safetensors", 0.8),)
|
|
assert pipe._unsloth_loras_baked is True
|
|
|
|
|
|
def _quant_lora_state(pipe, quant = "int8"):
|
|
return types.SimpleNamespace(
|
|
pipe = pipe,
|
|
transformer_quant = quant,
|
|
kind = "gguf",
|
|
family = types.SimpleNamespace(name = "z-image"),
|
|
hf_token = None,
|
|
speed_optims = ("compiled",),
|
|
)
|
|
|
|
|
|
def test_apply_loras_quant_unbaked_requires_reload(monkeypatch):
|
|
# A quantized pipe built WITHOUT adapters cannot take one at generation time (topology frozen after quantize_ + compile): a clean 400 telling the client to reload.
|
|
backend = DiffusionBackend()
|
|
pipe = _BakePipe()
|
|
with pytest.raises(ValueError, match = "Reload the model with the adapter selection"):
|
|
backend._apply_loras(_quant_lora_state(pipe), [("sloth", 1.0)], threading.Event())
|
|
# ...but a no-adapter generation on the same pipe stays a plain no-op.
|
|
backend._apply_loras(_quant_lora_state(pipe), [], threading.Event())
|
|
assert pipe.calls == []
|
|
|
|
|
|
def test_apply_loras_quant_baked_matrix(monkeypatch):
|
|
# Baked pipe: same set is a no-op; weight-only change calls set_adapters; empty scales all to 0 (reproducing the quantized base); a different adapter set errors.
|
|
backend = DiffusionBackend()
|
|
monkeypatch.setattr(
|
|
DiffusionBackend,
|
|
"_resolve_lora_set",
|
|
staticmethod(
|
|
lambda specs, **k: tuple((i, f"/adapters/{i}.safetensors", w) for (i, w) in specs)
|
|
),
|
|
)
|
|
|
|
def baked_pipe():
|
|
pipe = _BakePipe()
|
|
pipe._unsloth_loras = (("sloth", "/adapters/sloth.safetensors", 0.8),)
|
|
pipe._unsloth_loras_baked = True
|
|
return pipe
|
|
|
|
ev = threading.Event()
|
|
# same set: no-op
|
|
pipe = baked_pipe()
|
|
backend._apply_loras(_quant_lora_state(pipe), [("sloth", 0.8)], ev)
|
|
assert pipe.calls == []
|
|
# weight-only change: live set_adapters + marker update
|
|
pipe = baked_pipe()
|
|
backend._apply_loras(_quant_lora_state(pipe), [("sloth", 1.4)], ev)
|
|
assert pipe.calls == [("set", ("sloth",), (1.4,))]
|
|
assert pipe._unsloth_loras == (("sloth", "/adapters/sloth.safetensors", 1.4),)
|
|
# empty: scale everything to 0 (quantized base output), marker keeps paths
|
|
pipe = baked_pipe()
|
|
backend._apply_loras(_quant_lora_state(pipe), [], ev)
|
|
assert pipe.calls == [("set", ("sloth",), (0.0,))]
|
|
assert pipe._unsloth_loras == (("sloth", "/adapters/sloth.safetensors", 0.0),)
|
|
# empty again after zeroing: no further calls
|
|
backend._apply_loras(_quant_lora_state(pipe), [], ev)
|
|
assert len(pipe.calls) == 1
|
|
# different adapter set: topology change -> reload error
|
|
pipe = baked_pipe()
|
|
with pytest.raises(ValueError, match = "Reload the model with the new adapter selection"):
|
|
backend._apply_loras(_quant_lora_state(pipe), [("other", 1.0)], ev)
|
|
|
|
|
|
def test_assemble_pipe_routes_krea2_per_component(monkeypatch):
|
|
# krea repo ships transformers-5.x configs and no top-level tokenizer files, so from_pretrained dies in the tokenizer. The quant fast path must assemble per-component via load_krea2_pipeline.
|
|
from core.inference import diffusion as dmod
|
|
|
|
calls: dict = {}
|
|
|
|
class Pipe:
|
|
def to(self, device):
|
|
calls["device"] = device
|
|
return self
|
|
|
|
def fake_loader(
|
|
base,
|
|
dtype,
|
|
hf_token = None,
|
|
transformer = None,
|
|
text_encoder = None,
|
|
):
|
|
calls["base"] = base
|
|
calls["transformer"] = transformer
|
|
return Pipe()
|
|
|
|
monkeypatch.setattr(dmod, "load_krea2_pipeline", fake_loader)
|
|
|
|
class ExplodingPipeline:
|
|
@staticmethod
|
|
def from_pretrained(*a, **k):
|
|
raise AssertionError("krea-2 must not go through Pipeline.from_pretrained")
|
|
|
|
marker = object()
|
|
pipe = dmod.DiffusionBackend._assemble_pipe(
|
|
ExplodingPipeline,
|
|
"krea/Krea-2-Turbo",
|
|
marker,
|
|
"bf16",
|
|
None,
|
|
"cuda:0",
|
|
fam = types.SimpleNamespace(name = "krea-2"),
|
|
)
|
|
assert isinstance(pipe, Pipe)
|
|
assert calls == {"base": "krea/Krea-2-Turbo", "transformer": marker, "device": "cuda:0"}
|
|
|
|
|
|
def test_dense_quant_unusable_prequant_path_runs_dense_refit(fake_runtime, tmp_path, monkeypatch):
|
|
# A request-supplied transformer_prequant_path the loader refuses resolves to NO usable prequant source, so the dense-transformer fit re-check MUST run: it declines the fast path up front instead of evicting the resident pipeline and OOMing in the dense bf16 fallback.
|
|
from core.inference import diffusion as dmod
|
|
|
|
backend = DiffusionBackend()
|
|
_force_cuda_target(backend, monkeypatch)
|
|
monkeypatch.setattr(dmod, "dense_transformer_supported", lambda target: True)
|
|
monkeypatch.setattr(
|
|
dmod, "select_transformer_quant_scheme", lambda target, mode, family = None: "fp8"
|
|
)
|
|
# The REAL usable_prequant_source refuses a non-allowlisted path (unit-tested elsewhere); returning None here pins that outcome at this site.
|
|
monkeypatch.setattr(dmod, "usable_prequant_source", lambda fam, scheme, **kw: None)
|
|
monkeypatch.setattr(
|
|
DiffusionBackend,
|
|
"_dense_transformer_resident_bytes",
|
|
staticmethod(lambda base: 999 * 1024**3),
|
|
)
|
|
dense_refit_ran = []
|
|
orig_plan = DiffusionBackend._plan_memory
|
|
|
|
def spy_plan(
|
|
self,
|
|
*a,
|
|
transformer_resident_override_mib = None,
|
|
**k,
|
|
):
|
|
if transformer_resident_override_mib is not None:
|
|
dense_refit_ran.append(True)
|
|
return orig_plan(self, *a, **k)
|
|
|
|
monkeypatch.setattr(DiffusionBackend, "_plan_memory", spy_plan)
|
|
monkeypatch.setattr(
|
|
DiffusionBackend, "_load_dense_quant_pipeline", lambda self, *a, **k: (None, None)
|
|
)
|
|
(tmp_path / "m.gguf").write_bytes(b"x")
|
|
backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "m.gguf",
|
|
family_override = "z-image",
|
|
transformer_quant = "fp8",
|
|
transformer_prequant_path = str(tmp_path / "not-allowlisted.pt"),
|
|
)
|
|
# Unusable path -> no prequant shortcut -> the dense fit re-check ran.
|
|
assert dense_refit_ran == [True]
|
|
assert backend.status()["loaded"] is True
|
|
|
|
|
|
def test_transformer_quant_unsupported_scheme_skips_dense_download(
|
|
fake_runtime, tmp_path, monkeypatch
|
|
):
|
|
# An explicit unsupported scheme must fail the dense path BEFORE materialising the multi-GB dense transformer, then fall back to GGUF -- else the download runs under the load lock after the old model was evicted, only to fail at quantize.
|
|
from core.inference import diffusion as dmod
|
|
|
|
backend = DiffusionBackend()
|
|
_force_cuda_target(backend, monkeypatch)
|
|
monkeypatch.setattr(dmod, "dense_transformer_supported", lambda target: True)
|
|
monkeypatch.setattr(
|
|
dmod, "select_transformer_quant_scheme", lambda target, mode, family = None: None
|
|
)
|
|
monkeypatch.setattr(dmod, "resolve_prequant_source", lambda fam, scheme, **kw: None)
|
|
|
|
@classmethod
|
|
def _fp_fail(cls, *a, **k):
|
|
pytest.fail("dense transformer must not download when the scheme is unsupported")
|
|
|
|
monkeypatch.setattr(_FakeTransformer, "from_pretrained", _fp_fail, raising = False)
|
|
(tmp_path / "m.gguf").write_bytes(b"x")
|
|
status = backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "m.gguf",
|
|
family_override = "z-image",
|
|
transformer_quant = "fp8",
|
|
)
|
|
assert status["loaded"] is True
|
|
assert status["transformer_quant"] is None # fell back to GGUF
|
|
assert _FakeTransformer.last["path"] # GGUF from_single_file used
|
|
|
|
|
|
def test_base_file_downloaded_include_transformer_flag():
|
|
# Default: transformer/ shards are the GGUF job, so they are excluded from the prefetch list; the dense transformer-quant path opts them back in.
|
|
from core.inference.diffusion import _base_file_downloaded
|
|
|
|
assert _base_file_downloaded("transformer/diffusion_pytorch_model-00001.safetensors") is False
|
|
assert (
|
|
_base_file_downloaded(
|
|
"transformer/diffusion_pytorch_model-00001.safetensors", include_transformer = True
|
|
)
|
|
is True
|
|
)
|
|
# The flag must not admit anything else that is normally excluded.
|
|
assert _base_file_downloaded("assets/teaser.png", include_transformer = True) is False
|
|
assert _base_file_downloaded("README.md", include_transformer = True) is False
|
|
|
|
|
|
def test_dense_quant_prefetch_capacity_gate(fake_runtime, monkeypatch):
|
|
# On a device that cannot hold even the candidate post-quant resident set, the re-plan is certain to decline the dense path, so widening would fetch the multi-GB base transformer/ shards only to run the GGUF as-is. The gate compares steady_total against TOTAL capacity.
|
|
from core.inference import diffusion as dmod
|
|
from core.inference import diffusion_memory as dmem
|
|
|
|
backend = DiffusionBackend()
|
|
_force_cuda_target(backend, monkeypatch)
|
|
fam = detect_family("unsloth/Qwen-Image-GGUF")
|
|
|
|
def candidate_with(steady):
|
|
return lambda **kw: types.SimpleNamespace(prequant = False, steady_total_mib = steady)
|
|
|
|
monkeypatch.setattr(
|
|
dmem,
|
|
"snapshot_device_memory",
|
|
lambda target: types.SimpleNamespace(
|
|
total_mib = 24_564, free_mib = 24_000, memory_kind = "discrete_vram"
|
|
),
|
|
)
|
|
# int8 qwen steady (~22 GB DiT + 17 GB companions) cannot fit a 24 GB card.
|
|
monkeypatch.setattr(dmod, "resolve_dense_quant_candidate", candidate_with(39_900))
|
|
assert backend._dense_quant_prefetch_needed(fam, {"transformer_quant": "int8"}) is False
|
|
# A candidate that fits total capacity still widens.
|
|
monkeypatch.setattr(dmod, "resolve_dense_quant_candidate", candidate_with(12_000))
|
|
assert backend._dense_quant_prefetch_needed(fam, {"transformer_quant": "int8"}) is True
|
|
# Unknown sizes keep the old behaviour (widen: the loader may still take the dense path).
|
|
monkeypatch.setattr(
|
|
dmod,
|
|
"resolve_dense_quant_candidate",
|
|
lambda **kw: types.SimpleNamespace(prequant = False),
|
|
)
|
|
assert backend._dense_quant_prefetch_needed(fam, {"transformer_quant": "int8"}) is True
|
|
|
|
|
|
def test_dense_quant_prefetch_needed_gates(fake_runtime, monkeypatch):
|
|
# The transformer/ prefetch widens exactly when load_pipeline takes the dense-quant path: it defers to resolve_dense_quant_candidate (quant requested + device supported + scheme resolvable + no prequant checkpoint + disk). An explicit Speed="off" load never widens.
|
|
from core.inference import diffusion as dmod
|
|
|
|
backend = DiffusionBackend()
|
|
_force_cuda_target(backend, monkeypatch)
|
|
fam = detect_family("unsloth/Z-Image-Turbo-GGUF")
|
|
|
|
seen: list = []
|
|
|
|
def fake_candidate(
|
|
*,
|
|
fam,
|
|
target,
|
|
requested,
|
|
base_repo = None,
|
|
prequant_path = None,
|
|
force_dense = False,
|
|
logger = None,
|
|
):
|
|
seen.append(requested)
|
|
# A real (non-prequant) dense-quant candidate: scheme resolves AND disk fits, so the loader takes the dense build that needs the base repo bf16 transformer/ shards.
|
|
return types.SimpleNamespace(prequant = False)
|
|
|
|
monkeypatch.setattr(dmod, "resolve_dense_quant_candidate", fake_candidate)
|
|
|
|
# Explicit fp8 widens; the resolved mode is threaded through to the candidate resolver.
|
|
assert backend._dense_quant_prefetch_needed(fam, {"transformer_quant": "fp8"}) is True
|
|
assert seen[-1] == "fp8"
|
|
# UNSET defaults to the hardware ladder, so it widens, threading auto.
|
|
assert backend._dense_quant_prefetch_needed(fam, {}) is True
|
|
assert seen[-1] == "auto"
|
|
# A definite-offload memory policy forces load_pipeline onto offload regardless of the candidate smaller footprint, so the widened prefetch would download shards the offloaded GGUF path never uses (and a disk-full there has no GGUF fallback). balanced / low_vram must NOT widen.
|
|
before = len(seen)
|
|
assert (
|
|
backend._dense_quant_prefetch_needed(
|
|
fam, {"transformer_quant": "fp8", "memory_mode": "balanced"}
|
|
)
|
|
is False
|
|
)
|
|
assert (
|
|
backend._dense_quant_prefetch_needed(
|
|
fam, {"transformer_quant": "fp8", "memory_mode": "low_vram"}
|
|
)
|
|
is False
|
|
)
|
|
assert (
|
|
backend._dense_quant_prefetch_needed(fam, {"transformer_quant": "fp8", "cpu_offload": True})
|
|
is False
|
|
)
|
|
# The gate short-circuits BEFORE resolving the candidate (no wasted resolve).
|
|
assert len(seen) == before
|
|
# An explicit memory_mode still consulting the candidate: fast/auto can flip resident, so they widen when the candidate is dense-viable.
|
|
assert (
|
|
backend._dense_quant_prefetch_needed(
|
|
fam, {"transformer_quant": "fp8", "memory_mode": "fast"}
|
|
)
|
|
is True
|
|
)
|
|
# A cpu_offload flag is overridden by an explicit resident memory_mode, so it still widens.
|
|
assert (
|
|
backend._dense_quant_prefetch_needed(
|
|
fam, {"transformer_quant": "fp8", "memory_mode": "fast", "cpu_offload": True}
|
|
)
|
|
is True
|
|
)
|
|
# An explicit off pins running the GGUF as-is, so never widen.
|
|
assert backend._dense_quant_prefetch_needed(fam, {"transformer_quant": "none"}) is False
|
|
# An explicit Speed="off" (bit-exact) load suppresses the dense path, so never widen.
|
|
assert (
|
|
backend._dense_quant_prefetch_needed(fam, {"transformer_quant": "fp8", "speed_mode": "off"})
|
|
is False
|
|
)
|
|
# A PREQUANT candidate loads the small pre-quantized checkpoint, NOT the base repo dense shards, so the widened prefetch must NOT fire -- else it defeats the prequant savings and can hard-fail begin_load on a disk-full.
|
|
monkeypatch.setattr(
|
|
dmod, "resolve_dense_quant_candidate", lambda **kw: types.SimpleNamespace(prequant = True)
|
|
)
|
|
assert backend._dense_quant_prefetch_needed(fam, {"transformer_quant": "fp8"}) is False
|
|
# No viable candidate (unsupported scheme / no disk room) never widens. The disk guard averts filling the cache volume and hard-failing instead of falling back to GGUF.
|
|
monkeypatch.setattr(dmod, "resolve_dense_quant_candidate", lambda **kw: None)
|
|
assert backend._dense_quant_prefetch_needed(fam, {"transformer_quant": "fp8"}) is False
|
|
|
|
|
|
def test_diffusion_status_response_carries_resolved():
|
|
# The backend records per-control auto-policy provenance on state.resolved; the response model must DECLARE the field or Pydantic extra="ignore" drops it, leaving that plumbing dead.
|
|
from models.inference import DiffusionStatusResponse
|
|
|
|
rec = {"transformer_quant": {"value": "fp8", "source": "auto", "reason": "blackwell"}}
|
|
resp = DiffusionStatusResponse(loaded = True, resolved = rec)
|
|
# The typed field coerces the plain record into DiffusionResolvedControl objects; the serialized form must round-trip back, proving the field is DECLARED.
|
|
assert resp.model_dump()["resolved"] == rec
|
|
# Absent by default (nothing resolved / native engine).
|
|
assert DiffusionStatusResponse(loaded = False).resolved is None
|
|
|
|
|
|
def test_companion_cache_bytes_local_dir_excludes_transformer(tmp_path):
|
|
# A LOCAL diffusers base: sum the on-disk VAE / text-encoder weights so auto memory planning sees the resident companions, but exclude transformer/ (the GGUF supplies it) and non-weight files.
|
|
(tmp_path / "vae").mkdir()
|
|
(tmp_path / "vae" / "diffusion_pytorch_model.safetensors").write_bytes(b"x" * 100)
|
|
(tmp_path / "text_encoder").mkdir()
|
|
(tmp_path / "text_encoder" / "model.safetensors").write_bytes(b"y" * 50)
|
|
(tmp_path / "transformer").mkdir()
|
|
(tmp_path / "transformer" / "diffusion_pytorch_model.safetensors").write_bytes(b"z" * 9999)
|
|
(tmp_path / "model_index.json").write_bytes(b"{}") # non-weight file, ignored
|
|
total = DiffusionBackend._companion_cache_bytes(str(tmp_path))
|
|
assert total == 150 # vae + text_encoder only; transformer/ and json excluded
|
|
|
|
|
|
def test_plan_memory_dense_replan_does_not_double_count_prefetched_transformer(monkeypatch):
|
|
# Re-planning the dense transformer-quant candidate: the dense path prefetches the base repo transformer/ shards into the SAME blob cache _companion_cache_bytes sums, so reading that cache would count the transformer TWICE and force offload even when the quantised artifact fits.
|
|
# Here the cache is stubbed to the inflated value; the plan must still stay resident.
|
|
from core.inference import diffusion as dmod
|
|
from core.inference.diffusion_memory import OFFLOAD_NONE, DeviceMemory
|
|
|
|
backend = DiffusionBackend()
|
|
target = types.SimpleNamespace(device = "cuda", backend = "cuda", supports_model_cpu_offload = True)
|
|
# 40 GiB discrete card: comfortably fits transformer + real companions + headroom, but NOT a second copy of the bf16 transformer.
|
|
monkeypatch.setattr(
|
|
dmod,
|
|
"settled_snapshot_device_memory",
|
|
lambda t: DeviceMemory("cuda", "cuda", "discrete_vram", 40000, 40960),
|
|
)
|
|
monkeypatch.setattr(dmod, "estimate_image_runtime_mib", lambda **kw: 4000)
|
|
# The cache is inflated by the prefetched bf16 transformer on top of the real companions; if the re-plan consulted it the plan would offload.
|
|
monkeypatch.setattr(
|
|
DiffusionBackend,
|
|
"_companion_cache_bytes",
|
|
staticmethod(lambda base: (8000 + 24000) * 1024 * 1024),
|
|
)
|
|
fam = types.SimpleNamespace(name = "z-image")
|
|
plan = backend._plan_memory(
|
|
target,
|
|
None,
|
|
"org/base",
|
|
fam,
|
|
None,
|
|
False,
|
|
kind = "gguf",
|
|
transformer_resident_override_mib = 12000, # int8 candidate transient (~half bf16)
|
|
companion_override_mib = 8000, # auto-policy text-encoder + VAE estimate
|
|
)
|
|
# 12000 + 8000 + 4000 + 2048 overhead = 26048 MiB, fits the ~36 GiB budget. A double-count would have exceeded it and offloaded.
|
|
assert plan.offload_policy == OFFLOAD_NONE
|
|
|
|
|
|
def test_reset_step_cache_helper_is_best_effort():
|
|
# Prefers the real diffusers CacheMixin hook (_reset_stateful_cache): reset_stateful_hooks lives only on the HookRegistry, so the old lookup was a silent no-op that left stale FBCache residuals for the next generation.
|
|
calls = []
|
|
pipe = types.SimpleNamespace(
|
|
transformer = types.SimpleNamespace(_reset_stateful_cache = lambda: calls.append("real"))
|
|
)
|
|
DiffusionBackend._reset_step_cache(pipe)
|
|
assert calls == ["real"]
|
|
# _reset_stateful_cache wins when both are present.
|
|
calls.clear()
|
|
pipe = types.SimpleNamespace(
|
|
transformer = types.SimpleNamespace(
|
|
_reset_stateful_cache = lambda: calls.append("real"),
|
|
reset_stateful_hooks = lambda: calls.append("fallback"),
|
|
)
|
|
)
|
|
DiffusionBackend._reset_step_cache(pipe)
|
|
assert calls == ["real"]
|
|
# Falls back to reset_stateful_hooks for a transformer that exposes only that.
|
|
calls.clear()
|
|
pipe = types.SimpleNamespace(
|
|
transformer = types.SimpleNamespace(reset_stateful_hooks = lambda: calls.append("fallback"))
|
|
)
|
|
DiffusionBackend._reset_step_cache(pipe)
|
|
assert calls == ["fallback"]
|
|
# No transformer, or one without either hook, is a silent no-op (never raises).
|
|
DiffusionBackend._reset_step_cache(types.SimpleNamespace())
|
|
DiffusionBackend._reset_step_cache(types.SimpleNamespace(transformer = object()))
|
|
|
|
|
|
def test_generate_resets_step_cache_only_when_engaged(fake_runtime, tmp_path):
|
|
# FBCache residuals live on the resident transformer across generations, so each generate() must reset the stateful cache first -- but only when a cache is engaged.
|
|
(tmp_path / "model.gguf").write_bytes(b"weights")
|
|
backend = DiffusionBackend()
|
|
backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "model.gguf",
|
|
base_repo = "base/repo",
|
|
family_override = "z-image",
|
|
)
|
|
resets = []
|
|
# Use the real diffusers CacheMixin entry point; a genuine Flux/QwenImage transformer exposes this, not reset_stateful_hooks.
|
|
backend._state.pipe.transformer = types.SimpleNamespace(
|
|
_reset_stateful_cache = lambda: resets.append(True)
|
|
)
|
|
# No cache engaged (transformer_cache is None) -> reset must NOT run.
|
|
backend.generate(prompt = "a sloth")
|
|
assert resets == []
|
|
# Engage a cache; every subsequent generation resets the stateful cache first.
|
|
object.__setattr__(backend._state, "transformer_cache", "fbcache")
|
|
backend.generate(prompt = "a sloth")
|
|
backend.generate(prompt = "another sloth")
|
|
assert resets == [True, True]
|
|
|
|
|
|
def test_prefetch_returns_snapshot_dir_for_manifest(monkeypatch):
|
|
# The prefetched pipeline manifest directory is the local snapshot root; a config-only base list returns None so the hub id stays in use.
|
|
backend = DiffusionBackend()
|
|
monkeypatch.setattr(
|
|
"utils.hf_xet_fallback.hf_hub_download_with_xet_fallback",
|
|
lambda repo, fn, tok, **k: f"/cache/snap/{fn}",
|
|
)
|
|
root = backend._prefetch_files(
|
|
"base/repo", None, "base/repo", ["model_index.json", "vae/x.safetensors"], None
|
|
)
|
|
assert root == "/cache/snap"
|
|
assert (
|
|
backend._prefetch_files("base/repo", None, "base/repo", ["vae/x.safetensors"], None) is None
|
|
)
|
|
|
|
|
|
def test_pipeline_load_uses_predownloaded_dir(fake_runtime, tmp_path):
|
|
# With a prefetched snapshot, from_pretrained must receive the local dir -- its own hub sweep would re-download the root packaged singles the scoped prefetch skips (24 GB per FLUX.1).
|
|
backend = DiffusionBackend()
|
|
backend.load_pipeline(
|
|
"unsloth/Qwen-Image-2512-bnb-4bit",
|
|
model_kind = "pipeline",
|
|
_base_local_dir = str(tmp_path),
|
|
)
|
|
assert _FakePipeline.last["base"] == str(tmp_path)
|
|
backend.unload()
|
|
|
|
|
|
def test_unload_waits_for_in_flight_denoise_before_teardown():
|
|
# Regression: unload() must wait for a running denoise to exit (acquire _generate_lock) before _unload_locked() tears down process-wide state the denoise still depends on.
|
|
import threading
|
|
|
|
backend = DiffusionBackend()
|
|
|
|
denoise_active = {"v": False}
|
|
teardown_saw = [] # records denoise_active at the moment _unload_locked runs
|
|
|
|
cancel = threading.Event()
|
|
backend._active_generate_cancel = cancel
|
|
started = threading.Event()
|
|
finish = threading.Event()
|
|
|
|
# _generate_lock is the only lock a real denoise holds for its whole body.
|
|
def _denoise():
|
|
with backend._generate_lock:
|
|
denoise_active["v"] = True
|
|
started.set()
|
|
cancel.wait(2.0) # unload signals this
|
|
finish.wait(2.0) # the test lets us finish
|
|
denoise_active["v"] = False # about to release _generate_lock
|
|
|
|
def _fake_unload_locked():
|
|
teardown_saw.append(denoise_active["v"])
|
|
|
|
backend._unload_locked = _fake_unload_locked # instance attr shadows the method
|
|
|
|
d = threading.Thread(target = _denoise)
|
|
d.start()
|
|
assert started.wait(2.0) # denoise holds _generate_lock
|
|
|
|
unloaded = threading.Event()
|
|
|
|
def _unload():
|
|
backend.unload()
|
|
unloaded.set()
|
|
|
|
u = threading.Thread(target = _unload)
|
|
u.start()
|
|
assert cancel.wait(2.0) # unload has signalled the denoise and is now waiting on _generate_lock
|
|
# unload must NOT have torn down yet: it is blocked on the denoise's _generate_lock.
|
|
assert teardown_saw == []
|
|
assert not unloaded.wait(0.3)
|
|
|
|
finish.set() # let the denoise release _generate_lock
|
|
d.join(2.0)
|
|
u.join(2.0)
|
|
assert unloaded.is_set()
|
|
# Teardown ran exactly once, and only AFTER the denoise had exited.
|
|
assert teardown_saw == [False]
|
|
|
|
|
|
# Batched generation (prompt/seed lists, per-image generators, OOM backoff)
|
|
|
|
|
|
def _load_zimage_backend(tmp_path):
|
|
(tmp_path / "model.gguf").write_bytes(b"weights")
|
|
backend = DiffusionBackend()
|
|
backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "model.gguf",
|
|
base_repo = "base/repo",
|
|
family_override = "z-image",
|
|
)
|
|
return backend
|
|
|
|
|
|
def test_generate_seed_list_uses_one_generator_per_image(fake_runtime, tmp_path):
|
|
backend = _load_zimage_backend(tmp_path)
|
|
out = backend.generate(prompt = "a sloth", seeds = [11, 22, 33, 44])
|
|
assert len(out["images"]) == 4
|
|
assert out["seeds"] == [11, 22, 33, 44]
|
|
assert out["seed"] == 11 # base seed = first per-image seed
|
|
call = backend._state.pipe.last_kwargs
|
|
# A uniform prompt is encoded ONCE and fanned out; each image gets its own generator.
|
|
assert call["prompt"] == "a sloth"
|
|
assert call["num_images_per_prompt"] == 4
|
|
assert [g.manual for g in call["generator"]] == [11, 22, 33, 44]
|
|
|
|
|
|
def test_generate_prompt_list_one_image_per_prompt(fake_runtime, tmp_path):
|
|
backend = _load_zimage_backend(tmp_path)
|
|
out = backend.generate(prompt = "fallback", prompts = ["a", "b", "c"], seed = 100)
|
|
assert len(out["images"]) == 3
|
|
assert out["seeds"] == [100, 101, 102] # derived from the base seed
|
|
call = backend._state.pipe.last_kwargs
|
|
assert call["prompt"] == ["a", "b", "c"]
|
|
assert call["num_images_per_prompt"] == 1
|
|
assert [g.manual for g in call["generator"]] == [100, 101, 102]
|
|
|
|
|
|
def test_generate_prompt_list_with_matching_seed_list(fake_runtime, tmp_path):
|
|
backend = _load_zimage_backend(tmp_path)
|
|
out = backend.generate(prompt = "fallback", prompts = ["a", "b"], seeds = [5, 6])
|
|
assert out["seeds"] == [5, 6]
|
|
with pytest.raises(ValueError, match = "same length"):
|
|
backend.generate(prompt = "fallback", prompts = ["a", "b"], seeds = [5])
|
|
|
|
|
|
def test_generate_single_image_keeps_scalar_generator(fake_runtime, tmp_path):
|
|
# The single-image call shape is the bit-identical reference path: scalar prompt, ONE scalar generator (not a 1-list), num_images_per_prompt=1.
|
|
backend = _load_zimage_backend(tmp_path)
|
|
out = backend.generate(prompt = "one", seed = 5)
|
|
call = backend._state.pipe.last_kwargs
|
|
assert not isinstance(call["generator"], list)
|
|
assert call["generator"].manual == 5
|
|
assert call["num_images_per_prompt"] == 1
|
|
assert out["seeds"] == [5]
|
|
|
|
|
|
def test_generate_batched_seed_matches_solo_replay(fake_runtime, tmp_path):
|
|
# Per-image reproducibility: image i of a batched call is driven by the exact generator seed a solo replay of that image uses.
|
|
backend = _load_zimage_backend(tmp_path)
|
|
backend.generate(prompt = "p", seeds = [3, 9])
|
|
batched = [g.manual for g in backend._state.pipe.last_kwargs["generator"]]
|
|
backend.generate(prompt = "p", seed = 9)
|
|
solo = backend._state.pipe.last_kwargs["generator"].manual
|
|
assert batched[1] == solo == 9
|
|
|
|
|
|
def test_generate_prompt_list_rejected_off_txt2img(fake_runtime, tmp_path):
|
|
backend = _load_zimage_backend(tmp_path)
|
|
with pytest.raises(ValueError, match = "text-to-image only"):
|
|
backend.generate(prompt = "x", prompts = ["a", "b"], init_image = _tiny_png_b64())
|
|
|
|
|
|
class _CountingPipe(_FakePipe):
|
|
"""Records each forward's image count; optionally OOMs above ``max_images``."""
|
|
|
|
def __init__(self, max_images = None):
|
|
super().__init__()
|
|
self.batch_attempts = []
|
|
self.max_images = max_images
|
|
|
|
def __call__(
|
|
self,
|
|
*,
|
|
prompt = None,
|
|
**kwargs,
|
|
):
|
|
n = kwargs.get("num_images_per_prompt", 1)
|
|
if isinstance(prompt, list):
|
|
n *= len(prompt)
|
|
self.batch_attempts.append(n)
|
|
if self.max_images is not None and n > self.max_images:
|
|
raise _FakeOutOfMemoryError("CUDA out of memory. Tried to allocate everything")
|
|
return super().__call__(prompt = prompt, **kwargs)
|
|
|
|
|
|
# Structural stand-in for torch.cuda.OutOfMemoryError (matched by class name).
|
|
_FakeOutOfMemoryError = type("OutOfMemoryError", (RuntimeError,), {})
|
|
|
|
|
|
def test_generate_explicit_batch_size_caps_per_forward(fake_runtime, tmp_path):
|
|
backend = _load_zimage_backend(tmp_path)
|
|
pipe = _CountingPipe()
|
|
object.__setattr__(backend._state, "pipe", pipe)
|
|
out = backend.generate(prompt = "p", seeds = [1, 2, 3, 4], batch_size = 2)
|
|
assert pipe.batch_attempts == [2, 2]
|
|
assert len(out["images"]) == 4
|
|
assert out["seeds"] == [1, 2, 3, 4]
|
|
|
|
|
|
def test_generate_oom_backoff_halves_the_batch(fake_runtime, tmp_path):
|
|
backend = _load_zimage_backend(tmp_path)
|
|
pipe = _CountingPipe(max_images = 2)
|
|
object.__setattr__(backend._state, "pipe", pipe)
|
|
out = backend.generate(prompt = "p", seeds = [1, 2, 3, 4])
|
|
# The full batch OOMs once, then both halves run; images + seeds stay complete.
|
|
assert pipe.batch_attempts == [4, 2, 2]
|
|
assert len(out["images"]) == 4
|
|
assert out["seeds"] == [1, 2, 3, 4]
|
|
|
|
|
|
class _BoomPipe(_CountingPipe):
|
|
"""Fails every forward with a NON-OOM error (must not trigger backoff)."""
|
|
|
|
def __call__(
|
|
self,
|
|
*,
|
|
prompt = None,
|
|
**kwargs,
|
|
):
|
|
self.batch_attempts.append(kwargs.get("num_images_per_prompt", 1))
|
|
raise RuntimeError("shape mismatch")
|
|
|
|
|
|
def test_generate_non_oom_error_is_not_retried(fake_runtime, tmp_path):
|
|
backend = _load_zimage_backend(tmp_path)
|
|
pipe = _BoomPipe()
|
|
object.__setattr__(backend._state, "pipe", pipe)
|
|
with pytest.raises(RuntimeError, match = "shape mismatch"):
|
|
backend.generate(prompt = "p", seeds = [1, 2, 3, 4])
|
|
assert pipe.batch_attempts == [4] # no backoff retries on a non-OOM error
|
|
|
|
|
|
def test_generate_broadcasts_negative_prompt_across_a_mixed_prompt_batch(fake_runtime, tmp_path):
|
|
# A prompt LIST must carry a matching negative-prompt LIST: ZImagePipeline.encode_prompt asserts equal lengths, and the pipes that encode the negative separately would build batch-1 negative embeds against batch-N latents and fail in the transformer txt/img concat.
|
|
backend = _load_zimage_backend(tmp_path)
|
|
backend.generate(prompt = "fallback", prompts = ["a", "b", "c"], negative_prompt = "blurry")
|
|
call = backend._state.pipe.last_kwargs
|
|
assert call["prompt"] == ["a", "b", "c"]
|
|
assert call["negative_prompt"] == ["blurry", "blurry", "blurry"]
|
|
# An empty negative prompt is still omitted entirely (never sent as [""] * n).
|
|
backend.generate(prompt = "fallback", prompts = ["a", "b"])
|
|
assert backend._state.pipe.last_kwargs["negative_prompt"] is None
|
|
|
|
|
|
def test_generate_keeps_a_scalar_negative_prompt_off_the_list_paths(fake_runtime, tmp_path):
|
|
# Uniform-prompt and single-image forwards pass a SCALAR prompt, so the negative prompt must stay scalar too (a list would mismatch the batch-1 positive encode).
|
|
backend = _load_zimage_backend(tmp_path)
|
|
backend.generate(prompt = "a sloth", seeds = [1, 2, 3], negative_prompt = "blurry")
|
|
assert backend._state.pipe.last_kwargs["prompt"] == "a sloth"
|
|
assert backend._state.pipe.last_kwargs["negative_prompt"] == "blurry"
|
|
backend.generate(prompt = "a sloth", seed = 1, negative_prompt = "blurry")
|
|
assert backend._state.pipe.last_kwargs["negative_prompt"] == "blurry"
|
|
|
|
|
|
class _TracingPipe(_CountingPipe):
|
|
"""Appends ``("call", n)`` to a shared trace so resets can be interleaved with forwards."""
|
|
|
|
def __init__(
|
|
self,
|
|
trace,
|
|
max_images = None,
|
|
):
|
|
super().__init__(max_images = max_images)
|
|
self.trace = trace
|
|
|
|
def __call__(
|
|
self,
|
|
*,
|
|
prompt = None,
|
|
**kwargs,
|
|
):
|
|
n = kwargs.get("num_images_per_prompt", 1)
|
|
if isinstance(prompt, list):
|
|
n *= len(prompt)
|
|
self.trace.append(("call", n))
|
|
return super().__call__(prompt = prompt, **kwargs)
|
|
|
|
|
|
def test_generate_resets_the_step_cache_before_an_oom_retry(fake_runtime, tmp_path):
|
|
# A forward that RAISES skips the pipeline end-of-call maybe_free_model_hooks(), so its FBCache residual stays on the resident transformer.
|
|
# Without a reset before each retry the halved chunk compares a batch-2 residual against the stale batch-4 one, turning a recoverable OOM into a hard failure.
|
|
backend = _load_zimage_backend(tmp_path)
|
|
trace: list = []
|
|
pipe = _TracingPipe(trace, max_images = 2)
|
|
pipe.transformer = types.SimpleNamespace(_reset_stateful_cache = lambda: trace.append(("reset",)))
|
|
object.__setattr__(backend._state, "pipe", pipe)
|
|
object.__setattr__(backend._state, "transformer_cache", "fbcache")
|
|
out = backend.generate(prompt = "p", seeds = [1, 2, 3, 4])
|
|
assert len(out["images"]) == 4 and out["seeds"] == [1, 2, 3, 4]
|
|
# Every forward, including both post-OOM retries, is preceded by a reset.
|
|
assert trace == [
|
|
("reset",),
|
|
("call", 4),
|
|
("reset",),
|
|
("call", 2),
|
|
("reset",),
|
|
("call", 2),
|
|
]
|
|
|
|
|
|
def test_generate_resets_the_step_cache_before_every_chunk(fake_runtime, tmp_path):
|
|
# Same guarantee for an explicit per-forward cap (no OOM involved).
|
|
backend = _load_zimage_backend(tmp_path)
|
|
trace: list = []
|
|
pipe = _TracingPipe(trace)
|
|
pipe.transformer = types.SimpleNamespace(_reset_stateful_cache = lambda: trace.append(("reset",)))
|
|
object.__setattr__(backend._state, "pipe", pipe)
|
|
object.__setattr__(backend._state, "transformer_cache", "fbcache")
|
|
backend.generate(prompt = "p", seeds = [1, 2, 3], batch_size = 2)
|
|
assert trace == [("reset",), ("call", 2), ("reset",), ("call", 1)]
|
|
|
|
|
|
class _FakeSibling:
|
|
def __init__(self, rfilename, size):
|
|
self.rfilename = rfilename
|
|
self.size = size
|
|
|
|
|
|
class _FakeInfo:
|
|
def __init__(self, siblings):
|
|
self.siblings = siblings
|
|
|
|
|
|
GB = 1024**3
|
|
# A FLUX-shaped base repo: the packaged root single and the transformer shards are what a plain snapshot_download would drag in and the loader never opens.
|
|
_FLUX_BASE_SIBLINGS = [
|
|
_FakeSibling("model_index.json", 1000),
|
|
_FakeSibling("flux1-dev.safetensors", 24 * GB),
|
|
_FakeSibling("transformer/diffusion_pytorch_model-00001-of-00003.safetensors", 8 * GB),
|
|
_FakeSibling("text_encoder/model.safetensors", 2 * GB),
|
|
_FakeSibling("text_encoder/model.fp16.safetensors", 1 * GB),
|
|
_FakeSibling("vae/diffusion_pytorch_model.safetensors", 300),
|
|
_FakeSibling("assets/gallery.pdf", 5000),
|
|
_FakeSibling("README.md", 200),
|
|
]
|
|
|
|
|
|
def _fake_hf_api(monkeypatch, repos):
|
|
"""Point HfApi.model_info at a canned sibling list per repo id."""
|
|
|
|
class _Api:
|
|
def model_info(
|
|
self,
|
|
repo_id,
|
|
files_metadata = False,
|
|
token = None,
|
|
):
|
|
return _FakeInfo(repos[repo_id])
|
|
|
|
monkeypatch.setattr("huggingface_hub.HfApi", lambda *a, **k: _Api())
|
|
|
|
|
|
def test_download_plan_scopes_the_base_repo_files(monkeypatch):
|
|
# The plan drives the Hub download manager, so its file list must match what the loader actually reads. A full snapshot would add the 24 GB root single and the shards the GGUF replaces.
|
|
_fake_hf_api(
|
|
monkeypatch,
|
|
{
|
|
"unsloth/FLUX.1-dev-GGUF": [_FakeSibling("flux1-dev-Q4_K_M.gguf", 7 * GB)],
|
|
"black-forest-labs/FLUX.1-dev": _FLUX_BASE_SIBLINGS,
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
"core.inference.diffusion._resolve_base_repo",
|
|
lambda *a, **k: "black-forest-labs/FLUX.1-dev",
|
|
)
|
|
monkeypatch.setattr(
|
|
DiffusionBackend, "_dense_quant_prefetch_needed", lambda self, fam, kwargs: False
|
|
)
|
|
|
|
plan = DiffusionBackend().download_plan(
|
|
"unsloth/FLUX.1-dev-GGUF", gguf_filename = "flux1-dev-Q4_K_M.gguf"
|
|
)
|
|
|
|
assert [e["repo_id"] for e in plan["entries"]] == [
|
|
"unsloth/FLUX.1-dev-GGUF",
|
|
"black-forest-labs/FLUX.1-dev",
|
|
]
|
|
checkpoint, base = plan["entries"]
|
|
assert checkpoint["files"] == ["flux1-dev-Q4_K_M.gguf"]
|
|
assert checkpoint["bytes"] == 7 * GB
|
|
assert "flux1-dev.safetensors" not in base["files"]
|
|
assert not any(f.startswith("transformer/") for f in base["files"])
|
|
assert not any(f.startswith("assets/") for f in base["files"])
|
|
assert "model_index.json" in base["files"]
|
|
assert "text_encoder/model.safetensors" in base["files"]
|
|
# Sized per repo, so each download job gets its own expected bytes.
|
|
assert base["bytes"] < 24 * GB
|
|
assert plan["total_bytes"] == checkpoint["bytes"] + base["bytes"]
|
|
|
|
|
|
def test_download_plan_pipeline_kind_is_one_entry(monkeypatch):
|
|
# A pipeline load has no separate checkpoint repo: the repo IS the pipeline.
|
|
_fake_hf_api(monkeypatch, {"unsloth/some-pipeline": _FLUX_BASE_SIBLINGS})
|
|
|
|
plan = DiffusionBackend().download_plan("unsloth/some-pipeline", model_kind = "pipeline")
|
|
|
|
assert len(plan["entries"]) == 1
|
|
files = plan["entries"][0]["files"]
|
|
# The pipeline keeps its own transformer, but still drops fp16 twins and the root single.
|
|
assert any(f.startswith("transformer/") for f in files)
|
|
assert "flux1-dev.safetensors" not in files
|
|
assert "text_encoder/model.fp16.safetensors" not in files
|
|
|
|
|
|
def test_download_plan_is_empty_for_a_local_path(tmp_path, monkeypatch):
|
|
# Nothing to stage: the files are already on disk.
|
|
local = tmp_path / "my-model"
|
|
(local / "transformer").mkdir(parents = True)
|
|
(local / "model_index.json").write_text("{}", encoding = "utf-8")
|
|
monkeypatch.setattr("core.inference.diffusion._resolve_base_repo", lambda *a, **k: str(local))
|
|
monkeypatch.setattr(
|
|
DiffusionBackend, "_estimate_download_bytes", staticmethod(lambda *a, **k: (0, []))
|
|
)
|
|
|
|
plan = DiffusionBackend().download_plan(str(local), gguf_filename = "weights.gguf")
|
|
assert plan["entries"] == []
|
|
|
|
|
|
def test_download_plan_stages_the_precast_encoder_instead_of_the_dense_one(monkeypatch):
|
|
# An fp8 text-encoder request loads a hosted PRE-CAST checkpoint, so the plan must stage that file and NOT the base repo dense encoder shards.
|
|
# Without this the manager downloaded tens of GB the load never opens, and the load then pulled the pre-cast file inline, outside the manager progress and disk preflight.
|
|
_fake_hf_api(
|
|
monkeypatch,
|
|
{
|
|
"unsloth/FLUX.1-dev-GGUF": [_FakeSibling("flux1-dev-Q4_K_M.gguf", 7 * GB)],
|
|
"black-forest-labs/FLUX.1-dev": _FLUX_BASE_SIBLINGS,
|
|
"unsloth/FLUX.1-schnell-FP8": [
|
|
_FakeSibling("text_encoder_2-fp8.pt", 1 * GB),
|
|
_FakeSibling("README.md", 100),
|
|
],
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
"core.inference.diffusion._resolve_base_repo",
|
|
lambda *a, **k: "black-forest-labs/FLUX.1-dev",
|
|
)
|
|
monkeypatch.setattr(
|
|
DiffusionBackend, "_dense_quant_prefetch_needed", lambda self, fam, kwargs: False
|
|
)
|
|
# The pick resolves one hosted pre-cast encoder for text_encoder_2 (flux.1 hosts its T5-XXL).
|
|
monkeypatch.setattr(
|
|
"core.inference.diffusion_te_prequant.te_prequant_sources",
|
|
lambda fam, *, te_quant_mode, target: (
|
|
{
|
|
"text_encoder_2": types.SimpleNamespace(
|
|
kind = "repo",
|
|
location = "unsloth/FLUX.1-schnell-FP8",
|
|
filename = "text_encoder_2-fp8.pt",
|
|
)
|
|
}
|
|
if te_quant_mode == "fp8"
|
|
else {}
|
|
),
|
|
)
|
|
|
|
plan = DiffusionBackend().download_plan(
|
|
"unsloth/FLUX.1-dev-GGUF",
|
|
gguf_filename = "flux1-dev-Q4_K_M.gguf",
|
|
text_encoder_quant = "fp8",
|
|
)
|
|
by_repo = {e["repo_id"]: e for e in plan["entries"]}
|
|
assert "unsloth/FLUX.1-schnell-FP8" in by_repo
|
|
assert by_repo["unsloth/FLUX.1-schnell-FP8"]["files"] == ["text_encoder_2-fp8.pt"]
|
|
base = by_repo["black-forest-labs/FLUX.1-dev"]
|
|
# text_encoder_2 dense weights are gone; text_encoder (no hosted artifact here) stays, and so do the non-weight files the pre-cast loader still meta-inits from.
|
|
assert not any(
|
|
f.startswith("text_encoder_2/") and f.endswith(".safetensors") for f in base["files"]
|
|
)
|
|
assert "text_encoder/model.safetensors" in base["files"]
|
|
assert "model_index.json" in base["files"]
|
|
assert plan["total_bytes"] == sum(e["bytes"] for e in plan["entries"])
|
|
|
|
|
|
def test_download_plan_keeps_the_dense_encoder_without_an_fp8_request(monkeypatch):
|
|
# No fp8 request -> no hosted checkpoint -> the dense encoder is exactly as before.
|
|
_fake_hf_api(
|
|
monkeypatch,
|
|
{
|
|
"unsloth/FLUX.1-dev-GGUF": [_FakeSibling("flux1-dev-Q4_K_M.gguf", 7 * GB)],
|
|
"black-forest-labs/FLUX.1-dev": _FLUX_BASE_SIBLINGS,
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
"core.inference.diffusion._resolve_base_repo",
|
|
lambda *a, **k: "black-forest-labs/FLUX.1-dev",
|
|
)
|
|
monkeypatch.setattr(
|
|
DiffusionBackend, "_dense_quant_prefetch_needed", lambda self, fam, kwargs: False
|
|
)
|
|
plan = DiffusionBackend().download_plan(
|
|
"unsloth/FLUX.1-dev-GGUF", gguf_filename = "flux1-dev-Q4_K_M.gguf"
|
|
)
|
|
base = next(e for e in plan["entries"] if e["repo_id"] == "black-forest-labs/FLUX.1-dev")
|
|
assert "text_encoder/model.safetensors" in base["files"]
|
|
assert len(plan["entries"]) == 2
|
|
|
|
|
|
def test_download_plan_keeps_the_dense_encoder_when_the_precast_repo_is_unavailable(monkeypatch):
|
|
# A gated / renamed / unpublished artifact must NOT cost the dense encoder: the load falls back to the dense weights, so the plan has to stage them.
|
|
_fake_hf_api(
|
|
monkeypatch,
|
|
{
|
|
"unsloth/FLUX.1-dev-GGUF": [_FakeSibling("flux1-dev-Q4_K_M.gguf", 7 * GB)],
|
|
"black-forest-labs/FLUX.1-dev": _FLUX_BASE_SIBLINGS,
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
"core.inference.diffusion._resolve_base_repo",
|
|
lambda *a, **k: "black-forest-labs/FLUX.1-dev",
|
|
)
|
|
monkeypatch.setattr(
|
|
DiffusionBackend, "_dense_quant_prefetch_needed", lambda self, fam, kwargs: False
|
|
)
|
|
monkeypatch.setattr(
|
|
"core.inference.diffusion_te_prequant.te_prequant_sources",
|
|
lambda fam, *, te_quant_mode, target: {
|
|
"text_encoder": types.SimpleNamespace(
|
|
kind = "repo", location = "unsloth/does-not-exist", filename = "te-fp8.pt"
|
|
)
|
|
},
|
|
)
|
|
plan = DiffusionBackend().download_plan(
|
|
"unsloth/FLUX.1-dev-GGUF",
|
|
gguf_filename = "flux1-dev-Q4_K_M.gguf",
|
|
text_encoder_quant = "fp8",
|
|
)
|
|
base = next(e for e in plan["entries"] if e["repo_id"] == "black-forest-labs/FLUX.1-dev")
|
|
assert "text_encoder/model.safetensors" in base["files"]
|
|
assert not any(e["repo_id"] == "unsloth/does-not-exist" for e in plan["entries"])
|
|
|
|
|
|
# ── teardown fence ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_unload_fences_queued_generations_while_it_waits(fake_runtime, tmp_path):
|
|
# A generation queued behind the active one holds no cancel event, so unload's signal cannot
|
|
# reach it. Python locks are not FIFO, so when the active denoise released _generate_lock the
|
|
# queued request could get in ahead of the unload, see the still-loaded pipeline, and run a
|
|
# whole new denoise after the model was told to go away.
|
|
(tmp_path / "model.gguf").write_bytes(b"weights")
|
|
backend = DiffusionBackend()
|
|
backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "model.gguf",
|
|
base_repo = "base/repo",
|
|
family_override = "z-image",
|
|
)
|
|
|
|
seen: list[int] = []
|
|
real_unload_locked = backend._unload_locked
|
|
|
|
def _record_then_unload():
|
|
# Sampled at the moment unload holds both locks, i.e. exactly the window a queued
|
|
# generation could have slipped through.
|
|
seen.append(backend._teardown_waiters)
|
|
real_unload_locked()
|
|
|
|
backend._unload_locked = _record_then_unload
|
|
backend.unload()
|
|
|
|
assert seen == [1] # the fence was up for the whole wait
|
|
assert backend._teardown_waiters == 0 # and released once the pipeline was gone
|
|
|
|
|
|
def test_generation_refuses_while_a_teardown_is_waiting(fake_runtime, tmp_path):
|
|
# The fence's effect: with a teardown waiting on _generate_lock, a generation that wins the
|
|
# lock refuses instead of denoising on a pipeline that is being freed.
|
|
(tmp_path / "model.gguf").write_bytes(b"weights")
|
|
backend = DiffusionBackend()
|
|
backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "model.gguf",
|
|
base_repo = "base/repo",
|
|
family_override = "z-image",
|
|
)
|
|
assert backend.generate(prompt = "before", steps = 2)["images"]
|
|
|
|
backend._teardown_waiters = 1
|
|
with pytest.raises(RuntimeError, match = "cancelled"):
|
|
backend.generate(prompt = "during", steps = 2)
|
|
# Still loaded: the refusal is about the pending teardown, not a missing model.
|
|
assert backend._state is not None
|
|
|
|
backend._teardown_waiters = 0
|
|
assert backend.generate(prompt = "after", steps = 2)["images"]
|
|
|
|
|
|
def test_a_superseding_load_fences_queued_generations_too(fake_runtime, tmp_path):
|
|
# begin_load frees the old pipeline behind the same barrier, so it needs the same fence: a
|
|
# queued generation would otherwise run on the pipe the new load is about to drop.
|
|
(tmp_path / "model.gguf").write_bytes(b"weights")
|
|
backend = DiffusionBackend()
|
|
backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "model.gguf",
|
|
base_repo = "base/repo",
|
|
family_override = "z-image",
|
|
)
|
|
|
|
seen: list[int] = []
|
|
real_unload_locked = backend._unload_locked
|
|
|
|
def _record_then_unload():
|
|
seen.append(backend._teardown_waiters)
|
|
real_unload_locked()
|
|
|
|
backend._unload_locked = _record_then_unload
|
|
backend.load_pipeline(
|
|
str(tmp_path),
|
|
gguf_filename = "model.gguf",
|
|
base_repo = "base/repo",
|
|
family_override = "z-image",
|
|
)
|
|
|
|
assert seen == [1]
|
|
assert backend._teardown_waiters == 0
|