Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
3540520
feat(app): parallel multi-GPU session execution
lstein Jun 1, 2026
6bb89d6
fix(tests): restore global device after multi-GPU cache routing test
lstein Jun 1, 2026
a3be444
chore(ui): regenerate openapi schema and frontend types for generatio…
lstein Jun 1, 2026
be54889
fix(ui): regenerate openapi.json with uv to match CI generator
lstein Jun 1, 2026
a119b50
fix(model-manager): serialize model construction against VRAM moves t…
lstein Jun 2, 2026
7011446
fix(backend): fix outpainting crash caused by model download collisions
lstein Jun 2, 2026
a1fe375
fix(backend): make DiskImageFileStorage thread-safe for parallel sess…
lstein Jun 2, 2026
3db88d5
feat(ui): stack per-session progress bars during parallel generation
lstein Jun 2, 2026
351758c
fix(ui): make $progressEvents module-local to satisfy knip
lstein Jun 2, 2026
4f6613f
fix(ui): cap stacked tab progress bars to fit below the tab label
lstein Jun 2, 2026
2a65c4a
feat(config): support "auto" generation_devices to use all GPUs by de…
lstein Jun 2, 2026
4209780
chore(frontend): typegen+openapi
lstein Jun 3, 2026
914c577
docs(multi-gpu): add configuration information
lstein Jun 3, 2026
a928a75
chore(frontend): typegen + openapi again
lstein Jun 3, 2026
5e4e864
Merge branch 'main' into lstein/feat/multi-gpu
lstein Jun 3, 2026
48458c1
feat(settings): add Generation Devices selector to Settings dialog
lstein Jun 3, 2026
5100605
feat(settings): boldface the restart notice on Generation Devices
lstein Jun 3, 2026
200b3a3
feat(settings): show GPU name in Generation Devices badges
lstein Jun 3, 2026
cdcf7df
chore(frontend): openapi
lstein Jun 3, 2026
d521ba4
Merge branch 'main' into lstein/feat/multi-gpu
lstein Jun 5, 2026
8cef3cf
feat(multi-gpu): surface per-session GPU number in logs and UI
lstein Jun 12, 2026
3353e5a
Merge remote-tracking branch 'upstream/main' into lstein/feat/multi-gpu
lstein Jun 12, 2026
c48dfc8
Merge branch 'main' into lstein/feat/multi-gpu
lstein Jun 23, 2026
c300a16
Merge branch 'main' into lstein/feat/multi-gpu
lstein Jun 24, 2026
1f55a4f
feat(multi-gpu): show per-device names in startup log and progress ci…
lstein Jun 25, 2026
9c1e516
Merge branch 'main' into lstein/feat/multi-gpu
JPPhoto Jun 25, 2026
a1fde96
feat(model-cache): share one CPU copy of model weights across per-GPU…
lstein Jun 26, 2026
fbd95a8
fix(session-queue): cancel all in-progress items in bulk-cancel APIs …
lstein Jun 26, 2026
ed2a330
fix(multi-gpu): address review findings (cancel race, bulk delete, de…
lstein Jun 26, 2026
57e1e79
fix(ci): ruff format + make CPU-incompatible device test mock CUDA
lstein Jun 26, 2026
2d3802a
fix(multi-gpu): stop RAM blowup/swapping during concurrent generations
lstein Jun 26, 2026
6903911
fix(qwen-image): reserve VAE working memory so decode/encode don't OOM
lstein Jun 27, 2026
3e917dd
fix(flux2): tile reference-image VAE encode to avoid VRAM OOM
lstein Jun 27, 2026
902c77d
fix(multi-gpu): query execution device for VRAM-in-use accounting
lstein Jun 27, 2026
aa6fec8
fix(qwen-image): calibrate VAE working-memory estimate to the 3D-conv…
lstein Jun 27, 2026
43a46bd
feat(qwen-image): honor force_tiled_decode in the l2i node
lstein Jun 27, 2026
b84d450
fix(ui): stop progress disk flashing during indeterminate phases
lstein Jun 27, 2026
b275c38
Merge branch 'main' into lstein/feat/multi-gpu
lstein Jun 28, 2026
0037a21
feat(multi-gpu): offload text encoders to idle GPUs
lstein Jun 28, 2026
b61a8e1
fix(multi-gpu): adopt GGUF weights across devices to stop RAM spikes
lstein Jun 29, 2026
163cbb2
fix(multi-gpu): tie device #N label to cuda index, not filtered position
lstein Jun 29, 2026
04e2071
feat(multi-gpu): flash restart reminder when generation devices change
lstein Jun 29, 2026
f9e6fba
Merge branch 'main' into lstein/feat/multi-gpu
lstein Jun 30, 2026
1145318
Merge branch 'main' into lstein/feat/multi-gpu
lstein Jul 1, 2026
daf3963
Merge remote-tracking branch 'origin/main' into lstein/feat/multi-gpu
lstein Jul 5, 2026
5106379
feat(queue): device-affinity dequeue to reduce model reload thrash on…
lstein Jul 5, 2026
13a7bc8
fix(qwen): restore legacy key remapping for single-file VL encoders u…
lstein Jul 5, 2026
c17bb36
Merge branch 'main' into lstein/feat/multi-gpu
lstein Jul 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions docs/src/content/docs/configuration/invokeai-yaml.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,60 @@ Most common algorithms are supported, like `md5`, `sha256`, and `sha512`. These

These options set the paths of various directories and files used by InvokeAI. Any user-defined paths should be absolute paths.

#### Multi-GPU Generation

On a machine with more than one GPU, InvokeAI can run several generation sessions at the same time — one per GPU — instead of processing the queue one job at a time. Jobs are distributed fairly across users, so a single user's large batch cannot monopolize every GPU while others wait.

This is controlled by the `generation_devices` setting:

```yaml
generation_devices: auto # default value
```

| Value | Behavior |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `auto` | Use every available CUDA GPU, running one generation session per GPU concurrently. This is the default. |
| `[cuda:0,cuda:1]` | Use the specific devices listed, one session per device. Useful for reserving a GPU for other work. |
| `[cuda:0]` | Use a single specific device. Generation runs serially, as it did before multi-GPU support. |
| `[]` | Use the first detected device. Generation runs serially, as it did before multi-GPU support. |

Each entry in the list must be one of `cpu`, `cuda`, `mps`, or `cuda:N`, where `N` is a zero-based device number (`cuda:0` is the first GPU, `cuda:1` the second, and so on).

```yaml
# Use the first and third GPUs, leaving the second free for other tasks
generation_devices: [cuda:0, cuda:2]
```

Notes:

- On a system without a CUDA GPU, `auto` resolves to the single best available device (`mps` on Apple Silicon, otherwise `cpu`), so generation runs serially.
- Each active GPU gets its own model cache, and model weights are duplicated in system RAM for every device. Running many GPUs in parallel therefore increases RAM usage — ensure you have ample system memory before enabling a large device list.
- Duplicate entries are ignored; `[cuda:0, cuda:0]` is treated as `[cuda:0]`.
- You can restrict which physical GPUs InvokeAI sees with the `CUDA_VISIBLE_DEVICES` environment variable. When set, `auto` only enumerates the visible subset, and `cuda:N` indices refer to positions within that subset.

During parallel generation, the progress display shows one progress bar per active session, stacked vertically, each disappearing as its session completes.

#### Text Encoder Offload to Idle GPUs

When more than one GPU is configured for generation but not all of them are busy, InvokeAI can run a session's text/prompt encoder on a currently-idle GPU instead of the GPU running its denoise pipeline. This avoids evicting the denoise model from VRAM just to make room for the encoder, and lets the cached encoder be reused across generations — making repeated generations noticeably smoother.

This is controlled by the `offload_text_encoders_to_idle_gpus` setting:

```yaml
offload_text_encoders_to_idle_gpus: true # default value
```

| Value | Behavior |
| ------- | ---------------------------------------------------------------------------------------------------------------- |
| `true` | Run text encoders on an idle GPU when one is available. This is the default. |
| `false` | Always run text encoders on the same GPU as the rest of the pipeline (the behavior before this feature existed). |

Notes:

- This has no effect unless at least two `generation_devices` are configured. On a single device — or when every GPU is already busy with its own session — encoders run on the session's own GPU, exactly as if the setting were `false`.
- It is purely a placement optimization and does not change generated images.
- A borrowed GPU is used exclusively for the encoder while it runs, so it never interferes with a generation session running on that same GPU.

#### Image Subfolder Strategy

By default, generated images are stored in a single flat directory under `outputs/images/`. The `image_subfolder_strategy` setting lets you organize newly-created images into subfolders automatically. You can edit this setting in `invokeai.yaml` or, as an admin user, in the Settings panel.
Expand Down
33 changes: 33 additions & 0 deletions docs/src/content/docs/development/Guides/creating-nodes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,39 @@ import { Steps, LinkCard } from '@astrojs/starlight/components';
4. A maintainer will review the pull request and node. If the node is aligned with the direction of the project, you may be asked for permission to include it in the core project.
</Steps>

### Supporting multi-GPU text-encoder offload

On a machine with more than one GPU, InvokeAI can run several generation sessions at once — one per GPU. When fewer sessions are running than there are GPUs, the spare GPUs sit idle. To put that capacity to use, InvokeAI can run a session's **prompt/text encoder** on a currently-idle GPU instead of on the GPU running the denoise pipeline. This avoids evicting the denoise model from VRAM just to make room for the encoder, and lets the cached encoder be reused across generations.

This is controlled globally by the `offload_text_encoders_to_idle_gpus` config setting (enabled by default) and opted into **per node** via the `@invocation` decorator:

```python
from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation


@invocation(
"my_text_encoder",
title="Prompt - My Model",
category="conditioning",
version="1.0.0",
idle_gpu_offloadable=True, # opt in to idle-GPU offload
)
class MyTextEncoderInvocation(BaseInvocation):
...
```

When the feature is enabled and an idle GPU is available, the **entire node** is temporarily re-pinned to a borrowed idle GPU: any model it loads goes onto that GPU and runs there. If no idle GPU is free (e.g. every GPU is busy with its own session), the node simply runs on its own GPU, unchanged. The borrow holds the idle GPU exclusively for the duration of the node, so it can never run concurrently against a native session on that same GPU.

Because the whole node is moved to another device, only mark a node `idle_gpu_offloadable=True` if **all** of the following hold:

- **It is encoder-only.** Its sole GPU work is loading one or more encoder models and running their forward pass. It must not load or run the denoise/transformer or VAE, or do any other work tied to the session's own GPU.
- **It stores its result on the CPU before returning.** Move output tensors to the CPU (`tensor.detach().to("cpu")`) and save them as conditioning/tensors. The denoiser picks them up and moves them onto its own GPU later — this is what makes the cross-GPU handoff safe and device-agnostic.
- **It places inputs on the loaded model's device, not a fixed device.** Resolve the device from the model you just loaded (e.g. `get_effective_device(model)` from `invokeai.backend.model_manager.load.model_cache.utils`, or `TorchDevice.choose_torch_device()`), rather than hard-coding `cuda:0`. The built-in `flux_text_encoder` and `compel` nodes are good references.

:::caution[Only mark encoder-only nodes]
If a node that also runs the denoiser, VAE, or other session-GPU work is marked `idle_gpu_offloadable=True`, that work will be re-pinned to the wrong GPU and can misplace tensors or raise device-mismatch errors. When in doubt, leave it unset (the default is `False`) — the node will still work correctly, just without the offload optimization.
:::

### Community Node Template

Append the following template to your pull request and the [Community Nodes](../../../workflows/community-nodes) page when submitting a node to be added to the community nodes list:
Expand Down
22 changes: 22 additions & 0 deletions docs/src/generated/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,28 @@
"type": "<class 'str'>",
"validation": {}
},
{
"category": "DEVICE",
"default": "auto",
"description": "Devices to use for parallel generation. `auto` (the default) uses every available GPU, running one generation session per GPU concurrently and distributing jobs fairly across users. Provide an explicit list (e.g. `[cuda:0, cuda:1]`) to use specific devices, or a single-device list (e.g. `[cuda:0]`) to run serially. On systems without a GPU, `auto` resolves to the single `cpu`/`mps` device.<br>Valid values: `auto`, or a list whose entries are each `cpu`, `cuda`, `mps`, or `cuda:N` (where N is a device number)",
"env_var": "INVOKEAI_GENERATION_DEVICES",
"literal_values": [],
"name": "generation_devices",
"required": false,
"type": "typing.Union[typing.Literal['auto'], list[str]]",
"validation": {}
},
{
"category": "DEVICE",
"default": true,
"description": "When running on multiple GPUs, load text encoders onto a currently-idle GPU instead of the one running the denoise pipeline. This avoids churning the denoise model in and out of VRAM to make room for the encoder, and lets a cached encoder be reused across generations. Has no effect unless at least two `generation_devices` are configured and a GPU is idle; under full load encoders run on the session's own GPU as before.",
"env_var": "INVOKEAI_OFFLOAD_TEXT_ENCODERS_TO_IDLE_GPUS",
"literal_values": [],
"name": "offload_text_encoders_to_idle_gpus",
"required": false,
"type": "<class 'bool'>",
"validation": {}
},
{
"category": "DEVICE",
"default": "auto",
Expand Down
60 changes: 58 additions & 2 deletions invokeai/app/api/routers/app_info.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import locale
import re
from enum import Enum
from importlib.metadata import distributions
from pathlib import Path as FilePath
from threading import Lock
from typing import Any
from typing import Any, Literal, Union

import torch
import yaml
from fastapi import Body, HTTPException, Path
from fastapi.routing import APIRouter
from pydantic import BaseModel, Field, model_validator
from pydantic import BaseModel, Field, field_validator, model_validator

from invokeai.app.api.auth_dependencies import AdminUserOrDefault
from invokeai.app.api.dependencies import ApiDependencies
Expand Down Expand Up @@ -118,6 +119,16 @@ def _remove_nullable_default_from_schema(schema: dict[str, Any]) -> None:
schema.update(non_null_schemas[0])


_GENERATION_DEVICE_PATTERN = re.compile(r"^(cpu|mps|cuda(:\d+)?)$")


class GenerationDeviceOption(BaseModel):
"""A device that may be selected for generation."""

device: str = Field(description="The device identifier, e.g. 'cuda:0', 'mps', or 'cpu'")
name: str = Field(description="Human-readable device name")


class UpdateAppGenerationSettingsRequest(BaseModel):
"""Writable generation-related app settings."""

Expand All @@ -131,14 +142,59 @@ class UpdateAppGenerationSettingsRequest(BaseModel):
ge=0,
description="Keep the last N completed, failed, and canceled queue items on startup. Set to 0 to prune all terminal items.",
)
generation_devices: Union[Literal["auto"], list[str]] | None = Field(
default=None,
description="Devices to use for parallel generation. `auto` uses every available GPU; provide an explicit list (e.g. `[cuda:0, cuda:1]`) to use specific devices. Takes effect after restarting InvokeAI.",
json_schema_extra=_remove_nullable_default_from_schema,
)

