feat: multi-GPU parallel session execution#9263
Conversation
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>
|
JPPhoto
left a comment
There was a problem hiding this comment.
See prior comments in this PR.
# Conflicts: # invokeai/app/services/session_processor/session_processor_default.py # invokeai/app/services/session_queue/session_queue_common.py # invokeai/app/services/session_queue/session_queue_sqlite.py # invokeai/backend/model_manager/load/model_loaders/qwen_image.py # invokeai/frontend/web/openapi.json # invokeai/frontend/web/src/services/api/schema.ts
- Shared CPU weights: drop_model() now invalidates the model's canonical entries in SharedCpuWeightsStore, so a rebuild on another device can never adopt pre-settings-change weights still aliased by a locked (stale-marked) entry. release() is identity-checked so a stale holder's eviction cannot decrement a newly registered canonical. update_model_record holds MODEL_LOAD_LOCK.write_lock() (off the event loop) across the multi-cache drop to exclude in-flight loads. - Runtime config API: generation_devices is now fully validated at the route boundary — empty lists and unavailable devices (e.g. cuda:99) return 422 without mutating or persisting config, using the same TorchDevice resolution as startup. - Cache stats: /v2/models/stats aggregates per-device caches instead of reporting only the API thread's default cache. - Config/docs contract: session_queue_mode description now documents device-affinity reordering in single-user multi-GPU mode (and that explicit FIFO disables it), and that user rotation outranks priority across users in round_robin mode. Multi-GPU docs no longer claim generation_devices: [] is valid, and describe shared-RAM weight deduplication instead of per-GPU duplication. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@JPPhoto Thanks for the thorough pass — all five findings were real. Everything is addressed in aee12c3: Stale shared CPU weights surviving a settings change — Confirmed. Runtime config accepts invalid Single-user FIFO vs. device affinity — The affinity reordering in single-user multi-GPU mode is intentional (a single user mixing models across 2 GPUs is exactly the thrash case that motivated it), so I took the document-it option: the Stale multi-GPU docs — Fixed. The Cache stats endpoint — Confirmed. Open question (global priority vs. user rotation) — That ordering comes verbatim from #9086 on |
|
@lstein More:
|
# Conflicts: # invokeai/app/invocations/anima_denoise.py # invokeai/app/services/image_files/image_files_disk.py # invokeai/frontend/web/src/services/events/setEventListeners.tsx
…tion, MPS validation) - SharedCpuWeightsStore.invalidate() now retires still-referenced entries instead of dropping them from accounting, so RamBudget keeps counting retired weights until the last locked holder releases them. Prevents admitting models past max_cache_ram_gb while a replacement and a stale copy are both resident. - /models/stats aggregation takes max of cache_size and high_watermark across per-device caches (they share one global RamBudget, so summing over-reported an N-GPU system ~N times); event counters are still summed. - TorchDevice.get_generation_devices() rejects 'mps' when MPS is unavailable, so the runtime_config API 422s instead of persisting a device that fails at first tensor op. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@JPPhoto Your July 12 findings are all addressed in e69b67e (pushed, CI green) — ready for re-review whenever you have a chance:
And you were right on the fourth point — the changed DB-facing tests have no file-backed dependency, so they stay on memory DBs. Separately: to save you a round, I ran a deep self-review of the whole branch and found the issues below (each independently verified against the code). I'm intentionally not fixing them yet so your re-review of the current head isn't chasing a moving target — they'll land as a follow-up batch (each with an exposure test) once you've had a look. Listing them now for transparency:
(For completeness, three further candidates from the same review were investigated and refuted — the terminal-status guard in the frontend workflow coordinator already covers trailing progress events, |
JPPhoto
left a comment
There was a problem hiding this comment.
Some issues:
-
invokeai/backend/patches/layer_patcher.py:214: FLUX Control LoRA shape expansion installs a newtorch.nn.Parameterwithout holdingMODEL_LOAD_LOCK.read_lock(). A concurrent cold model load holds the write lock whileaccelerate.init_empty_weights()replaces the process-globalregister_parameter; the LoRA worker can therefore register its expanded parameter onmeta, leaving the running model corrupted or failing later with "Cannot copy out of meta tensor". Test: coordinate two threads so one holdsMODEL_LOAD_LOCK.write_lock()insideinit_empty_weights()while the other applies a shape-expandingFluxControlLoRALayer, then assert the expanded module parameter remains on its original real device and contains the patched values. -
invokeai/app/invocations/flux_redux.py:95andinvokeai/app/invocations/flux_denoise.py:602:FluxReduxInvocationis newly markedidle_gpu_offloadable=True, but it storesredux_conditioningon the borrowed GPU, and the consumer callsredux_cond_data.to(...)without assigning the returned tensor. When Redux runs on an idle GPU and FLUX denoise resumes on the session GPU, the embeddings remain on the borrowed device and downstream concatenation operates on mixed devices. Test: run Redux with the worker pinned tocuda:0and the offload context borrowingcuda:1, then consume the saved conditioning in FLUX denoise and assert the stored tensor is CPU-backed and the consumed tensor is assigned tocuda:0. -
invokeai/backend/model_manager/load/model_cache/model_cache.py:1054: the global RAM budget cannot evict entries that are retained only by another device cache, andput()admits the new model even when_make_room_internal()exhausts its local stack without obtaining enough global space. The addedtest_eviction_cannot_free_ram_held_by_another_deviceexplicitly acceptsbudget.total_in_use() == 2 * Sunder a1.4 * Slimit; if the other cache remains idle, this is permanent rather than transient, somax_cache_ram_gbno longer acts as a maximum and repeated cross-device loads can drive the process into OOM. Test: place one shared unlocked model in two caches, request a different model from one cache under a budget smaller than both copies, and assert the implementation either coordinates eviction from the other cache or refuses admission without exceedingRamBudget.max_bytes. -
invokeai/app/services/session_queue/session_queue_sqlite.py:555:_get_current_workflow_call_chain_item_ids()protects the chain of only the single row returned byget_current(... LIMIT 1). With multiple GPU workers, several items can bein_progress;cancel_all_except_current()ordelete_all_except_current()can cancel or delete thewaitingparents and pending children belonging to every active item except the arbitrarily selected one, breaking those workflow-call executions while their workers continue. Test: create two independent workflow-call chains with onein_progresschild in each, invoke both "except current" operations, and assert every active worker's complete ancestor/descendant chain remains intact. -
invokeai/app/services/session_processor/session_processor_default.py:636: the post-dequeue guard skips a newly claimed row wheneverworker.cancel_eventis set, but does not return that row to pending or mark it canceled. A delayed cancellation event for the previous item can arrive after the worker clears the event but beforedequeue()replacesworker.queue_item; the handler sees the previous item, sets the event, and causes the fresh non-terminal item to be abandoned permanently inin_progress. Test: delay the previous item's cancellation handler until aftercancel_event.clear(), allow the worker to claim a second item, release the handler before the guard, and assert the second item is canceled or processed rather than leftin_progress. -
invokeai/app/services/session_queue/session_queue_sqlite.py:747:_cancel_in_progress_matching()selects IDs in one transaction and updates each ID in later transactions without handlingSessionQueueItemNotFoundError. A concurrent queue clear or delete between those operations removes the selected row, causing a bulk cancel request to fail with an unhandled exception; iterating over every active GPU item widens the race introduced by this PR. Test: pause the helper after its ID query, clear or delete the matching rows from another connection, resume it, and assert the bulk cancellation completes while safely ignoring vanished items. -
invokeai/backend/util/devices.py:201andinvokeai/app/services/config/config_default.py:217: the new defaultgeneration_devices: autoexpands to every visible CUDA device without considering an existing explicit legacydevice: cuda:N. An upgraded installation that pinneddeviceto avoid a display GPU or reserve GPUs for other workloads will silently start workers on every GPU. Test: configuredevice: cuda:1, leave the newly introducedgeneration_devicesunset/defaulted, mock three visible GPUs, and assert upgrade-compatible resolution uses onlycuda:1unless the user explicitly configures multiple generation devices. Document the precedence and migration behavior indocs/src/content/docs/configuration/invokeai-yaml.mdx. -
invokeai/app/services/session_processor/session_processor_default.py:416: multi-device startup does not preserve theSessionRunnerBasecontract. ADefaultSessionRunnersubclass is cloned as a plainDefaultSessionRunner, losing overrides and state, while any other implementation is reused by all workers even though everystart()overwrites its stored cancel event. Cancellation and custom execution behavior can consequently target the wrong worker. Test: start two workers first with aDefaultSessionRunnersubclass that overridesrun(), then with an independentSessionRunnerBaseimplementation that stores its cancel event, and assert each worker receives a distinct runner of the original behavioral type with its own event. -
invokeai/app/services/invocation_stats/invocation_stats_default.py:60andinvokeai/app/services/session_processor/session_processor_default.py:146:collect_stats()assigns the graph'sCacheStatsto the native device cache before_maybe_offload_to_idle_gpu()changes the thread-local device. Encoder loads then update the borrowed cache's existing stats object, which may belong to another or already-reset graph, so the active graph omits its offloaded model hits, misses, sizes, and cache activity. Test: give the native and borrowed caches distinct stats objects, run an offloaded encoder, and assert all encoder cache activity is attributed to the active graph without mutating stale statistics from another graph. -
invokeai/app/services/events/events_common.py:159: progress events derivedevicefrom the temporary thread-local session device. During an idle-GPU-offloaded encoder node this is the borrowed GPU, even though the queue item's persisteddeviceidentifies the GPU executing the session; the UI badge therefore jumps to the borrowed GPU and back during one queue item. Test: build progress events for a queue item assigned tocuda:0while the thread is temporarily pinned tocuda:1, and assert the event continues to report the queue item's execution device. -
invokeai/frontend/web/src/features/gallery/components/ImageViewer/context.tsx:126: terminal handling removes the finished item's entry from$progressDatabut clears the shared$progressEventand$progressImagewithout checking which item those globals represent. With two active previews, canceling or failing item A can clear item B's latest global preview; after the map falls back to one item,CurrentImagePreviewrequires the now-null global image and hides B's still-running preview until another image event arrives. Test: publish image progress for items A and B with B as the latest global event, cancel A, and assert B remains visible as the single full-size preview. -
invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsGenerationDevices.tsx:224: inactive and removable device tags are implemented asTagelements withrole="button"andonClick, but notabIndexor keyboard activation handler. Keyboard-only users cannot focus or activate the new generation-device controls. Test: render active and inactive device tags, navigate using Tab, activate them with Enter and Space, and assert the same add/remove mutations occur as with pointer clicks.
Some of these are related to newly introduced code, while others are pre-existing assumptions made invalid by this branch's concurrency.
Directly introduced by this branch:
- FLUX Redux offload device mismatch.
- Global RAM budget exceeding its configured cap.
- Post-dequeue cancel guard abandoning an
in_progressitem. generation_devices: autooverriding legacy device pinning.- Incorrect cloning/reuse of custom session runners.
- Offloaded encoder statistics assigned to the wrong cache.
- Progress events reporting the borrowed GPU.
- Multi-session preview state clearing another active preview.
- Keyboard-inaccessible generation-device controls.
Pre-existing code made unsafe or newly reachable by this branch:
- FLUX Control LoRA parameter registration can overlap model construction. The
setattr()predates the PR, but concurrent workers and the new global model-load locking contract create the failing overlap. - "Except current" workflow operations preserve only one active chain. The workflow-chain helper predates the PR, but multiple simultaneous
in_progressitems are introduced by this branch. - The queue deletion race existed in narrower form, but the branch's new
_cancel_in_progress_matching()multi-item loop directly adds the reported unhandled path and widens its exposure.
Resolves conflict with invoke-ai#9367 (auth on model-manager/app-info endpoints): - get_stats keeps multi-GPU cache aggregation, gains the AdminUserOrDefault param - get_generation_device_options gains CurrentUserOrDefault (used by non-admin progress device badges as well as the admin settings modal) - stats/device-option tests patch auth_dependencies.ApiDependencies and provide a single-user configuration
Backend: - layer_patcher: hold MODEL_LOAD_LOCK.read_lock() across patch application so FLUX Control LoRA shape expansion (register_parameter) cannot overlap a concurrent model construction's process-global init_empty_weights patch - flux_redux/flux_denoise: store Redux conditioning on CPU (it may be produced on a borrowed idle GPU) and assign the .to() result when consuming it - model_cache/ram_budget: coordinate eviction across device caches — when a cache's own stack is exhausted and the global budget is still short, peers evict their unlocked entries (non-blocking lock, deadlock-free), so max_cache_ram_gb holds even when RAM is retained only by an idle device - session_queue: 'except current' operations protect the workflow-call chain of EVERY in-progress item, not one arbitrary get_current() row - session_queue: _cancel_in_progress_matching tolerates rows deleted by a concurrent clear between its id SELECT and the per-item cancel - session_processor: the post-dequeue cancel guard cancels the freshly claimed item when skipping it (a stale cancel_event must not abandon it in_progress) - session_processor: _clone_session_runner refuses to downgrade DefaultSessionRunner subclasses or share custom runners across workers - session_processor: an offloaded encoder's cache activity is attributed to the running session's CacheStats (borrowed cache's stale stats pointer swapped for the borrow duration) - events: progress events report the queue item's persisted device, not the thread-local (temporarily borrowed) one - devices/config docs: generation_devices 'auto' defers to an explicitly pinned legacy 'device:' setting so upgrades don't start workers on every GPU Frontend: - ImageViewer context: a terminal status only clears the shared progress event/image globals when that item owns them (multi-GPU: canceling item A no longer blanks item B's live preview) - SettingsGenerationDevices: device tags are keyboard-operable (tabIndex + Enter/Space activation) Each fix has an exposure test per the review's suggestions.
JPPhoto
left a comment
There was a problem hiding this comment.
A few issues; I would treat 4 as merge blockers:
- Global RAM budget can remain exceeded after peer-lock contention:
invokeai/backend/model_manager/load/model_cache/model_cache.py. - A stale cancellation event can cancel an unrelated newly claimed job:
invokeai/app/services/session_processor/session_processor_default.py. - FLUX.2 Klein retains conditioning tensors on a borrowed GPU:
invokeai/app/invocations/flux2_klein_text_encoder.py. - User-scoped clearing can reject the owner or interrupt another user's generation:
invokeai/app/api/routers/session_queue.pyandinvokeai/app/services/session_processor/session_processor_default.py.
The other 4 are reasonable follow-ups:
- Incorrect fallback when the latest image-preview owner terminates.
- Stale image-preview entries after queue clearing.
- Misleading "Auto (all GPUs)" UI and schema descriptions.
- Duplicate cancellation counts during concurrent bulk requests.
Here's the list:
-
invokeai/backend/model_manager/load/model_cache/model_cache.py:1095: Cross-cache eviction is only best-effort becauseevict_unlocked_for_peer()returns immediately when the peer cache lock is contended. If the peer contains an unlocked model but briefly holds its cache lock, the requesting cache skips eviction and still admits the new model. Releasing the peer lock does not trigger another budget check, so usage can remain aboveRamBudget.max_bytesindefinitely. Test: place an unlocked shared model in two caches under a1.4 * Sbudget, hold the peer cache lock while the first cache inserts a differentS-sized model, then release the lock and assert total usage does not remain at2 * S. -
invokeai/app/services/session_processor/session_processor_default.py:657: The revised post-dequeue guard prevents an item from remainingin_progress, but it does so by canceling the newly claimed item when a delayed cancellation event belongs to the previous item. The added regression test explicitly expects item 42 to be canceled even though no cancellation targeted it. A stale event can therefore discard an unrelated user's queued generation. Test: delay the previous item's cancellation handler until the worker is between clearing its event and replacingworker.queue_item, claim a second item, then assert the second item runs normally and is not canceled. -
invokeai/app/invocations/flux2_klein_text_encoder.py:77:Flux2KleinTextEncoderInvocationis markedidle_gpu_offloadable=True, but_encode_prompt()returnsprompt_embedsandpooled_embedson the borrowed GPU andinvoke()saves them without moving them to CPU. The forward serializer cache retains those GPU tensors after the device-pool lock is released, consuming VRAM on a GPU that may immediately begin another session. Test: run the invocation with its thread pinned to a borrowed CUDA device and assert every tensor passed tocontext.conditioning.save()is detached and CPU-backed. -
invokeai/app/api/routers/session_queue.py:380andinvokeai/app/services/session_processor/session_processor_default.py:538: User-scoped queue clearing still assumes one current item.get_current()returns one arbitraryin_progressrow, so Alice can receive a 403 when Bob's row is selected even while Alice also has an active item. If Alice's row is selected,clear()emits aQueueClearedEventcontaining Alice'suser_id, but_on_queue_cleared()ignores that field and sets the cancel event for every worker in the queue. Bob's session then stops without Bob's row being deleted or marked terminal, leaving it abandoned asin_progress. Test: run Alice and Bob items concurrently, clear as Alice with each possibleget_current()selection, and assert the request succeeds, all Alice rows and workers are cleared, and Bob's worker and database row remain active and unchanged. -
invokeai/frontend/web/src/features/gallery/components/ImageViewer/context.tsx:126andinvokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx:196: The terminal-state fix only handles completion of a non-global item. If B publishes progress, then A publishes the latest global preview, and A terminates, the handler removes A from$progressDataand clears the global atoms. B remains in$progressData, butwithProgressdepends on the now-null global$progressImage, so B's still-running preview disappears. For successful A completion with auto-switch enabled, the stale completed A preview can remain displayed instead of B. Test: publish image progress for B and then A, terminate A, and assert B immediately becomes the full-size live preview for canceled, failed, and completed terminal states. -
invokeai/frontend/web/src/features/gallery/components/ImageViewer/context.tsx:66: The image-viewer progress map listens for invocation progress and per-item status changes but notqueue_cleared. A clear operation deletes all but at most one active row without emitting terminal events for each item, so the remaining$progressDataentries survive indefinitely and can appear beside previews from later generations. Test: publish previews for two active items, dispatch a queue-cleared event that covers them, and assert the per-item map, global preview atoms, and resolving state are cleared. -
invokeai/backend/util/devices.py:213,invokeai/app/services/config/config_default.py:217, andinvokeai/frontend/web/public/locales/en.json:1859:generation_devices: autonow resolves to the legacy pinneddevice, but the configuration schema, generated settings, API description, and Settings UI still say that Auto uses every GPU. An upgraded installation withdevice: cuda:1displays "Auto (all GPUs)" while starting only one worker, and selecting Auto in the UI cannot produce the advertised behavior without separately changing the legacy setting. Test: render the settings control withdevice: cuda:1,generation_devices: auto, and multiple available GPUs, and assert the displayed behavior matches the single pinned device. Updatedocs/src/generated/settings.jsonthrough its configuration-description source. -
invokeai/app/services/session_queue/session_queue_sqlite.py:783:_cancel_in_progress_matching()claims to count only rows actually moved tocanceled, but it appends any row whose returned status iscanceled. If two bulk cancellation requests select the same active row before either update completes, the first request cancels it while the second sees the already-terminal row returned by_set_queue_item_status()and also counts it. Both responses can report that they canceled the same item. Test: synchronize two bulk cancellation calls after their item-ID queries, allow both to continue, and assert the item is counted as newly canceled by only one request.
…A on a cold cache apply_smart_model_patches() held MODEL_LOAD_LOCK.read_lock() across its patch loop, but callers pass a lazy generator (e.g. flux_text_encoder._t5_lora_iterator) that constructs each LoRA via context.models.load() on demand. A cold-cache load takes MODEL_LOAD_LOCK.write_lock(); since the lock is non-reentrant and write-preferring, acquiring the write lock while this same thread already holds the read lock deadlocks (write waits for readers==0, but the consuming thread is that reader). The generation hung silently right after the encoder/tokenizer load, whenever a LoRA was applied and not already cached. Materialize the patch iterable before taking the read lock so every LoRA construction takes (and releases) the write lock first; the read lock then covers patch application only, which is its actual purpose (FLUX Control LoRA shape expansion calls register_parameter and must exclude concurrent construction). Compatible with wan_denoise's per-call iterator factory, and unrelated to the SD UNet path, which loads the LoRA before calling the singular patcher (no lock held). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1. model_cache: a peer whose lock is contended during cross-cache eviction no longer leaves the shared RAM budget exceeded indefinitely. evict_unlocked_for_peer returns None on contention; the requester records a reconcile request on each skipped peer, and the synchronized-decorator hook honors it as soon as the peer's current operation releases the lock (outermost frame only — the RLock may be held reentrantly). The pending flag stays set until the budget is actually satisfied, so overshoot held by locked entries reconciles when their unlock releases the lock. 2. session_processor: a stale cancellation event from the previous item no longer cancels the freshly claimed, unrelated item. The post-dequeue guard now treats the DB status as the authority: a terminal row is skipped; a set cancel_event with a non-terminal row is a stale signal (a genuine cancel writes the row terminal BEFORE emitting) and is cleared, with a post-clear terminal re-check closing the clear's own race window. A shutdown-raced claim is still canceled so it isn't abandoned in_progress. 3. flux2_klein_text_encoder: conditioning is detached and moved to CPU before context.conditioning.save(), matching flux_text_encoder and flux_redux — the node is idle_gpu_offloadable, and GPU-resident embeddings would pin VRAM on a borrowed device after its pool lock is released. 4. session_queue clear: user-scoped clearing no longer assumes one current item. clear() cancels every in-progress item in scope via _cancel_in_progress_matching (each item's own status-changed event signals the worker running exactly that item) before deleting rows — same pattern as delete_by_destination; the router's arbitrary get_current() check (which could 403 the owner or cancel another user's item) is removed; and _on_queue_cleared honors the event's user_id so a scoped clear cannot stop other users' workers and abandon their rows. Each fix carries the regression test JPPhoto specified: contended-peer budget reconcile, stale-event-runs-item (plus the mid-clear race and shutdown cases), CPU-backed Klein conditioning, and Alice/Bob concurrent clear isolation at both the service and the event-handler layer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@JPPhoto Thanks for the fast re-review. All four merge blockers are fixed in 1. RAM budget stuck exceeded after peer-lock contention ( 2. Stale cancellation event canceling an unrelated fresh claim ( 3. Klein conditioning retained on a borrowed GPU ( 4. User-scoped clear assuming one current item ( Verification: 1,406 tests pass across the routers/services/model-cache suites plus the new regressions; ruff (CI-pinned 0.11.2) check and format clean. The four follow-ups (preview-owner termination fallback, stale 🤖 Generated with Claude Code |
The merge-blocker fix 68edb02 reworded the clear route's docstring, which is the OpenAPI operation description — openapi.json and schema.ts must follow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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_devicesconfig setting (defaults toauto= 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:
invokeai/backend/util/devices.py): a thread-localset/get/clear_session_deviceonTorchDevice;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.model_manager_default,model_load_default): oneModelCacheper 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).session_queue_sqlite.dequeue): a lock makes select+claim atomic so concurrent workers never grab the same queue item.session_processor_default): one_SessionWorkerper device, each pinningtorch.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.LocktoObjectSerializerForwardCacheand madeDiskImageFileStoragethread-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.backend/util/device_pool.py):GENERATION_DEVICE_POOLgives 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).@invocation(idle_gpu_offloadable=True), mirroring the existingbottleneckClassVar. 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:
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 —GGMLTensordoesn't implementaten.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 adoptedGGMLTensorshares 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:
#Nsuffix 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-filteredgeneration_deviceslist. Previously, disabling e.g.cuda:1renumbered the survivors in the backend startup log (cuda:2became#2), disagreeing with the frontend, which always labels over the full set. Now both stay consistent —cuda:2remains#3.generation_devicesonly takes effect after a restart.Related Issues / Discussions
QA Instructions
On a multi-GPU machine:
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.generation_devices: [cuda:0]and confirm generation runs serially, exactly as before this PR.generation_devices: [cuda:0, cuda:2]and confirm only those devices are used.autoresolves to the one best device and behavior is unchanged.Running ... on idle device ...), and that the denoise model is not evicted to load the encoder. Setoffload_text_encoders_to_idle_gpus: falseand confirm the encoder runs on the session's own GPU.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
What's Newcopy (if doing a release after this PR)