Skip to content

feat: multi-GPU parallel session execution - #9263

Merged
lstein merged 84 commits into
invoke-ai:mainfrom
lstein:lstein/feat/multi-gpu
Jul 30, 2026
Merged

feat: multi-GPU parallel session execution#9263
lstein merged 84 commits into
invoke-ai:mainfrom
lstein:lstein/feat/multi-gpu

Conversation

@lstein

@lstein lstein commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR adds multi-GPU parallel generation: on a machine with more than one GPU, InvokeAI runs several generation sessions concurrently — one per GPU — instead of draining the queue one job at a time. Jobs are distributed fairly across users so a single user's large batch can't monopolize every GPU while others wait.

It's controlled by a new generation_devices config setting (defaults to auto = use every available CUDA GPU). Setting it to a single device, or leaving CUDA out of the picture, preserves the previous serial behavior exactly. The choice of GPUs can also be controlled via a new section of the Settings dialogue (restart required to take effect).

Demo (turn on the sound!)

invoke-mgpu.mp4

How it works — the change is built around five small backend seams plus a frontend update, rather than per-node edits:

  • Device context (invokeai/backend/util/devices.py): a thread-local set/get/clear_session_device on TorchDevice; choose_torch_device() consults it first. This is the lynchpin — the ~79 existing call sites resolve to the worker's GPU with no per-node changes.
  • Per-device model caches (model_manager_default, model_load_default): one ModelCache per device, resolved by the current thread's device, with fan-out for clear/drop/shutdown. Model construction is serialized against VRAM moves to prevent meta-device corruption. A single global RAM budget is shared across the per-device caches, and identical CPU weights are deduplicated across devices (see RAM management below).
  • Atomic dequeue (session_queue_sqlite.dequeue): a lock makes select+claim atomic so concurrent workers never grab the same queue item.
  • Worker pool (session_processor_default): one _SessionWorker per device, each pinning torch.cuda.set_device + the session device, with its own runner and cancel event; cancellation is routed per item. Profiling is disabled when more than one worker is active.
  • Concurrency hardening: added a Lock to ObjectSerializerForwardCache and made DiskImageFileStorage thread-safe for parallel sessions.

Frontend: during parallel generation the progress display stacks one progress bar per active session (each disappears as its session finishes), and the image viewer tiles per-session progress previews when ≥2 sessions are active.

Idle-GPU text-encoder offload

When more than one generation device is configured and a GPU is idle, a session's text/prompt encoder runs on the idle GPU instead of the one running its denoise pipeline. This avoids evicting the denoise model from VRAM to make room for the encoder, and lets a cached encoder be reused across generations. Under full load (no idle GPU) behavior is unchanged. Controlled by offload_text_encoders_to_idle_gpus (default on); inspired by #9310.

  • Device-pool arbiter (backend/util/device_pool.py): GENERATION_DEVICE_POOL gives each generation device one exclusive-use lock. A native session blocking-acquires its own device's lock for the whole run; an encoder node try-borrows an idle device's lock for the duration of that node. A borrowed encoder and a native session are therefore mutually exclusive on a GPU — preventing the shared-encoder corruption that produced garbled images — and the design is deadlock-free (borrows are non-blocking; a session only ever blocks on its own device).
  • Opt-in marker: nodes declare support via @invocation(idle_gpu_offloadable=True), mirroring the existing bottleneck ClassVar. Applied to the text/prompt-encoder nodes (compel + sdxl/refiner, flux, sd3, qwen-image, anima, cogview4, flux2 klein, z-image, flux_redux). The runner re-pins the worker thread to the borrowed device for the node; conditioning is stored on the CPU so the denoiser picks it up on its own GPU afterward.

RAM management for parallel sessions

Running N sessions in parallel multiplies memory pressure, so this PR also makes the model cache parallel-aware:

  • Shared global RAM budget, clamped to a safe fraction of system RAM, so summing per-device cache sizes across GPUs can't claim ~N× RAM and drive the box into swap.
  • Cross-device weight de-duplication: when a second GPU loads a model another GPU already holds, it adopts the resident CPU weights (a meta-weight structural clone + load_state_dict(assign=True)) instead of re-reading from disk and materializing a second copy. This is loader-agnostic and now also covers GGUF models — GGMLTensor doesn't implement aten.empty_like, which previously made the largest quantized models (e.g. a Q8_0 transformer) silently re-load on every device and spike RAM; the adopted GGMLTensor shares the quantized storage, so it's one copy across devices.

Generation Devices settings refinements

A few small fixes to the Generation Devices selector and its logging:

  • Stable device numbering: the disambiguating #N suffix on identically-named GPUs is now tied to each device's cuda index (its position in the full available-device set) rather than its position in the possibly-filtered generation_devices list. Previously, disabling e.g. cuda:1 renumbered the survivors in the backend startup log (cuda:2 became #2), disagreeing with the frontend, which always labels over the full set. Now both stay consistent — cuda:2 remains #3.
  • Restart reminder: reworded the Settings caption to "Restart InvokeAI for changes to take effect." and flash that same warning as a toast on every successful change, since generation_devices only takes effect after a restart.

Related Issues / Discussions

QA Instructions

On a multi-GPU machine:

  • With default config (generation_devices: auto), enqueue a batch larger than the GPU count and confirm multiple sessions run simultaneously (one per GPU), with stacked progress bars and tiled previews in the viewer.
  • Set generation_devices: [cuda:0] and confirm generation runs serially, exactly as before this PR.
  • Set generation_devices: [cuda:0, cuda:2] and confirm only those devices are used.
  • Cancel an in-flight item and confirm only that session stops.
  • On a single-GPU / CPU / MPS machine, confirm auto resolves to the one best device and behavior is unchanged.
  • Idle-GPU offload: with ≥2 GPUs and a single running session, confirm the text encoder runs on a different (idle) GPU than the denoiser (debug log: Running ... on idle device ...), and that the denoise model is not evicted to load the encoder. Set offload_text_encoders_to_idle_gpus: false and confirm the encoder runs on the session's own GPU.
  • Parallel RAM: run several parallel sessions with the same model (ideally a GGUF/quantized one) and confirm process RAM stays bounded — the transformer/text-encoder show an Adopted shared CPU weights ... log on the second device rather than a second disk load.

New automated tests cover device routing (test_model_load_device_routing.py), dequeue concurrency (test_session_queue_dequeue_concurrency.py), device resolution (test_devices.py), the device-pool lock semantics and offload mutual-exclusion (test_device_pool.py, test_encoder_offload.py), and cross-device weight adoption incl. GGUF (test_shared_weight_adoption.py).

Merge Plan

