Skip to content

fix(model cache): release shared weights when a cache goes away - #9403

Open
lstein wants to merge 2 commits into
invoke-ai:mainfrom
lstein:lstein/fix/multigpu-shared-weights-collect
Open

fix(model cache): release shared weights when a cache goes away#9403
lstein wants to merge 2 commits into
invoke-ai:mainfrom
lstein:lstein/fix/multigpu-shared-weights-collect

Conversation

@lstein

@lstein lstein commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

Follow-on to #9263, addressing the first of the non-blocking issues deferred from review there: a ModelCache dropped without shutdown()/clear() never routes its resident records through _delete_cache_entry() — the only caller of release_shared_weights() — so the process-global SharedCpuWeightsStore kept the entry's refcount and canonical tensors forever, and every surviving peer cache saw phantom RAM in the shared budget (evicting or refusing capacity for bytes no live cache held).

Design

Each cached-model wrapper now registers a weakref.finalize fallback when it acquires shared weights. Two constraints shape the implementation:

  • 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 same rule ModelCache.release_first_use_grace documents. So the finalizer only enqueues the release into a SimpleQueue (lock-free, reentrant-safe); every public store method drains the queue under the lock, so the bytes disappear from the accounting no later than the next store operation — in particular the next budget query.
  • The eviction path stays exactly-once: release_shared_weights() detaches the finalizer before releasing synchronously, so a wrapper that was evicted and later collected cannot decrement a peer's reference. The finalizer's args carry the key and the canonical dict — not the wrapper (finalize holds args strongly; referencing self would make the wrapper immortal) — and the state-dict identity check keeps the release correct across invalidate()'s retired entries.

Tests

Five regression tests, each verified to fail before the fix:

  • dropping a cache returns the store's refcount, bytes, and RamBudget.total_in_use() to zero (the test JPPhoto specified);
  • the collection-time release is enqueue-only — the refcount is untouched until the next store operation drains it (this is the GC-deadlock-safety property, asserted deterministically);
  • eviction + later collection release exactly once across two caches sharing a key;
  • a retired (invalidate()d while referenced) entry is freed by a collected holder via state-dict identity;
  • CachedModelWithPartialLoad behaves identically to CachedModelOnlyFullLoad.

One existing test constructed a wrapper without binding it and relied on the abandoned wrapper leaking its reference; it now binds the wrapper.

Status

Stacked on #9263 (lstein/feat/multi-gpu); the diff shows that branch's commits until it merges. Marked draft until then — rebase onto main and un-draft after #9263 lands.

🤖 Generated with Claude Code

@github-actions github-actions Bot added api python PRs that change python files invocations PRs that change invocations backend PRs that change backend files services PRs that change app services frontend PRs that change frontend files python-tests PRs that change python tests docs PRs that change docs labels Jul 29, 2026
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>
@lstein
lstein force-pushed the lstein/fix/multigpu-shared-weights-collect branch from b295ec7 to f959cef Compare July 30, 2026 01:10
@lstein
lstein marked this pull request as ready for review July 30, 2026 01:14
@lstein lstein added the 6.14.1 label Jul 31, 2026
@lstein lstein moved this to 6.14.1: Bug fixes to 6.14.0 in Invoke - Community Roadmap Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

6.14.1 api backend PRs that change backend files docs PRs that change docs frontend PRs that change frontend files invocations PRs that change invocations python PRs that change python files python-tests PRs that change python tests services PRs that change app services

Projects

Status: 6.14.1: Bug fixes to 6.14.0

Development

Successfully merging this pull request may close these issues.

2 participants