fix(model cache): evict records at shutdown() instead of only releasing shared weights - #9494
fix(model cache): evict records at shutdown() instead of only releasing shared weights#9494lstein wants to merge 8 commits into
Conversation
Nothing released a cache's SharedCpuWeightsStore references except
_delete_cache_entry(): shutdown() left every resident record's refcount
held, and a cache dropped without shutdown() (test teardown; any future
wiring that rebuilds caches at runtime) stranded the canonical tensors
and their accounting forever. Today's production wiring tears the store
down together with its caches, so the live exposure is cross-test
pollution of the process-global store and RAM pinned past
ModelManagerService.stop() — but the refcount invariant ('every acquire
is paired with exactly one release') was simply not upheld, and this
makes it self-healing before any wiring change turns it into a real
peer-accounting bug.
Two mechanisms, for the two ways a cache goes away:
- shutdown() now releases its resident records' shared references
synchronously — it runs in a normal thread context, so the direct
(locking) release is safe there, and teardown does not depend on a
later store operation happening.
- Each wrapper registers a weakref.finalize fallback for the
dropped-without-shutdown case. The finalizer runs in GC context,
where taking the store's non-reentrant lock could self-deadlock (a
collection can fire inside acquire()'s critical section on the same
thread — the rule ModelCache.release_first_use_grace documents), so
it only ENQUEUES into a SimpleQueue; every public store method drains
the queue under the lock. The finalizer is registered inside the
acquire's try (a registration failure must release too), its args
carry the key and canonical dict rather than the wrapper (finalize
holds args strongly — referencing self would make the wrapper
immortal), and release_shared_weights() detaches it before releasing
synchronously so eviction-then-collection releases exactly once. The
state-dict identity keeps releases correct across invalidate()'s
retired entries.
RamBudget.total_in_use() now documents why its store read must stay
outside the budget lock: the drain allocates under the store lock, so
GC can run _on_cache_collected (store→budget) there, and a
budget→store order anywhere would complete the deadlock cycle.
Six regression tests, verified to fail before the fix, covering:
shutdown releases synchronously with an empty queue; collection returns
refcount/bytes/budget to zero; the collection-time release is
enqueue-only (never applied inline by GC); eviction + collection
release exactly once across two caches; a retired (invalidated) entry
is freed by a collected holder; and the partial-load wrapper behaves
like the full-load one. One existing test relied on an abandoned
wrapper leaking its reference and now binds it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ng shared weights shutdown() released the resident records' shared-store references while retaining the records themselves, so the accounting stopped describing reality: - The store (and RamBudget) reported zero for bytes whose tensors the retained wrappers still held. - A post-shutdown load of the same key on a peer cache registered a duplicate canonical alongside the still-resident released copy. - A post-shutdown eviction of a released record (put() after shutdown() is reachable: Invoker.stop() stops the model manager before the session processor) read uses_shared_weights as already-False and debited the non-shared budget for bytes that were admitted as shared. shutdown() now routes idle records through _delete_cache_entry(), which releases shared ownership and budget accounting together, exactly once. Records still in use — locked by an in-flight generation or inside the put()->lock() admission window — keep their references and are marked stale; unlock() evicts them through the existing stale path when the generation lets go, so the accounting stays truthful at every point. All five regression tests verified to fail against the previous shutdown() behavior. Follow-on to invoke-ai#9403, addressing JPPhoto's review comment there. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lete_cache_entry Surfaced by adversarial review of the shutdown() change: a stale-marked record can be detached while still locked (the VRAM-move error paths call _delete_cache_entry on a locked record) and its key re-admitted before the record's last unlock(). The stale-eviction path matched by key only, so it popped the NEW record — detaching it from the cache and all accounting — and, the old record's shared release having already happened, read uses_shared_weights as False and debited the non-shared budget for bytes that were admitted as shared. The hazard predates the shutdown() change (drop_model() sets the same flag), but shutdown() now arms stale marks at every server stop that overlaps in-flight work, so close it here: _delete_cache_entry() and unlock()'s stale eviction act only when the record passed in IS the record currently held under its key; a delete of a detached record is a full no-op. Regression test verified to fail against the key-only matching. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
JPPhoto
left a comment
There was a problem hiding this comment.
Merge blockers:
invokeai/backend/model_manager/load/model_cache/model_cache.py:567-570:shutdown()evicts warm records betweenget()andLoadedModelWithoutConfig.__enter__(). Delayed lock uses detached record; peer load creates duplicate canonical weights while budget counts only one. Test: warm-load/unlockm, pause afterget(), callshutdown(), then enter and loadmon peer; assert distinct state dicts and both copies counted.
Other findings/issues:
invokeai/backend/model_manager/load/model_cache/model_cache.py:541,567-568: Abandoned first-use records are marked stale, but post-shutdown finalizer work is dropped by_dispatch_deferred()at:819-824; nounlock()means record and shared RAM remain pinned. Test:put()normally, createLoadedModelWithoutConfigwithout entering, shut down, delete it, collect, and assert record/store refcount/budget reach zero.
Suggestions:
- Consider tracking every
get()-to-lock()holder through shutdown, with explicit cleanup for abandoned admissions.
…andonment Two defects found in review of the shutdown eviction change (JPPhoto, 2026-08-13): 1. shutdown() racing the gap between get() and the LoadedModel's first lock evicted the warm record out from under its holder: the holder locked a detached record whose shared-store ownership had just been released, so a peer's reload of the same key minted a duplicate canonical copy while the budget counted one. 2. A record retained by the shutdown sweep for a never-locked holder could never be evicted if that holder was simply dropped: the abandonment finalizer's deferred work was discarded post-shutdown (and the worker was stopped), pinning the record, its shared-store refcount and its budget bytes for the life of the process. The fix tracks every wrapper's get()->lock() window with a per-record hold count (CacheRecord.first_use_holds), armed in LoadedModelWithoutConfig's constructor and released exactly once per wrapper — on its first lock, or by its weakref finalizer if it is dropped un-entered. Held records are treated like locked ones by every eviction path (shutdown, budget reconcile, peer-requested eviction, make_room, drop_model, unlock's stale eviction); stale-marked records whose last holder is abandoned are evicted by the deferred worker, which now outlives shutdown() for exactly that purpose (it already exits via the cache-collection finalizer). Holds are only granted while a worker is alive to carry the finalizer's release, and a worker death zeroes surviving holds at the next start so no record can stay shielded with nothing left to unshield it. Admissions landing after shutdown() are marked stale at birth so their final release evicts them too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s at shutdown Hardening from adversarial review of the first-use-hold mechanism: - Hold releases (the wrapper's first-lock release and the abandonment finalizer's deferred release) now quote the epoch the hold was armed under, and dead-worker recovery bumps the record's epoch when it zeroes stranded holds. Without this, a surviving wrapper's late release — or a release enqueued before the worker died and drained after the restart — would decrement a fresh hold armed by a different wrapper under the healthy replacement worker, silently unshielding that wrapper's window. - shutdown() now runs the dead-worker hold recovery itself (and clears the put()-grace flags in the same situation): a hold whose abandonment release was dropped by the dead-thread dispatch check has no other releaser, and after shutdown no put() is guaranteed to run the usual next-start recovery — the sweep would stale-retain the record, its shared-store refcount and its budget bytes for the life of the process. - register_first_use_hold() declines to arm on a record that is no longer the occupant under its key: an eviction already won the race against the wrapper's construction and a hold on a detached record shields nothing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Both findings confirmed — thank you, they were both real. Fixed in 5fda854 + d000e0a, following your suggestion of tracking every get()-to-lock() holder through shutdown. Blocker — shutdown() between get() and One residual, disclosed rather than papered over: the few instructions between Non-blocker — abandoned first-use record post-shutdown. Confirmed: the finalizer's deferred work was dropped twice over ( Hardening that fell out of adversarially reviewing the mechanism, in the same push:
Twelve new tests; each was reverted-and-confirmed-failing against the code it guards, including both of your scenarios. Full model_manager suite green, ruff clean. |
JPPhoto
left a comment
There was a problem hiding this comment.
To fix:
invokeai/backend/model_manager/load/model_cache/model_cache.py:716-723 (ModelCache.put)re-armsawaiting_first_useforput()calls after shutdown. If loading is canceled beforeget()or wrapper construction, the stale record remains retained and keeps shared RAM accounted indefinitely.Test:callcache.shutdown(); cache.put("m", DummyModule()); observecache._cached_models["m"].awaiting_first_useand nonzero budget usage.
Corner/impossible cases:
invokeai/backend/model_manager/load/model_cache/model_cache.py:584-620, 869-890 (shutdown/_dispatch_deferred)can strand a wrapper hold if the deferred worker dies after shutdown's liveness check. The finalizer then drops its release, while no later admission runs hold recovery.Test:shut down with a live held wrapper, terminate the worker immediately afterward, delete the wrapper, and rungc.collect(); the stale record andfirst_use_holdsremain.
Suggestions:
-
Consider disabling admission grace after shutdown, or synchronously evicting unwrapped post-shutdown records.
-
Consider making worker termination recovery independent of a later
put(), such as a terminal shutdown sweep or a synchronous fallback release.
Summary
Follow-on to #9403, addressing the issue @JPPhoto flagged in his approving review there:
shutdown()released the resident records' shared-store references but kept the records (and their models) in_cached_models, so the accounting stopped describing reality. Concretely:RamBudget.total_in_use()— reported zero for bytes whose tensors the retained wrappers still held.shutdown()is not a hard barrier (Invoker.stop()stops the model manager before the session processor, whose workers are cancelled but not joined), so a post-shutdown load of the same key on a peer cache registered a duplicate canonical alongside the still-resident released copy — two copies in RAM, one counted.release_shared_weights()flipsuses_shared_weightsto False, so a post-shutdown eviction of such a record (put()aftershutdown()triggering_make_room_internal) debited the non-shared budget for bytes that were admitted as shared — uncounting another still-resident non-shared model's contribution when the cache held a mix.Design
Per the review suggestion,
shutdown()retains ownership until record eviction:_delete_cache_entry(), which releases shared-store ownership and budget accounting together, exactly once, at the moment the record actually goes away. The release stays synchronous (the original motivation for the shutdown-time release: finalizers only enqueue, and at teardown nothing may drain the queue).put()→lock()admission window (awaiting_first_use) — keep their references and are markedis_stale; the existing stale path inunlock()evicts them when the generation lets go. A record never unlocked keeps its bytes and its accounting until process exit, which is the truthful description of a model that really is still resident.Second commit: identity guard in stale eviction
An adversarial review of the first commit surfaced a related pre-existing hazard that the shutdown change arms at every server stop overlapping in-flight work:
unlock()'s stale eviction and_delete_cache_entry()matched records by key, not identity. A stale-marked record can be detached while still locked (the VRAM-move error paths delete locked records) and the key re-admitted before the record's lastunlock(); the key-only match then popped the new record — detaching it from the cache and all accounting — and debited the non-shared budget for the old record's shared-admitted bytes. Both sites now act only when the record passed in is the current occupant of its key, making a delete of a detached record a full no-op.Tests
Six regression tests, each verified to fail against the code it guards:
unlock()evicts it exactly once;unlock()leaves a re-admitted same-key record (and the budget) untouched;Status
Stacked on #9403 (
lstein/fix/multigpu-shared-weights-collect); the diff shows that branch's commit until it merges. Marked draft until then — rebase ontomainand un-draft after #9403 lands.🤖 Generated with Claude Code