From 3e539e35b9e4209a7fb711831714d288059427ec Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 2 Nov 2026 08:25:32 -0500 Subject: [PATCH 1/2] fix: reject unsafe VRAM budget loads before upstream creation --- README.md | 12 ++++++ src/sd_dart_device.cpp | 6 +++ src/sd_dart_exit.cpp | 21 ++++++++++ src/sd_dart_internal.h | 5 +++ src/sd_dart_log.cpp | 4 ++ src/sd_dart_wrapper.h | 6 +++ tests/native/exit_teardown_test.cpp | 60 ++++++++++++++++++++++++++++- 7 files changed, 112 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b159511..8f089d4 100644 --- a/README.md +++ b/README.md @@ -634,6 +634,18 @@ 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. +### VRAM-budget load safety + +Until [#34](https://github.com/leehack/stable-diffusion-native/issues/34) is +repaired upstream, `sd_dart_new_sd_ctx` returns NULL and records an actionable +`sd_dart_last_error()` when nonempty `max_vram` is combined with +`auto_fit=false`. It also rejects an empty/default backend on an integrated-only +GPU with a budget: select an explicit backend so auto-fit includes that device. +These checks run before upstream context creation. The raw `new_sd_ctx` export +remains unguarded, and consumers must retain their existing restrictions until +a published artifact and affected-device regression are qualified. This is a +safety restriction, not a repair of upstream memory planning. + ## Release Run the `Native Build & Release` workflow with a tag such as `v0.1.0`. Tags are diff --git a/src/sd_dart_device.cpp b/src/sd_dart_device.cpp index 0e6d57f..3704c1f 100644 --- a/src/sd_dart_device.cpp +++ b/src/sd_dart_device.cpp @@ -53,6 +53,12 @@ void copy_text(char* destination, size_t capacity, const char* text) { } // namespace +bool sd_dart_default_gpu_is_integrated() { + register_backends(); + return ggml_backend_dev_by_type(GGML_BACKEND_DEVICE_TYPE_GPU) == nullptr && + ggml_backend_dev_by_type(GGML_BACKEND_DEVICE_TYPE_IGPU) != nullptr; +} + int32_t sd_dart_gpu_device_count(void) { if (!SD_DART_GPU_BACKEND) { return SD_DART_GPU_NO_BACKEND; diff --git a/src/sd_dart_exit.cpp b/src/sd_dart_exit.cpp index 8808f4c..40b1754 100644 --- a/src/sd_dart_exit.cpp +++ b/src/sd_dart_exit.cpp @@ -417,6 +417,27 @@ SdDartStaticsCall::~SdDartStaticsCall() { sd_ctx_t* sd_dart_new_sd_ctx(const sd_ctx_params_t* sd_ctx_params) { Call call(kObjectCall | kWorkCall); ErrorWindow errors; + // The pinned upstream asserts when a VRAM budget prevents its load-time + // prediction probe. Auto-fit skips planning on an integrated-only default + // GPU, so that route needs an explicit backend before it is safe too. + if (sd_ctx_params != nullptr && sd_ctx_params->max_vram != nullptr && + sd_ctx_params->max_vram[0] != '\0') { + if (!sd_ctx_params->auto_fit) { + sd_dart_log_load_rejected("max_vram requires auto_fit=true; the pinned runtime may abort without auto-fit."); + return nullptr; + } + if (sd_ctx_params->backend == nullptr || sd_ctx_params->backend[0] == '\0') { + try { + if (sd_dart_default_gpu_is_integrated()) { + sd_dart_log_load_rejected("max_vram on an integrated-only GPU requires an explicit backend so auto-fit can plan it."); + return nullptr; + } + } catch (...) { + sd_dart_log_load_rejected("Cannot inspect the default GPU safely for max_vram; select an explicit backend."); + return nullptr; + } + } + } #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 diff --git a/src/sd_dart_internal.h b/src/sd_dart_internal.h index a07f73d..b880a61 100644 --- a/src/sd_dart_internal.h +++ b/src/sd_dart_internal.h @@ -42,5 +42,10 @@ void sd_dart_log_call_end(); // failure conservatively invalidates every generation overlapping it. uint64_t sd_dart_log_backend_failures(); void sd_dart_log_generation_failed(); +void sd_dart_log_load_rejected(const char* reason); + +// Called only inside the tracked context-creation call. May throw on a +// backend registry failure, which the caller must convert to a load error. +bool sd_dart_default_gpu_is_integrated(); #endif // SD_DART_INTERNAL_H diff --git a/src/sd_dart_log.cpp b/src/sd_dart_log.cpp index 15a3a59..2a2b3f9 100644 --- a/src/sd_dart_log.cpp +++ b/src/sd_dart_log.cpp @@ -292,6 +292,10 @@ void sd_dart_log_generation_failed() { record(SD_LOG_ERROR, "", "Metal backend failed; discard the image result and recreate the context before generating again."); } +void sd_dart_log_load_rejected(const char* reason) { + record(SD_LOG_ERROR, "", reason); +} + void sd_dart_log_enable(void) { // Upstream and ggml keep their callbacks in unsynchronized globals, so // they are written once rather than on every call. diff --git a/src/sd_dart_wrapper.h b/src/sd_dart_wrapper.h index 2f57216..6dc6c43 100644 --- a/src/sd_dart_wrapper.h +++ b/src/sd_dart_wrapper.h @@ -206,6 +206,12 @@ SD_API void sd_dart_exit_set_wait_ms(int32_t wait_ms, int32_t work_wait_ms); // isolates. SD_API void sd_dart_exit_teardown(void); +// Returns NULL with sd_dart_last_error() when max_vram is nonempty and +// auto_fit is false, or when auto_fit uses an integrated-only default GPU +// without an explicit backend. The pinned upstream can abort in these cases. +// Safe loads still require sufficient device memory; raw new_sd_ctx() is not +// guarded by this wrapper. +// // new_sd_ctx() that tracks the context in the CONTEXT stage before it // returns. Free the context with sd_dart_exit_free(). A load cannot be // cancelled: teardown waits for it, with the longer bound. Blocks after diff --git a/tests/native/exit_teardown_test.cpp b/tests/native/exit_teardown_test.cpp index 0973e05..d81eba1 100644 --- a/tests/native/exit_teardown_test.cpp +++ b/tests/native/exit_teardown_test.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -1760,6 +1761,8 @@ int test_exit_in_fork_child() { } // namespace +std::atomic test_device_type{GGML_BACKEND_DEVICE_TYPE_GPU}; + struct ggml_backend_device { const char* name = "MTL0"; }; @@ -1795,7 +1798,7 @@ ggml_backend_dev_t ggml_backend_dev_get(size_t) { ggml_backend_dev_t ggml_backend_dev_by_type(enum ggml_backend_dev_type type) { RegistryRead read; - return type == GGML_BACKEND_DEVICE_TYPE_GPU ? device_registry().device : nullptr; + return type == test_device_type.load() ? device_registry().device : nullptr; } const char* ggml_backend_dev_name(ggml_backend_dev_t device) { @@ -1807,7 +1810,7 @@ const char* ggml_backend_dev_description(ggml_backend_dev_t) { } enum ggml_backend_dev_type ggml_backend_dev_type(ggml_backend_dev_t) { - return GGML_BACKEND_DEVICE_TYPE_GPU; + return test_device_type.load(); } void ggml_backend_dev_memory(ggml_backend_dev_t, size_t* free, size_t* total) { @@ -1911,6 +1914,58 @@ void free_sd_image_generation_result(sd_image_generation_result_t* result) { // A Metal build loads with batched CFG off from a copy of the caller's // parameters; any other build passes them through. +int test_vram_load_guard() { + sd_ctx_params_t params{}; + params.max_vram = "1"; + sd_dart_log_set_level(SD_LOG_ERROR + 1); + const auto before = loads_begun.load(); + CHECK(sd_dart_new_sd_ctx(¶ms) == nullptr); + CHECK(loads_begun.load() == before); + CHECK(sd_dart_exit_tracked_count() == 0); + CHECK(last_error().find("auto_fit=true") != std::string::npos); + + params.auto_fit = true; + test_device_type = GGML_BACKEND_DEVICE_TYPE_IGPU; + CHECK(sd_dart_new_sd_ctx(¶ms) == nullptr); + CHECK(loads_begun.load() == before); + CHECK(last_error().find("explicit backend") != std::string::npos); + params.backend = ""; + CHECK(sd_dart_new_sd_ctx(¶ms) == nullptr); + CHECK(loads_begun.load() == before); + + // Explicit auto-fit planning, a discrete default GPU and no budget remain + // admitted, and a rejected call must not poison a later successful load. + params.backend = "gpu"; + sd_ctx_t* context = sd_dart_new_sd_ctx(¶ms); + CHECK(context != nullptr && loads_begun.load() == before + 1); + CHECK(last_error().empty()); + sd_dart_exit_free(context); + params.backend = nullptr; + test_device_type = GGML_BACKEND_DEVICE_TYPE_GPU; + context = sd_dart_new_sd_ctx(¶ms); + CHECK(context != nullptr && loads_begun.load() == before + 2); + sd_dart_exit_free(context); + // A CPU-only registry does not have the unplanned integrated-GPU path. + test_device_type = GGML_BACKEND_DEVICE_TYPE_CPU; + context = sd_dart_new_sd_ctx(¶ms); + CHECK(context != nullptr); + sd_dart_exit_free(context); + test_device_type = GGML_BACKEND_DEVICE_TYPE_GPU; + hold_query.store(+[] { throw std::runtime_error("registry unavailable"); }); + CHECK(sd_dart_new_sd_ctx(¶ms) == nullptr); + CHECK(last_error().find("Cannot inspect") != std::string::npos); + hold_query.store(nullptr); + params.auto_fit = false; + for (const char* budget : {static_cast(nullptr), ""}) { + params.max_vram = budget; + context = sd_dart_new_sd_ctx(¶ms); + CHECK(context != nullptr); + sd_dart_exit_free(context); + } + CHECK(sd_dart_exit_tracked_count() == 0); + return 0; +} + int test_batched_cfg() { sd_ctx_params_t params{}; params.n_threads = 3; @@ -1979,6 +2034,7 @@ int main(int argc, char** argv) { {"generation-without-images", test_generation_without_images}, {"log-at-exit", test_log_at_exit}, {"batched-cfg", test_batched_cfg}, + {"vram-load-guard", test_vram_load_guard}, #if defined(__APPLE__) {"exit-in-flight", test_exit_in_flight}, #endif From 795aa2cbf86532fcdb43cb19ed39125c89b659dd Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 2 Nov 2026 08:29:20 -0500 Subject: [PATCH 2/2] fix: reject VRAM budgets when placement disables auto-fit --- README.md | 3 ++- src/sd_dart_exit.cpp | 6 ++++-- src/sd_dart_wrapper.h | 3 ++- tests/native/exit_teardown_test.cpp | 21 ++++++++++++++++++++- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8f089d4..ac35ba5 100644 --- a/README.md +++ b/README.md @@ -639,7 +639,8 @@ so a batched CFG step returns a wrong image without an error. Until [#34](https://github.com/leehack/stable-diffusion-native/issues/34) is repaired upstream, `sd_dart_new_sd_ctx` returns NULL and records an actionable `sd_dart_last_error()` when nonempty `max_vram` is combined with -`auto_fit=false`. It also rejects an empty/default backend on an integrated-only +`auto_fit=false` or a nonempty `params_backend` (which disables auto-fit even +when `auto_fit=true`). It also rejects an empty/default backend on an integrated-only GPU with a budget: select an explicit backend so auto-fit includes that device. These checks run before upstream context creation. The raw `new_sd_ctx` export remains unguarded, and consumers must retain their existing restrictions until diff --git a/src/sd_dart_exit.cpp b/src/sd_dart_exit.cpp index 40b1754..c5045b6 100644 --- a/src/sd_dart_exit.cpp +++ b/src/sd_dart_exit.cpp @@ -422,8 +422,10 @@ sd_ctx_t* sd_dart_new_sd_ctx(const sd_ctx_params_t* sd_ctx_params) { // GPU, so that route needs an explicit backend before it is safe too. if (sd_ctx_params != nullptr && sd_ctx_params->max_vram != nullptr && sd_ctx_params->max_vram[0] != '\0') { - if (!sd_ctx_params->auto_fit) { - sd_dart_log_load_rejected("max_vram requires auto_fit=true; the pinned runtime may abort without auto-fit."); + const bool explicit_params_backend = sd_ctx_params->params_backend != nullptr && + sd_ctx_params->params_backend[0] != '\0'; + if (!sd_ctx_params->auto_fit || explicit_params_backend) { + sd_dart_log_load_rejected("max_vram requires auto_fit=true and an empty params_backend; the pinned runtime disables auto-fit with an explicit params_backend and may abort without planning."); return nullptr; } if (sd_ctx_params->backend == nullptr || sd_ctx_params->backend[0] == '\0') { diff --git a/src/sd_dart_wrapper.h b/src/sd_dart_wrapper.h index 6dc6c43..0d85aec 100644 --- a/src/sd_dart_wrapper.h +++ b/src/sd_dart_wrapper.h @@ -207,7 +207,8 @@ SD_API void sd_dart_exit_set_wait_ms(int32_t wait_ms, int32_t work_wait_ms); SD_API void sd_dart_exit_teardown(void); // Returns NULL with sd_dart_last_error() when max_vram is nonempty and -// auto_fit is false, or when auto_fit uses an integrated-only default GPU +// auto_fit is false or params_backend is nonempty (which disables auto-fit), +// or when auto_fit uses an integrated-only default GPU // without an explicit backend. The pinned upstream can abort in these cases. // Safe loads still require sufficient device memory; raw new_sd_ctx() is not // guarded by this wrapper. diff --git a/tests/native/exit_teardown_test.cpp b/tests/native/exit_teardown_test.cpp index d81eba1..a7fdd0c 100644 --- a/tests/native/exit_teardown_test.cpp +++ b/tests/native/exit_teardown_test.cpp @@ -1918,13 +1918,31 @@ int test_vram_load_guard() { sd_ctx_params_t params{}; params.max_vram = "1"; sd_dart_log_set_level(SD_LOG_ERROR + 1); - const auto before = loads_begun.load(); + auto before = loads_begun.load(); CHECK(sd_dart_new_sd_ctx(¶ms) == nullptr); CHECK(loads_begun.load() == before); CHECK(sd_dart_exit_tracked_count() == 0); CHECK(last_error().find("auto_fit=true") != std::string::npos); params.auto_fit = true; + // Upstream disables planning for any nonempty params_backend, even when + // auto_fit is true and the runtime backend is explicitly selected. + params.backend = "gpu"; + for (const char* placement : {"gpu", "cpu", "diffusion=gpu"}) { + params.params_backend = placement; + CHECK(sd_dart_new_sd_ctx(¶ms) == nullptr); + CHECK(loads_begun.load() == before); + CHECK(sd_dart_exit_tracked_count() == 0); + CHECK(last_error().find("empty params_backend") != std::string::npos); + } + params.params_backend = ""; + sd_ctx_t* explicit_context = sd_dart_new_sd_ctx(¶ms); + CHECK(explicit_context != nullptr); + CHECK(last_error().empty()); + sd_dart_exit_free(explicit_context); + before = loads_begun.load(); + params.backend = nullptr; + params.params_backend = nullptr; test_device_type = GGML_BACKEND_DEVICE_TYPE_IGPU; CHECK(sd_dart_new_sd_ctx(¶ms) == nullptr); CHECK(loads_begun.load() == before); @@ -1956,6 +1974,7 @@ int test_vram_load_guard() { CHECK(last_error().find("Cannot inspect") != std::string::npos); hold_query.store(nullptr); params.auto_fit = false; + params.params_backend = "gpu"; for (const char* budget : {static_cast(nullptr), ""}) { params.max_vram = budget; context = sd_dart_new_sd_ctx(¶ms);