From e560851fca5826958fd885ad610ab371505f53e7 Mon Sep 17 00:00:00 2001 From: AlexCherrypi Date: Tue, 9 Jun 2026 17:53:49 +0200 Subject: [PATCH] metal : wind down leftover residency sets at teardown instead of aborting ggml_metal_rsets_free() did GGML_ASSERT([rsets->data count] == 0) and so called abort() when the Metal device is torn down (a C++ static destructor at process exit) while residency sets are still registered. On macOS 15+ this crashes the app on every quit: a residency set is removed from the collection only by ggml_metal_buffer_free(), so an app that exits without freeing every buffer (letting the OS reclaim the model on quit) leaves sets registered. The device does not own the buffers and cannot free them from its destructor, so make teardown defensive instead: stop the keep-alive heartbeat, then wind down residency on any leftover sets (endResidency + removeAllAllocations, mirroring ggml_metal_buffer_rset_free but without -release, since each set is still owned by its not-yet-freed buffer) before releasing the collection. The backing buffers are reclaimed by the OS as the process exits. No behavior change when all buffers were freed (the array is empty). Co-Authored-By: Claude Opus 4.8 (1M context) --- ggml/src/ggml-metal/ggml-metal-device.m | 28 ++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/ggml/src/ggml-metal/ggml-metal-device.m b/ggml/src/ggml-metal/ggml-metal-device.m index 05d7f43051ba..8372adbd3cde 100644 --- a/ggml/src/ggml-metal/ggml-metal-device.m +++ b/ggml/src/ggml-metal/ggml-metal-device.m @@ -618,14 +618,36 @@ void ggml_metal_rsets_free(ggml_metal_rsets_t rsets) { return; } - // note: if you hit this assert, most likely you haven't deallocated all Metal resources before exiting - GGML_ASSERT([rsets->data count] == 0); - + // Stop the keep-alive heartbeat before touching the collection, so the drain below + // cannot race the background thread's residency requests. atomic_store_explicit(&rsets->d_stop, true, memory_order_relaxed); dispatch_group_wait(rsets->d_group, DISPATCH_TIME_FOREVER); dispatch_release(rsets->d_group); + // The device is torn down from a C++ static destructor at process exit. If the host did + // not free every Metal buffer first, that buffer's residency set is still registered + // here, so the array is non-empty. The original code did GGML_ASSERT([rsets->data count] + // == 0), which calls abort() and crashes the app on every quit on macOS 15+. The device + // does not own the buffers and cannot free them from its destructor, so instead of + // aborting, defensively wind down residency on any leftover sets (mirroring + // ggml_metal_buffer_rset_free, minus -release, since each set is still owned by its + // not-yet-freed buffer) before releasing the collection. The backing buffers are + // reclaimed by the OS as the process exits. No behavior change when all buffers were + // freed (the array is empty). +#if defined(GGML_METAL_HAS_RESIDENCY_SETS) + if (@available(macOS 15.0, iOS 18.0, tvOS 18.0, visionOS 2.0, *)) { + if ([rsets->data count] != 0) { + GGML_LOG_DEBUG("%s: %ld residency set(s) still registered at teardown; winding down\n", + __func__, (long) [rsets->data count]); + for (id rset in rsets->data) { + [rset endResidency]; + [rset removeAllAllocations]; + } + } + } +#endif + [rsets->data release]; [rsets->lock release];