Skip to content

fix(model cache): evict records at shutdown() instead of only releasing shared weights - #9494

Open
lstein wants to merge 12 commits into
invoke-ai:mainfrom
lstein:lstein/fix/multigpu-shutdown-evict-records
Open

fix(model cache): evict records at shutdown() instead of only releasing shared weights#9494
lstein wants to merge 12 commits into
invoke-ai:mainfrom
lstein:lstein/fix/multigpu-shutdown-evict-records

Conversation

@lstein

@lstein lstein commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

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:

  • The store — and therefore 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.
  • A third defect surfaced while confirming the report: release_shared_weights() flips uses_shared_weights to False, so a post-shutdown eviction of such a record (put() after shutdown() 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:

  • Idle records are routed through _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).
  • In-use records — locked by an in-flight generation, or inside the put()lock() admission window (awaiting_first_use) — keep their references and are marked is_stale; the existing stale path in unlock() 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 last unlock(); 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:

  • shutdown evicts idle records and zeroes store refcount + budget, with the wrapper actually collectable (the zero accounting is true);
  • a locked record retains its refcount and budget bytes across shutdown, and the last unlock() evicts it exactly once;
  • same for a record inside the admission window;
  • a peer cache reloading the key after this cache's shutdown adopts the same canonical state dict (identity-checked) instead of registering a duplicate — the reacquire test the review specified;
  • a detached stale record's last unlock() leaves a re-admitted same-key record (and the budget) untouched;
  • the timeout test now asserts timer cancellation directly and that the idle record is evicted.

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 onto main and un-draft after #9403 lands.

🤖 Generated with Claude Code

lstein and others added 6 commits July 29, 2026 21:10
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>
@github-actions github-actions Bot added python PRs that change python files backend PRs that change backend files python-tests PRs that change python tests labels Aug 13, 2026

@JPPhoto JPPhoto left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merge blockers:

  • invokeai/backend/model_manager/load/model_cache/model_cache.py:567-570: shutdown() evicts warm records between get() and LoadedModelWithoutConfig.__enter__(). Delayed lock uses detached record; peer load creates duplicate canonical weights while budget counts only one. Test: warm-load/unlock m, pause after get(), call shutdown(), then enter and load m on 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; no unlock() means record and shared RAM remain pinned. Test: put() normally, create LoadedModelWithoutConfig without 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.

@lstein
lstein marked this pull request as ready for review August 17, 2026 01:44
@lstein lstein added the 6.14.1 label Aug 17, 2026
@lstein lstein moved this to 6.14.1: Bug fixes to 6.14.0 in Invoke - Community Roadmap Aug 17, 2026
lstein and others added 2 commits August 16, 2026 22:17
…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>
@lstein

lstein commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

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 __enter__(). Reproduced exactly as described: the warm record is past its admission grace, so the sweep evicted it, the holder locked the detached record via the tolerated issue-7513 path, and a peer reload minted a duplicate canonical while the budget counted one. The fix adds CacheRecord.first_use_holds: armed in LoadedModelWithoutConfig.__init__, released exactly once per wrapper — on its first lock, or by its weakref finalizer if the wrapper is dropped without ever locking. Every eviction path (shutdown sweep, budget reconcile, peer-requested eviction, make_room, drop_model, and unlock's stale eviction) now treats a held record like a locked one: shutdown marks it stale and retains its store ownership and accounting, the holder locks the attached record, and whatever ends the window — the post-use unlock, or the abandonment path — performs the eviction. test_shutdown_retains_record_inside_get_to_lock_window follows your recipe and asserts the peer adopts the same canonical (store.peek identity, refcount 2, budget unchanged).

One residual, disclosed rather than papered over: the few instructions between get() returning and the wrapper's constructor arming the hold remain unshielded. Closing that would mean arming inside get(), whose failure mode is a permanently shielded record whenever the wrapper is never constructed (an exception in between) — and the cache's design treats "shielded with nothing left to unshield it" as strictly worse than the pre-existing tolerated detached-lock fallback. An eviction landing in that gap settles the accounting exactly once (the identity guards from the previous commit), and register_first_use_hold declines to arm on a record that already lost that race.

Non-blocker — abandoned first-use record post-shutdown. Confirmed: the finalizer's deferred work was dropped twice over (_dispatch_deferred's shutdown check plus the worker's own, and the worker had consumed _DEFERRED_STOP anyway). The deferred worker now outlives shutdown() — it is stopped by the existing cache-collection finalizer instead — post-shutdown deferred work is processed, and the abandonment handler itself evicts a stale record whose last holder is gone, because no unlock() is ever coming for it. Your exact test recipe is test_abandoned_holder_reaches_zero_after_shutdown: put() normally, create the LoadedModel without entering, shut down, delete it, collect, and record / store refcount / budget all reach zero.

Hardening that fell out of adversarially reviewing the mechanism, in the same push:

  • put() after shutdown() (reachable, per the earlier note) marks the record stale at birth, so its final release evicts it instead of leaving it resident until process exit.
  • Holds are granted only while a worker is alive to carry the finalizer's release. A worker death zeroes surviving holds at the next worker start and at shutdown(), and every release quotes the epoch it was armed under — so a stale release (a surviving wrapper's late first lock, or a release enqueued before the death and drained after the restart) can never consume a hold armed afresh by a different wrapper.

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.

