Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ jobs:
env:
# ggml skips CPU-type Vulkan devices such as lavapipe unless listed.
GGML_VK_VISIBLE_DEVICES: "0"
- name: Check the context defaults and conversion ABI
if: runner.os != 'Windows' && matrix.smoke
run: python3 -m unittest discover -s tests -p test_runtime_api.py -v
env:
SD_RUNTIME_API_TARGET: ${{ matrix.target }}
SD_REQUIRE_RUNTIME: "1"
- uses: dart-lang/setup-dart@v1
if: runner.os == 'Linux' && matrix.smoke
- name: Verify cooperative host shutdown
Expand Down
13 changes: 12 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -95,5 +95,16 @@ foreach(backend CUDA HIPBLAS METAL VULKAN OPENCL SYCL MUSA)
set(SD_DART_GPU_BACKEND 1)
endif()
endforeach()
if(SD_METAL)
set(SD_DART_METAL 1)
else()
set(SD_DART_METAL 0)
endif()
target_compile_definitions(stable-diffusion PRIVATE
SD_DART_GPU_BACKEND=${SD_DART_GPU_BACKEND})
SD_DART_GPU_BACKEND=${SD_DART_GPU_BACKEND}
SD_DART_METAL=${SD_DART_METAL})

# tools/build.py checks these: a missing definition falls back to 0 in the
# sources and would drop the Metal guard without a build error.
file(GENERATE OUTPUT "${CMAKE_BINARY_DIR}/sd_dart_definitions-$<CONFIG>.txt"
CONTENT "$<JOIN:$<TARGET_PROPERTY:stable-diffusion,COMPILE_DEFINITIONS>,\n>\n")
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ alive; see [Exit on Linux](#exit-on-linux).

| Function | Behavior |
| --- | --- |
| `sd_dart_new_sd_ctx` | `new_sd_ctx` that tracks the context before it returns. |
| `sd_dart_new_sd_ctx` | `new_sd_ctx` that tracks the context before it returns. A Metal build loads with batched CFG off ([Upstream qualification](#upstream-qualification)). |
| `sd_dart_generate_image` | `generate_image` as a call in flight: teardown cancels it and waits for it. |
| `sd_dart_cancel_generation` | `sd_cancel_generation` for a tracked context, from any thread. Does nothing for a context that is not tracked, including one already freed. Never blocks. |
| `sd_dart_exit_free` | Untracks an object and frees it with the function it was tracked with (`free_sd_ctx` for a context). Does nothing when it is not tracked, so it also works as a Dart `NativeFinalizer`. |
Expand Down Expand Up @@ -605,6 +605,27 @@ python3 tools/apple_xcframework.py consumer dist/stable-diffusion-native-apple-x
Flutter companion does (a dynamic library that re-exports the framework), runs
the macOS probe and builds the iOS device and simulator slices.

## Upstream qualification

Upstream 982 has its
[ABI audit, carry inventory, evidence and outstanding gates](docs/upstream_982_qualification.md).
It carries two fixes the pin lacks: unbatched CFG in Metal builds (below) and
the [`pickle_line_span.patch`](patches/README.md) for legacy checkpoints above
2 GiB.
Its larger context struct requires regenerated consumer bindings. A null
`flash_attn_mode` or `conv_direct_mode` with its legacy flags `false`, the
initializer default, now selects upstream's auto probes, which change output
and are not seed-reproducible on Metal; to keep earlier behavior, pass the
explicit modes the document maps from the legacy flags.
`tests/test_runtime_api.py` exercises a built runtime's defaults and component
conversion without models or a GPU.

In a Metal build, the Apple targets, `sd_dart_new_sd_ctx` runs CFG unbatched
whatever `sd_ctx_params_t.batched_cfg` says: ggml-metal's group normalization
writes only the first batch item
([leejet/stable-diffusion.cpp#2149](https://github.com/leejet/stable-diffusion.cpp/issues/2149)),
so a batched CFG step returns a wrong image without an error.

## Release

Run the `Native Build & Release` workflow with a tag such as `v0.1.0`. Tags are
Expand Down
230 changes: 230 additions & 0 deletions docs/upstream_982_qualification.md

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions src/sd_dart_exit.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
#include <dlfcn.h>
#endif

// Set by the top-level CMakeLists.txt: whether Metal is compiled in.
#ifndef SD_DART_METAL
#define SD_DART_METAL 0
#endif

namespace {

struct Entry {
Expand Down Expand Up @@ -412,6 +417,17 @@ SdDartStaticsCall::~SdDartStaticsCall() {
sd_ctx_t* sd_dart_new_sd_ctx(const sd_ctx_params_t* sd_ctx_params) {
Call call(kObjectCall | kWorkCall);
ErrorWindow errors;
#if SD_DART_METAL
// ggml-metal's GROUP_NORM writes only the first batch item
// (leejet/stable-diffusion.cpp#2149), so batched CFG returns a wrong
// image as a success that no backend failure reveals.
sd_ctx_params_t unbatched;
if (sd_ctx_params != nullptr && sd_ctx_params->batched_cfg) {
unbatched = *sd_ctx_params;
unbatched.batched_cfg = false;
sd_ctx_params = &unbatched;
}
#endif
sd_ctx_t* context = new_sd_ctx(sd_ctx_params);
insert(context, free_context, cancel_context, SD_DART_EXIT_STAGE_CONTEXT);
return context;
Expand Down
7 changes: 7 additions & 0 deletions src/sd_dart_wrapper.h
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,13 @@ SD_API void sd_dart_exit_teardown(void);
// returns. Free the context with sd_dart_exit_free(). A load cannot be
// cancelled: teardown waits for it, with the longer bound. Blocks after
// teardown.
//
// In a library built with Metal, the Apple targets, the context runs CFG
// unbatched whatever sd_ctx_params_t.batched_cfg says, also on the CPU:
// ggml-metal's group normalization leaves every batch item after the first
// unwritten (leejet/stable-diffusion.cpp#2149), and the image a batched CFG
// step then returns is wrong without any error. The caller's parameters are
// not modified.
SD_API sd_ctx_t* sd_dart_new_sd_ctx(const sd_ctx_params_t* sd_ctx_params);

// generate_image() as a call in flight. Enables native log recording and
Expand Down
33 changes: 33 additions & 0 deletions tests/native/exit_teardown_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ std::atomic<int> loads_begun{0};
std::atomic<int> calls_after_teardown{0};
// The arguments the stand-ins were last called with.
std::atomic<const sd_ctx_params_t*> last_load_params{nullptr};
std::atomic<bool> last_load_batched_cfg{false};
std::atomic<int> last_load_threads{0};
std::atomic<const sd_img_gen_params_t*> last_request{nullptr};
std::atomic<sd_image_t**> last_images_out{nullptr};

Expand Down Expand Up @@ -1789,6 +1791,8 @@ sd_ctx_t* new_sd_ctx(const sd_ctx_params_t* sd_ctx_params) {
loads_begun.fetch_add(1);
calls_after_teardown.fetch_add(teardown_returned.load() ? 1 : 0);
last_load_params.store(sd_ctx_params);
last_load_batched_cfg.store(sd_ctx_params->batched_cfg);
last_load_threads.store(sd_ctx_params->n_threads);
if (bool (*hold)() = hold_load.load()) {
hold();
}
Expand Down Expand Up @@ -1853,6 +1857,34 @@ void free_sd_images(sd_image_t* images, int count) {
++image_batches_freed;
}

// A Metal build loads with batched CFG off from a copy of the caller's
// parameters; any other build passes them through.
int test_batched_cfg() {
sd_ctx_params_t params{};
params.n_threads = 3;
params.batched_cfg = true;
sd_ctx_t* batched = sd_dart_new_sd_ctx(&params);
CHECK(batched != nullptr);
CHECK(sd_dart_exit_tracked_count() == 1);
CHECK(params.batched_cfg);
CHECK(last_load_threads.load() == 3);
#if SD_DART_METAL
CHECK(last_load_params.load() != &params);
CHECK(!last_load_batched_cfg.load());
#else
CHECK(last_load_params.load() == &params);
CHECK(last_load_batched_cfg.load());
#endif
params.batched_cfg = false;
sd_ctx_t* unbatched = sd_dart_new_sd_ctx(&params);
CHECK(last_load_params.load() == &params);
CHECK(!last_load_batched_cfg.load());
sd_dart_exit_free(unbatched);
sd_dart_exit_free(batched);
CHECK(sd_dart_exit_tracked_count() == 0);
return 0;
}

int main(int argc, char** argv) {
const std::string scenario = argc > 1 ? argv[1] : "";
const struct {
Expand Down Expand Up @@ -1893,6 +1925,7 @@ int main(int argc, char** argv) {
{"last-error", test_last_error},
{"generation-backend-failure", test_generation_backend_failure},
{"log-at-exit", test_log_at_exit},
{"batched-cfg", test_batched_cfg},
#if defined(__APPLE__)
{"exit-in-flight", test_exit_in_flight},
#endif
Expand Down
49 changes: 49 additions & 0 deletions tests/native/runtime_api_test.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#include "sd_dart_wrapper.h"

#include <cstddef>
#include <cstdio>
#include <cstring>
#include <type_traits>

#define CHECK(condition) do { if (!(condition)) { \
std::fprintf(stderr, "CHECK(%s) failed\n", #condition); return 1; } } while (0)

// Every shipped target is 64-bit. Appended context members require consumers
// to regenerate bindings even when preceding members keep their offsets.
static_assert(sizeof(void*) == 8, "this probe describes the shipped 64-bit ABI");
static_assert(sizeof(sd_ctx_params_t) == 336, "context ABI changed");
static_assert(offsetof(sd_ctx_params_t, batched_cfg) == 289, "CFG ABI changed");
static_assert(offsetof(sd_ctx_params_t, linear_scale) == 292, "context ABI changed");
static_assert(offsetof(sd_ctx_params_t, tokenizer) == 304, "context ABI changed");
static_assert(offsetof(sd_ctx_params_t, conditioning_cache_size) == 316, "context ABI changed");
static_assert(offsetof(sd_ctx_params_t, flash_attn_mode) == 320, "attention mode ABI changed");
static_assert(offsetof(sd_ctx_params_t, conv_direct_mode) == 328, "convolution mode ABI changed");
static_assert(sizeof(sd_img_gen_params_t) == 552, "image ABI changed");
static_assert(sizeof(sd_vid_gen_params_t) == 584, "video ABI changed");
using Convert = bool (*)(const char*, const char*, const char*, const char*, const char*, const char*,
const char*, sd_type_t, const char*, bool, int, const sd_lora_t*, int);
static_assert(std::is_same<decltype(&convert_with_components), Convert>::value, "conversion ABI changed");

int main(int argc, char** argv) {
CHECK(argc == 3);
struct GuardedParams {
sd_ctx_params_t params;
unsigned char guard[32];
} guarded;
std::memset(&guarded, 0xA5, sizeof(guarded));
sd_ctx_params_init(&guarded.params);
for (unsigned char value : guarded.guard) CHECK(value == 0xA5);
const sd_ctx_params_t& params = guarded.params;
CHECK(params.batched_cfg);
CHECK(params.flash_attn_mode == nullptr && params.conv_direct_mode == nullptr);
CHECK(!params.flash_attn && !params.diffusion_flash_attn);
CHECK(!params.diffusion_conv_direct && !params.vae_conv_direct);
CHECK(params.conditioning_cache_size == 4);
CHECK(!sd_ctx_supports_ref_image_conditioning(nullptr));
const sd_preview_info_t preview = sd_get_preview_info();
CHECK(preview.sample_pass == 0 && preview.total_steps == 0);
CHECK(convert_with_components(argv[1], nullptr, nullptr, nullptr, nullptr, nullptr,
argv[2], SD_TYPE_F16, "", false, 1, nullptr, 0));
std::puts("runtime API defaults, bounds, capability query and conversion passed");
return 0;
}
8 changes: 8 additions & 0 deletions tests/test_exit_teardown.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,14 @@ def supports(self, flag: str) -> bool:
def test_tracks_waits_and_frees_in_order(self) -> None:
self.build_and_run()

def test_metal_build_runs_cfg_unbatched(self) -> None:
with tempfile.TemporaryDirectory() as directory:
binary = Path(directory) / "exit_teardown_test"
subprocess.run(compile_command(binary, SOURCES, "-DSD_DART_METAL=1"), check=True)
result = subprocess.run([str(binary), "batched-cfg"], capture_output=True, text=True,
timeout=60)
self.assertEqual(0, result.returncode, result.stdout + result.stderr)

def test_scenarios_are_clean_under_sanitizers(self) -> None:
for sanitizer in SANITIZERS:
flag = f"-fsanitize={sanitizer}"
Expand Down
55 changes: 55 additions & 0 deletions tests/test_runtime_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Exercise the changed upstream ABI against a built runtime without a model or GPU."""

import os
import platform
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "tools"))
import build # noqa: E402
from smoke_test import write_safetensors # noqa: E402


class RuntimeApiTest(unittest.TestCase):
def skip_unless_required(self, reason: str) -> None:
"""Skips, or fails with SD_REQUIRE_RUNTIME=1, as the CI build jobs set."""
if os.environ.get("SD_REQUIRE_RUNTIME"):
self.fail(reason)
self.skipTest(reason)

def test_context_defaults_and_component_conversion(self):
target_name = os.environ.get("SD_RUNTIME_API_TARGET", "")
if not target_name and sys.platform == "darwin":
target_name = "macos-arm64" if platform.machine() == "arm64" else "macos-x64"
if not target_name or sys.platform == "win32":
self.skip_unless_required("set SD_RUNTIME_API_TARGET to a built macOS or Linux target")
target = build.TARGETS[target_name]
library = build.BIN_ROOT / target.name / "lib" / target.library
if not library.is_file():
self.skip_unless_required(f"no built runtime for {target.name}")
with tempfile.TemporaryDirectory() as directory:
directory = Path(directory)
source = directory / "probe.safetensors"
output = directory / "probe.gguf"
write_safetensors(source)
binary = directory / "runtime_api_test"
subprocess.run([
os.environ.get("CXX") or shutil.which("c++"), "-std=c++17", "-O1",
f"-I{ROOT / 'src'}", f"-I{build.HEADER.parent}",
str(ROOT / "tests/native/runtime_api_test.cpp"),
f"-L{library.parent}", "-lstable-diffusion", f"-Wl,-rpath,{library.parent}",
"-o", str(binary),
], check=True)
result = subprocess.run([str(binary), str(source), str(output)],
capture_output=True, text=True, timeout=30)
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
self.assertEqual(b"GGUF", output.read_bytes()[:4])


if __name__ == "__main__":
unittest.main()
20 changes: 19 additions & 1 deletion tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tools"))

import apple_xcframework # noqa: E402
from build import HEADERS, TARGETS, WRAPPER_HEADER # noqa: E402
from build import HEADERS, TARGETS, WRAPPER_HEADER, wrapper_definition_problems # noqa: E402
from package_release import TAG_PATTERN # noqa: E402
from sd_api import api_symbols # noqa: E402
from validate_artifacts import ( # noqa: E402
Expand Down Expand Up @@ -195,6 +195,24 @@ def test_native_hosts_declare_their_cpu(self):
if target.os in ("linux", "windows"):
self.assertTrue(target.host_arch, target.name)

def test_metal_targets_must_configure_the_metal_guard(self):
apple = TARGETS["ios-arm64"]
self.assertEqual([], wrapper_definition_problems(
apple, {"SD_DART_METAL=1", "SD_DART_GPU_BACKEND=1", "GGML_SHARED"}))
for definitions in ({"SD_DART_GPU_BACKEND=1"},
{"SD_DART_METAL=0", "SD_DART_GPU_BACKEND=1"}):
self.assertEqual(["ios-arm64 configured stable-diffusion without SD_DART_METAL=1"],
wrapper_definition_problems(apple, definitions))

def test_other_targets_must_leave_it_off(self):
self.assertEqual([], wrapper_definition_problems(
TARGETS["linux-x64-vulkan"], {"SD_DART_METAL=0", "SD_DART_GPU_BACKEND=1"}))
self.assertEqual([], wrapper_definition_problems(
TARGETS["android-arm64"], {"SD_DART_METAL=0", "SD_DART_GPU_BACKEND=0"}))
self.assertEqual(["windows-x64 configured stable-diffusion without SD_DART_METAL=0"],
wrapper_definition_problems(
TARGETS["windows-x64"], {"SD_DART_METAL=1", "SD_DART_GPU_BACKEND=0"}))


class AppleXcframeworkTest(unittest.TestCase):
def test_every_apple_target_lands_in_one_slice(self):
Expand Down
2 changes: 1 addition & 1 deletion third_party/stable-diffusion.cpp
18 changes: 18 additions & 0 deletions tools/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,23 @@ def configure_args(target: Target, work_dir: Path) -> list[str]:
return args


def wrapper_definition_problems(target: Target, definitions: set[str]) -> list[str]:
"""What the wrapper sources were configured with that this target contradicts."""
expected = {
f"SD_DART_METAL={1 if 'metal' in target.accelerators else 0}",
f"SD_DART_GPU_BACKEND={1 if set(target.accelerators) - {'cpu'} else 0}",
}
return [f"{target.name} configured stable-diffusion without {definition}"
for definition in sorted(expected - definitions)]


def check_wrapper_definitions(target: Target, work_dir: Path) -> None:
path = work_dir / "sd_dart_definitions-Release.txt"
definitions = set(path.read_text().split()) if path.is_file() else set()
for problem in wrapper_definition_problems(target, definitions):
fail(problem)


def find_library(build_dir: Path, library: str) -> Path:
matches = [p for p in build_dir.rglob(library) if p.is_file() and not p.is_symlink()]
if not matches:
Expand Down Expand Up @@ -224,6 +241,7 @@ def build(target: Target, jobs: int) -> Path:
work_dir.mkdir(parents=True, exist_ok=True)
cmake_args = configure_args(target, work_dir)
run(["cmake", "-S", str(REPO_ROOT), "-B", str(work_dir), *cmake_args])
check_wrapper_definitions(target, work_dir)
run(["cmake", "--build", str(work_dir), "--config", "Release", "-j", str(jobs)])

out_dir = BIN_ROOT / target.name
Expand Down
Loading
Loading