Skip to content

Commit 68b9017

Browse files
Fix: compute device partial load (invoke-ai#9375)
* fix(vae): decode on the model's intended compute device, not current residency (invoke-ai#9373) VAE decode inferred its device via get_effective_device(vae). Under partial loading with VRAM pressure, all VAE weights can be temporarily offloaded to RAM, so that returned CPU — placing the latents on CPU and, because the autocast layers follow the input device, running the entire decode on CPU. Expose LoadedModel.compute_device (the model's intended device, stable across partial-load residency and still honoring cpu_only) and use it in all 8 decode invocations. Adds a regression test reproducing the offloaded-weights case. * fix(text-encoder): encode on the intended compute device, not current residency Same class of bug as invoke-ai#9373, for text encoders: get_effective_device() returns CPU when partial loading has offloaded all weights, running the whole encode on CPU. Fully autocast-capable encoders (e.g. CLIP) are affected because repair_required_tensors_on_device() pins nothing to the compute device. Use LoadedModel.compute_device in compel, sd3, z_image, flux2_klein, cogview4 and qwen_image encoders, and thread it through HFEncoder for the FLUX encoder. Updates the affected tests to assert the intended-device behavior. * fix(tests): set vae_info.compute_device in qwen/z-image decode mocks Like the anima decode test, the qwen-image and z-image working-memory tests build vae_info as a bare MagicMock and drive invoke(). Since invoke-ai#9373 places latents on vae_info.compute_device, latents.to(device=...) raised TypeError — but both tests wrapped invoke() in `except Exception: pass`, so the failure was silently swallowed and the decode path never actually ran. Set compute_device to torch.device("cpu") so the tests exercise the real decode path instead of masking the error. * fix(text-encoder): anima encodes on the intended compute device, not current residency The anima text encoder inferred its device via text_encoder.device (HF PreTrainedModel residency), which returns CPU when partial loading has temporarily offloaded all Qwen3 weights to RAM — running the whole encode on the CPU. This is the same class of bug as invoke-ai#9373; the other text encoders and VAE decodes in this PR were already fixed, but the anima encoder was missed. Use LoadedModel.compute_device instead. This also corrects the device passed to TorchDevice.choose_anima_inference_dtype(). Adds a regression test covering both the offloaded-accelerator case and the cpu_only case. --------- Co-authored-by: Lincoln Stein <lincoln.stein@gmail.com>
1 parent 350eda6 commit 68b9017

27 files changed

Lines changed: 403 additions & 67 deletions