Standard merge. No DB schema or redux migrations. Touches the session processor and model cache, so worth a careful look from those areas' owners.

The idle-GPU text-encoder offload (originally prototyped as a follow-on PR) is now included in this branch, along with the cross-device GGUF weight de-duplication that keeps parallel-session RAM bounded.

Checklist

  • The PR has a short but descriptive title, suitable for a changelog
  • Tests added / updated (if applicable)
  • ❗Changes to a redux slice have a corresponding migration — N/A, no slice changes
  • Documentation added / updated (if applicable)
  • Updated What's New copy (if doing a release after this PR)

lstein and others added 14 commits May 31, 2026 23:26
Run one generation session per configured GPU concurrently, with a tiled
progress preview. Multi-user isolation is unchanged. Backed by five seams:

- Per-thread device context (TorchDevice.set/get/clear_session_device);
  choose_torch_device() consults it first, so all device-selecting call sites
  resolve to the calling worker's GPU with no per-node changes.
- Per-device model caches: build_model_manager builds one ModelCache per
  generation device; ModelLoadService.ram_cache resolves by current thread
  device; ram_caches fans out clear/drop/shutdown.
- Atomic concurrent dequeue: a dequeue lock makes select+claim atomic so
  concurrent workers never claim the same item (works on FIFO; round-robin
  from invoke-ai#9086 slots in later).
- Worker pool: one _SessionWorker per device, each pinning torch.cuda.set_device
  and its session device, with its own runner and cancel event; cancellation
  routes via an {item_id -> worker} lookup. Single-device installs keep the
  exact legacy single-worker behavior. Profiling disabled when >1 worker.
- New config `generation_devices`; unset = legacy single-worker mode.

Frontend: the canvas staging area already tiles per queue item; the main
ImageViewer now tracks progress per session and renders a tile grid
(ProgressImageTiles) when more than one session is active.

Also adds a lock to ObjectSerializerForwardCache for concurrent access.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
test_model_load_device_routing mutated the process-wide get_config()
singleton (device = "cuda:0") to exercise the per-thread cache routing,
but never restored it. The leaked CUDA device was then picked up by a
later test (test_model_load::test_loading) via choose_torch_device(),
which crashed with "Torch not compiled with CUDA enabled" on the
CUDA-less CI runner. Add an autouse fixture to save/restore device and
clear any pinned session device.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n_devices

Regenerate openapi.json (make frontend-openapi) and the frontend
schema.ts types (make frontend-typegen) so they include the new
generation_devices config field, fixing the openapi-checks and
typegen-checks CI jobs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`make frontend-openapi` used a bare `python` from a different environment
that emitted the CacheStats @DataClass docstring as a schema description.
CI generates the schema via `uv run`, which does not, so openapi-checks
failed on the diff. Regenerate with the uv-locked environment to drop the
stray description while keeping the generation_devices field.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…o prevent meta-device corruption

Parallel multi-GPU session workers could intermittently crash with "unrecognized
device meta" (denoise) or "Cannot copy out of meta tensor; no data!" (l2i), because
model loading relies on process-global, non-thread-safe monkey-patches.

accelerate.init_empty_weights() (used directly by the loaders and implicitly by
diffusers' default low_cpu_mem_usage=True in from_pretrained) swaps
torch.nn.Module.register_parameter globally for the duration of a load, routing every
newly-registered parameter to the meta device. The model cache's VRAM load/unload runs
nn.Module.load_state_dict(assign=True), whose assign path does setattr -> __setattr__ ->
register_parameter. When one worker's VRAM move overlapped another worker's from_pretrained,
the move's real weights got hijacked onto meta and blew up on the next .to(device).

Introduce MODEL_LOAD_LOCK, a write-preferring readers-writer lock:
- write lock = model construction (_load_and_cache, load_model_from_path), exclusive.
- read lock  = VRAM load/unload (ModelCache.lock(), repair_required_tensors_on_device).

VRAM transfers across GPUs still overlap each other; they only block while a construction
holds the write lock. The lock is always acquired before any per-cache lock to keep a
consistent order and avoid an AB-BA deadlock with the writer's make_room/put.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ions

Image.open() is lazy: it reads the header but defers pixel decoding (and
holds the file handle open) until the first .load()/.copy()/.convert(). The
opened object was cached and the same object handed to every caller, so in
multi-GPU parallel mode two session-processor worker threads could call
.copy() on it concurrently and race on the shared file handle and decoder
state. This surfaced as "broken data stream when reading image file" and
"AssertionError: self.png is not None" during inpainting with batch >1.

Force the decode (image.load()) before the object enters the cache so the
cached object is safe for concurrent reads, and guard the cache structures
(__cache / __cache_ids) with a lock since they are now mutated from multiple
threads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The generation progress bars (under the Invoke button and the Viewer tab)
both read a single global $lastProgressEvent atom, which every session
overwrites. With parallel multi-GPU sessions this made the bar jump back
and forth between sessions.

Track progress per queue item id and render one bar per in-flight session,
stacked vertically, each removed as its session reaches a terminal state.

- stores.ts: add $progressEvents (map keyed by item_id),
  $activeProgressEvents (sorted), and set/clear helpers.
- setEventListeners.tsx: populate per-item progress on invocation_progress;
  clear per item on terminal status; clear all on connect/disconnect/queue
  cleared.
- ProgressBar.tsx: render a vertical stack of bars (one per active session)
  with a single-bar fallback for the idle / model-loading window; add
  containerProps so dockview tabs can position the stack.
- Dockview tab call sites: move positioning into containerProps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
$progressEvents is only referenced within stores.ts (via the
$activeProgressEvents computed and the set/clear helpers), so exporting
it tripped knip's unused-exports check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
With 4 GPUs the stacked per-session progress bars grew past the bottom
strip of the dockview tab and overlapped the "Viewer" label.

Add a fitHeightPx prop: in fit mode the stack is capped to the available
strip (10px below the ~40px tab's centered label) and the bars flex to
share it, shrinking below their natural height only once they no longer
fit. With 1-2 sessions the bars keep their familiar thin height; with 3+
they scale down to stay within the strip. The sidebar bar is unaffected
and continues to stack at natural height (it has the vertical room).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…fault

generation_devices now accepts "auto" (the new default), which expands to
every visible CUDA device — so multi-GPU parallel generation works out of
the box without manually listing devices. On GPU-less systems "auto"
resolves to the single cpu/mps device, preserving serial behavior.

- config_default.py: type is now Union[Literal["auto"], list[str]],
  default "auto"; validator accepts "auto" or a list of device strings.
- devices.py: add TorchDevice.get_generation_devices(), the single resolver
  that expands "auto", normalizes, and deduplicates.
- session_processor / model_manager: both consumers use the resolver
  instead of iterating the raw config value (which would have iterated the
  characters of the "auto" string).
- Regenerated docs/src/generated/settings.json.
- Tests for the resolver (auto-with/without-CUDA, dedup, empty).

An explicit single-device list (e.g. [cuda:0]) or an empty list opts out
of parallelism.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions github-actions Bot added api python PRs that change python files 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 Jun 3, 2026
@lstein lstein added the 6.14.0 label Jun 3, 2026
@lstein lstein moved this to 6.14.x Theme: USER EXPERIENCE in Invoke - Community Roadmap Jun 3, 2026
@JPPhoto

JPPhoto commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

An explanation of the latest commit:

Previously, cached_model_keys() and weakref finalizers created new daemon threads per event. If Thread.start() failed, deferred work could be lost, leaving RAM over budget indefinitely.

Now each ModelCache owns one lazy deferred-work worker and SimpleQueue:

  • Worker starts on first put(), before cache mutation.
  • Finalizers enqueue first-use-grace releases.
  • cached_model_keys() enqueues budget reconciliation.
  • Worker serially handles maintenance outside caller and collector threads.
  • shutdown() queues a stop marker.
  • Task errors are logged without killing worker.
  • Failed initial worker startup leaves admission unchanged and retryable.

Per-GPU execution remains parallel. Each GPU session worker and each per-device ModelCache remain independent. Only maintenance tasks within one cache are serialized; model execution, loading, VRAM movement, and other GPU caches continue concurrently.

lstein and others added 2 commits July 28, 2026 21:55
…silently

Follow-ups from an adversarial review of c384ea1, which replaced the
per-release background threads with one long-lived worker per cache.

- The worker's `work` local stays bound while it blocks in the next get(), so
  the last-processed CacheRecord — and transitively its model's CPU weights —
  was pinned until some unrelated item happened to be queued behind it. That is
  worse than an ordinary leak: _release_first_use_grace's release hook can evict
  that very record, removing it from the cache AND subtracting its bytes from
  the RamBudget, so the budget under-reported a model that was still resident
  and the next admission over-committed. Reproduced on a two-cache budget: after
  eviction plus an explicit gc.collect(), both the record and its module were
  still alive; queueing one more item freed them. The per-call threads this
  replaced did not have the bug — Thread._bootstrap_inner deletes _args on exit.
  Clear the reference in a finally before looping back.

- `if self._deferred_work_thread.ident is None` is a "was it ever started"
  check, not a liveness check: ident is never cleared and a Thread cannot be
  restarted. A worker lost to an unexpected error (a logging handler that
  raises, os.fork(), or shutdown() before the first put(), which leaves its
  _DEFERRED_STOP queued for the thread that put() then starts) was gone for the
  life of the process, silently disabling every later grace release and budget
  reconcile — the failure this mechanism exists to prevent. Create a fresh
  thread whenever the previous one has exited, and never after shutdown().

- Only the worker drains the queue, but both dispatch sites enqueued
  unconditionally. cached_model_keys() runs on every dequeue
  (session_queue_sqlite._get_device_resident_model_keys), so an idle-device
  cache that never admitted a model — and so has no worker — accumulated one
  queued reconcile per dequeue forever; post-shutdown the same held for both
  sites, stranding CacheRecords in a queue nothing would drain. Route both
  through _dispatch_deferred, which drops the item when no worker is running.
  Dropping loses nothing: such a cache has nothing to evict, and put() re-runs a
  pending reconcile through the synchronized release hook when it admits one.

- shutdown()'s early return meant a keep-alive timer re-armed by a
  post-shutdown put() was never cancelled by a later shutdown(). Don't arm
  timers on a shut-down cache.

Tests: the two *_thread_start_failure_* tests installed their monkeypatch after
put() had already started the worker, and c384ea1 removed the only
Thread.start() from those paths — the patched raise was unreachable, so both
passed without exercising their premise. Retargeted to what they actually
verify (the finalizer and the lookup must not block, and the reconcile still
happens). Four regression tests added; each fails against c384ea1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cache

Second round of adversarial-review follow-ups on the deferred-work thread.

- The worker held a bound method, so a running thread — reachable from
  threading._active — kept its ModelCache alive, and with it every CacheRecord
  and every model's CPU weights. A cache released without shutdown() was
  therefore immortal, which is the opposite of what RamBudget's weakref registry
  is built for. Measured against the parent commit 7ffee4d: five caches
  dropped without shutdown() left 5/5 caches and 5/5 models resident and five
  worker threads running, where the parent left 0/5 and no threads. The previous
  `ident is None` guard had accidentally bounded this (a cache whose worker died
  could never re-acquire a pinning thread); reviving the worker removed that
  bound, so the fix has to remove the strong reference itself. The worker is now
  a module-level function taking a weakref, and a weakref.finalize pushes
  _DEFERRED_STOP when the cache is collected so the parked thread exits instead
  of leaking one thread per abandoned cache.

- Thread.start() is called from put(), which runs under both the cache lock and
  MODEL_LOAD_LOCK's write lock while completing a load. Under thread/pid
  exhaustion (RLIMIT_NPROC, a container's pids.max) its RuntimeError escaped and
  failed a generation whose model had already been fully constructed — to lose
  an optimization that _dispatch_deferred is explicitly designed to survive the
  absence of. Log and carry on; the next admission retries.

- _dispatch_deferred justified dropping work with "a cache without a worker has
  never admitted a model". That was false: put() after shutdown() is reachable
  in production, because Invoker.stop() stops model_manager before
  session_processor, so an in-flight generation can admit a model after every
  cache has been shut down — and thread exhaustion reaches the same state
  without a teardown to bound it. Such a record was admitted with the first-use
  grace, then permanently shielded from both asynchronous eviction paths with
  its bytes still charged to the shared budget. Make the claim true instead of
  rewording it: put() grants the grace only when a worker is running to release
  it. lock() still clears it on the normal path, so nothing changes when the
  worker is healthy.

Tests: the post-shutdown half of the drop test never set the pending flag, so
cached_model_keys() short-circuited before reaching the dispatch guard and the
assertion held unconditionally. Poll the budget in the pin test rather than
reading it the instant the key disappears (_delete_cache_entry pops before it
releases the weights). Two regression tests added; all seven of this series'
new tests fail against c384ea1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lstein

lstein commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Merged main and ran an adversarial review over the deferred-cache-work series (55a6fc4, c7dddb6, c384ea1). Head is now 08599c321d.

Merge with main

One real conflict, in invokeai/app/invocations/qwen_image_latents_to_image.py. This branch added a force_tiled_decode branch; the Krea-2 PR (#9304) restructured the same block — added as_qwen_image_vae, moved SeamlessExt.static_patch_model inside model_on_device, deepening the indentation. Resolved by keeping the tiling logic at main's indentation.

openapi.json and schema.ts auto-merged, so I regenerated both rather than trusting the textual merge — they come out byte-identical. Backend suite, tsc, eslint, prettier, ruff, and the 1675 frontend tests are all clean.

Review findings

Four independent fresh-context reviews (state lifecycle; locking/deadlock; callers and test quality; then a second round against the fix itself). Every finding was verified against the code before acting on it — several needed rescoping and two didn't survive checking.

Seven confirmed defects, all introduced by c384ea1 when the per-release threads became one long-lived worker per cache. Fixed in 4976f281 and 08599c32.

1. The worker pinned the record it had just released — a RAM leak on the normal path

work = self._deferred_work_queue.get() stays bound while the thread blocks in the next get(). The damaging part is that _release_first_use_grace's release hook can evict that very record — removing it from _cached_models and subtracting its bytes from RamBudget — so the budget under-reported a model that was still resident, and the next admission over-committed. Reproduction against c384ea1:

record still alive after eviction + gc.collect(): True
model  still alive after eviction + gc.collect(): True
record alive after ANOTHER queue item: False

The per-call threads this replaced did not have the bug: Thread._bootstrap_inner deletes _args on exit.

2. The worker made its ModelCache immortal

A running thread is reachable from threading._active and holds its target, so a bound method pinned the cache, every CacheRecord, and every model's CPU weights. Measured against the parent 7ffee4db04 — five caches dropped without shutdown():

caches alive models alive worker threads
7ffee4db04 0/5 0/5 0
c384ea18 5/5 5/5 5

Production shuts every cache down in ModelManagerService.stop(), so the blast radius there is non-clean exits — but it also leaked threads and models across the test suite, and it defeated the weakref registry RamBudget is built on. The worker is now a module-level function taking a weakref.ref, with a weakref.finalize that pushes _DEFERRED_STOP so the parked thread exits instead of leaking one per abandoned cache.

3. ident is None was a "was it ever started" check, not a liveness check

Thread.ident is never cleared and a Thread can't be restarted, so a worker lost to an unexpected error was gone for the life of the process — silently disabling every later grace release and budget reconcile, i.e. exactly the "release is lost and the record keeps shielding an idle cache" failure c7dddb6 was written to prevent. Reachable with no injected fault: shutdown() before the first put() leaves its _DEFERRED_STOP queued for the thread that put() then starts, which consumes it and exits.

4. Both dispatch sites enqueued into a queue only the worker drains

cached_model_keys() runs on every dequeue (session_queue_sqlite._get_device_resident_model_keys), so an idle-device cache that never admitted a model — and therefore has no worker — accumulated one queued reconcile per dequeue, forever. Post-shutdown the same held for both sites, stranding CacheRecords in a queue nothing would drain.

5. A Thread.start() failure failed the generation

put() runs under both the cache lock and MODEL_LOAD_LOCK's write lock while completing a load, so a RuntimeError: can't start new thread (RLIMIT_NPROC, a container's pids.max) aborted a load whose model had already been fully constructed — to lose an optimization the dispatch path is explicitly designed to survive the absence of. Now logged and skipped; the next admission retries.

6. The justification for dropping deferred work was false

_dispatch_deferred reasoned "a cache without a worker has never admitted a model, so it has nothing to release or evict". Not true: Invoker.stop() iterates vars(self.services), and model_manager is stopped before session_processor, so an in-flight generation can put() after every cache has been shut down. Thread exhaustion reaches the same state with no teardown to bound it. Such a record was admitted with awaiting_first_use=True and then permanently invisible to both async eviction paths while its bytes stayed charged to the shared budget.

Rather than reword the comment, I made the claim true: put() grants the first-use grace only when a worker is alive to release it. lock() still clears it on the normal path, so nothing changes when the worker is healthy — and a dropped CacheRecord is now one that was never shielded in the first place.

7. shutdown()'s early return orphaned a keep-alive timer

A post-shutdown put() re-armed the timer, and the idempotent shutdown() never cancelled it. Fixed at the source — a shut-down cache doesn't arm timers.

Tests

The two *_thread_start_failure_* tests were vacuous: both installed their Thread.start monkeypatch after put() had already started the worker, and c384ea1 had removed the only Thread.start() from those paths, so the injected failure was unreachable. They'd have passed with or without any start-failure handling. Retargeted to what they actually verify (the finalizer and the lookup must not block, and the reconcile still happens).

Seven regression tests added; each one fails against c384ea1. Cache suite run 10× with no flakes; full backend suite green at 3729 passed, 118 skipped.

Known limitations, deliberately not fixed

  • Dropping a ModelCache leaves a SharedCpuWeightsStore refcount behind. I checked — identical on 7ffee4db04, so it's pre-existing and orthogonal to this series.
  • A shutdown() landing between _dispatch_deferred's liveness check and its put() can strand a single item. Closing it needs a lock that method can't take (it runs inside a weakref.finalize callback), and the cost is one record during teardown. Documented in the code.

# Conflicts:
#	invokeai/app/services/session_processor/session_processor_default.py
#	invokeai/app/services/session_queue/session_queue_sqlite.py
@lstein

lstein commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

main moved again while the above was going up (#9355, "Optimize iterator graph materialization"), so there's a second merge on top — head is now 0503c2e8bb. Two conflicts, one of which is a real semantic decision worth a look:

session_processor_default.py — both sides changed the same invoke line. This branch wraps it in _maybe_offload_to_idle_gpu; #9355 added a control_collection capture after it. Kept both.

session_queue_sqlite.py::_set_queue_item_status — this is the one to review. Both sides restructured the method:

  • this branch added a device parameter and device = COALESCE(?, device) to the UPDATE, so a claim records the GPU that took the item;
  • Optimize iterator graph materialization #9355 added a queue_item parameter and folded the two transactions into one, capturing an updated_status_row and patching the caller's object in place to avoid re-reading (and re-parsing the session graph of) the row.

Resolved as #9355's single-transaction structure carrying this branch's device column. One thing that does not fall out for free: #9355's post-UPDATE SELECT names its columns explicitly, so I added device to that list. Without it, the in-place patch path would leave queue_item.device holding whatever the pre-claim read had — i.e. the UI would lose the GPU label on exactly the path #9355 optimizes. dequeue() now passes both device= and queue_item=, inside the existing dequeue lock and after the affinity swap.

Re-verified after the second merge: backend 3761 passed, frontend 1675 passed, tsc/eslint/prettier/ruff clean, and openapi.json + schema.ts again regenerate byte-identically.

@JPPhoto

JPPhoto commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Fixing now:

  • invokeai/backend/model_manager/load/model_cache/model_cache.py:ModelCache.put() and _delete_cache_entry(): Non-shared admissions increment RamBudget, but cache collection only stops deferred worker; it never routes resident records through _delete_cache_entry(), sole path decrementing non-shared bytes. Dropping a cache using keep_ram_copy_of_weights=False collects cache and model while budget remains permanently charged, causing surviving peer caches to see phantom RAM use and evict or reject capacity unnecessarily. Test: create shared RamBudget, admit non-shared model, drop cache without clearing it, force collection, then assert cache is collected and budget.total_in_use() returns to zero.

@JPPhoto

JPPhoto commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

@lstein I am going to work on this merge blocker:

  • invokeai/backend/model_manager/load/load_base.py:LoadedModelWithoutConfig.model: LoRA iterators retain only ModelPatchRaw, then discard LoadedModel. Its finalizer removes first-use grace before LayerPatcher.apply_smart_model_patches() finishes using the patch. Peer reconciliation may evict the still-live patch and subtract its RAM charge, allowing RAM overcommit and possible OOM. Test: materialize a LoRA iterator, force wrapper collection and peer reconciliation, then assert the record and budget charge remain until the patch context exits.

Please add the rest of these in a follow-up PR:

  • invokeai/backend/model_manager/load/model_cache/ram_budget.py:RamBudget._on_cache_collected(): Cache collection releases owned non-shared accounting but never calls each resident wrapper's release_shared_weights(). A dropped cache using the process-global SharedCpuWeightsStore is collected while its canonical tensors and refcount remain. Test: admit a shared model, drop the cache, force collection, then assert store refcount and bytes return to zero.

  • invokeai/frontend/web/src/features/gallery/components/ImageViewer/context.tsx:126: When the latest global preview owner terminates, its map entry is removed but globals are not reassigned from remaining active sessions. The surviving preview disappears or the completed preview remains resolving. Test: publish B then A previews, terminalize A for every terminal status and auto-switch mode, and assert B immediately becomes the sole visible preview.

  • invokeai/frontend/web/src/features/gallery/components/ImageViewer/context.tsx:66: Viewer progress state is not cleared on queue_cleared, disconnect, or authenticated socket replacement. Sessions lacking later terminal events leave stale previews across clears, reconnects, or user changes. Test: populate viewer state, trigger each lifecycle event, and assert the progress map, globals, resolving state, and finished-item state are reset.

  • invokeai/app/services/config/config_default.py:generation_devices: Schema, invokeai/frontend/web/public/locales/en.json:1931, and the configuration table claim auto uses every GPU, but a pinned legacy device makes it resolve to one device. Settings can display "Auto (all GPUs)" while only one worker starts. Test: configure device: cuda:1, generation_devices: auto, and multiple GPUs; assert runtime resolution and all displayed/generated descriptions agree. Update docs/src/content/docs/configuration/invokeai-yaml.mdx.

  • invokeai/app/services/session_queue/session_queue_sqlite.py:_cancel_in_progress_matching(): Two concurrent bulk cancellations can select the same active row. After the first cancels it, the second also counts the already-canceled row as newly canceled. Test: use an in-memory SQLite database and barriers after both initial selects; assert the combined newly-canceled count is one.

JPPhoto and others added 3 commits July 29, 2026 14:10
Review fixes for the LoRA-pinning commit (1142430):

- Fix the CI failure it shipped: test_krea2_text_encoder's fake
  LoadedModel lacked model_in_ram(), and the encoder's LoRA iterator now
  calls it. The fake now models the pin (with depth tracking), and the
  test asserts the patch spec carries a working pin.

- Stop a keep-alive Timer.start() failure from leaking a permanent pin.
  @record_activity runs after lock_in_ram() has incremented the lock
  count but before model_in_ram()'s unlock-pairing try block is entered,
  so a RuntimeError under thread/pid exhaustion would pin the record
  (and its shared-budget bytes) for the life of the process. The timer
  is an optimization: log and continue instead.

- Give lock_in_ram() the same already-dropped-record diagnostic as
  lock()/unlock(), so a pin on a detached record produces a matching
  lock-side message (issue 7513).

- Pin the LoRA cache record in LoRAExt.patch_unet (the modular-denoise
  path) while its tensors are read during direct patching. This was the
  one remaining producer that dropped its LoadedModel handle at load
  time, leaving the record evictable by a peer cache mid-patch.

- Close test vacuities: the pin-retention test in test_layer_patcher
  could not detect a dropped cache_pins.close() (the ExitStack would be
  collected silently), and the cache-side pin test only covered a warm
  record, leaving lock_in_ram's grace-clearing dead code under test.
  Added pin-release assertions for the normal, body-raise,
  restore-raise, and mid-materialization-raise paths (all verified to
  fail with close() neutered), a cold-record grace/finalizer test, and
  a Timer-failure regression test (verified to fail pre-fix).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… the LoRAExt pin

Self-review fixes for d63c1f3:

- The synchronized-decorator's post-release reconcile hook runs inside
  the caller's frame after the method body, so a raise there (e.g.
  TorchDevice.empty_cache on a sick CUDA context after an eviction)
  escaped lock_in_ram()/lock() after the lock count was incremented —
  the same permanent-pin leak as the Timer.start() case, via a
  different path. The reconcile is deferrable housekeeping: swallow and
  log; the pending flag is only cleared once the budget is satisfied,
  so the next lock release retries. Regression test verified to fail
  pre-fix.

- LoRAExt.patch_unet's pin now spans the yielded scope, not just the
  patch application: despite force_direct_patching=True, fp8-storage
  modules are routed to sidecar patching (float8 weights cannot be
  patched in place), which stores a live reference to the cached
  patch's layers inside the UNet for the whole denoise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lstein

lstein commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Adversarial review of 1142430 ("fix(model cache): pin LoRA patches during use"). Three independent fresh-context reviews (state lifecycle; locking/deadlock/concurrency; callers, coverage and test quality), plus a fourth adversarial pass over the fixes themselves. Every finding verified against the code before acting. Head is now 272a2ef2ea (d63c1f37e0 + 272a2ef2ea).

First: the mechanism is right. The 3-tuple PatchSpec carrying a model_in_ram() pin, entered before the read lock and held in an ExitStack through restoration, closes the unlocked-use gap cleanly — all 18 iterator producers are converted, no consumer breaks on the new shape, the lock ordering is uniform with every other path (pins are entered before MODEL_LOAD_LOCK.read_lock() and released after it, so lock_in_ram/unlock never run under the RW lock in either direction), and eviction under a fully-pinned budget degrades to a tolerated overshoot rather than a raise or a hang. The WAN expert-swap factory produces fresh iterators and fresh pins per swap with no accumulation and no double-enter.

Confirmed defects, fixed in d63c1f3

1. The commit shipped a red CI

test_krea2_text_encoder is one of only two tests that actually consume a real LoRA iterator (everything else stubs apply_smart_model_patches and never drains the generator), and its fake LoadedModel was a SimpleNamespace without model_in_ram:

E   AttributeError: 'types.SimpleNamespace' object has no attribute 'model_in_ram'

All six python-tests matrix jobs failed at this SHA. The fake now models the pin with depth tracking, and the test asserts the yielded spec carries a working pin — which also makes it the first invocation-level test to defend the 3-tuple invariant at all.

2. A keep-alive Timer.start() failure leaked a permanent pin

Decorator order is synchronized(record_activity(lock_in_ram)): the record's lock count is incremented, then _record_activity() arms a threading.Timer — and Thread.start() raises RuntimeError under thread/pid exhaustion (RLIMIT_NPROC, a container's pids.max). The exception propagated out of lock_in_ram before model_in_ram()'s unlock-pairing try was entered (load_base.py:115 vs :118), so the record stayed locked forever: invisible to _make_room_internal, evict_unlocked_for_peer, _reconcile_budget_if_pending and _on_timeout, with its bytes charged to the shared budget for the life of the process — and the unsatisfiable reconcile re-ran on every subsequent release.

The shape is pre-existing (__enter__ and model_on_device call lock() the same way), but this commit multiplies the call sites by the LoRA count per generation, and thread exhaustion is a condition this file already handles explicitly for the deferred worker. Fixed at the source: the timer is an optimization, so _record_activity now logs and continues, which repairs every locking caller at once. Regression test verified to fail pre-fix.

3. LoRAExt.patch_unet was the one producer the sweep missed

The modular-denoise path (USE_MODULAR_DENOISE) still did models.load(...).model — dropping the LoadedModel handle immediately, firing the first-use finalizer, and leaving the record evictable by a peer cache while apply_smart_model_patch was still reading its tensors. It was the only remaining del lora_model in the repo. Now pinned across the patch application (direct patching folds the deltas into the UNet, so the pin doesn't need to outlive it).

4. The pin-retention test could not detect the failure that matters

test_apply_smart_model_patches_retains_patch_cache_handle_for_context_lifetime verifies retention, but its release-side assertion is satisfied by garbage collection: neuter cache_pins.close() entirely and the test still passes (ExitStack has no __del__; the weakref clears anyway). A dropped close() is the catastrophic regression — every LoRA record locked forever — and nothing would have caught it. Added direct __exit__-count assertions for the normal path, body-raise, restore-raise, and a producer raising mid-materialization after a pin is entered; all four fail with close() neutered.

Also: the cache-side pin test only exercised a warm record, so both lines the commit added to the cache — awaiting_first_use = False and the finalizer detach — were dead code under test. Added a cold-record variant.

5. lock_in_ram silently pinned a detached record

lock() and unlock() both log the issue-7513 "already dropped from the RAM cache" diagnostic; lock_in_ram didn't, so a pin on a record evicted in the load→pin window (e.g. by the keep-alive timeout, which ignores the first-use grace by design) produced only the unlock-side warning at the end of the generation with no matching lock-side message. Added the same diagnostic.

Second round: findings against the fixes themselves (fixed in 272a2ef)

A fresh adversarial pass over d63c1f37e0 found two real gaps in my own fixes:

  • The Timer hardening closed only one of the raise-after-lock paths. The synchronized decorator's post-release reconcile hook also runs inside lock_in_ram's frame after the lock count is incremented, and it can raise (TorchDevice.empty_cache() on a sick CUDA context after an eviction) — same permanent-pin leak, different path. The reconcile is deferrable housekeeping (the deferred worker already treats it that way), so the hook now swallows and logs; the pending flag is only cleared once the budget is satisfied, so the next lock release retries. Regression test verified to fail pre-fix.
  • My narrowed LoRAExt pin was unsound for fp8 UNets. The fp8 check in apply_smart_model_patch overrides force_direct_patching and routes to sidecar patching, which stores a live reference to the cached patch's layers inside the UNet's modules — so the pin must span the yielded scope, not just the application. Widened, with the comment now stating the real reason.

Reported, deliberately not fixed here

  • Sidecar patches make the pin's name a lie (pre-existing). _apply_model_layer_wrapper_patch moves the cached patch layer to the execution device in place (layer_patcher.py:301) and — unlike the direct path (:285) — never moves it back. For GGUF/fp8 paths the pinned record's tensors are largely in VRAM while the budget accounts them as RAM, and after clear_patches() the cached ModelPatchRaw retains GPU tensors until eviction. Predates this commit; worth a follow-up issue.
  • generate_ti_list has the same lifetime hole the commit fixes for LoRAs: raw TextualInversionModelRaw refs held through the whole CLIP encode with every handle dropped (ti_utils.py:20-46, consumed three lines from the rewritten _lora_loaders in compel.py). Small models, same defect class; follow-up.
  • LoRAExt never clears its sidecar patches (pre-existing). It passes original_modules={} — a throwaway dict — so when the fp8 path routes to sidecar patching, clear_patches() is never called and the patch (holding cached LoRA tensors) stays attached to the cached UNet's modules across generations. Modular denoise is off by default; follow-up.
  • GC-reentrancy hazard (hard to trigger). unlock() unconditionally evaluates RamBudget.available()SharedCpuWeightsStore.total_bytes_in_use() under the store's non-reentrant lock (model_cache.py:948 → ram_budget.py:112 → shared_cpu_weights.py:52), and store.acquire() sums tensor sizes inside that same lock — the exact combination the release_first_use_grace docstring forbids for finalizer-context code. A GC-driven close of an abandoned, still-entered model_in_ram generator inside acquire()'s critical section would self-deadlock the process. No intended path can abandon an entered pin (they live in an ExitStack inside a with-managed generator), so this is a hazard note, not a defect: the pin chain now has three GC-tracked links (tuple → ExitStack → patcher generator frame) where model_on_device has one.
  • A zero-weight LoRA (patch_weight == 0) is pinned for the whole scope despite apply_smart_model_patch early-returning; harmless.
  • 17 of 18 producers still have no test asserting they yield a pinned 3-tuple — a 2-tuple regression in any of them silently reverts to the old unlocked behavior. A parametrized sweep would defend this; left as a suggestion.

Attacks that found nothing

Producer/consumer completeness (18/18 converted; no for patch, weight in consumer breaks; sidecar _patches_and_weights is a different type); lock-order inversion on every path holding two of {MODEL_LOAD_LOCK, cache lock, budget lock, store lock}; write-preferring-reader starvation cycles; pin __enter__/producer/restore raising with pins entered (ExitStack unwinds correctly in all cases); double-enter of one pin CM (fresh generators everywhere, WAN swapper releases before re-acquiring); same LoRA key twice in one graph (lock counts balance, second finalizer is None); premature GC of the handle between spec creation and pin entry (the CM strongly retains the LoadedModel); finalizer-vs-pin races (release_first_use_grace short-circuits on cleared grace and re-checks identity + lock); _locks underflow; LRU-stack corruption; unbounded eviction walks with many pinned entries; VRAM regression from skipping pinned records in _offload_unlocked_models (LoRA records report cur_vram_bytes() == 0); lint/type fallout from the signature churn.

Validation

  • Backend: 3771 passed, 118 skipped (was red at 1142430).
  • Cache + patcher suites green; every new regression test verified to fail against the code it guards.
  • ruff check + format clean.

lstein added a commit to lstein/InvokeAI that referenced this pull request Jul 29, 2026
…ancel

Two concurrent bulk cancellations could select the same in-progress row;
the first canceled it and the second, seeing the already-terminal row
returned by _set_queue_item_status(), counted it again — both responses
reporting that they canceled the same item.

The terminal guard and the UPDATE already ran atomically (a single
transaction under SqliteDatabase's process-wide lock); what was missing
was exposing WHO performed the transition. _set_queue_item_status() now
delegates to _transition_queue_item_status(), which additionally returns
whether THIS call moved the row, and _cancel_in_progress_matching()
counts only actual transitions — so exactly one racing caller observes
itself as the canceller. The docstring records that the atomicity comes
from the database lock, not the SQL, so a future move to per-thread
connections doesn't silently reintroduce the double count.

Includes a two-thread barrier test (in-memory SQLite) asserting each
item is counted exactly once across concurrent bulk cancels; the
pre-fix counter reports 4 for 2 items under the same interleaving.

Follow-up to PR invoke-ai#9263 (JPPhoto review, 2026-07-25).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lstein added a commit to lstein/InvokeAI that referenced this pull request Jul 29, 2026
… auto copy

The schema description, Settings UI copy, and configuration guide's
behavior table all said `generation_devices: auto` uses every available
GPU, but TorchDevice.get_generation_devices() deliberately resolves it to
the single pinned legacy `device` when one is set — only a later docs
note disclosed the exception. An upgraded install with `device: cuda:1`
displayed "Auto (all GPUs)" while starting one worker.

- config_default.py: the field description now states the precedence
  (and that an explicit list overrides `device`).
- Settings UI: the badge is "Auto" (not "Auto (all GPUs)") and the help
  text explains the legacy-device exception and the override.
- invokeai-yaml.mdx: the behavior table row for `auto` states the
  exception where the value is introduced, not only in the notes.
- Regenerated docs/src/generated/settings.json, openapi.json, schema.ts
  from the description source.
- New test asserts all four copy locations describe the precedence.

Follow-up to PR invoke-ai#9263 (JPPhoto review, 2026-07-25).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lstein added a commit to lstein/InvokeAI that referenced this pull request Jul 29, 2026
… queue lifecycle events

Two related lifecycle gaps in the image viewer's multi-session preview
state (ImageViewer/context.tsx):

1. Terminal-owner fallback: when the session owning the shared
   $progressEvent/$progressImage globals reached a terminal state, its
   tile was removed but the globals were cleared (or parked on the
   finished session's stale frame via the resolve illusion). Since the
   tiled view only renders with >1 active session, the remaining
   session's still-running preview disappeared until its next image
   event. The globals are now handed to the most recently updated
   remaining session immediately, for every terminal status.

2. Stale lifecycle: $progressData was cleaned only by per-item terminal
   events. It is now also cleared on queue_cleared (scoped like
   workflowExecutionCoordinator.onQueueCleared, and marking the cleared
   items finished so a trailing progress event from a worker stopped
   only by the clear cannot repopulate the preview), on socket
   disconnect, and on $socket replacement (auth-token/user change).

The store logic is extracted into viewerProgressLifecycle.ts so it can
be unit tested without rendering; the provider keeps the socket
subscriptions and ownership/scope checks. 16 new vitest cases cover
promotion across terminal statuses and auto-switch modes, non-owner
termination, clear scoping (own/unscoped/foreign/sanitized), and
disconnect resets.

Follow-up to PR invoke-ai#9263 (JPPhoto review, 2026-07-25).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lstein

lstein commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

@JPPhoto all five non-blocking items now live in stacked draft PRs (each based on this branch's current head 272a2ef2ea; they'll be rebased onto main and un-drafted once this merges):

Item PR
RamBudget._on_cache_collected() — shared-weights refs stranded when a cache goes away #9403 (new) — shutdown() now releases resident records' shared references synchronously, and a weakref.finalize fallback covers a cache dropped without shutdown. The finalizer only enqueues (releasing inline in GC context could self-deadlock on the store's non-reentrant lock — the same constraint release_first_use_grace documents); every public store method drains the queue. Six regression tests, incl. the one you specified (refcount/bytes/budget → 0 after collection).
context.tsx:126 — surviving preview not promoted when the owner terminates #9389 — promotion of the most recently updated remaining session, parametrized over terminal statuses × auto-switch.
context.tsx:66 — no reset on queue_cleared/disconnect/socket replacement #9389 — same PR (same file/feature): lifecycle handlers reset the progress map, globals, resolving flag, and finished-items LRU.
generation_devices: auto copy vs pinned legacy device #9388 — schema description, Settings UI copy, and the configuration guide all disclose the legacy-device precedence; resolution behavior itself already had a test (test_get_generation_devices_auto_respects_pinned_legacy_device).
_cancel_in_progress_matching() double count #9387_transition_queue_item_status reports whether this call moved the row and the bulk counter uses that; two-thread barrier test on in-memory SQLite counts each item exactly once (pre-fix counts 4 for 2 items).

All four were rebased onto / built against 272a2ef2ea and are green locally (backend 3777 passed on #9403's branch; frontend 1691 passed on #9389's).

Also closed #9397 — your 1142430 supersedes it with a strictly stronger mechanism (a real cache pin instead of just keeping the wrapper alive).

@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.

Approved, and I'm assuming you've done adequate single- and multi-GPU testing.

@lstein
lstein merged commit 061ba35 into invoke-ai:main Jul 30, 2026
17 of 23 checks passed
@lstein
lstein deleted the lstein/feat/multi-gpu branch July 30, 2026 00:43
lstein added a commit to lstein/InvokeAI that referenced this pull request Jul 30, 2026
… queue lifecycle events

Two related lifecycle gaps in the image viewer's multi-session preview
state (ImageViewer/context.tsx):

1. Terminal-owner fallback: when the session owning the shared
   $progressEvent/$progressImage globals reached a terminal state, its
   tile was removed but the globals were cleared (or parked on the
   finished session's stale frame via the resolve illusion). Since the
   tiled view only renders with >1 active session, the remaining
   session's still-running preview disappeared until its next image
   event. The globals are now handed to the most recently updated
   remaining session immediately, for every terminal status.

2. Stale lifecycle: $progressData was cleaned only by per-item terminal
   events. It is now also cleared on queue_cleared (scoped like
   workflowExecutionCoordinator.onQueueCleared, and marking the cleared
   items finished so a trailing progress event from a worker stopped
   only by the clear cannot repopulate the preview), on socket
   disconnect, and on $socket replacement (auth-token/user change).

The store logic is extracted into viewerProgressLifecycle.ts so it can
be unit tested without rendering; the provider keeps the socket
subscriptions and ownership/scope checks. 16 new vitest cases cover
promotion across terminal statuses and auto-switch modes, non-owner
termination, clear scoping (own/unscoped/foreign/sanitized), and
disconnect resets.

Follow-up to PR invoke-ai#9263 (JPPhoto review, 2026-07-25).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lstein added a commit to lstein/InvokeAI that referenced this pull request Jul 30, 2026
…ancel

Two concurrent bulk cancellations could select the same in-progress row;
the first canceled it and the second, seeing the already-terminal row
returned by _set_queue_item_status(), counted it again — both responses
reporting that they canceled the same item.

The terminal guard and the UPDATE already ran atomically (a single
transaction under SqliteDatabase's process-wide lock); what was missing
was exposing WHO performed the transition. _set_queue_item_status() now
delegates to _transition_queue_item_status(), which additionally returns
whether THIS call moved the row, and _cancel_in_progress_matching()
counts only actual transitions — so exactly one racing caller observes
itself as the canceller. The docstring records that the atomicity comes
from the database lock, not the SQL, so a future move to per-thread
connections doesn't silently reintroduce the double count.

Includes a two-thread barrier test (in-memory SQLite) asserting each
item is counted exactly once across concurrent bulk cancels; the
pre-fix counter reports 4 for 2 items under the same interleaving.

Follow-up to PR invoke-ai#9263 (JPPhoto review, 2026-07-25).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lstein added a commit to lstein/InvokeAI that referenced this pull request Jul 30, 2026
… auto copy

The schema description, Settings UI copy, and configuration guide's
behavior table all said `generation_devices: auto` uses every available
GPU, but TorchDevice.get_generation_devices() deliberately resolves it to
the single pinned legacy `device` when one is set — only a later docs
note disclosed the exception. An upgraded install with `device: cuda:1`
displayed "Auto (all GPUs)" while starting one worker.

- config_default.py: the field description now states the precedence
  (and that an explicit list overrides `device`).
- Settings UI: the badge is "Auto" (not "Auto (all GPUs)") and the help
  text explains the legacy-device exception and the override.
- invokeai-yaml.mdx: the behavior table row for `auto` states the
  exception where the value is introduced, not only in the notes.
- Regenerated docs/src/generated/settings.json, openapi.json, schema.ts
  from the description source.
- New test asserts all four copy locations describe the precedence.

Follow-up to PR invoke-ai#9263 (JPPhoto review, 2026-07-25).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lstein added a commit to lstein/InvokeAI that referenced this pull request Jul 30, 2026
… queue lifecycle events

Two related lifecycle gaps in the image viewer's multi-session preview
state (ImageViewer/context.tsx):

1. Terminal-owner fallback: when the session owning the shared
   $progressEvent/$progressImage globals reached a terminal state, its
   tile was removed but the globals were cleared (or parked on the
   finished session's stale frame via the resolve illusion). Since the
   tiled view only renders with >1 active session, the remaining
   session's still-running preview disappeared until its next image
   event. The globals are now handed to the most recently updated
   remaining session immediately, for every terminal status.

2. Stale lifecycle: $progressData was cleaned only by per-item terminal
   events. It is now also cleared on queue_cleared (scoped like
   workflowExecutionCoordinator.onQueueCleared, and marking the cleared
   items finished so a trailing progress event from a worker stopped
   only by the clear cannot repopulate the preview), on socket
   disconnect, and on $socket replacement (auth-token/user change).

The store logic is extracted into viewerProgressLifecycle.ts so it can
be unit tested without rendering; the provider keeps the socket
subscriptions and ownership/scope checks. 16 new vitest cases cover
promotion across terminal statuses and auto-switch modes, non-owner
termination, clear scoping (own/unscoped/foreign/sanitized), and
disconnect resets.

Follow-up to PR invoke-ai#9263 (JPPhoto review, 2026-07-25).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lstein added a commit that referenced this pull request Aug 3, 2026
* fix: don't hold VRAM in an idle server (CUDA context at startup)

Since #9263, a freshly started server held ~128-256 MiB of VRAM per GPU
before serving any request. Two startup paths were responsible:

- ModelCache.__init__ sized the RAM cache via torch.cuda.mem_get_info(),
  which creates a CUDA context on the execution device — and #9263 now
  builds one ModelCache per generation device at startup. Total VRAM is
  read from cudaGetDeviceProperties instead, which reports the same
  value (verified byte-identical) without creating a context.

- Each session-processor worker called torch.cuda.set_device() at
  thread start (i.e. at boot). The CUDA-side pin is now deferred until
  the worker claims its first queue item; the pin is per-thread and the
  thread persists, so pinning before the first item is equivalent. The
  TorchDevice.set_session_device() threadlocal pin, which drives all
  device-selection logic, still happens at thread start.

Verified on a live server: with this change the process no longer
appears in nvidia-smi compute-apps while idle, and queue items still
process correctly on the lazily-pinned worker.

Closes #9413

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: cover idle CUDA context prevention

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Jonathan <34005131+JPPhoto@users.noreply.github.com>
Co-authored-by: JPPhoto <jpollack@jpollackphoto.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

6.14.0 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.x Theme: USER EXPERIENCE

Development

Successfully merging this pull request may close these issues.

3 participants