diff --git a/README.md b/README.md index b159511..ac35ba5 100644 --- a/README.md +++ b/README.md @@ -634,6 +634,19 @@ 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` 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 +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..c5045b6 100644 --- a/src/sd_dart_exit.cpp +++ b/src/sd_dart_exit.cpp @@ -417,6 +417,29 @@ 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') { + 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') { + 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..0d85aec 100644 --- a/src/sd_dart_wrapper.h +++ b/src/sd_dart_wrapper.h @@ -206,6 +206,13 @@ 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 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. +// // 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..a7fdd0c 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,77 @@ 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); + 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); + 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; + params.params_backend = "gpu"; + 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 +2053,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