fix(model cache): release shared weights when a cache goes away - #9403
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>
b295ec7 to
f959cef
Compare
JPPhoto
left a comment
There was a problem hiding this comment.
Looks good! Just one thing to address in a different PR:
-
invokeai/backend/model_manager/load/model_cache/model_cache.py:550:shutdown()releases shared-store ownership but retains cache records/models. RAM accounting becomes zero while tensors remain live; later same-key acquire creates duplicate canonical weights. Test: put shared model, callshutdown(), verify record/tensors remain while store reports zero, then reacquire same key and compare state-dict identity. Consider clearing records during shutdown, or retain ownership until record eviction; preserve accounting truth.Why does this matter?
shutdown()is not a hard barrier:- It does not prevent later
put(),get(), orlock()calls. ModelManagerService.stop()shuts caches down before stopping the session processor.- Session workers are cancelled but not joined; the code explicitly says
put()after shutdown can occur.
- It does not prevent later
Evidence: model_cache.py:633, model_manager_default.py:64, session_processor_default.py:532.
The issue is real, though likely non-blocking unless shutdown races active work.
|
Confirmed and addressed in #9494 (draft until this PR merges). It routes shutdown()'s idle records through |
Summary
Follow-on to #9263, addressing the first of the non-blocking issues deferred from review there: a
ModelCachedropped withoutshutdown()/clear()never routes its resident records through_delete_cache_entry()— the only caller ofrelease_shared_weights()— so the process-globalSharedCpuWeightsStorekept 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.finalizefallback when it acquires shared weights. Two constraints shape the implementation:acquire()'s critical section on the same thread — the same ruleModelCache.release_first_use_gracedocuments. So the finalizer only enqueues the release into aSimpleQueue(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.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 (finalizeholds args strongly; referencingselfwould make the wrapper immortal) — and the state-dict identity check keeps the release correct acrossinvalidate()'s retired entries.Tests
Five regression tests, each verified to fail before the fix:
RamBudget.total_in_use()to zero (the test JPPhoto specified);invalidate()d while referenced) entry is freed by a collected holder via state-dict identity;CachedModelWithPartialLoadbehaves identically toCachedModelOnlyFullLoad.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 ontomainand un-draft after #9263 lands.🤖 Generated with Claude Code