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 12 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.
…stein/fix/multigpu-shutdown-evict-records # Conflicts: # invokeai/backend/model_manager/load/model_cache/model_cache.py # tests/backend/model_manager/load/model_cache/test_model_cache_ram_budget.py
…he worker's own death Two follow-ups from review. put() after shutdown() no longer arms the post-admission grace. That flag's backstop releaser is the sweep at the top of the next put(), and after shutdown no further put() is guaranteed: a load cancelled between put() and the LoadedModel's construction leaves no wrapper (hence no finalizer either), so an armed flag would stand for the life of the process, hiding the record from every asynchronous eviction path while its bytes stayed charged to the shared budget. Withholding it costs only the shield -- the record stays stale at birth, so its eventual release still evicts it, and a loader that does come back gets the ordinary first_use_holds shield. The deferred worker now runs stranded-shield recovery from inside its own dying frame. Previously recovery depended on something else happening first -- the next admission, or shutdown() -- and neither is guaranteed when the worker dies *after* shutdown()'s liveness check: the records the shutdown sweep retained for a live holder were left shielded by holds nothing could release. The recovery is scoped by thread identity (a replacement worker's shields are its own) and retires the worker slot before sweeping, so a concurrent admission cannot arm a shield the recovery is about to zero. It also drains the queue the dead worker left behind, whose _AbandonedHolderRelease items pin their models' CPU weights. _ensure_deferred_worker and shutdown() now share the same recovery, which also lifts orphaned admission graces and evicts whatever that leaves unshielded. Three tests, each reverted-and-confirmed-failing against the code it guards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GjAw9hpJe8d1GyvpdKJFGw
… records Adversarial review of the previous commit found three problems with the shared recovery it introduced. The recovery cleared the put()-set admission grace unconditionally. On a live cache that is a new failure mode, not a fix: the dying worker is is_alive() for as long as it unwinds, so a cold load landing in that window starts no replacement worker and is admitted with the ordinary grace, which the recovery then zeroed while the loader was still between put() and get() -- a reconcile could evict the record and the loader's get() would raise IndexError. The grace only actually loses a releaser once the cache is shut down (its backstop is the next put()'s sweep, not the worker), so it is now lifted only then. The recovery also evicted stale-unshielded records from _ensure_deferred_worker, which register_first_use_hold calls before arming -- so a second wrapper's construction could detach the very record it was about to shield, releasing shared-store ownership while live wrappers still held the tensors. That is the accounting lie shutdown() itself refuses to make. The eviction moved to _evict_stale_unshielded_entries, called only from the dying worker and only on a shut-down cache, where nothing else can ever run it; it now also collects and empties the device cache the way the other abandonment path does. Keeping shutdown()'s call to pure field assignments restores its old property that the branch cannot raise before the resident-record sweep. The queue drain the previous commit added did not close the pin it targeted: _dispatch_deferred's liveness gate is unsynchronized, so a finalizer that read the worker slot just before it was retired still enqueues after the drain. The drain is gone; _AbandonedHolderRelease now holds its record weakly instead, so a stranded item pins nothing, and the worker clears the strong reference it resolves before parking on the next get(). Also moves _reconcile_budget_if_pending's lock acquisition adjacent to its try: a BaseException between the two leaked the cache RLock to an unwinding thread, blocking every other thread for the life of the process. Five tests, each reverted-and-confirmed-failing against the code it guards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GjAw9hpJe8d1GyvpdKJFGw
…ot the slot A second adversarial pass found that retiring the worker slot from inside the dying worker silently disabled both remaining recovery sites, which gated on "a dead thread still occupying the slot". The dying recovery deliberately leaves a live cache's admission grace standing -- the next put()'s sweep is still its backstop -- and hands the lift to shutdown(); with the slot already empty, shutdown() skipped it and stale-retained the record, its shared-store reference and its budget bytes for the life of the process. Both gates now key on "no live worker": shutdown() lifts when the slot is empty or dead, and the worker start recovers unconditionally (it has already returned if a worker is alive). That also makes a failed recovery retryable, which matters because the recovery was not exception-safe and had already retired the slot by the time it could raise. _clear_stranded_first_use_holds now unshields every record before reporting any of them -- a logging handler that raises is one of the ways the worker dies in the first place, and logging inline let that same handler abort the sweep partway -- and the post-eviction gc/empty_cache housekeeping, which the codebase already documents can raise from a sick CUDA context, no longer takes the eviction down with it. Also corrects two overstated claims in the weakref rationale: a stranded queue item can be drained later by a replacement worker (the queue is per-cache, not per-worker), and the hold decrement in _release_abandoned_holder runs before the identity check -- it is inert on a detached record for a different reason, which the docstring now gives. Moving _reconcile_budget_if_pending's acquire adjacent to its try narrows the RLock-leak window rather than closing it; said so. Three tests, each reverted-and-confirmed-failing against the code it guards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GjAw9hpJe8d1GyvpdKJFGw
|
Both confirmed and fixed in 4445d2d, and the merge conflicts with To fix — Corner case — the worker dying after Adversarially reviewing my own mechanism afterwards was worth more than the original fix, so in the interest of not hiding it: the first two rounds of that fix were wrong in three ways, all now corrected and each pinned by a test that fails against the broken version.
Smaller hardening in the same pass:
Two residuals disclosed rather than papered over. A Eleven new tests across the three commits. Every production line the round-1 and round-2 fixes touch was individually reverted and confirmed to fail exactly the test that guards it. Full |
JPPhoto
left a comment
There was a problem hiding this comment.
A few reachable and possible issues:
-
invokeai/backend/model_manager/load/model_cache/model_cache.py:654-688, 1091-1116: An admission can fail betweenput()andget(). With a live worker,shutdown()marks it stale but leavesawaiting_first_use; no finalizer or later sweep removes it. Effect: model, shared-store reference, and budget charge persist until cache/process destruction. Likelihood: plausible shutdown cancellation/error. Recovery: manual eviction or restart. Test:put()withoutget(), callshutdown()while worker lives, then verify record and budget remain. -
invokeai/backend/model_manager/load/load_base.py:59-87; invokeai/backend/model_manager/load/model_cache/model_cache.py:684-688, 1027-1052: Hold registration occurs afterget(). If shutdown evicts during that gap, registration returnsNone, then the wrapper locks a detached record. Effect: peer reload can create duplicate canonical weights while budget counts one copy, risking OOM. Likelihood: rare but reachable during shutdown racing an in-flight load. Recovery: no automatic reattachment; restart/cache rebuild. Test: pauseregister_first_use_hold(), runshutdown(), resume, then reload the key on a peer and compare store/budget accounting.
Suggestions:
- Consider making wrapper claiming atomic with cache lookup, and explicitly retire unclaimed admissions during shutdown.
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