unsloth/studio/backend/tests/test_diffusion_memory.py
Daniel Han 6b9b1c72d3
Studio diffusion (Phase 7): accuracy-preserving speed pass (2.2x via GGUF compile) (#6690)
* Studio diffusion: cross-platform device policy, fp16 guard, lock split, validate-before-evict

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

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

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

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

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

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

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

73 prior + 35 new CPU tests pass.

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

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

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

112 CPU tests pass.

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

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

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

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

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

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

121 CPU tests pass.

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

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

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

127 CPU tests pass.

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

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

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

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

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

129 CPU tests pass.

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

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

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

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

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

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

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

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

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

* Studio diffusion (Phase 6): img2img / inpaint / edit / LoRA / upscale on the native engine

Builds on Phase 4's native stable-diffusion.cpp engine, extending it from
text-to-image to the wider feature surface, since sd.cpp supports all of these
through the binary already. Pure command-builder additions plus one engine
method, so the txt2img path is unchanged.

- sd_cpp_args.py: SdCppGenParams gains image-conditioning fields. init_img +
  strength make a run img2img, adding mask makes it inpaint, ref_images drives
  FLUX-Kontext / Qwen-Image-Edit style editing (repeated --ref-image), and
  lora_dir + the <lora:name:weight> prompt syntax select LoRAs. New
  SdCppUpscaleParams + build_sd_cpp_upscale_command for the ESRGAN upscale run
  mode (input image + esrgan model, no prompt / text encoders).
- sd_cpp_engine.py: the subprocess runner is factored into a shared _run() so
  generate() (now carrying the conditioning flags) and a new upscale() reuse
  the same streaming / error / output-check path.
- scripts/sd_cpp_smoke.py: --task {txt2img,img2img,upscale} with --init-img /
  --strength / --upscale-model / --upscale-repeats.

Tests: 10 new across the img2img / inpaint / edit / LoRA flag construction, the
upscale builder and its validation, and the engine's img2img + upscale paths.
Full diffusion suite 176 passing.

Verified on a B200 box through SdCppEngine: img2img (Z-Image-Turbo Q4_K, the
init image conditioned at strength 0.6, 4.8s) and ESRGAN upscale
(512x512 -> 2048x2048 via RealESRGAN_x4plus_anime_6B, 2.7s), both producing
coherent images. Video and the diffusers-path feature wiring are deferred.

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

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

* Studio diffusion (Phase 7): accuracy-preserving speed pass

Re-review of the diffusion stack (#6675/#6679/#6680) surfaced one real accuracy
bug and a dead-on-arrival speed path; this fixes both and adds the lossless /
near-lossless wins, all measured on a B200.

Correctness:
- TF32 global-state leak (fix). speed_mode=max flipped torch.backends.*.allow_tf32
  process-wide and never restored them, so a later `off` load silently inherited
  TF32 and was no longer bit-identical. Added snapshot_backend_flags /
  restore_backend_flags (TF32 + cudnn.benchmark), captured before the speed layer
  runs and restored on unload. Verified: load max -> unload -> load off is now
  byte-identical (PSNR inf) to a fresh off.
- sd-cli timeout could hang forever. _run() blocked in `for line in stdout` and
  only checked the timeout after EOF, so a child stuck in model load / GPU init
  with no output ignored the timeout. Drained stdout on a reader thread with a
  wall-clock deadline. Added a silent-hang regression test.

Speed (diffusers path), near-lossless, opt-in tiers:
- Regional torch.compile now runs on the GGUF transformer. The is_gguf gate (and
  Z-Image's supports_torch_compile=False) were stale: compile_repeated_blocks
  compiles and runs ~2.2x faster on the GGUF Z-Image transformer on
  torch 2.9.1 / diffusers 0.38 (the per-op dequant stays eager, the rest of the
  block compiles). Measured: off 1.80s -> default 0.82s/gen (+54.7%), PSNR 37.7 dB
  vs eager -- far above the Q4 quant noise floor (~21 dB), so it does not move
  output quality. Gate relaxed; default tier delivers it.
- cudnn.benchmark added to the default tier (autotunes the fixed-shape VAE convs).
- torch.inference_mode() around the pipeline call (lossless, strictly faster than
  the no_grad diffusers uses internally).

Memory path:
- VAE tiling (not bit-identical >1MP) restricted to the model/sequential/CPU tiers;
  the balanced (group) tier keeps exact slicing only, so it is now bit-identical to
  the resident image (verified PSNR inf) and slightly faster.
- Group offload adds non_blocking + record_stream on the CUDA stream path to
  overlap each block's H2D copy with compute (lossless; gated on the installed
  diffusers signature so older versions still work).

Native (sd.cpp) path:
- native_speed_flags: a first-class speed knob (default -> --diffusion-fa, a
  near-lossless CUDA win that was previously only added on offload tiers; max also
  -> --diffusion-conv-direct). conv-direct stays opt-in: measured +45% on CUDA, so
  it is never auto-on. Engine generate() merges it, de-duped against offload flags.

Default profile: a GGUF model with no explicit speed_mode now resolves to the
`default` profile (resolve_speed_mode), since compile's perturbation sits below the
quantisation noise floor and so does not reduce quality versus the dense reference;
out of the box a GGUF Z-Image generation drops from 1.80s to 0.81s. Dense models
stay `off` / bit-identical, and an explicit speed_mode -- including "off" -- is
always honored, so the byte-identical path remains one flag away and is the
regression reference.

Tooling: scripts/compile_probe.py (eager vs compiled GGUF probe), scripts/
perf_verify.py (the B200 verification above), and diffusion_bench.py gains
--speed-mode so the speed tiers are benchmarkable.

Tests: 183 passing (was 166); new coverage for the backend-flag snapshot/restore,
GGUF compile eligibility, the balanced tiling/slicing split, native_speed_flags +
the engine de-dup, and the sd-cli silent-hang timeout.

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

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

* Studio diffusion (Phase 7): max tier uses max-autotune-no-cudagraphs + engine/lever benchmarks

The opt-in `max` speed tier now compiles the repeated block with
mode=max-autotune-no-cudagraphs (dynamic=False) instead of the default mode:
Triton autotuning for GEMM/conv-heavier models, gated to the tier where a longer
cold compile is acceptable. CUDA-graph modes (reduce-overhead / max-autotune) are
deliberately avoided -- both crash on the regionally-compiled block (its static
output buffer is overwritten across denoise steps), measured.

Adds two reproducible benchmarks used to validate the optimization research:
- scripts/compare_engines.py: PyTorch (diffusers GGUF) vs native sd.cpp head-to-head.
- scripts/leverage_probe.py: coordinate_descent_tuning + FirstBlockCache probes.

Measured on B200 (Z-Image Q4_K_M, 1024px, 8 steps): default compile 0.80s/gen;
coordinate_descent_tuning 0.79s (within noise, already covered by max-autotune);
FirstBlockCache does not run on Z-Image (diffusers 0.38 block-detection / Dynamo).

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

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

* Studio diffusion (Phase 7): robust backend-flag snapshot/restore and restore on failed speeded load

- snapshot_backend_flags reads each flag defensively (getattr + hasattr), so a build/platform
  missing one (no cuda.matmul on CPU/MPS) still captures the rest instead of skipping the
  whole snapshot. restore_backend_flags restores each flag independently so one failure can't
  leave the others leaked process-wide.
- load_pipeline restores the flags (and clears the GPU cache) when the build fails after
  apply_speed_optims mutated the process-wide flags but before _state captured them for unload
  to restore -- otherwise a failed default/max load left cudnn.benchmark/TF32 on and
  contaminated later off generations.

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

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

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

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

* Studio diffusion (Phase 7) review fixes: offload fallback + bench scripts

- diffusion_memory: when group offload is unavailable and the plan falls back to
  whole-module offload, enable VAE tiling (the group plan left it off, but the fallback
  is the low-VRAM path where the decode spike can OOM). Covers both the group and
  sequential fallback branches.
- perf_verify: include the balanced-vs-off PSNR in the pass/fail condition, so a
  balanced bit-identity regression actually fails the check instead of exiting 0.
- compare_engines: --vae/--llm default to None (were author-absolute /mnt paths), and
  the load-progress poll has a 30 min deadline instead of looping forever on a hang.
- test for the group->model fallback enabling VAE tiling.

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

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

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

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

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

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

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

* Studio diffusion (Phase 6) review round 2: img2img source dims + upscale repeats

Codex review on the native engine arg builder:

- build_sd_cpp_command emitted --width/--height unconditionally, so an
  img2img/inpaint/edit run that left dims unset forced a 1024x1024 resize/crop of
  the input. width/height are now Optional (None = unset): an image-conditioned
  run (init_img or ref_images) with unset dims omits the flags so sd.cpp derives
  the size from the input image (set_width_and_height_if_unset); a plain txt2img
  run with unset dims keeps the prior 1024x1024 default; explicit dims are always
  honored. width/height are read only by the builder, so the type change is local.

- build_sd_cpp_upscale_command used a truthiness guard (params.repeats and ...)
  that silently swallowed repeats=0 into sd-cli's default of one pass, turning an
  explicit no-op into a real upscale. It now rejects repeats < 1 with ValueError
  and emits the flag for any explicit value != 1.

Tests: img2img unset dims omit width/height (init_img and ref_images), explicit
dims emitted, txt2img keeps 1024; upscale rejects repeats=0 and omits the flag at
the default. (Two pre-existing binary-discovery tests fail only because a real
sd-cli is installed in this dev environment; unrelated to this change.)

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

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

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com>
2026-07-01 15:31:50 -03:00

485 lines
17 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
"""Unit tests for the diffusion memory planner (``diffusion_memory.py``).
Hermetic and CPU-only: no torch, diffusers, GPU, or network. The device target
and the device-memory snapshot are constructed directly, so the planner's policy
matrix and the applier's pipeline calls are exercised in isolation.
"""
from __future__ import annotations
import types
import pytest
from core.inference.diffusion_memory import (
DEFAULT_IMAGE_HEIGHT,
DEFAULT_IMAGE_WIDTH,
MEMORY_MODE_BALANCED,
MEMORY_MODE_FAST,
MEMORY_MODE_LOW_VRAM,
OFFLOAD_GROUP,
OFFLOAD_MODEL,
OFFLOAD_NONE,
OFFLOAD_SEQUENTIAL,
DeviceMemory,
MemoryPlan,
apply_memory_plan,
estimate_gguf_dense_mib,
estimate_image_runtime_mib,
infer_gguf_quant_label,
normalize_memory_mode,
plan_diffusion_memory,
snapshot_device_memory,
)
def _target(
*,
device = "cuda",
backend = "cuda",
supports_offload = True,
):
"""A duck-typed stand-in for DiffusionDeviceTarget (only the fields the
planner / snapshot read)."""
return types.SimpleNamespace(
device = device,
backend = backend,
supports_model_cpu_offload = supports_offload,
)
def _discrete(free_mib, total_mib = None):
return DeviceMemory("cuda", "cuda", "discrete_vram", free_mib, total_mib or free_mib)
# ── mode normalisation ────────────────────────────────────────────────────────
def test_normalize_memory_mode_accepts_and_rejects():
assert normalize_memory_mode(None) is None
assert normalize_memory_mode(" ") is None
assert normalize_memory_mode("LOW-VRAM") == "low_vram"
assert normalize_memory_mode("Balanced") == "balanced"
with pytest.raises(ValueError):
normalize_memory_mode("ultra")
# ── filename / size estimates ─────────────────────────────────────────────────
@pytest.mark.parametrize(
"filename,expected",
[
("z-image-turbo-Q4_K_M.gguf", "Q4_K_M"),
("flux1-dev-Q8_0.gguf", "Q8_0"),
("model-BF16.gguf", "BF16"),
("qwen-image-IQ4_XS.gguf", "IQ4_XS"),
("no-quant-here.gguf", None),
(None, None),
],
)
def test_infer_gguf_quant_label(filename, expected):
assert infer_gguf_quant_label(filename) == expected
def test_estimate_gguf_dense_mib_expansion():
# 4-bit roughly quadruples once dequantised to bf16; F16 is already dense.
assert estimate_gguf_dense_mib(1000, "Q4_K_M") == 4000
assert estimate_gguf_dense_mib(1000, "Q8_0") == 2000
assert estimate_gguf_dense_mib(1000, "BF16") == 1000
assert estimate_gguf_dense_mib(None, "Q4_K_M") is None
# Unknown quant falls back to the conservative 4-bit-ish factor.
assert estimate_gguf_dense_mib(1000, None) == 4000
def test_estimate_image_runtime_scales_with_pixels_and_family():
base = estimate_image_runtime_mib(width = DEFAULT_IMAGE_WIDTH, height = DEFAULT_IMAGE_HEIGHT)
bigger = estimate_image_runtime_mib(width = 2048, height = 2048)
assert bigger > base
# Distilled / turbo families get a discount.
turbo = estimate_image_runtime_mib(
width = DEFAULT_IMAGE_WIDTH, height = DEFAULT_IMAGE_HEIGHT, family = "z-image-turbo"
)
assert turbo < base
# ── planner: device classes ───────────────────────────────────────────────────
def test_cpu_target_never_offloads_but_tiles():
plan = plan_diffusion_memory(
target = _target(device = "cpu", backend = "cpu", supports_offload = False),
device_memory = DeviceMemory("cpu", "cpu", "system_memory", 8000, 16000),
model_dense_mib = 4000,
runtime_headroom_mib = 2000,
)
assert plan.offload_policy == OFFLOAD_NONE
# CPU/MPS have no separate device pool, so VAE tiling is on to cap the spike.
assert plan.vae_tiling and plan.vae_slicing
def test_mps_unified_never_auto_offloads():
plan = plan_diffusion_memory(
target = _target(device = "mps", backend = "mps", supports_offload = False),
device_memory = DeviceMemory("mps", "mps", "unified_memory", 4000, 32000),
model_dense_mib = 20000,
runtime_headroom_mib = 4000,
)
assert plan.offload_policy == OFFLOAD_NONE
assert any("unified" in r for r in plan.reasons)
def test_unified_cuda_skips_offload_even_if_offload_capable():
# An integrated CUDA SoC reports unified memory; CPU offload would free nothing.
plan = plan_diffusion_memory(
target = _target(device = "cuda", backend = "cuda", supports_offload = True),
device_memory = DeviceMemory("cuda", "cuda", "unified_memory", 2000, 16000),
model_dense_mib = 12000,
runtime_headroom_mib = 4000,
)
assert plan.offload_policy == OFFLOAD_NONE
# ── planner: auto budget tiers on a discrete GPU ──────────────────────────────
def test_auto_resident_when_roomy():
# 80 GB card, ~16 GB model: fits with headroom -> stay resident (bit-identical).
plan = plan_diffusion_memory(
target = _target(),
device_memory = _discrete(80000),
model_dense_mib = 12000,
runtime_headroom_mib = 4000,
)
assert plan.offload_policy == OFFLOAD_NONE
assert plan.vae_tiling is False and plan.vae_slicing is False # roomy -> no tiling
def test_auto_model_offload_on_tight_fit():
# 24 GB free -> reserve max(2048, 2400)=2400 -> budget 21600, 0.85*budget=18360.
# required = 16000+4000+1000 = 21000: over 0.85*budget but still under budget
# -> whole-module offload.
plan = plan_diffusion_memory(
target = _target(),
device_memory = _discrete(24000, 24000),
model_dense_mib = 16000,
runtime_headroom_mib = 4000,
base_overhead_mib = 1000,
)
assert plan.offload_policy == OFFLOAD_MODEL
assert plan.vae_tiling is True # offloading -> device is tight -> tile
def test_auto_group_offload_when_transformer_overflows_but_companions_fit():
# Big transformer pushes the resident total over budget, but the companions
# (text encoder + VAE) still fit -> stream the transformer (fast, moderate cut).
plan = plan_diffusion_memory(
target = _target(),
device_memory = _discrete(8000, 8000),
model_dense_mib = 40000,
companion_dense_mib = 1500,
runtime_headroom_mib = 1000,
base_overhead_mib = 1000,
)
assert plan.offload_policy == OFFLOAD_GROUP
# Group keeps the VAE resident, so it uses exact slicing but NOT lossy tiling
# -> balanced stays bit-identical while still capping the offload footprint.
assert plan.vae_slicing is True and plan.vae_tiling is False
def test_auto_model_offload_when_companions_exceed_budget():
# The text encoder itself is too big to stay resident -> offload everything.
plan = plan_diffusion_memory(
target = _target(),
device_memory = _discrete(8000, 8000),
model_dense_mib = 40000,
companion_dense_mib = 30000,
runtime_headroom_mib = 4000,
)
assert plan.offload_policy == OFFLOAD_MODEL
def test_auto_model_offload_when_companion_size_unknown():
# Without a companion estimate the planner can't prove group fits -> safest cut.
plan = plan_diffusion_memory(
target = _target(),
device_memory = _discrete(8000, 8000),
model_dense_mib = 40000,
runtime_headroom_mib = 4000,
)
assert plan.offload_policy == OFFLOAD_MODEL
def test_auto_stays_resident_when_budget_unknown():
plan = plan_diffusion_memory(
target = _target(),
device_memory = _discrete(None, None),
model_dense_mib = 40000,
runtime_headroom_mib = 4000,
)
assert plan.offload_policy == OFFLOAD_NONE
assert any("unknown" in r for r in plan.reasons)
# ── planner: explicit modes + cpu_offload override ────────────────────────────
def test_explicit_modes_force_policy_regardless_of_budget():
roomy = _discrete(80000)
assert (
plan_diffusion_memory(
target = _target(),
device_memory = roomy,
model_dense_mib = 1000,
runtime_headroom_mib = 1000,
requested_mode = MEMORY_MODE_FAST,
).offload_policy
== OFFLOAD_NONE
)
assert (
plan_diffusion_memory(
target = _target(),
device_memory = roomy,
model_dense_mib = 1000,
runtime_headroom_mib = 1000,
requested_mode = MEMORY_MODE_BALANCED,
).offload_policy
== OFFLOAD_GROUP
)
assert (
plan_diffusion_memory(
target = _target(),
device_memory = roomy,
model_dense_mib = 1000,
runtime_headroom_mib = 1000,
requested_mode = MEMORY_MODE_LOW_VRAM,
).offload_policy
== OFFLOAD_MODEL
)
def test_fast_falls_back_to_model_offload_when_it_does_not_fit():
plan = plan_diffusion_memory(
target = _target(),
device_memory = _discrete(8000, 8000),
model_dense_mib = 40000,
runtime_headroom_mib = 4000,
requested_mode = MEMORY_MODE_FAST,
)
assert plan.offload_policy == OFFLOAD_MODEL
def test_explicit_cpu_offload_overrides_resident_auto_choice():
# Roomy GPU -> auto would stay resident, but cpu_offload=True forces offload.
plan = plan_diffusion_memory(
target = _target(),
device_memory = _discrete(80000),
model_dense_mib = 4000,
runtime_headroom_mib = 2000,
explicit_offload = True,
)
assert plan.offload_policy == OFFLOAD_MODEL
assert any("explicit cpu_offload" in r for r in plan.reasons)
def test_explicit_cpu_offload_ignored_on_cpu_target():
plan = plan_diffusion_memory(
target = _target(device = "cpu", backend = "cpu", supports_offload = False),
device_memory = DeviceMemory("cpu", "cpu", "system_memory", 8000, 16000),
model_dense_mib = 4000,
runtime_headroom_mib = 2000,
explicit_offload = True,
)
assert plan.offload_policy == OFFLOAD_NONE
# ── snapshot ──────────────────────────────────────────────────────────────────
def test_snapshot_cpu_target_uses_system_memory(monkeypatch):
import core.inference.diffusion_memory as mem
monkeypatch.setattr(mem, "_system_memory_mib", lambda: (16000, 9000))
snap = snapshot_device_memory(_target(device = "cpu", backend = "cpu"))
assert snap.memory_kind == "system_memory"
assert snap.free_mib == 9000 and snap.total_mib == 16000
def test_snapshot_cuda_reads_mem_get_info(monkeypatch):
import sys
fake_torch = types.ModuleType("torch")
fake_torch.cuda = types.SimpleNamespace(
mem_get_info = lambda: (10 * 1024 * 1024 * 1024, 24 * 1024 * 1024 * 1024),
get_device_properties = lambda i: types.SimpleNamespace(integrated = False),
)
monkeypatch.setitem(sys.modules, "torch", fake_torch)
snap = snapshot_device_memory(_target())
assert snap.memory_kind == "discrete_vram"
assert snap.free_mib == 10 * 1024 and snap.total_mib == 24 * 1024
def test_snapshot_never_raises_on_probe_failure(monkeypatch):
import sys
fake_torch = types.ModuleType("torch")
def _boom():
raise RuntimeError("no cuda")
fake_torch.cuda = types.SimpleNamespace(mem_get_info = _boom)
monkeypatch.setitem(sys.modules, "torch", fake_torch)
snap = snapshot_device_memory(_target())
assert snap.free_mib is None and snap.total_mib is None
# ── applier ───────────────────────────────────────────────────────────────────
class _RecordingPipe:
def __init__(self) -> None:
self.calls: list[str] = []
def to(self, device):
self.calls.append(f"to:{device}")
return self
def enable_model_cpu_offload(self):
self.calls.append("model_offload")
def enable_sequential_cpu_offload(self):
self.calls.append("sequential_offload")
def enable_vae_tiling(self):
self.calls.append("vae_tiling")
def enable_vae_slicing(self):
self.calls.append("vae_slicing")
def _plan(policy, *, tiling):
return plan_diffusion_memory(
target = _target(),
device_memory = _discrete(80000) if policy == OFFLOAD_NONE else _discrete(4000, 8000),
model_dense_mib = 1000 if policy == OFFLOAD_NONE else 40000,
runtime_headroom_mib = 1000,
requested_mode = {
OFFLOAD_NONE: MEMORY_MODE_FAST,
OFFLOAD_GROUP: MEMORY_MODE_BALANCED,
OFFLOAD_MODEL: MEMORY_MODE_LOW_VRAM,
}[policy],
)
def _manual_plan(policy, *, tiling):
"""Build a plan for a policy the auto/explicit modes no longer emit (sequential)."""
return MemoryPlan(
requested_mode = "manual",
offload_policy = policy,
vae_tiling = tiling,
vae_slicing = tiling,
device_memory = _discrete(4000, 8000),
estimates = {},
)
def test_apply_none_places_resident():
pipe = _RecordingPipe()
effective, tiled = apply_memory_plan(pipe, _plan(OFFLOAD_NONE, tiling = False), device = "cuda")
assert pipe.calls == ["to:cuda"] # no tiling on a roomy resident run
assert effective == OFFLOAD_NONE and tiled is False
def test_apply_model_offload_engages_offload_and_tiling():
pipe = _RecordingPipe()
effective, tiled = apply_memory_plan(pipe, _plan(OFFLOAD_MODEL, tiling = True), device = "cuda")
assert "model_offload" in pipe.calls
assert "to:cuda" not in pipe.calls # offload owns placement; never both
assert "vae_tiling" in pipe.calls and "vae_slicing" in pipe.calls
assert effective == OFFLOAD_MODEL and tiled is True
def test_apply_vae_tiling_falls_back_to_vae_submodule():
# Z-Image-style pipeline: no pipeline-level enable_vae_tiling, only pipe.vae.
class _VaeOnly:
def __init__(self):
self.vae = types.SimpleNamespace(
tiled = False,
sliced = False,
enable_tiling = self._tile,
enable_slicing = self._slice,
)
def _tile(self):
self.vae.tiled = True
def _slice(self):
self.vae.sliced = True
def enable_model_cpu_offload(self):
self.offloaded = True
pipe = _VaeOnly()
effective, tiled = apply_memory_plan(pipe, _plan(OFFLOAD_MODEL, tiling = True), device = "cuda")
assert tiled is True and pipe.vae.tiled and pipe.vae.sliced
def test_apply_group_falls_back_to_model_without_transformer():
# The recording pipe has no .transformer, so group offload can't engage and the
# applier falls back to whole-module offload, reporting the real policy.
pipe = _RecordingPipe()
effective, _ = apply_memory_plan(pipe, _plan(OFFLOAD_GROUP, tiling = True), device = "cuda")
assert effective == OFFLOAD_MODEL and "model_offload" in pipe.calls
def test_apply_group_fallback_enables_vae_tiling():
# A balanced/group plan keeps the VAE resident (tiling off); when group offload can't
# engage and we drop to whole-module offload, the applier must turn VAE tiling ON to
# cap the decode-time spike on what is now a low-VRAM path.
plan = _plan(OFFLOAD_GROUP, tiling = True)
assert plan.vae_tiling is False # group plan leaves tiling off by design
pipe = _RecordingPipe() # no .transformer -> group offload falls back to model
effective, tiled = apply_memory_plan(pipe, plan, device = "cuda")
assert effective == OFFLOAD_MODEL
assert tiled is True and "vae_tiling" in pipe.calls
def test_apply_sequential_offload():
pipe = _RecordingPipe()
effective, _ = apply_memory_plan(
pipe, _manual_plan(OFFLOAD_SEQUENTIAL, tiling = True), device = "cuda"
)
assert "sequential_offload" in pipe.calls and "to:cuda" not in pipe.calls
assert effective == OFFLOAD_SEQUENTIAL
def test_apply_sequential_falls_back_to_model_offload_when_unsupported():
# Sequential offload is unreliable for GGUF on some diffusers versions; the
# applier must fall back to whole-module offload and report what actually ran.
class _NoSeqPipe(_RecordingPipe):
def enable_sequential_cpu_offload(self):
raise RuntimeError("sequential offload not supported for this transformer")
pipe = _NoSeqPipe()
effective, _ = apply_memory_plan(
pipe, _manual_plan(OFFLOAD_SEQUENTIAL, tiling = True), device = "cuda"
)
assert effective == OFFLOAD_MODEL
assert "model_offload" in pipe.calls
def test_apply_tolerates_pipe_without_vae_savers():
# A pipeline missing enable_vae_* must not crash the applier.
class _Bare:
def __init__(self):
self.moved = None
def to(self, device):
self.moved = device
bare = _Bare()
_, tiled = apply_memory_plan(bare, _plan(OFFLOAD_NONE, tiling = False), device = "cpu")
assert bare.moved == "cpu" and tiled is False