invokeai/app/invocations/anima_latents_to_image.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
from invokeai.app.invocations.primitives import ImageOutput
2929
from invokeai.app.services.shared.invocation_context import InvocationContext
3030
from invokeai.backend.flux.modules.autoencoder import AutoEncoder as FluxAutoEncoder
31-
from invokeai.backend.model_manager.load.model_cache.utils import get_effective_device
3231
from invokeai.backend.util.devices import TorchDevice
3332
from invokeai.backend.util.vae_working_memory import (
3433
estimate_vae_working_memory_anima,
@@ -127,8 +126,10 @@ def invoke(self, context: InvocationContext) -> ImageOutput:
127126
raise TypeError(f"Expected AutoencoderKLWan or FluxAutoEncoder, got {type(vae).__name__}.")
128127

129128
vae_dtype = next(iter(vae.parameters())).dtype
130-
# Use the VAE's actual device (may be CPU if the model is configured cpu_only).
131-
latents = latents.to(device=get_effective_device(vae), dtype=vae_dtype)
129+
# Use the VAE's intended compute device (CUDA/MPS, or CPU if configured cpu_only). Do NOT infer it from
130+
# current param residency: partial loading may have temporarily offloaded all weights to RAM, which would
131+
# wrongly place the latents (and thus the whole decode) on the CPU (see #9373).
132+
latents = latents.to(device=vae_info.compute_device, dtype=vae_dtype)
132133

133134
TorchDevice.empty_cache()
134135

invokeai/app/invocations/anima_text_encoder.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,10 @@ def _encode_prompt(
125125
(_, text_encoder) = exit_stack.enter_context(text_encoder_info.model_on_device())
126126
(_, tokenizer) = exit_stack.enter_context(tokenizer_info.model_on_device())
127127

128-
device = text_encoder.device
128+
# Use the encoder's intended compute device, not its current parameter residency: partial loading may
129+
# have temporarily offloaded all weights to RAM, which would wrongly run the whole encode on the CPU (see
130+
# #9373). Qwen3 is fully autocast-capable, so nothing pins it to the compute device otherwise.
131+
device = text_encoder_info.compute_device
129132

130133
# Apply LoRA models to the text encoder
131134
lora_dtype = TorchDevice.choose_anima_inference_dtype(device)

invokeai/app/invocations/cogview4_latents_to_image.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
from invokeai.app.invocations.model import VAEField
1818
from invokeai.app.invocations.primitives import ImageOutput
1919
from invokeai.app.services.shared.invocation_context import InvocationContext
20-
from invokeai.backend.model_manager.load.model_cache.utils import get_effective_device
2120
from invokeai.backend.stable_diffusion.extensions.seamless import SeamlessExt
2221
from invokeai.backend.util.devices import TorchDevice
2322
from invokeai.backend.util.vae_working_memory import estimate_vae_working_memory_cogview4
@@ -55,8 +54,10 @@ def invoke(self, context: InvocationContext) -> ImageOutput:
5554
):
5655
context.util.signal_progress("Running VAE")
5756
assert isinstance(vae, (AutoencoderKL))
58-
# Use the VAE's actual device (may be CPU if the model is configured cpu_only).
59-
latents = latents.to(get_effective_device(vae))
57+
# Use the VAE's intended compute device (CUDA/MPS, or CPU if configured cpu_only). Do NOT infer it from
58+
# current param residency: partial loading may have temporarily offloaded all weights to RAM, which would
59+
# wrongly place the latents (and thus the whole decode) on the CPU (see #9373).
60+
latents = latents.to(vae_info.compute_device)
6061

6162
vae.disable_tiling()
6263

invokeai/app/invocations/cogview4_text_encoder.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from invokeai.app.invocations.model import GlmEncoderField
77
from invokeai.app.invocations.primitives import CogView4ConditioningOutput
88
from invokeai.app.services.shared.invocation_context import InvocationContext
9-
from invokeai.backend.model_manager.load.model_cache.utils import get_effective_device
109
from invokeai.backend.stable_diffusion.diffusion.conditioning_data import (
1110
CogView4ConditioningInfo,
1211
ConditioningFieldData,
@@ -52,8 +51,11 @@ def _glm_encode(self, context: InvocationContext, max_seq_len: int) -> torch.Ten
5251
glm_text_encoder_info.model_on_device() as (_, glm_text_encoder),
5352
context.models.load(self.glm_encoder.tokenizer).model_on_device() as (_, glm_tokenizer),
5453
):
54+
# Repair any required tensors left on the CPU by a previous interrupted run, then run on the encoder's
55+
# intended compute device. Do NOT infer the device from current parameter residency: partial loading may
56+
# have temporarily offloaded all weights to RAM, which would wrongly run the whole encode on the CPU.
5557
repaired_tensors = glm_text_encoder_info.repair_required_tensors_on_device()
56-
device = get_effective_device(glm_text_encoder)
58+
device = glm_text_encoder_info.compute_device
5759
if repaired_tensors > 0:
5860
context.logger.warning(
5961
f"Recovered {repaired_tensors} required GLM tensor(s) onto {device} after a partial device mismatch."

invokeai/app/invocations/compel.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
from invokeai.app.invocations.primitives import ConditioningOutput
2020
from invokeai.app.services.shared.invocation_context import InvocationContext
2121
from invokeai.app.util.ti_utils import generate_ti_list
22-
from invokeai.backend.model_manager.load.model_cache.utils import get_effective_device
2322
from invokeai.backend.model_patcher import ModelPatcher
2423
from invokeai.backend.patches.layer_patcher import LayerPatcher
2524
from invokeai.backend.patches.model_patch_raw import ModelPatchRaw
@@ -104,7 +103,7 @@ def _lora_loader() -> Iterator[Tuple[ModelPatchRaw, float]]:
104103
textual_inversion_manager=ti_manager,
105104
dtype_for_device_getter=TorchDevice.choose_torch_dtype,
106105
truncate_long_prompts=False,
107-
device=get_effective_device(text_encoder),
106+
device=text_encoder_info.compute_device,
108107
split_long_text_mode=SplitLongTextMode.SENTENCES,
109108
)
110109

@@ -213,7 +212,7 @@ def _lora_loader() -> Iterator[Tuple[ModelPatchRaw, float]]:
213212
truncate_long_prompts=False, # TODO:
214213
returned_embeddings_type=ReturnedEmbeddingsType.PENULTIMATE_HIDDEN_STATES_NON_NORMALIZED, # TODO: clip skip
215214
requires_pooled=get_pooled,
216-
device=get_effective_device(text_encoder),
215+
device=text_encoder_info.compute_device,
217216
split_long_text_mode=SplitLongTextMode.SENTENCES,
218217
)
219218

