fix(fp8): never apply FP8 storage to already-quantized weights - #9416
Conversation
Z-Image was excluded from FP8 storage in invoke-ai#8945 because diffusers' enable_layerwise_casting() was called with the global torch dtype (fp16) while Z-Image loads in bf16: skipped modules stayed bf16, hooked ones produced fp16, and attention crashed. That root cause was fixed later in the same PR — the compute dtype now comes from the model's own parameters — so the exclusion is obsolete. Removing it alone is not enough. Our hook-based cast (invoke-ai#9231) dropped one thing diffusers' enable_layerwise_casting() did: honoring the model's declared _skip_layerwise_casting_patterns. Z-Image needs it, and not for quality — 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 restores the weight, and F.linear dies with: RuntimeError: "addmm_cuda" not implemented for 'Float8_e4m3fn' which is why ZImageTransformer2DModel declares ['t_embedder', 'cap_embedder']. _apply_fp8_to_nn_module now takes extra_skip_patterns and the caller passes the model's list. For other models this is a strict superset of our defaults (FLUX/SD3 pos_embed+norm, UNet norm, CogView4 also proj_out), so it only ever skips more. Also wire the cast into ZImageCheckpointModel: only the diffusers loader called it, so the toggle was a silent no-op for single-file Z-Image models even though both paths build the same ZImageTransformer2DModel. Tested end to end on CUDA: transformer resident VRAM drops from ~11.5GB to 5880MB for both Z-Image-Turbo (diffusers) and Z-Image-Turbo (checkpoint, 14.37GB file), with clean output images in both cases.
The fp8_storage toggle was shown for Anima main models but did nothing: AnimaCheckpointModel never called _apply_fp8_layerwise_casting. Wire it in — the state dict is cast to a single model_dtype before load_state_dict, so the layerwise cast has one unambiguous compute dtype to restore to. Wiring alone renders a heavily dithered image with no fine detail. The cause is t_embedder: it produces the adaln_lora conditioning consumed by every block, so casting it to FP8 corrupts every token everywhere. None of the generic skip patterns match it — they target diffusers' module names (norm, pos_embed, patch_embed, proj_in/out) and this architecture names things differently. AnimaTransformer now declares _skip_layerwise_casting_patterns, the same attribute diffusers models use, so the loader needs no special-casing. Measured on CUDA, same seed/steps/CFG each run: casting nothing = broken at 1994MB; t_embedder alone = clean at 2010MB; adding x_embedder and final_layer changes nothing further (2012MB) and is kept as margin on the I/O layers; adaln_modulation was tested too and is deliberately not listed — it costs 168MB and made no difference. Against a bf16 reference (3988MB) the FP8 result keeps the same composition and loses only a little micro-detail.
Every quantized-format loader reaches _apply_fp8_layerwise_casting, and the cast
there is not a no-op. Verified on real layers:
- GGUF raises "Operation changed the dtype of GGMLTensor unexpectedly" at load.
- bnb NF4 corrupts silently: bnb.nn.LinearNF4 subclasses nn.Linear, so the
isinstance check passes and the packed uint8 payload is cast to float8.
Inference still returns finite numbers and the model just produces garbage
(max abs deviation 50.4 against a reference forward pass).
Both are reachable today by enabling the fp8_storage toggle, which the UI offered
for these models.
Guard on two levels, because a format check alone is not enough — an externally
quantized checkpoint can carry a plain `diffusers` format (e.g. SDNQ):
- _should_use_fp8 rejects gguf_quantized and both bnb formats.
- _apply_fp8_to_nn_module skips any module whose params are non-floating-point
or a torch.Tensor subclass, regardless of the model's declared format.
Frontend hides the toggle for quantized formats, so the control is not shown for
something the backend refuses.
Verified end to end: with fp8_storage forced true in the DB (the legacy case the
UI no longer offers), a GGUF Z-Image model now loads cleanly with no FP8 casting
and no GGMLTensor error, while non-quantized models still show the toggle and
still get cast.
Resolves a conflict in `_should_use_fp8` where both sides restructured the same guard chain. Upstream moved device support probing to the end of the chain (invoke-ai#9401, XPU), so it runs only for a model that actually wants FP8. This branch still had the older `_torch_device.type != "cuda"` precondition at the top, which would have short-circuited every non-CUDA device and undone that. Dropped it; the trailing `_device_supports_fp8_storage` call covers the same ground. Also reconciles a semantic conflict git merged cleanly: upstream added a parametrised case pinning Z-Image as excluded, while this stack removes that exclusion (invoke-ai#9414 gives Z-Image checkpoints a uniform model dtype, and `test_should_use_fp8_allows_z_image` documents why the exclusion is obsolete). Replaced the Z-Image case with a quantized one, which pins the property this branch is actually about: the quantized-format guard runs ahead of the device probe. The now-unused `BaseModelType` import is gone.
# Conflicts: # invokeai/backend/model_manager/load/load_default.py
main added a device-probe parametrize listing Z-Image as an excluded model. This branch removes that exclusion, so the entry contradicts `test_should_use_fp8_allows_z_image` and the case now returns the probe's value instead of False. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts: # invokeai/backend/model_manager/load/load_default.py # tests/backend/model_manager/load/test_load_default_fp8.py # uv.lock
|
This would allow me to run krea for once, please oh please put this in 6.14.0 👀 my dear @lstein |
# Conflicts: # invokeai/backend/model_manager/load/load_default.py # invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/MainModelDefaultSettings/MainModelDefaultSettings.tsx # tests/backend/model_manager/load/test_load_default_fp8.py
`ModelFormat.SDNQQuantized` was missing from both `_QUANTIZED_MODEL_FORMATS` and the
frontend's `isQuantized` list, even though it is a first-class quantized format —
`Main_SDNQ_{FLUX,Flux2,ZImage}_Config` and `Main_SDNQ_Diffusers_{FLUX,Flux2,ZImage}_Config`
all declare `format: Literal[ModelFormat.SDNQQuantized]`. So `_should_use_fp8` still
returned True for SDNQ main models and Model Manager still rendered the FP8 switch for
them: exactly the dead control this change removes for GGUF and bnb.
Not a corruption path today — no SDNQ loader calls `_apply_fp8_layerwise_casting` — but
neither is any other quantized format, which is the point of guarding all four.
Also:
- Parametrize the format test over `ModelFormat` members instead of raw strings. The set
under test holds strings, so a string-only test passes even if the enum values drift.
- Add `test_quantized_format_set_matches_the_taxonomy`, which pins every entry to a real
`ModelFormat` value so a rename cannot silently re-enable FP8 for that format.
- Drop the claim that "every quantized-format loader reaches this helper" from the code
comment and the test docstring. Walking `ModelLoaderRegistry` shows that none of the 17
quantized-format loaders calls `_apply_fp8_layerwise_casting` anywhere in its MRO. The
guard is defense-in-depth for the next loader that gets wired up, not a live crash fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01561rwg8YRLzkjUu3kjc73R
lstein
left a comment
There was a problem hiding this comment.
Adversarial review at d8b984162d, then merged main and pushed two follow-ups — now at b187ae46c2. Approving.
What I changed on the branch
1. Resolved the merge conflicts. #9414 landed on main as a squash (b06e57e63f) while this branch carried it as a merge of the original commits, so all three shared files conflicted. Every hunk was "ours adds, theirs is unchanged" — resolved by keeping the branch side and letting the rest auto-merge. The resulting tree against main is exactly #9415's Anima changes plus this PR's guard; nothing from #9414 is duplicated or lost.
2. Added sdnq_quantized to both lists (b187ae46c2). ModelFormat.SDNQQuantized was missing from _QUANTIZED_MODEL_FORMATS and from the frontend's isQuantized, even though it is first-class: Main_SDNQ_{FLUX,Flux2,ZImage}_Config and Main_SDNQ_Diffusers_{FLUX,Flux2,ZImage}_Config all declare format: Literal[ModelFormat.SDNQQuantized]. So _should_use_fp8 still said yes for SDNQ main models and Model Manager still rendered the switch for them — the same dead control this PR removes for GGUF and bnb. Mutation-verified: dropping the entry fails the new [sdnq_quantized] case.
Alongside it, the format test now parametrizes over ModelFormat members instead of raw strings (the set under test holds strings, so a string-only test passes even if the enum values drift), and test_quantized_format_set_matches_the_taxonomy pins every entry to a real ModelFormat value.
3. Corrected the description. It claimed "every quantized-format loader reaches _apply_fp8_layerwise_casting". Walking ModelLoaderRegistry and checking each class's MRO down to ModelLoader: none of the 17 quantized-format loaders calls it — not FluxGGUFCheckpointModel, not FluxBnbQuantizednf4bCheckpointModel, not the Qwen-Image / Z-Image / Krea-2 / Wan / FLUX.2 GGUF loaders, not the three SDNQ loaders, not the T5/Qwen3/Gemma2/Mistral encoders. (qwen_image.py:243 is in QwenImageCheckpointModel, not the GGUF one.) Same on main. So "reproduce on main: any GGUF main model fails at load" does not reproduce; only the direct _apply_fp8_to_nn_module snippet does.
That does not make the change less worth taking — it makes it defense-in-depth, which is the right framing given that FP8 is being wired into loaders one model at a time (#9414, #9415) and the next one to be wired would walk straight into the crash with _should_use_fp8 saying yes. The body, the code comment and the test docstring now say that instead.
Attacks that failed
ModelFormat.X in frozenset[str]— my main suspicion, sinceEnum.__hash__ishash(self._name_). It holds: the MRO is(ModelFormat, str, Enum, object), sostr.__hash__wins andhash(member) == hash(member.value).- Real
GGMLTensorin aLinearviaload_state_dict(assign=True):type(p)andtype(p.data)both stayGGMLTensorandis_floating_point()is False — caught by both signals, so either one alone would still do it. - False positives —
nn.Parameter(plain).dataistorch.Tensor, notParameter, so ordinary layers still cast. Zero params flagged across the real FLUX.1-dev transformer (780 params) andAnimaTransformer(601) built on meta.GGMLTensorandSDNQTensorare the onlytorch.Tensorsubclasses in the repo, so the subclass signal has nothing benign to trip on. - Mutation testing — deleting the format guard, the param guard, or either signal inside
_is_quantized_parameach fails exactly one test. Nothing here is decorative. - Frontend —
useMainModelDefaultSettingsalways suppliesfp8Storageand RHF'sshouldUnregisterdefaults to false, sodata.fp8Storage.isEnabledinonSubmitstill resolves for the hidden field. Same pattern as the existingisFluxFamilyfields, and a persisted legacytrueround-trips unchanged rather than being silently cleared.
One follow-up, not blocking
_apply_fp8_layerwise_casting derives the compute dtype from next(model.parameters()).dtype before the param-level check runs. For exactly the case the backstop advertises — a plain-format checkpoint whose weights were quantized externally — a quantized first parameter makes that torch.uint8:
get_model_compute_dtype -> torch.uint8
attn weight after cast: torch.float8_e4m3fn (the quantized layer is correctly skipped)
forward RAISED: RuntimeError: expected m1 and m2 to have the same dtype, but got: c10::BFloat16 != unsigned char
max abs weight drift vs original: 0.486
The quantized layers are skipped correctly, but the pre-hook on the layers that were cast does p.data.to(uint8) and destroys them. set_fp8_compute_dtype only rejects float8, not integer dtypes. One-line fix — derive from the first floating-point parameter — but it is not reachable today (it needs a quantized model on a cast-calling loader), so it does not need to widen this PR. Noted in the description as a follow-up.
Conflict resolutions, all in code that invoke-ai#9414/invoke-ai#9415/invoke-ai#9416 also touched: - anima_transformer.py, MainModelDefaultSettings.tsx, test_load_default_fp8.py: took main. Those branches were refined after this one forked from them, so main carries the newer text: the corrected `adaln_modulation` comment, the `sdnq_quantized` format, and the `ModelFormat`-parametrized quantized-format test plus `test_quantized_format_set_matches_the_taxonomy`. - anima.py, z_image.py: took this branch's fp8-scales blocks, resolved in place so main's `ANIMA_TRANSFORMER_CONFIG` extraction survives alongside them. - load_default.py: took main for `_QUANTIZED_MODEL_FORMATS` and the `_should_use_fp8` comment, this branch for the `skip` callback (signature, docstring, loop). The merged `_apply_fp8_to_nn_module` now composes all four exclusion mechanisms: default patterns, model-declared patterns, the `skip` callback, and the quantized-param backstop. Checked rather than assumed: main's `_should_use_fp8` comment says no quantized-format loader reaches the cast, this branch's said the opposite. Scanned every `@ModelLoaderRegistry.register` with a quantized format - none calls `_apply_fp8_layerwise_casting`, and `_should_use_fp8` has no other caller. Main is right. openapi.json auto-merged: all 77 config attributes from main preserved, plus `fp8_compute` and `fp8_compute_full_precision_hints`.
Summary
FP8 storage must never be applied to weights that are already quantized. The cast is not a no-op
on such a payload:
ValueError: Operation changed the dtype of GGMLTensor unexpectedly.bnb.nn.LinearNF4subclassesnn.Linear, so theisinstancecheck passes and the packed uint8 payload is cast to float8. Inference still returns finite
numbers — the model just produces garbage.
You can see the silent one directly, no model download needed (needs CUDA):
This is defense-in-depth, not a live crash fix
An earlier revision of this description claimed that "every quantized-format loader reaches
_apply_fp8_layerwise_casting". That is not true, and the QA steps that followed from it didnot reproduce. Walking
ModelLoaderRegistryand checking each class's MRO down toModelLoadershows that none of the 17 quantized-format loaders calls the helper —FluxGGUFCheckpointModel,FluxBnbQuantizednf4bCheckpointModel,Flux2GGUFCheckpointModel,QwenImageGGUFCheckpointModel,ZImageGGUFCheckpointModel,Krea2GGUFCheckpointModel,WanGGUFCheckpointModel, the three SDNQ main loaders, and the T5 / Qwen3 / Gemma2 / Mistralencoder loaders all build and return the model without it. (
qwen_image.pydoes call it, butfrom
QwenImageCheckpointModel, not the GGUF one.) The same is true onmain.So enabling
fp8_storageon a GGUF or bnb model today does nothing rather than breaking it. Whatthis PR fixes is that the door is standing open:
_should_use_fp8answers "yes" for these models,the UI offers the toggle for them, and FP8 support is being wired into loaders one model at a time
(#9414 Z-Image, #9415 Anima). The next loader to be wired up would hit the crash / the silent
corruption above, with nothing in the way.
The guard
Two levels, because a format check alone is not enough — externally quantized weights can ship
under a plain
diffusersformat:_should_use_fp8rejectsgguf_quantized,bnb_quantized_nf4b,bnb_quantized_int8bandsdnq_quantized— every quantized member ofModelFormat._apply_fp8_to_nn_moduleskips any module whose params are non-floating-point (bnb's packeduint8) or a
torch.Tensorsubclass (GGMLTensor,SDNQTensor), regardless of the declaredformat. This also covers a model that already carries a persisted
fp8_storage=truefrombefore this PR.
Frontend hides the toggle for the same four formats, so the UI stops offering a control the
backend refuses.
Known gap (follow-up, not addressed here)
_apply_fp8_layerwise_castingderives the compute dtype fromnext(model.parameters()).dtypebefore the param-level check runs. For the case the backstop exists for — a plain-format
checkpoint with externally quantized weights — a quantized first parameter makes that
torch.uint8, soget_model_compute_dtype()returnsuint8and the pre-hook on the layers thatwere cast does
p.data.to(uint8). The quantized layers are correctly skipped, but the model isstill broken. The fix is to derive the compute dtype from the first floating-point parameter;
it is not reachable today (it needs a quantized model on a cast-calling loader) so it is left for
a follow-up rather than widening this PR.
Related Issues / Discussions
Follow-up to #8945 (FP8 storage). Same series as #9414 (Z-Image), #9415 (Anima) and #9478.
QA Instructions
The behaviour change is not observable through the UI for a GGUF/bnb/SDNQ model beyond the toggle
disappearing, because the cast never ran for those models in the first place. What is worth
checking:
UI — open a GGUF, bnb or SDNQ main model in Model Manager → Default Settings. The FP8
Storage switch is gone. Open a non-quantized model (SDXL, FLUX checkpoint, Z-Image diffusers)
→ the switch is still there and still works.
No regression on non-quantized models — a model with FP8 enabled must still log
FP8 layerwise casting enabled ...and load at roughly half its usual VRAM.Legacy DB value — the backend must not rely on the UI. Force the old value back in:
Then generate with that model. Expect: model loads at its normal size, no
FP8 layerwise casting enabledline in the log.The direct repro above — the one path that does exercise the corruption. Needs CUDA and
bitsandbytes.Unit tests:
The new tests cover the format check (parametrized over all four quantized
ModelFormatmembers,not raw strings, so a drift in the enum values fails), a taxonomy check that pins every entry in
_QUANTIZED_MODEL_FORMATSto a realModelFormatvalue, and the param-level guard (both signals:non-floating-point payload and
torch.Tensorsubclass), plus a control assertion that an ordinarylayer in the same model is still cast.
Merge Plan
Merge after #9415 to avoid conflicts in
load_default.py,MainModelDefaultSettings.tsxandtest_load_default_fp8.py. The guard itself has no logical dependency on the earlier PRs — it isa self-contained change — but this branch carries #9415 merged in, so it is not a drop-in
replacement for it.
No DB schema, no redux slice, no API schema change. Existing
fp8_storage=truevalues persistedagainst quantized models stay in the DB and are simply ignored from now on; no migration needed.
Checklist
What's Newcopy (if doing a release after this PR) — n/a