@field_validator("generation_devices")
@classmethod
def validate_generation_devices(
cls, v: Union[Literal["auto"], list[str], None]
) -> Union[Literal["auto"], list[str], None]:
if v is None or v == "auto":
return v
for device in v:
if not _GENERATION_DEVICE_PATTERN.match(device):
raise ValueError(
f"Invalid generation device '{device}'. Valid values are 'auto', 'cpu', 'mps', 'cuda', or 'cuda:N'."
)
return v

@model_validator(mode="after")
def validate_explicit_nulls(self) -> "UpdateAppGenerationSettingsRequest":
if "image_subfolder_strategy" in self.model_fields_set and self.image_subfolder_strategy is None:
raise ValueError("image_subfolder_strategy may not be null")
if "generation_devices" in self.model_fields_set and self.generation_devices is None:
raise ValueError("generation_devices may not be null")
return self


@app_router.get(
"/generation_device_options",
operation_id="get_generation_device_options",
status_code=200,
response_model=list[GenerationDeviceOption],
)
async def get_generation_device_options() -> list[GenerationDeviceOption]:
"""List the devices available for generation, for use with the `generation_devices` setting."""
options: list[GenerationDeviceOption] = []
if torch.cuda.is_available():
for index in range(torch.cuda.device_count()):
device = f"cuda:{index}"
try:
name = torch.cuda.get_device_name(index)
except Exception:
name = device
options.append(GenerationDeviceOption(device=device, name=name))
elif torch.backends.mps.is_available():
options.append(GenerationDeviceOption(device="mps", name="Apple MPS"))
else:
options.append(GenerationDeviceOption(device="cpu", name="CPU"))
return options