@lstein
lstein requested a review from JPPhoto August 17, 2026 03:27

@JPPhoto JPPhoto left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To fix:

  • invokeai/backend/model_manager/load/model_cache/model_cache.py:716-723 (ModelCache.put) re-arms awaiting_first_use for put() calls after shutdown. If loading is canceled before get() or wrapper construction, the stale record remains retained and keeps shared RAM accounted indefinitely. Test: call cache.shutdown(); cache.put("m", DummyModule()); observe cache._cached_models["m"].awaiting_first_use and 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 run gc.collect(); the stale record and first_use_holds remain.

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.

lstein and others added 4 commits August 29, 2026 12:30
…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
@lstein

lstein commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator Author

Both confirmed and fixed in 4445d2d, and the merge conflicts with main are resolved (two hunks, both from #9403's shutdown() shared-weights release loop landing upstream after this branch's merge-base — this PR replaces that loop, so our side won both; all eight tests main added to test_model_cache_ram_budget.py in the meantime survive the resolution).

To fix — put() re-arming the grace after shutdown(). Confirmed exactly as you described. The grace has three releasers — the loader's own lock(), the wrapper's abandonment finalizer, and the sweep at the top of the next put() — and a load cancelled before the LoadedModel is constructed has no wrapper, hence no finalizer, so it depends entirely on that sweep. After shutdown no further put() is guaranteed, and the flag stands for the life of the process. put() now withholds it once _shutdown_event is set; the record is still stale at birth, so a loader that does come back still evicts on release, and it gets the ordinary first_use_holds shield from its wrapper. test_post_shutdown_admission_gets_no_first_use_grace follows your recipe.

Corner case — the worker dying after shutdown()'s liveness check. Confirmed, and taking your second suggestion rather than the first: recovery is now a property of the death itself. _run_deferred_work has an outer finally that distinguishes its two orderly exits (_DEFERRED_STOP, collected cache) from an abnormal one and calls _recover_from_dead_worker(current_thread()), which 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. test_worker_death_after_shutdown_recovers_its_stranded_holds is your recipe with the death made abnormal (a BaseException out of the worker's per-item handler); the record, its store refcount and its budget bytes all reach zero off the death alone.

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.

  • The recovery cleared the admission grace unconditionally. On a live cache that is a new bug, 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(), so a reconcile could evict the record and the loader's get() would raise. The grace only actually loses a releaser once the cache is shut down.
  • It 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 precisely the accounting lie shutdown() itself refuses to make. The eviction now lives in _evict_stale_unshielded_entries, called only from the dying worker and only on a shut-down cache, where nothing else can ever run it.
  • Retiring the worker slot 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 grace standing and hands the lift to shutdown() — which, with the slot already empty, skipped it and stale-retained the record forever. Both gates now key on "no live worker", which also makes a failed recovery retryable at the next worker start.

Smaller hardening in the same pass:

  • _AbandonedHolderRelease holds its CacheRecord weakly. _dispatch_deferred's liveness gate is unsynchronized by necessity (a finalizer must not take the cache lock), so a caller that read the worker slot just before it was retired still enqueues against a thread that is alive only because it is unwinding — and on a shut-down cache that item may never be drained. A strong reference pinned the model's whole CPU state dict for the life of the process while the store and the budget both reported the bytes released. The worker also clears the strong reference it resolves before parking on the next get(); the existing test_deferred_worker_does_not_pin_the_record_it_just_released caught that one mid-flight.
  • _clear_stranded_first_use_holds 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, leaving the records it never reached shielded for good. The post-eviction gc/empty_cache housekeeping no longer takes the eviction down with it either.
  • _reconcile_budget_if_pending acquires the cache lock adjacent to its try. A BaseException between the two leaked the RLock to an unwinding thread, blocking every other thread for the life of the process. This narrows the window rather than closing it — that residue is inherent to acquiring without with, which the non-blocking flag rules out here — and the comment says so.

Two residuals disclosed rather than papered over. A put()-set grace whose loader is cancelled before get() is still stale-retained at shutdown() while the worker is healthy: the alternative is evicting a record whose loader may still hold its tensors, which is the duplicate-canonical lie, and test_shutdown_retains_admission_window_records pins that choice deliberately. And _evict_stale_unshielded_entries does not fire the on_cache_models_cleared callbacks the other eviction sites do; it only runs on a shut-down cache, so nobody is listening.

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 tests/backend/model_manager/ green (1229 passed), ruff clean.

@JPPhoto
JPPhoto self-requested a review August 30, 2026 03:24

@JPPhoto JPPhoto left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few reachable and possible issues:

  • invokeai/backend/model_manager/load/model_cache/model_cache.py:654-688, 1091-1116: An admission can fail between put() and get(). With a live worker, shutdown() marks it stale but leaves awaiting_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() without get(), call shutdown() 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 after get(). If shutdown evicts during that gap, registration returns None, 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: pause register_first_use_hold(), run shutdown(), 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

6.14.1 backend PRs that change backend files python PRs that change python files python-tests PRs that change python tests

Projects

Status: 6.14.1: Bug fixes to 6.14.0

Development

Successfully merging this pull request may close these issues.

2 participants