Six review findings, three of them evict-then-fail orderings: - The chat load reclaimed the GPU without telling the arbiter it existed. A chat load holds no llama-server process until its GGUF has downloaded, which is minutes, so a competing Images/Video acquire in that window found nothing to cancel, took the GPU, and the chat load then spawned onto the same device. It now registers an in-flight marker through acquire_for's register hook (under the arbiter lock, as the image and video loads do), the evictor cancels a marked load, and the route undoes itself if ownership moved while it loaded. - The Hub-download conflict check ran after that handoff, so a GGUF the download manager already owns destroyed the resident Images/Video pipeline and then 409'd, having loaded nothing. It moves above the handoff, together with the marker it handshakes with. - The image load released the engine router's transition lock before registering the load, so a second load choosing the other engine could unload the still-idle engine this one captured; the load then landed on a deactivated engine, where generate, status, unload and the arbiter's evictor can no longer reach it. Registration now happens under that lock and refuses if the engine changed. - Training a DiT family on a host with no GPU was accepted: nf4 is not a CPU fallback, its 4-bit load goes through bitsandbytes, which requires CUDA, XPU or MPS. The start unloaded the working Images pipeline, pulled the text encoders, and only then died in the child. Rejected before the teardown now, and /info stops advertising a precision that always 400s. SDXL keeps its documented fp32-on-CPU path. - Both diffusion pages kept the routed-pick marker forever, so re-picking the same checkpoint (after chat evicted it) neither loaded nor cleared the query string. The marker is released once the query is gone. The Images key also carried a stray NUL byte, which made the file read as binary to grep and other tooling. - diffusers dropped Python 3.9 in 0.38, so the unconditional >=0.39.0 pin left pip no candidate at all on 3.9 and made every install that composes the huggingface extras unresolvable there. The floor is conditional now. Also fixes tests that were already red on the branch: two hand-built request fakes had gone stale against fields this branch added, and the handoff-ordering test only failed on a host with fewer than two GPUs.
330 lines
13 KiB
Python
330 lines
13 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
|
|
|
|
"""Tests for the diffusion engine router (diffusers vs native sd.cpp selection)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from core.inference import diffusion_engine_router as r
|
|
from core.inference.diffusion_families import detect_family
|
|
from core.inference.sd_cpp_engine import ENGINE_DIFFUSERS, ENGINE_SD_CPP
|
|
|
|
_ENVS = (
|
|
"UNSLOTH_DIFFUSION_ENGINE",
|
|
"UNSLOTH_DIFFUSION_SD_CPP",
|
|
"UNSLOTH_DIFFUSION_SD_CPP_MPS",
|
|
"UNSLOTH_DIFFUSION_SD_CPP_INSTALL",
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse = True)
|
|
def _clean_env_and_state(monkeypatch):
|
|
for e in _ENVS:
|
|
monkeypatch.delenv(e, raising = False)
|
|
# A light status-capable stub so neither selection nor active_status() imports the
|
|
# heavy diffusers/sd.cpp backends; the active engine NAME comes from module state.
|
|
monkeypatch.setattr(
|
|
r,
|
|
"get_active_diffusion_engine",
|
|
lambda: SimpleNamespace(status = lambda: {"loaded": False, "repo_id": None}),
|
|
)
|
|
# Default: no resident sd-server (so existing tests exercise the sd-cli path only) and
|
|
# a stubbed runnability probe, so neither reaches the real install/exec path.
|
|
monkeypatch.setattr(r, "ensure_sd_server_binary", lambda **_: None)
|
|
monkeypatch.setattr(r, "_server_binary_runnable", lambda *_a, **_k: True)
|
|
yield
|
|
|
|
|
|
def _set_device(monkeypatch, backend):
|
|
monkeypatch.setattr(
|
|
r,
|
|
"resolve_diffusion_device_target",
|
|
lambda: SimpleNamespace(backend = backend, device = backend),
|
|
)
|
|
|
|
|
|
def _set_binary(monkeypatch, path):
|
|
monkeypatch.setattr(r, "ensure_sd_cpp_binary", lambda **_: path)
|
|
|
|
|
|
def _set_runnable(monkeypatch, version = "sd-cli v0"):
|
|
"""Stub the runnability probe so a stubbed binary path is treated as executable
|
|
(the router now probes ``SdCppEngine(...).version()`` before committing to native)."""
|
|
monkeypatch.setattr(r, "SdCppEngine", lambda **_: SimpleNamespace(version = lambda: version))
|
|
|
|
|
|
def _select(fam_name = "z-image"):
|
|
"""Activate the engine for a family and return which engine was chosen."""
|
|
r.select_and_activate_engine(detect_family(fam_name))
|
|
return r.active_engine_name()
|
|
|
|
|
|
# ── core selection matrix ─────────────────────────────────────────────────────
|
|
|
|
|
|
def test_cpu_with_binary_and_supported_family_picks_sd_cpp(monkeypatch):
|
|
_set_device(monkeypatch, "cpu")
|
|
_set_binary(monkeypatch, "/usr/bin/sd-cli")
|
|
_set_runnable(monkeypatch)
|
|
assert _select() == ENGINE_SD_CPP
|
|
assert r.active_engine_name() == ENGINE_SD_CPP
|
|
|
|
|
|
def test_cpu_with_only_sd_server_picks_sd_cpp(monkeypatch):
|
|
# An sd-server-only install (no runnable sd-cli) must still route to native: the
|
|
# backend prefers the resident server, so a runnable sd-server is native availability.
|
|
_set_device(monkeypatch, "cpu")
|
|
_set_binary(monkeypatch, None) # no sd-cli
|
|
monkeypatch.setattr(r, "SdCppEngine", lambda **_: SimpleNamespace(version = lambda: None))
|
|
monkeypatch.setattr(r, "ensure_sd_server_binary", lambda **_: "/usr/bin/sd-server")
|
|
assert _select() == ENGINE_SD_CPP
|
|
|
|
|
|
def test_present_but_not_runnable_binary_falls_back(monkeypatch):
|
|
# A binary that exists but cannot run (version() -> None) must fall back to
|
|
# diffusers at selection, not commit native and fail inside the load.
|
|
_set_device(monkeypatch, "cpu")
|
|
_set_binary(monkeypatch, "/usr/bin/sd-cli")
|
|
monkeypatch.setattr(r, "SdCppEngine", lambda **_: SimpleNamespace(version = lambda: None))
|
|
assert _select() == ENGINE_DIFFUSERS
|
|
assert "binary unavailable" in (r.active_status()["fallback_reason"] or "")
|
|
|
|
|
|
@pytest.mark.parametrize("gpu", ["cuda", "rocm", "xpu"])
|
|
def test_gpu_backends_use_diffusers(monkeypatch, gpu):
|
|
_set_device(monkeypatch, gpu)
|
|
_set_binary(monkeypatch, "/usr/bin/sd-cli") # even with a binary, GPU stays diffusers
|
|
assert _select() == ENGINE_DIFFUSERS
|
|
assert "uses diffusers" in (r.active_status()["fallback_reason"] or "")
|
|
|
|
|
|
def test_forced_diffusers_overrides_cpu(monkeypatch):
|
|
_set_device(monkeypatch, "cpu")
|
|
_set_binary(monkeypatch, "/usr/bin/sd-cli")
|
|
monkeypatch.setenv("UNSLOTH_DIFFUSION_ENGINE", "diffusers")
|
|
assert _select() == ENGINE_DIFFUSERS
|
|
assert "forced" in (r.active_status()["fallback_reason"] or "")
|
|
|
|
|
|
def test_sd_cpp_disabled_uses_diffusers(monkeypatch):
|
|
_set_device(monkeypatch, "cpu")
|
|
_set_binary(monkeypatch, "/usr/bin/sd-cli")
|
|
monkeypatch.setenv("UNSLOTH_DIFFUSION_SD_CPP", "0")
|
|
assert _select() == ENGINE_DIFFUSERS
|
|
assert "disabled" in (r.active_status()["fallback_reason"] or "")
|
|
|
|
|
|
def test_mps_default_diffusers_but_optin_sd_cpp(monkeypatch):
|
|
_set_device(monkeypatch, "mps")
|
|
_set_binary(monkeypatch, "/usr/bin/sd-cli")
|
|
_set_runnable(monkeypatch)
|
|
# Default: MPS is not native-eligible -> diffusers.
|
|
assert _select() == ENGINE_DIFFUSERS
|
|
# Opt in: MPS routes to sd.cpp.
|
|
monkeypatch.setenv("UNSLOTH_DIFFUSION_SD_CPP_MPS", "1")
|
|
assert _select() == ENGINE_SD_CPP
|
|
|
|
|
|
def test_unsupported_family_falls_back(monkeypatch):
|
|
_set_device(monkeypatch, "cpu")
|
|
_set_binary(monkeypatch, "/usr/bin/sd-cli")
|
|
monkeypatch.setattr(r, "family_sd_cpp_supported", lambda fam: False)
|
|
assert _select() == ENGINE_DIFFUSERS
|
|
assert "no native sd.cpp asset mapping" in (r.active_status()["fallback_reason"] or "")
|
|
|
|
|
|
def test_missing_binary_falls_back(monkeypatch):
|
|
_set_device(monkeypatch, "cpu")
|
|
_set_binary(monkeypatch, None) # install unavailable
|
|
assert _select() == ENGINE_DIFFUSERS
|
|
assert "binary unavailable" in (r.active_status()["fallback_reason"] or "")
|
|
|
|
|
|
def test_force_sd_cpp_on_gpu_when_binary_present(monkeypatch):
|
|
_set_device(monkeypatch, "cuda")
|
|
_set_binary(monkeypatch, "/usr/bin/sd-cli")
|
|
_set_runnable(monkeypatch)
|
|
monkeypatch.setenv("UNSLOTH_DIFFUSION_ENGINE", "sd_cpp")
|
|
assert _select() == ENGINE_SD_CPP
|
|
|
|
|
|
def test_force_sd_cpp_without_binary_falls_back(monkeypatch):
|
|
_set_device(monkeypatch, "cuda")
|
|
_set_binary(monkeypatch, None)
|
|
monkeypatch.setenv("UNSLOTH_DIFFUSION_ENGINE", "sd_cpp")
|
|
assert _select() == ENGINE_DIFFUSERS
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"backend, expected",
|
|
[("rocm", "rocm"), ("cuda", "cuda"), ("xpu", "vulkan"), ("cpu", "auto"), ("mps", "auto")],
|
|
)
|
|
def test_install_accelerator_maps_backend(backend, expected):
|
|
assert r._install_accelerator_for(backend) == expected
|
|
|
|
|
|
def test_force_native_install_uses_gpu_accelerator(monkeypatch):
|
|
# Forcing sd_cpp on a ROCm host with no binary must install the ROCm build, not the
|
|
# default CPU one -- otherwise the forced-native generation silently runs on CPU.
|
|
_set_device(monkeypatch, "rocm")
|
|
_set_runnable(monkeypatch)
|
|
seen = {}
|
|
|
|
def _fake_ensure(**kwargs):
|
|
seen.update(kwargs)
|
|
return "/usr/bin/sd-cli"
|
|
|
|
monkeypatch.setattr(r, "ensure_sd_cpp_binary", _fake_ensure)
|
|
monkeypatch.setenv("UNSLOTH_DIFFUSION_ENGINE", "sd_cpp")
|
|
assert _select() == ENGINE_SD_CPP
|
|
assert seen.get("accelerator") == "rocm"
|
|
|
|
|
|
# ── active_status annotation ──────────────────────────────────────────────────
|
|
|
|
|
|
def test_active_status_injects_engine_and_reason(monkeypatch):
|
|
_set_device(monkeypatch, "cpu")
|
|
_set_binary(monkeypatch, None)
|
|
_select() # -> diffusers fallback (no binary)
|
|
st = r.active_status()
|
|
assert st["engine"] == ENGINE_DIFFUSERS
|
|
assert st["fallback_reason"] and "binary unavailable" in st["fallback_reason"]
|
|
|
|
|
|
# ── engine-switch eviction ordering ───────────────────────────────────────────
|
|
|
|
|
|
def test_switch_unloads_old_engine_before_publishing_new(monkeypatch):
|
|
# The arbiter's diffusion evictor unloads get_active_diffusion_engine(); if the router
|
|
# published the new (empty) engine BEFORE the old one finished unloading, a concurrent
|
|
# chat/video acquire_for could evict the empty engine and take the GPU while the old
|
|
# model was still resident (two large models briefly co-resident -> OOM). Assert the
|
|
# OLD engine stays the published active target until its unload() completes, then the
|
|
# new engine is published.
|
|
seen = {}
|
|
|
|
def _fake_engine():
|
|
return SimpleNamespace(
|
|
unload = lambda: seen.__setitem__("active_during_unload", r.active_engine_name()),
|
|
status = lambda: {"loaded": False, "repo_id": None},
|
|
)
|
|
|
|
monkeypatch.setattr(r, "get_active_diffusion_engine", lambda: _fake_engine())
|
|
r._active_engine_name = ENGINE_SD_CPP
|
|
r._activate(ENGINE_DIFFUSERS, "switch test")
|
|
assert seen["active_during_unload"] == ENGINE_SD_CPP
|
|
assert r.active_engine_name() == ENGINE_DIFFUSERS
|
|
|
|
|
|
def test_no_switch_keeps_engine_and_refreshes_reason(monkeypatch):
|
|
# When the engine does not change, _activate must not spuriously unload anything and must
|
|
# still refresh the recorded fallback reason (the diffusers-only steady state).
|
|
calls = {"unload": 0}
|
|
|
|
def _fake_engine():
|
|
return SimpleNamespace(
|
|
unload = lambda: calls.__setitem__("unload", calls["unload"] + 1),
|
|
status = lambda: {"loaded": False, "repo_id": None},
|
|
)
|
|
|
|
monkeypatch.setattr(r, "get_active_diffusion_engine", lambda: _fake_engine())
|
|
r._active_engine_name = ENGINE_DIFFUSERS
|
|
r._activate(ENGINE_DIFFUSERS, "still diffusers")
|
|
assert calls["unload"] == 0
|
|
assert r.active_engine_name() == ENGINE_DIFFUSERS
|
|
assert r.active_status()["fallback_reason"] == "still diffusers"
|
|
|
|
|
|
def test_activate_serializes_switch_and_concurrent_query(monkeypatch):
|
|
# Regression: without the transition lock a second _activate during the slow unload() reads the
|
|
# not-yet-updated active engine and returns it. Assert the query is blocked until the switch ends.
|
|
import threading
|
|
|
|
r._active_engine_name = ENGINE_DIFFUSERS
|
|
r._fallback_reason = None
|
|
|
|
release_unload = threading.Event()
|
|
unload_started = threading.Event()
|
|
|
|
def _slow_unload():
|
|
unload_started.set()
|
|
release_unload.wait(2.0)
|
|
|
|
engine = SimpleNamespace(status = lambda: {"loaded": False, "repo_id": None}, unload = _slow_unload)
|
|
monkeypatch.setattr(r, "get_active_diffusion_engine", lambda: engine)
|
|
|
|
switch_done = threading.Event()
|
|
|
|
def _switch():
|
|
r._activate(ENGINE_SD_CPP, None) # diffusers -> sd_cpp: unloads the old engine (blocks)
|
|
switch_done.set()
|
|
|
|
t = threading.Thread(target = _switch)
|
|
t.start()
|
|
assert unload_started.wait(2.0) # switch is mid-unload, holding the transition lock
|
|
|
|
query_done = threading.Event()
|
|
|
|
def _query():
|
|
r._activate(ENGINE_DIFFUSERS, None) # would hit the "no change" branch pre-fix
|
|
query_done.set()
|
|
|
|
q = threading.Thread(target = _query)
|
|
q.start()
|
|
# Serialized: the query cannot complete while the switch holds the transition lock.
|
|
assert not query_done.wait(0.4)
|
|
|
|
release_unload.set()
|
|
t.join(2.0)
|
|
q.join(2.0)
|
|
assert switch_done.is_set() and query_done.is_set()
|
|
|
|
|
|
def test_begin_load_on_refuses_an_engine_that_was_switched_away(monkeypatch):
|
|
# A load selects its engine, then yields (device probe, arbiter acquire) before registering.
|
|
# A second load choosing the OTHER engine transitions in that gap and unloads the still-idle
|
|
# engine this one captured, so registering there would leave a model that generate / status /
|
|
# unload and the arbiter's evictor can no longer reach (they all resolve the ACTIVE engine).
|
|
diffusers = SimpleNamespace(name = "diffusers")
|
|
sd_cpp = SimpleNamespace(name = "sd_cpp")
|
|
active = {"engine": diffusers}
|
|
monkeypatch.setattr(r, "get_active_diffusion_engine", lambda: active["engine"])
|
|
|
|
started: list[str] = []
|
|
assert r.begin_load_on(diffusers, lambda: started.append("ok") or "status") == "status"
|
|
assert started == ["ok"]
|
|
|
|
# A competing request switched the active engine after this one captured `diffusers`.
|
|
active["engine"] = sd_cpp
|
|
with pytest.raises(RuntimeError, match = "engine changed"):
|
|
r.begin_load_on(diffusers, lambda: started.append("leaked"))
|
|
assert started == ["ok"]
|
|
|
|
|
|
def test_begin_load_on_holds_the_transition_lock_while_registering(monkeypatch):
|
|
# The check and the registration must be one operation: taken under the same lock a switch
|
|
# takes, so no _activate can slip between them.
|
|
import threading
|
|
|
|
engine = SimpleNamespace(name = "diffusers")
|
|
monkeypatch.setattr(r, "get_active_diffusion_engine", lambda: engine)
|
|
|
|
inside = threading.Event()
|
|
release = threading.Event()
|
|
|
|
def _slow_start():
|
|
inside.set()
|
|
release.wait(2.0)
|
|
return "status"
|
|
|
|
t = threading.Thread(target = lambda: r.begin_load_on(engine, _slow_start))
|
|
t.start()
|
|
assert inside.wait(2.0)
|
|
assert r._transition_lock.locked()
|
|
release.set()
|
|
t.join(2.0)
|