diff --git a/invokeai/backend/model_manager/load/load_default.py b/invokeai/backend/model_manager/load/load_default.py
index d646d4466d3..5d547193d6f 100644
--- a/invokeai/backend/model_manager/load/load_default.py
+++ b/invokeai/backend/model_manager/load/load_default.py
@@ -344,12 +344,7 @@ def get_size_fs(
def _should_use_fp8(self, config: AnyModelConfig, submodel_type: Optional[SubModelType] = None) -> bool:
"""Check if FP8 layerwise casting should be applied to a model."""
- # Z-Image has dtype mismatch issues with diffusers' layerwise casting
- # (skipped modules produce bf16, hooked modules expect fp16).
- from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType
-
- if hasattr(config, "base") and config.base == BaseModelType.ZImage:
- return False
+ from invokeai.backend.model_manager.taxonomy import ModelType
# VAEs are excluded — fp8 storage causes noticeable quality degradation in decode.
if hasattr(config, "type") and config.type == ModelType.VAE:
@@ -430,7 +425,19 @@ def _apply_fp8_layerwise_casting(
# `register_forward_hook` path fires around `nn.Module._call_impl` without replacing
# `forward`, so `CustomLinear.forward` is still reached.
if isinstance(model, torch.nn.Module):
- self._apply_fp8_to_nn_module(model, storage_dtype=storage_dtype, compute_dtype=compute_dtype)
+ # Diffusers models declare their own precision-sensitive modules in
+ # `_skip_layerwise_casting_patterns`, and `enable_layerwise_casting()` honors them. Since
+ # we no longer call it, we have to apply that list ourselves — it is not cosmetic. Z-Image's
+ # `TimestepEmbedder.forward` reads `self.mlp[0].weight.dtype` and casts its *input* to it;
+ # with an fp8 weight the input becomes float8 before our pre-hook can restore the weight,
+ # and `F.linear` dies with `"addmm_cuda" not implemented for 'Float8_e4m3fn'`. Hence
+ # `['t_embedder', 'cap_embedder']` for that model.
+ self._apply_fp8_to_nn_module(
+ model,
+ storage_dtype=storage_dtype,
+ compute_dtype=compute_dtype,
+ extra_skip_patterns=tuple(getattr(model, "_skip_layerwise_casting_patterns", None) or ()),
+ )
else:
return model
@@ -443,7 +450,12 @@ def _apply_fp8_layerwise_casting(
return model
@staticmethod
- def _apply_fp8_to_nn_module(model: torch.nn.Module, storage_dtype: torch.dtype, compute_dtype: torch.dtype) -> None:
+ def _apply_fp8_to_nn_module(
+ model: torch.nn.Module,
+ storage_dtype: torch.dtype,
+ compute_dtype: torch.dtype,
+ extra_skip_patterns: tuple[str, ...] = (),
+ ) -> None:
"""Apply FP8 layerwise casting to a plain nn.Module.
Mirrors diffusers' `apply_layerwise_casting` semantics: only the layer classes in
@@ -452,6 +464,10 @@ def _apply_fp8_to_nn_module(model: torch.nn.Module, storage_dtype: torch.dtype,
Without the skip list, precision-sensitive tiny learned scalars (e.g. FLUX RMSNorm.scale)
get crushed to FP8 and quality degrades noticeably.
+ `extra_skip_patterns` carries the model's own declared exclusions (diffusers'
+ `_skip_layerwise_casting_patterns`), which are model-specific and cannot be inferred from
+ layer types or generic name patterns.
+
Records the compute dtype on the model. After the cast, `model.dtype` reports the float8
storage dtype, which must never be used to create or cast tensors — torch has no arithmetic
kernels for it (see `get_model_compute_dtype`). The marker is set here rather than at the
@@ -459,10 +475,11 @@ def _apply_fp8_to_nn_module(model: torch.nn.Module, storage_dtype: torch.dtype,
"""
set_fp8_compute_dtype(model, compute_dtype)
+ skip_patterns = _FP8_DEFAULT_SKIP_PATTERNS + tuple(extra_skip_patterns)
for module_name, module in model.named_modules():
if not isinstance(module, _FP8_SUPPORTED_PYTORCH_LAYERS):
continue
- if any(re.search(pattern, module_name) for pattern in _FP8_DEFAULT_SKIP_PATTERNS):
+ if any(re.search(pattern, module_name) for pattern in skip_patterns):
continue
params = list(module.parameters(recurse=False))
if not params:
diff --git a/invokeai/backend/model_manager/load/model_loaders/z_image.py b/invokeai/backend/model_manager/load/model_loaders/z_image.py
index 534db4e8bd2..0ca5dfb010a 100644
--- a/invokeai/backend/model_manager/load/model_loaders/z_image.py
+++ b/invokeai/backend/model_manager/load/model_loaders/z_image.py
@@ -482,6 +482,11 @@ def _load_from_singlefile(
sd[k] = sd[k].to(model_dtype)
model.load_state_dict(sd, assign=True)
+
+ # Every param is uniform `model_dtype` at this point (the loop above casts the whole state
+ # dict, including ComfyUI fp8 checkpoints, whose scale metadata was filtered out above), so
+ # the layerwise cast has a single unambiguous compute dtype to restore to.
+ model = self._apply_fp8_layerwise_casting(model, config, SubModelType.Transformer)
return model
diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/MainModelDefaultSettings/MainModelDefaultSettings.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/MainModelDefaultSettings/MainModelDefaultSettings.tsx
index b69bf117bc6..91d255f5a49 100644
--- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/MainModelDefaultSettings/MainModelDefaultSettings.tsx
+++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/MainModelDefaultSettings/MainModelDefaultSettings.tsx
@@ -56,10 +56,6 @@ export const MainModelDefaultSettings = memo(({ modelConfig }: Props) => {
return ['flux', 'flux2'].includes(modelConfig.base);
}, [modelConfig]);
- const isZImage = useMemo(() => {
- return modelConfig.base === 'z-image';
- }, [modelConfig]);
-
const defaultSettingsDefaults = useMainModelDefaultSettings(modelConfig);
const optimalDimension = useMemo(() => {
const modelBase = modelConfig?.base;
@@ -148,7 +144,7 @@ export const MainModelDefaultSettings = memo(({ modelConfig }: Props) => {
{!isFluxFamily && }
- {!isZImage && }
+
>
);
diff --git a/tests/backend/model_manager/load/test_load_default_fp8.py b/tests/backend/model_manager/load/test_load_default_fp8.py
index 7909b5620e5..17b96c3ad93 100644
--- a/tests/backend/model_manager/load/test_load_default_fp8.py
+++ b/tests/backend/model_manager/load/test_load_default_fp8.py
@@ -62,7 +62,8 @@ def _make_config(model_type: ModelType, fp8: bool, base: BaseModelType = BaseMod
[
(_make_config(ModelType.VAE, fp8=True), None),
(_make_config(ModelType.LoRA, fp8=True), None),
- (_make_config(ModelType.Main, fp8=True, base=BaseModelType.ZImage), None),
+ # Z-Image used to be listed here. It is no longer excluded — see
+ # `test_should_use_fp8_allows_z_image` for why the exclusion became obsolete.
(_make_config(ModelType.Main, fp8=True), SubModelType.Tokenizer),
(_make_config(ModelType.Main, fp8=False), None),
],
@@ -313,6 +314,72 @@ def __init__(self):
assert model.rms.scale.dtype == compute_dtype
+def test_apply_fp8_to_nn_module_honors_extra_skip_patterns():
+ """A model's own `_skip_layerwise_casting_patterns` must be applied on top of our defaults."""
+
+ class _Model(torch.nn.Module):
+ def __init__(self):
+ super().__init__()
+ self.t_embedder = torch.nn.Linear(4, 4)
+ self.attn = torch.nn.Linear(4, 4)
+
+ storage_dtype = torch.float16
+ compute_dtype = torch.float32
+ model = _Model()
+ for p in model.parameters():
+ p.data = p.data.to(compute_dtype)
+
+ ModelLoader._apply_fp8_to_nn_module(
+ model, storage_dtype, compute_dtype, extra_skip_patterns=("t_embedder", "cap_embedder")
+ )
+
+ assert model.attn.weight.dtype == storage_dtype
+ assert model.t_embedder.weight.dtype == compute_dtype
+
+
+def test_apply_fp8_layerwise_casting_passes_model_declared_skip_patterns():
+ """Regression test for Z-Image + fp8 crashing with
+ `RuntimeError: "addmm_cuda" not implemented for 'Float8_e4m3fn'`.
+
+ Diffusers models declare precision-sensitive modules in `_skip_layerwise_casting_patterns`, and
+ `enable_layerwise_casting()` honors them. Our hook-based replacement must read that list too —
+ it is not redundant with `_FP8_DEFAULT_SKIP_PATTERNS`. `ZImageTransformer2DModel` declares
+ `['t_embedder', 'cap_embedder']` because `TimestepEmbedder.forward` reads
+ `self.mlp[0].weight.dtype` and casts its *input* to it: with an fp8 weight the input becomes
+ float8 before the pre-hook restores the weight, and `F.linear` has no float8 kernel.
+ """
+
+ class _FakeZImage(torch.nn.Module):
+ _skip_layerwise_casting_patterns = ["t_embedder", "cap_embedder"]
+
+ def __init__(self):
+ super().__init__()
+ self.t_embedder = torch.nn.Sequential(torch.nn.Linear(4, 4), torch.nn.Linear(4, 4))
+ self.cap_embedder = torch.nn.Linear(4, 4)
+ self.layers = torch.nn.Linear(4, 4)
+
+ loader = _make_loader(device="cuda")
+ model = _FakeZImage().to(torch.bfloat16)
+
+ with patch.object(ModelLoader, "_should_use_fp8", return_value=True):
+ loader._apply_fp8_layerwise_casting(model, _make_config(ModelType.Main, fp8=True, base=BaseModelType.ZImage))
+
+ # The declared modules keep their compute dtype...
+ assert model.t_embedder[0].weight.dtype == torch.bfloat16
+ assert model.cap_embedder.weight.dtype == torch.bfloat16
+ # ...while everything else is stored in fp8, so the toggle still saves VRAM.
+ assert model.layers.weight.dtype == torch.float8_e4m3fn
+
+
+def test_should_use_fp8_allows_z_image():
+ """Z-Image was excluded while we used diffusers' `enable_layerwise_casting()` with the global
+ torch dtype (fp16) as compute dtype, which clashed with the model's bf16 weights. The compute
+ dtype now comes from the model itself, so the exclusion is obsolete.
+ """
+ loader = _make_loader(device="cuda")
+ assert loader._should_use_fp8(_make_config(ModelType.Main, fp8=True, base=BaseModelType.ZImage)) is True
+
+
def test_wrap_forward_reaches_custom_linear_after_apply_custom_layers():
"""Production order: `_load_model` applies FP8 wrapping, THEN `ModelCache.put()` calls
`apply_custom_layers_to_model` which constructs a NEW `CustomLinear` object via