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.
This commit is contained in:
parent
7ca5573498
commit
a7b8f825da
7 changed files with 1298 additions and 0 deletions
230
studio/backend/tests/test_sd_cpp_engine.py
Normal file
230
studio/backend/tests/test_sd_cpp_engine.py
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
# 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 sd-cli engine + routing (``sd_cpp_engine.py``).
|
||||
|
||||
Hermetic: the binary finder is driven against a tmp filesystem, and ``generate``
|
||||
runs a fake ``subprocess.Popen`` that emits canned lines and writes the output
|
||||
PNG -- no real ``sd-cli``, no GPU.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from core.inference import sd_cpp_engine as eng
|
||||
from core.inference.sd_cpp_engine import (
|
||||
ENGINE_DIFFUSERS,
|
||||
ENGINE_SD_CPP,
|
||||
SdCppEngine,
|
||||
find_sd_cpp_binary,
|
||||
runtime_env,
|
||||
select_diffusion_engine,
|
||||
)
|
||||
from core.inference.sd_cpp_args import SdCppGenParams, SdCppModelFiles
|
||||
|
||||
|
||||
# ── binary discovery ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _clear_env(monkeypatch):
|
||||
monkeypatch.delenv("SD_CLI_PATH", raising = False)
|
||||
monkeypatch.delenv("UNSLOTH_SD_CPP_PATH", raising = False)
|
||||
|
||||
|
||||
def test_find_prefers_sd_cli_path_env(tmp_path, monkeypatch):
|
||||
_clear_env(monkeypatch)
|
||||
binary = tmp_path / "sd-cli"
|
||||
binary.write_text("#!/bin/sh\n")
|
||||
monkeypatch.setenv("SD_CLI_PATH", str(binary))
|
||||
# even with PATH empty, the direct env wins
|
||||
monkeypatch.setattr(eng.shutil, "which", lambda *_a: None)
|
||||
assert find_sd_cpp_binary() == str(binary)
|
||||
|
||||
|
||||
def test_find_custom_install_dir_build_layout(tmp_path, monkeypatch):
|
||||
_clear_env(monkeypatch)
|
||||
root = tmp_path / "sdcpp"
|
||||
built = root / "build" / "bin" / "sd-cli"
|
||||
built.parent.mkdir(parents = True)
|
||||
built.write_text("x")
|
||||
monkeypatch.setenv("UNSLOTH_SD_CPP_PATH", str(root))
|
||||
monkeypatch.setattr(eng.shutil, "which", lambda *_a: None)
|
||||
assert find_sd_cpp_binary() == str(built)
|
||||
|
||||
|
||||
def test_find_falls_back_to_path(tmp_path, monkeypatch):
|
||||
_clear_env(monkeypatch)
|
||||
monkeypatch.setattr(eng.Path, "home", staticmethod(lambda: tmp_path / "nohome"))
|
||||
monkeypatch.setattr(eng.shutil, "which", lambda stem: "/usr/bin/sd-cli" if stem == "sd-cli" else None)
|
||||
assert find_sd_cpp_binary() == "/usr/bin/sd-cli"
|
||||
|
||||
|
||||
def test_find_returns_none_when_absent(tmp_path, monkeypatch):
|
||||
_clear_env(monkeypatch)
|
||||
monkeypatch.setattr(eng.Path, "home", staticmethod(lambda: tmp_path / "nohome"))
|
||||
monkeypatch.setattr(eng.shutil, "which", lambda *_a: None)
|
||||
assert find_sd_cpp_binary() is None
|
||||
|
||||
|
||||
# ── availability / version ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_engine_unavailable_when_no_binary():
|
||||
e = SdCppEngine(binary = None)
|
||||
assert e.is_available() is False
|
||||
assert e.version() is None
|
||||
|
||||
|
||||
def test_engine_version_parsed_and_cached(tmp_path, monkeypatch):
|
||||
binary = tmp_path / "sd-cli"
|
||||
binary.write_text("x")
|
||||
e = SdCppEngine(binary = str(binary))
|
||||
calls = {"n": 0}
|
||||
|
||||
def _fake_run(*_a, **_k):
|
||||
calls["n"] += 1
|
||||
return types.SimpleNamespace(stdout = "stable-diffusion.cpp version master-721\n", stderr = "")
|
||||
|
||||
monkeypatch.setattr(eng.subprocess, "run", _fake_run)
|
||||
assert e.version() == "stable-diffusion.cpp version master-721"
|
||||
assert e.version() == "stable-diffusion.cpp version master-721"
|
||||
assert calls["n"] == 1 # cached after the first probe
|
||||
|
||||
|
||||
# ── runtime env (bundled shared libs) ───────────────────────────────────────
|
||||
|
||||
|
||||
def test_runtime_env_prepends_binary_dir_to_lib_path():
|
||||
var = eng._lib_path_var()
|
||||
env = runtime_env("/opt/sdcpp/bin/sd-cli", {var: "/existing"})
|
||||
first = env[var].split(os.pathsep)[0]
|
||||
assert first == "/opt/sdcpp/bin"
|
||||
assert "/existing" in env[var]
|
||||
|
||||
|
||||
def test_runtime_env_handles_missing_lib_path():
|
||||
var = eng._lib_path_var()
|
||||
env = runtime_env("/opt/sdcpp/bin/sd-cli", {})
|
||||
assert env[var] == "/opt/sdcpp/bin"
|
||||
|
||||
|
||||
# ── generate (fake subprocess) ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class _FakePopen:
|
||||
"""Stand-in for subprocess.Popen: streams ``lines`` then writes ``out_file``
|
||||
(unless ``write`` is False) and exits with ``returncode``."""
|
||||
|
||||
captured_cmd: list[str] = []
|
||||
captured_env: dict = {}
|
||||
|
||||
def __init__(self, cmd, *, lines, returncode, out_file, write, env = None):
|
||||
type(self).captured_cmd = list(cmd)
|
||||
type(self).captured_env = dict(env or {})
|
||||
self._lines = list(lines)
|
||||
self.returncode = returncode
|
||||
self._out_file = out_file
|
||||
self._write = write
|
||||
|
||||
@property
|
||||
def stdout(self):
|
||||
return iter(self._lines)
|
||||
|
||||
def wait(self, timeout = None):
|
||||
if self._write:
|
||||
Path(self._out_file).write_bytes(b"\x89PNG\r\n")
|
||||
return self.returncode
|
||||
|
||||
def poll(self):
|
||||
return self.returncode
|
||||
|
||||
def kill(self):
|
||||
pass
|
||||
|
||||
|
||||
def _patch_popen(monkeypatch, *, lines, returncode, out_file, write = True):
|
||||
def _factory(cmd, **kw):
|
||||
return _FakePopen(cmd, lines = lines, returncode = returncode, out_file = out_file,
|
||||
write = write, env = kw.get("env"))
|
||||
|
||||
monkeypatch.setattr(eng.subprocess, "Popen", _factory)
|
||||
|
||||
|
||||
def _engine(tmp_path):
|
||||
binary = tmp_path / "sd-cli"
|
||||
binary.write_text("x")
|
||||
return SdCppEngine(binary = str(binary))
|
||||
|
||||
|
||||
def test_generate_success_returns_path_and_collects_logs(tmp_path, monkeypatch):
|
||||
e = _engine(tmp_path)
|
||||
out = tmp_path / "img.png"
|
||||
_patch_popen(monkeypatch, lines = ["loading model", "step 1/8", "done"], returncode = 0, out_file = out)
|
||||
seen: list[str] = []
|
||||
files = SdCppModelFiles(diffusion_model = "/m/z.gguf", vae = "/m/ae.sft", llm = "/m/q.gguf")
|
||||
params = SdCppGenParams(prompt = "a cat", steps = 8, seed = 1)
|
||||
|
||||
result = e.generate(files, params, output_path = str(out), on_log = seen.append)
|
||||
|
||||
assert result == out and out.is_file()
|
||||
assert seen == ["loading model", "step 1/8", "done"]
|
||||
# the real argv was built and handed to Popen
|
||||
assert "--diffusion-model" in _FakePopen.captured_cmd
|
||||
assert str(out) == _FakePopen.captured_cmd[_FakePopen.captured_cmd.index("--output") + 1]
|
||||
# the subprocess env carries the binary's dir on the library path
|
||||
var = eng._lib_path_var()
|
||||
assert str(Path(e.binary).resolve().parent) in _FakePopen.captured_env.get(var, "")
|
||||
|
||||
|
||||
def test_generate_raises_on_nonzero_exit(tmp_path, monkeypatch):
|
||||
e = _engine(tmp_path)
|
||||
out = tmp_path / "img.png"
|
||||
_patch_popen(monkeypatch, lines = ["boom: bad gguf"], returncode = 1, out_file = out, write = False)
|
||||
with pytest.raises(RuntimeError, match = "exited 1"):
|
||||
e.generate(SdCppModelFiles(diffusion_model = "/m/z.gguf"),
|
||||
SdCppGenParams(prompt = "x"), output_path = str(out))
|
||||
|
||||
|
||||
def test_generate_raises_when_no_output_despite_success(tmp_path, monkeypatch):
|
||||
e = _engine(tmp_path)
|
||||
out = tmp_path / "img.png"
|
||||
_patch_popen(monkeypatch, lines = ["ok"], returncode = 0, out_file = out, write = False)
|
||||
with pytest.raises(RuntimeError, match = "no image"):
|
||||
e.generate(SdCppModelFiles(diffusion_model = "/m/z.gguf"),
|
||||
SdCppGenParams(prompt = "x"), output_path = str(out))
|
||||
|
||||
|
||||
def test_generate_raises_when_binary_missing():
|
||||
e = SdCppEngine(binary = None)
|
||||
with pytest.raises(RuntimeError, match = "not found"):
|
||||
e.generate(SdCppModelFiles(diffusion_model = "/m/z.gguf"),
|
||||
SdCppGenParams(prompt = "x"), output_path = "/tmp/x.png")
|
||||
|
||||
|
||||
# ── engine routing ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_routing_gpu_backends_use_diffusers():
|
||||
for backend in ("cuda", "rocm", "xpu"):
|
||||
assert select_diffusion_engine(backend, native_available = True) == ENGINE_DIFFUSERS
|
||||
|
||||
|
||||
def test_routing_cpu_and_mps_use_native_when_available():
|
||||
assert select_diffusion_engine("cpu", native_available = True) == ENGINE_SD_CPP
|
||||
assert select_diffusion_engine("mps", native_available = True) == ENGINE_SD_CPP
|
||||
|
||||
|
||||
def test_routing_cpu_falls_back_to_diffusers_without_binary():
|
||||
assert select_diffusion_engine("cpu", native_available = False) == ENGINE_DIFFUSERS
|
||||
|
||||
|
||||
def test_routing_prefer_native_overrides_gpu():
|
||||
assert select_diffusion_engine("cuda", native_available = True, prefer_native = True) == ENGINE_SD_CPP
|
||||
# but only if a binary is actually available
|
||||
assert select_diffusion_engine("cuda", native_available = False, prefer_native = True) == ENGINE_DIFFUSERS
|
||||
Loading…
Add table
Add a link
Reference in a new issue