Repository navigation
v0.2.0-1
Pre-release
Pre-release
Free tracked contexts at exit before ggml-metal teardown (#15) * Free tracked contexts at exit before ggml-metal teardown ggml-metal aborts in its static destructor, with GGML_ASSERT([rsets->data count] == 0), when a process exits while a Metal buffer is still allocated. A Flutter macOS quit calls exit() without shutting the Dart isolates down, a hot restart drops them without running finalizers, and an isolate that is killed during new_sd_ctx never sees the context that call returns, so the caller cannot always free its contexts first. src/sd_dart_exit.cpp keeps a mutex-protected registry of live objects, the design of leehack/llamadart-native#99 for this library's own ggml: - sd_dart_new_sd_ctx tracks the context before it returns, and sd_dart_exit_free untracks an object inside the call that frees it, so nothing is left untracked or freed twice whenever an isolate is killed. sd_dart_exit_track tracks any other object for a native caller. - sd_dart_generate_image is a call in flight. Teardown asks every tracked context to cancel, repeats that until the calls in flight have ended, since generate_image clears a cancellation that precedes it, and frees only then, by stage. If a call is still running after wait_ms it frees nothing. - sd_dart_cancel_generation cancels a tracked context under the registry lock, so a cancellation from another thread cannot reach a context that teardown has freed. - Once teardown has begun, a thread that ends its outermost call in flight or reaches the registry outside one never returns to its caller. - On Apple platforms teardown runs during exit(), before the first static of the library is destroyed: the library defines __cxa_atexit for its own image, hidden and not exported, and registers every static destructor behind a call to teardown. A plain atexit handler only precedes the statics that exist when it is registered, and ggml-metal creates its device and buffer types on first use. validate_artifacts.py and apple_xcframework.py validate fail a slice that imports the system function. A context from new_sd_ctx is not tracked, and exiting with one alive behaves as before. With nothing tracked and no load in flight, teardown returns at once. tests/test_exit_teardown.py runs the registry against stand-ins for upstream, also under AddressSanitizer and ThreadSanitizer. Refs #10 * Run exit teardown against a built runtime and a real context The stand-in tests show what the registry does, not that a context is really freed, nor that the shipped library runs teardown before its statics go. tests/test_exit_teardown_runtime.py links a test against the built macOS runtime. The test writes its own model: a PixArt transformer with one small block, a one-block T5 encoder and a TAESD decoder, 12 MB, which the runtime loads and samples on Metal and on the CPU like any other. Only the tensors the runtime reads the dimensions from are listed; the rest come from its own validation errors, so there is no tensor list to maintain and nothing to download. The scenarios leave the process with a tracked context: idle, during a load, during a generation that is held until teardown is waiting for it, and with a second context on another device. Objects tracked around the contexts check that each context is still there before its stage and freed after it, which the allocator shows where Metal does not abort. tests/dart/exit_teardown.dart does the same under a Dart VM: an isolate killed with, or during, a load or a generation, and C exit() while one generates. CI gets an exit-teardown job on macOS, for the runtime as shipped and for one built with AddressSanitizer. On Linux the build job checks that a context left alive at exit is harmless on the CPU and reports what Vulkan does. Refs #10 * Only load models on a Metal device that cannot compute On GitHub's macOS runners every scenario that generates on the default device failed, the untracked control included: ggml-metal crashes with a null dereference in ggml_metal_op_bin as soon as the virtual GPU computes. Loading a model there works, and a loaded context already holds Metal buffers. The test now asks first whether each backend can generate at all. Where the default device cannot, the scenarios that can do without a generation skip it there, the two that need one run on the CPU alone, and the Dart VM harness runs on the CPU. Checked locally with GGML_METAL_NO_RESIDENCY=1, which is the other thing those runners differ in: a library whose static destructors bypass teardown still fails idle and late-load on Metal that way. Refs #10 * Wait for a free in flight, and longer for a load or a generation An audit of 2de1e1a found two ways a quit still reached ggml-metal's abort. A quit while the last tracked context was being freed. sd_dart_exit_free untracks under the lock and frees outside it, and teardown returned at once when nothing was tracked, so the static destructors ran under the free. With SD-Turbo on Metal, an exit within 3 ms of the start of that free failed 35 times out of 36. The registry now counts the calls in flight that create or free a tracked object, in the same critical section as the untracking, and teardown returns early only when nothing is tracked and no such call is in flight. sd_dart_new_sd_ctx had the same gap between the two critical sections it counted itself in: a teardown in between returned at once and the load began after it. Every call scope now enters and leaves in one critical section. A quit during an uninterruptible phase longer than the 2 s wait. stable-diffusion.cpp reads a cancellation only between the phases of a generation, and the VAE decode of one image takes 4.1 s at 768 x 768 and 7.4 s at 1024 x 1024 with SD-Turbo on an M4 Max: 17 of 25 quits during the first aborted. Teardown now waits up to 15 s while sd_dart_new_sd_ctx or sd_dart_generate_image is in flight, and 2 s as before for any other call. It still cancels every 20 ms, so the wait ends when the call does, and it still frees nothing when the bound runs out. With no call in flight it does not wait: an idle quit takes 0.03 s with either bound. sd_dart_exit_set_wait_ms takes both bounds. So quitting during a large generation can delay the exit of the process by up to 15 s. The header and the README say what the bound covers and what still exceeds it. New stand-in scenarios: a free and a load in flight with nothing tracked, a load loop racing teardown (run 400 times), a generation and a load that outlast the shorter bound, each bound running out, an idle teardown, and a generation that exits from its own callback. Refs #10 * Test a quit during the last free, and require Metal on request free-quit exits while the only tracked context is being freed, on the built runtime: with the early return of 2de1e1a it dies of SIGBUS or SIGSEGV on Metal, also without residency sets and without a generation, which is what the hosted jobs run. The Dart VM harness gets the same race as exit-in-free, takes a real model and its image size, and its probe no longer assumes a TAESD decoder in the model file. The fallback for a default device that cannot generate was only visible as a note. SD_REQUIRE_METAL_GENERATION=1 makes it a failure, for runs on a Mac. validate_artifacts.py says what its __cxa_atexit check does not show. Refs #10