@app_router.get(
"/runtime_config", operation_id="get_runtime_config", status_code=200, response_model=InvokeAIAppConfigWithSetFields
)
Expand Down
11 changes: 8 additions & 3 deletions invokeai/app/api/routers/model_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,11 @@ async def update_model_record(
# nn.Module at load time, so toggling them on a cached model is otherwise silently a no-op until
# the entry is evicted. Drop any unlocked cached entries for this model so the next load rebuilds.
if _load_settings_changed(previous_config, config):
dropped = ApiDependencies.invoker.services.model_manager.load.ram_cache.drop_model(key)
# Drop the model from every per-device cache so the next load on any GPU rebuilds it.
dropped = sum(
cache.drop_model(key)
for cache in ApiDependencies.invoker.services.model_manager.load.ram_caches.values()
)
if dropped:
logger.info(
f"Dropped {dropped} cached entr{'y' if dropped == 1 else 'ies'} for model {key} after settings change."
Expand Down Expand Up @@ -1304,9 +1308,10 @@ async def get_stats() -> Optional[CacheStats]:
)
async def empty_model_cache(current_admin: AdminUserOrDefault) -> None:
"""Drop all models from the model cache to free RAM/VRAM. 'Locked' models that are in active use will not be dropped."""
# Request 1000GB of room in order to force the cache to drop all models.
# Request 1000GB of room in order to force each per-device cache to drop all models.
ApiDependencies.invoker.services.logger.info("Emptying model cache.")
ApiDependencies.invoker.services.model_manager.load.ram_cache.make_room(1000 * 2**30)
for cache in ApiDependencies.invoker.services.model_manager.load.ram_caches.values():
cache.make_room(1000 * 2**30)


class HFTokenStatus(str, Enum):
Expand Down
6 changes: 4 additions & 2 deletions invokeai/app/invocations/anima_denoise.py
Original file line number Diff line number Diff line change
Expand Up @@ -608,7 +608,7 @@ def _run_transformer(ctx: torch.Tensor, x: torch.Tensor, t: torch.Tensor) -> tor

if driver is not None:
user_step = 0
pbar = tqdm(total=total_steps, desc="Denoising (Anima)")
pbar = tqdm(total=total_steps, desc=f"Denoising (Anima){TorchDevice.get_session_device_label()}")
for it in driver.iterations():
timestep = torch.tensor(
[it.sigma_curr * ANIMA_MULTIPLIER], device=device, dtype=inference_dtype
Expand Down Expand Up @@ -655,7 +655,9 @@ def _run_transformer(ctx: torch.Tensor, x: torch.Tensor, t: torch.Tensor) -> tor
pbar.close()
else:
# Built-in Euler implementation (default for Anima)
for step_idx in tqdm(range(total_steps), desc="Denoising (Anima)"):
for step_idx in tqdm(
range(total_steps), desc=f"Denoising (Anima){TorchDevice.get_session_device_label()}"
):
sigma_curr = sigmas[step_idx]
sigma_prev = sigmas[step_idx + 1]

Expand Down
1 change: 1 addition & 0 deletions invokeai/app/invocations/anima_text_encoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
category="conditioning",
version="1.4.0",
classification=Classification.Prototype,
idle_gpu_offloadable=True,
)
class AnimaTextEncoderInvocation(BaseInvocation):
"""Encodes and preps a prompt for an Anima image.
Expand Down
10 changes: 10 additions & 0 deletions invokeai/app/invocations/baseinvocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,12 @@ def invoke_internal(self, context: InvocationContext, services: "InvocationServi

bottleneck: ClassVar[Bottleneck]

idle_gpu_offloadable: ClassVar[bool] = False
"""Whether this node's entire execution may be temporarily re-pinned to an idle GPU when
`offload_text_encoders_to_idle_gpus` is enabled in multi-GPU mode. Only set this to True on nodes
that exclusively load encoder model(s), run a forward pass, and store their result on the CPU —
i.e. nodes that do no work tied to the session's own GPU. Set via the `@invocation` decorator."""

UIConfig: ClassVar[UIConfigBase]

model_config = ConfigDict(
Expand Down Expand Up @@ -459,6 +465,7 @@ def get_output_for_type(cls, output_type: str) -> type[BaseInvocationOutput] | N
"type",
"workflow",
"bottleneck",
"idle_gpu_offloadable",
}

RESERVED_INPUT_FIELD_NAMES = {"metadata", "board"}
Expand Down Expand Up @@ -643,6 +650,7 @@ def invocation(
use_cache: Optional[bool] = True,
classification: Classification = Classification.Stable,
bottleneck: Bottleneck = Bottleneck.GPU,
idle_gpu_offloadable: bool = False,
) -> Callable[[Type[TBaseInvocation]], Type[TBaseInvocation]]:
"""
Registers an invocation.
Expand All @@ -655,6 +663,7 @@ def invocation(
:param Optional[bool] use_cache: Whether or not to use the invocation cache. Defaults to True. The user may override this in the workflow editor.
:param Classification classification: The classification of the invocation. Defaults to FeatureClassification.Stable. Use Beta or Prototype if the invocation is unstable.
:param Bottleneck bottleneck: The bottleneck of the invocation. Defaults to Bottleneck.GPU. Use Network if the invocation is network-bound.
:param bool idle_gpu_offloadable: Whether this node's whole execution may run on a borrowed idle GPU when `offload_text_encoders_to_idle_gpus` is enabled. Only set True for encoder-only nodes that store their result on the CPU and do no work on the session's own GPU. Defaults to False.
"""

def wrapper(cls: Type[TBaseInvocation]) -> Type[TBaseInvocation]:
Expand Down Expand Up @@ -712,6 +721,7 @@ def wrapper(cls: Type[TBaseInvocation]) -> Type[TBaseInvocation]:
cls.model_fields["use_cache"].default = use_cache

cls.bottleneck = bottleneck
cls.idle_gpu_offloadable = idle_gpu_offloadable

# Add the invocation type to the model.

Expand Down
2 changes: 1 addition & 1 deletion invokeai/app/invocations/cogview4_denoise.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ def _run_diffusion(
assert isinstance(transformer, CogView4Transformer2DModel)

# Denoising loop
for step_idx in tqdm(range(total_steps)):
for step_idx in tqdm(range(total_steps), desc=f"Denoising{TorchDevice.get_session_device_label()}"):
t_curr = timesteps[step_idx]
sigma_curr = sigmas[step_idx]
sigma_prev = sigmas[step_idx + 1]
Expand Down
1 change: 1 addition & 0 deletions invokeai/app/invocations/cogview4_text_encoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
category="prompt",
version="1.0.0",
classification=Classification.Prototype,
idle_gpu_offloadable=True,
)
class CogView4TextEncoderInvocation(BaseInvocation):
"""Encodes and preps a prompt for a cogview4 image."""
Expand Down
Loading
Loading