invokeai/app/invocations/flux2_klein_text_encoder.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
from invokeai.app.invocations.model import Qwen3EncoderField
2626
from invokeai.app.invocations.primitives import FluxConditioningOutput
2727
from invokeai.app.services.shared.invocation_context import InvocationContext
28-
from invokeai.backend.model_manager.load.model_cache.utils import get_effective_device
2928
from invokeai.backend.patches.layer_patcher import LayerPatcher
3029
from invokeai.backend.patches.lora_conversions.flux_lora_constants import FLUX_LORA_T5_PREFIX
3130
from invokeai.backend.patches.model_patch_raw import ModelPatchRaw
@@ -101,8 +100,11 @@ def _encode_prompt(self, context: InvocationContext, exit_stack: ExitStack) -> T
101100
tokenizer_info = context.models.load(self.qwen3_encoder.tokenizer)
102101
(_, tokenizer) = exit_stack.enter_context(tokenizer_info.model_on_device())
103102

103+
# Repair any required tensors left on the CPU by a previous interrupted run, then run on the encoder's
104+
# intended compute device. Do NOT infer the device from current parameter residency: partial loading may
105+
# have temporarily offloaded all weights to RAM, which would wrongly run the whole encode on the CPU.
104106
repaired_tensors = text_encoder_info.repair_required_tensors_on_device()
105-
device = get_effective_device(text_encoder)
107+
device = text_encoder_info.compute_device
106108
if repaired_tensors > 0:
107109
context.logger.warning(
108110
f"Recovered {repaired_tensors} required Qwen3 tensor(s) onto {device} after a partial device mismatch."

invokeai/app/invocations/flux2_vae_decode.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
from invokeai.app.invocations.primitives import ImageOutput
2121
from invokeai.app.services.shared.invocation_context import InvocationContext
2222
from invokeai.backend.model_manager.load.load_base import LoadedModel
23-
from invokeai.backend.model_manager.load.model_cache.utils import get_effective_device
2423
from invokeai.backend.util.devices import TorchDevice
2524

2625

@@ -52,8 +51,10 @@ def _vae_decode(self, vae_info: LoadedModel, latents: torch.Tensor) -> Image.Ima
5251
"""
5352
with vae_info.model_on_device() as (_, vae):
5453
vae_dtype = next(iter(vae.parameters())).dtype
55-
# Use the VAE's actual device (may be CPU if the model is configured cpu_only).
56-
device = get_effective_device(vae)
54+
# Use the VAE's intended compute device (CUDA/MPS, or CPU if configured cpu_only). Do NOT infer it from
55+
# current param residency: partial loading may have temporarily offloaded all weights to RAM, which would
56+
# wrongly place the latents (and thus the whole decode) on the CPU (see #9373).
57+
device = vae_info.compute_device
5758
latents = latents.to(device=device, dtype=vae_dtype)
5859

5960
# Decode using diffusers API

invokeai/app/invocations/flux_text_encoder.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,9 @@ def _t5_encode(self, context: InvocationContext) -> torch.Tensor:
115115
)
116116
)
117117

118-
t5_encoder = HFEncoder(t5_text_encoder, t5_tokenizer, False, self.t5_max_seq_len)
118+
t5_encoder = HFEncoder(
119+
t5_text_encoder, t5_tokenizer, False, self.t5_max_seq_len, device=t5_encoder_info.compute_device
120+
)
119121

120122
if context.config.get().log_tokenization:
121123
self._log_t5_tokenization(context, t5_tokenizer)
@@ -158,7 +160,9 @@ def _clip_encode(self, context: InvocationContext) -> torch.Tensor:
158160
# There are currently no supported CLIP quantized models. Add support here if needed.
159161
raise ValueError(f"Unsupported model format: {clip_text_encoder_config.format}")
160162

161-
clip_encoder = HFEncoder(clip_text_encoder, clip_tokenizer, True, 77)
163+
clip_encoder = HFEncoder(
164+
clip_text_encoder, clip_tokenizer, True, 77, device=clip_text_encoder_info.compute_device
165+
)
162166

163167
if context.config.get().log_tokenization:
164168
self._log_clip_tokenization(context, clip_tokenizer)

invokeai/app/invocations/flux_vae_decode.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
from invokeai.app.services.shared.invocation_context import InvocationContext
1717
from invokeai.backend.flux.modules.autoencoder import AutoEncoder
1818
from invokeai.backend.model_manager.load.load_base import LoadedModel
19-
from invokeai.backend.model_manager.load.model_cache.utils import get_effective_device
2019
from invokeai.backend.util.devices import TorchDevice
2120
from invokeai.backend.util.vae_working_memory import estimate_vae_working_memory_flux
2221

@@ -48,8 +47,10 @@ def _vae_decode(self, vae_info: LoadedModel, latents: torch.Tensor) -> Image.Ima
4847
with vae_info.model_on_device(working_mem_bytes=estimated_working_memory) as (_, vae):
4948
assert isinstance(vae, AutoEncoder)
5049
vae_dtype = next(iter(vae.parameters())).dtype
51-
# Use the VAE's actual device (may be CPU if the model is configured cpu_only).
52-
latents = latents.to(device=get_effective_device(vae), dtype=vae_dtype)
50+
# Use the VAE's intended compute device (CUDA/MPS, or CPU if configured cpu_only). Do NOT infer it from
51+
# current param residency: partial loading may have temporarily offloaded all weights to RAM, which would
52+
# wrongly place the latents (and thus the whole decode) on the CPU (see #9373).
53+
latents = latents.to(device=vae_info.compute_device, dtype=vae_dtype)
5354
img = vae.decode(latents)
5455

5556
img = img.clamp(-1, 1)

invokeai/app/invocations/latents_to_image.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
from invokeai.app.invocations.model import VAEField
1919
from invokeai.app.invocations.primitives import ImageOutput
2020
from invokeai.app.services.shared.invocation_context import InvocationContext
21-
from invokeai.backend.model_manager.load.model_cache.utils import get_effective_device
2221
from invokeai.backend.stable_diffusion.extensions.seamless import SeamlessExt
2322
from invokeai.backend.stable_diffusion.vae_tiling import patch_vae_tiling_params
2423
from invokeai.backend.util.devices import TorchDevice
@@ -70,8 +69,10 @@ def invoke(self, context: InvocationContext) -> ImageOutput:
7069
):
7170
context.util.signal_progress("Running VAE decoder")
7271
assert isinstance(vae, (AutoencoderKL, AutoencoderTiny))
73-
# Use the VAE's actual device (may be CPU if the model is configured cpu_only).
74-
device = get_effective_device(vae)
72+
# Use the VAE's intended compute device (CUDA/MPS, or CPU if configured cpu_only). Do NOT infer it from
73+
# current param residency: partial loading may have temporarily offloaded all weights to RAM, which would
74+
# wrongly place the latents (and thus the whole decode) on the CPU (see #9373).
75+
device = vae_info.compute_device
7576
latents = latents.to(device)
7677
# Force fp32 when running on CPU (e.g. when the VAE is configured cpu_only). fp16 conv does run on CPU with
7778
# the pinned torch, but it's much slower than fp32 there, and SD/SDXL VAE has known fp16 overflow issues

0 commit comments

Comments
 (0)