Repository navigation
Release loaded chat model GPU state in the atexit handler (closes #2952) - #3043
Conversation
The process-wide atexit handler only drained the embedding-only LLAMA_STATES registry before dropping LLAMA_BACKEND, so a loaded chat model's Metal residency sets outlived the backend on exit() and reproduced the SIGABRT-on-quit crash class for chat sessions. Generalize the registry to any Mutex<Option<T>> model slot via a GpuResidentState trait object, register ChatEngine's state on load, and dedupe re-registration of the same slot on reload. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Pragmatic Code Review — PR #3043Automated pragmatic-code-review: REQUEST CHANGES (posted as a comment — GitHub blocks self-approval). Review TypeInitial review. The PR had no earlier reviews or comments. Requirements Check (#2952)
Code Review Findings🔴 Critical / Blocker1. The registry now keeps every chat model resident after unload or switch (multi-GB Metal leak)
Both paths drop the
The same latent problem already existed for embedding: an Fix (small): have the registry observe slots, not own them. Store static GPU_STATES: Mutex<Vec<Weak<dyn GpuResidentState>>> = Mutex::new(Vec::new());
pub(crate) fn register_state_for_cleanup<T: Send + 'static>(state: &Arc<Mutex<Option<T>>>) {
let state: Arc<dyn GpuResidentState> = Arc::clone(state) as _;
let weak = Arc::downgrade(&state);
let mut states = GPU_STATES.lock().unwrap_or_else(|p| p.into_inner());
states.retain(|s| s.strong_count() > 0 && !Weak::ptr_eq(s, &weak));
states.push(weak);
}
// atexit: states.iter().filter_map(Weak::upgrade).filter(|s| s.release()).count()This also removes the need to lock slots during pruning (see finding 2). A slot that is unloaded but still owned has almost no cost to keep, and the Test to add: register a slot, drop the owning 🟡 Improvements2. Registration can now stall behind a chat generation running on a different engine
This is not a deadlock. Lock order is always 3. Quitting during a chat generation now waits for that generation to finish
4. The test proves the dedupe and the release on distinct types, but not the lifetime contract The test is well built. It scopes itself to its own entries with 🟢 Looks Good
Nits
RecommendationREQUEST CHANGES. The fix to the atexit path is correct, but it turns every chat unload and model switch into a permanent multi-GB GPU leak, because the registry owns the slot. Switching to a |
malibio
left a comment
There was a problem hiding this comment.
Automated pragmatic-code-review: REQUEST CHANGES (posted as a comment — GitHub blocks self-approval). The strong-Arc GPU_STATES registry keeps dropped chat engines' models resident on unload/switch; see the review comment for details and the Weak-based fix.
- Blocker: the registry held a strong Arc to each slot, and services unload a chat model by dropping the engine (ChatEngine::unload_model has no production caller), so every unload/model switch left the old model resident on the GPU. Store Weak refs, prune dead ones, upgrade at exit. - Registration no longer locks slots to prune, so it cannot stall behind an in-flight generation holding one. - Drop the now-unused is_loaded and the unused release count. - Add a test that dropping the owner frees its model. Skipped: quit blocking until an in-flight chat turn finishes. Waiting is the correct behavior (freeing mid-decode is worse), and interrupting generation on shutdown is a separate feature. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review addressed (23398e9)✅ 🔴 Blocker 1: registry pinned chat models: confirmed. Pre-push gate: all tests passed. Re-review needed: the blocker fix changes the registry's ownership model. |
Pragmatic Code Review (Re-review): PR #3043Automated pragmatic-code-review: APPROVE (posted as a comment — GitHub blocks self-approval). Review TypeRe-review of Previous Review Summary
VerificationBlocker 1: the registry pinned models. RESOLVED.
Improvement 2: registration locked slots. RESOLVED. New test proves the ownership rule. CONFIRMED. Skip rationales. SOUND.
FindingsCritical IssuesNone. Suggested ImprovementsNone. Nitpicks
RecommendationAPPROVE. The |
malibio
left a comment
There was a problem hiding this comment.
Automated pragmatic-code-review: APPROVE (posted as a comment — GitHub blocks self-approval). Weak registry resolves the blocker and the lock-coupling finding; new test guards the ownership rule. See the re-review comment.
Re-review addressedVerdict was APPROVE with one non-blocking nit. ⏭️ Nit: entries for dropped owners are cleared only at the next registration. Skipped. Each stale entry is a tiny No code changes; no re-review needed. |
Problem
The process-wide atexit handler (shared by embedding and chat) only drained the embedding-only
LLAMA_STATESregistry before droppingLLAMA_BACKEND.ChatEngine::load_modelregistered the handler but never its own state, so a loaded chat model's Metal residency sets outlived the backend onexit()— the same SIGABRT-on-quit crash class previously fixed for embeddings.Fix
embedding.rs: replace theLlamaState-typed registry withGPU_STATES: Vec<Arc<dyn GpuResidentState>>, a trait implemented for anyMutex<Option<T>>model slot. The atexit callback releases every registered state before dropping the backend.register_state_for_cleanupis now generic andpub(crate), and it deduplicates re-registration of the same slot when a model reloads.chat/mod.rs:load_modelregistersself.stateright afterregister_atexit_handler(), the same way embedding does.Tests
registry_releases_states_of_distinct_types: registers two different state types (plus a duplicate registration) and checks each is registered once, released (dropped), and idempotent. It works on the registered entries directly rather than draining the global registry, which would unload models that parallel tests have loaded.-D warningsclean;--no-default-featuresand chat-only feature builds compile.Out of scope
Graceful shutdown (
drain_and_release_gpu,LocalAgentService::shutdown) still doesn't unload the chat engine. The atexit path now covers the crash either way, because it runs on everyexit().🤖 Generated with Claude Code