diff --git a/invokeai/backend/anima/anima_transformer.py b/invokeai/backend/anima/anima_transformer.py index 36c5764e97e..670c3f70cca 100644 --- a/invokeai/backend/anima/anima_transformer.py +++ b/invokeai/backend/anima/anima_transformer.py @@ -985,6 +985,27 @@ class AnimaTransformer(MiniTrainDIT): text embeddings before they are fed to the DiT cross-attention layers. """ + # Modules that must keep their compute dtype when FP8 storage is enabled. Read by + # `ModelLoader._apply_fp8_layerwise_casting`, which uses the same attribute name diffusers + # models use, so no loader-side special-casing is needed. + # + # The generic skip patterns don't reach these: they match `norm`, `pos_embed`, `patch_embed` + # and `proj_in/out`, but this architecture names the equivalent modules differently. + # + # `t_embedder` is the one that matters, and it is not a rounding-quality nicety: with it cast + # to FP8 the model renders a heavily dithered image with no fine detail at all (verified + # against a bf16 run at the same seed/steps/CFG). It feeds `adaln_lora` into every block, so + # its error is applied to every token everywhere. Measured, same seed each time: casting + # nothing = broken; `t_embedder` alone = clean, and adding either of the two below changes + # nothing further. They are kept as ~2MB of margin on the I/O layers, matching what diffusers + # skips by default for comparable DiTs. `adaln_modulation` was also tested and is deliberately + # NOT listed — it costs 168MB and made no difference. + _skip_layerwise_casting_patterns = [ + "t_embedder", # timestep embedding MLP -> adaln_lora for every block + "x_embedder", # patch embedding (named `patch_embed` in diffusers models) + "final_layer", # output projection + ] + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.llm_adapter = LLMAdapter() diff --git a/invokeai/backend/model_manager/load/load_default.py b/invokeai/backend/model_manager/load/load_default.py index 5d547193d6f..6491fc3f9e5 100644 --- a/invokeai/backend/model_manager/load/load_default.py +++ b/invokeai/backend/model_manager/load/load_default.py @@ -156,6 +156,35 @@ def _device_supports_fp8_storage(device: torch.device, logger: Optional[Logger] r"^proj_out$", ) +# Model formats whose weights are already quantized. FP8 storage is meaningless for them (the +# payload is packed integers, not values we may re-encode) and actively harmful — see +# `_should_use_fp8`. Declared as strings to keep this module free of a taxonomy import at module +# scope; compared against `config.format`, which is a `ModelFormat` str-enum. Must list every +# quantized member of `ModelFormat`; `test_quantized_format_set_matches_the_taxonomy` pins the +# strings to the enum so a rename cannot silently disable the check. +_QUANTIZED_MODEL_FORMATS: frozenset[str] = frozenset( + { + "gguf_quantized", + "bnb_quantized_nf4b", + "bnb_quantized_int8b", + "sdnq_quantized", + } +) + + +def _is_quantized_param(param: torch.nn.Parameter) -> bool: + """Whether `param` holds a quantized payload that must not be re-encoded as FP8. + + Two signals, both observed in practice: + + - Not floating point. bnb's NF4/INT8 weights are packed `uint8` (and `bnb.nn.LinearNF4` + subclasses `nn.Linear`, so a class check alone does not catch them). Casting those to float8 + succeeds silently and the layer then returns finite garbage. + - A `torch.Tensor` *subclass*, e.g. `GGMLTensor`, which keeps its quantized payload plus + metadata and rejects dtype changes outright. + """ + return not param.data.is_floating_point() or type(param.data) is not torch.Tensor + # The construction path is not thread-safe on its own; it monkey-patches process-global torch state # (see MODEL_LOAD_LOCK). Concurrent callers must hold the MODEL_LOAD_LOCK write lock (see @@ -346,6 +375,18 @@ def _should_use_fp8(self, config: AnyModelConfig, submodel_type: Optional[SubMod """Check if FP8 layerwise casting should be applied to a model.""" from invokeai.backend.model_manager.taxonomy import ModelType + # Already-quantized models are excluded. Their weights are packed integer payloads, not + # values we may re-encode, and casting them is not a no-op: + # - GGUF raises `Operation changed the dtype of GGMLTensor unexpectedly`. + # - bnb NF4 corrupts *silently* — `bnb.nn.LinearNF4` subclasses `nn.Linear`, so the packed + # uint8 payload is cast to float8, inference still returns finite numbers, and the model + # just produces garbage. + # No quantized-format loader calls `_apply_fp8_layerwise_casting` today, so this is a guard + # against the next loader that gets wired up (they are being added one model at a time) + # rather than a fix for a live crash. + if hasattr(config, "format") and config.format in _QUANTIZED_MODEL_FORMATS: + return False + # VAEs are excluded — fp8 storage causes noticeable quality degradation in decode. if hasattr(config, "type") and config.type == ModelType.VAE: return False @@ -468,6 +509,11 @@ def _apply_fp8_to_nn_module( `_skip_layerwise_casting_patterns`), which are model-specific and cannot be inferred from layer types or generic name patterns. + Modules holding already-quantized weights are skipped regardless of their class. This is a + backstop behind the format check in `_should_use_fp8`, which cannot see quantization that + is not reflected in the model's format (e.g. a `diffusers`-format checkpoint whose weights + were quantized by an external tool). + 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 @@ -484,6 +530,8 @@ def _apply_fp8_to_nn_module( params = list(module.parameters(recurse=False)) if not params: continue + if any(_is_quantized_param(p) for p in params): + continue for param in params: param.data = param.data.to(storage_dtype) diff --git a/invokeai/backend/model_manager/load/model_loaders/anima.py b/invokeai/backend/model_manager/load/model_loaders/anima.py index 533ca12623c..a2c334ee6d6 100644 --- a/invokeai/backend/model_manager/load/model_loaders/anima.py +++ b/invokeai/backend/model_manager/load/model_loaders/anima.py @@ -181,6 +181,12 @@ def _load_from_singlefile( f"Checkpoint is missing {len(load_result.missing_keys)} keys " f"(expected for inv_freq buffers). First 5: {load_result.missing_keys[:5]}" ) + + # Without this the `fp8_storage` toggle is shown for Anima models but does nothing. The + # state dict was cast to a single `model_dtype` above, so the layerwise cast has one + # unambiguous compute dtype to restore to. AnimaTransformer is a plain nn.Module, so this + # takes the hook-based path in `_apply_fp8_to_nn_module`. + 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 91d255f5a49..7f8c7de862a 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,6 +56,15 @@ export const MainModelDefaultSettings = memo(({ modelConfig }: Props) => { return ['flux', 'flux2'].includes(modelConfig.base); }, [modelConfig]); + // Already-quantized weights cannot also be stored as FP8 — the backend refuses it (see + // `_should_use_fp8`), so offering the switch would be a control that silently does nothing. + // Keep in sync with `_QUANTIZED_MODEL_FORMATS` in `load_default.py`. + const isQuantized = useMemo(() => { + return ['gguf_quantized', 'bnb_quantized_nf4b', 'bnb_quantized_int8b', 'sdnq_quantized'].includes( + modelConfig.format + ); + }, [modelConfig]); + const defaultSettingsDefaults = useMainModelDefaultSettings(modelConfig); const optimalDimension = useMemo(() => { const modelBase = modelConfig?.base; @@ -144,7 +153,7 @@ export const MainModelDefaultSettings = memo(({ modelConfig }: Props) => { {!isFluxFamily && } - + {!isQuantized && } ); 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 17b96c3ad93..4b63597a57e 100644 --- a/tests/backend/model_manager/load/test_load_default_fp8.py +++ b/tests/backend/model_manager/load/test_load_default_fp8.py @@ -23,6 +23,7 @@ from invokeai.backend.model_manager.load.load_default import ( _FP8_PROBE_FAILURE_REPORTED, _FP8_STORAGE_SUPPORTED, + _QUANTIZED_MODEL_FORMATS, ModelLoader, _device_supports_fp8_storage, ) @@ -32,7 +33,7 @@ from invokeai.backend.model_manager.load.model_cache.torch_module_autocast.torch_module_autocast import ( apply_custom_layers_to_model, ) -from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType, SubModelType +from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat, ModelType, SubModelType def _make_loader(device: str = "cuda") -> ModelLoader: @@ -57,6 +58,13 @@ def _make_config(model_type: ModelType, fp8: bool, base: BaseModelType = BaseMod ) +def _make_quantized_config(fmt: ModelFormat = ModelFormat.GGUFQuantized): + """A config carrying a quantized `format`, which `_make_config` deliberately omits.""" + config = _make_config(ModelType.Main, fp8=True) + config.format = fmt + return config + + @pytest.mark.parametrize( "config,submodel", [ @@ -64,6 +72,8 @@ def _make_config(model_type: ModelType, fp8: bool, base: BaseModelType = BaseMod (_make_config(ModelType.LoRA, fp8=True), 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. + # A quantized model takes its place: its guard must also sit ahead of the device probe. + (_make_quantized_config(), None), (_make_config(ModelType.Main, fp8=True), SubModelType.Tokenizer), (_make_config(ModelType.Main, fp8=False), None), ], @@ -371,6 +381,107 @@ def __init__(self): assert model.layers.weight.dtype == torch.float8_e4m3fn +def test_anima_transformer_declares_t_embedder_skip(): + """Regression guard for Anima + FP8 rendering a heavily dithered image. + + `AnimaTransformer.t_embedder` produces the `adaln_lora` conditioning consumed by every block, + so casting it to FP8 corrupts every token of every block — verified against a bf16 run at the + same seed/steps/CFG. None of the generic `_FP8_DEFAULT_SKIP_PATTERNS` match it (this + architecture doesn't use diffusers' module names), so the model has to declare it itself. + """ + from invokeai.backend.anima.anima_transformer import AnimaTransformer + + assert "t_embedder" in AnimaTransformer._skip_layerwise_casting_patterns + + # And the declared patterns actually reach the cast, matched against dotted module paths. + class _Model(torch.nn.Module): + def __init__(self): + super().__init__() + self.t_embedder = torch.nn.Sequential(torch.nn.Linear(4, 4), torch.nn.Linear(4, 4)) + self.blocks = torch.nn.Linear(4, 4) + + model = _Model().to(torch.float32) + ModelLoader._apply_fp8_to_nn_module( + model, + storage_dtype=torch.float16, + compute_dtype=torch.float32, + extra_skip_patterns=tuple(AnimaTransformer._skip_layerwise_casting_patterns), + ) + + assert model.t_embedder[0].weight.dtype == torch.float32 + assert model.blocks.weight.dtype == torch.float16 + + +@pytest.mark.parametrize( + "fmt", + [ + ModelFormat.GGUFQuantized, + ModelFormat.BnbQuantizednf4b, + ModelFormat.BnbQuantizedLlmInt8b, + ModelFormat.SDNQQuantized, + ], +) +def test_should_use_fp8_excludes_quantized_formats(fmt: ModelFormat): + """Already-quantized weights must never be re-encoded as FP8. + + Casting them is not a no-op: GGUF raises `Operation changed the dtype of GGMLTensor + unexpectedly`, and bnb NF4 corrupts silently (`bnb.nn.LinearNF4` subclasses `nn.Linear`, so its + packed uint8 payload is cast to float8 and inference then returns finite garbage). + + Parametrized over `ModelFormat` members rather than raw strings: `_QUANTIZED_MODEL_FORMATS` + holds strings, so testing it with strings would pass even if the enum values drifted. + """ + loader = _make_loader(device="cuda") + config = _make_config(ModelType.Main, fp8=True) + config.format = fmt + assert loader._should_use_fp8(config) is False + + +def test_quantized_format_set_matches_the_taxonomy(): + """Every entry in `_QUANTIZED_MODEL_FORMATS` must still name a real `ModelFormat` value. + + The set is declared as raw strings to keep `load_default` free of a taxonomy import at module + scope, so nothing else stops a rename in `ModelFormat` from silently disabling the check — + `config.format` would simply never match again, and FP8 would be re-enabled for that format. + """ + assert _QUANTIZED_MODEL_FORMATS <= {fmt.value for fmt in ModelFormat} + + +def test_apply_fp8_skips_quantized_params_regardless_of_format(): + """Backstop behind the format check, for quantization the model's format does not reveal + (e.g. a `diffusers`-format checkpoint quantized by an external tool). + + Both signals are covered: a non-floating-point payload (bnb's packed uint8) and a + `torch.Tensor` subclass (GGUF's `GGMLTensor`). + """ + + class _FakeQuantTensor(torch.Tensor): + """Stands in for GGMLTensor: a Tensor subclass carrying a quantized payload.""" + + class _Model(torch.nn.Module): + def __init__(self): + super().__init__() + self.packed = torch.nn.Linear(4, 4, bias=False) # bnb-style uint8 payload + self.subclassed = torch.nn.Linear(4, 4, bias=False) # GGUF-style tensor subclass + # NB: not `normal` — that would be caught by the `norm` skip pattern. + self.attn = torch.nn.Linear(4, 4, bias=False) + + model = _Model().to(torch.bfloat16) + model.packed.weight = torch.nn.Parameter(torch.zeros(8, 1, dtype=torch.uint8), requires_grad=False) + model.subclassed.weight = torch.nn.Parameter( + torch.zeros(4, 4, dtype=torch.bfloat16).as_subclass(_FakeQuantTensor), requires_grad=False + ) + + ModelLoader._apply_fp8_to_nn_module(model, torch.float8_e4m3fn, torch.bfloat16) + + assert model.packed.weight.dtype == torch.uint8 + assert not model.packed._forward_pre_hooks, "a quantized layer must not get cast hooks either" + assert model.subclassed.weight.dtype == torch.bfloat16 + assert not model.subclassed._forward_pre_hooks + # Control: an ordinary layer in the same model is still cast. + assert model.attn.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