Skip to content

fix(fp8): never apply FP8 storage to already-quantized weights - #9416

Merged
lstein merged 22 commits into
invoke-ai:mainfrom
Pfannkuchensack:feat/fp8_quantized_guard
Aug 25, 2026
Merged

fix(fp8): never apply FP8 storage to already-quantized weights#9416
lstein merged 22 commits into
invoke-ai:mainfrom
Pfannkuchensack:feat/fp8_quantized_guard

Conversation

@Pfannkuchensack

@Pfannkuchensack Pfannkuchensack commented Jul 31, 2026

Copy link
Copy Markdown
Member

Summary

Stacked on #9415. #9414 is now merged into main, so this branch carries #9415's Anima
changes plus its own. #9478 stacks on top. No logical dependency — the guard stands alone — but
all of them touch _should_use_fp8, _apply_fp8_to_nn_module, MainModelDefaultSettings.tsx
and the same test file, so merging in order avoids conflicts.

FP8 storage must never be applied to weights that are already quantized. The cast is not a no-op
on such a payload:

  • GGUF raises ValueError: Operation changed the dtype of GGMLTensor unexpectedly.
  • 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 — the model just produces garbage.

You can see the silent one directly, no model download needed (needs CUDA):

import torch, bitsandbytes as bnb
from invokeai.backend.model_manager.load.load_default import ModelLoader

l4 = bnb.nn.LinearNF4(256, 256, bias=False)
l4.weight = bnb.nn.Params4bit(torch.randn(256, 256), requires_grad=False, quant_type="nf4")
l4 = l4.cuda()
x = torch.randn(1, 256, device="cuda", dtype=torch.bfloat16)
ref = l4(x).clone()

ModelLoader._apply_fp8_to_nn_module(l4, torch.float8_e4m3fn, torch.bfloat16)
print("max abs diff:", (l4(x).float() - ref.float()).abs().max().item())
# before this PR: ~50   (no error, no warning)
# after:           0.0

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 did
not reproduce.
Walking ModelLoaderRegistry and checking each class's MRO down to
ModelLoader shows 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 / Mistral
encoder loaders all build and return the model without it. (qwen_image.py does call it, but
from QwenImageCheckpointModel, not the GGUF one.) The same is true on main.

So enabling fp8_storage on a GGUF or bnb model today does nothing rather than breaking it. What
this PR fixes is that the door is standing open: _should_use_fp8 answers "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 diffusers format:

  • _should_use_fp8 rejects gguf_quantized, bnb_quantized_nf4b, bnb_quantized_int8b and
    sdnq_quantized — every quantized member of ModelFormat.
  • _apply_fp8_to_nn_module skips any module whose params are non-floating-point (bnb's packed
    uint8) or a torch.Tensor subclass (GGMLTensor, SDNQTensor), regardless of the declared
    format. This also covers a model that already carries a persisted fp8_storage=true from
    before 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_casting derives the compute dtype from next(model.parameters()).dtype
before 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, so get_model_compute_dtype() returns uint8 and the pre-hook on the layers that
were cast does p.data.to(uint8). The quantized layers are correctly skipped, but the model is
still 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:

  1. 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.

  2. 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.

  3. Legacy DB value — the backend must not rely on the UI. Force the old value back in:

    curl -X PATCH http://127.0.0.1:9090/api/v2/models/i/<gguf-model-key> \
      -H "Content-Type: application/json" \
      -d '{"default_settings":{"fp8_storage":true}}'
    

    Then generate with that model. Expect: model loads at its normal size, no
    FP8 layerwise casting enabled line in the log.

  4. The direct repro above — the one path that does exercise the corruption. Needs CUDA and
    bitsandbytes.

Unit tests:

uv run --extra cuda --extra test pytest tests/backend/model_manager/load -q --no-cov

The new tests cover the format check (parametrized over all four quantized ModelFormat members,
not raw strings, so a drift in the enum values fails), a taxonomy check that pins every entry in
_QUANTIZED_MODEL_FORMATS to a real ModelFormat value, and the param-level guard (both signals:
non-floating-point payload and torch.Tensor subclass), plus a control assertion that an ordinary
layer in the same model is still cast.

Merge Plan

Merge after #9415 to avoid conflicts in load_default.py, MainModelDefaultSettings.tsx and
test_load_default_fp8.py. The guard itself has no logical dependency on the earlier PRs — it is
a 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=true values persisted
against quantized models stay in the DB and are simply ignored from now on; no migration needed.

Checklist

  • The PR has a short but descriptive title, suitable for a changelog
  • Tests added / updated (if applicable)
  • ❗Changes to a redux slice have a corresponding migration — n/a, no redux changes
  • Documentation added / updated (if applicable) — n/a
  • Updated What's New copy (if doing a release after this PR) — n/a

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.
@github-actions github-actions Bot added python PRs that change python files backend PRs that change backend files frontend PRs that change frontend files python-tests PRs that change python tests labels Jul 31, 2026
@lstein lstein added the 6.14.1 label Aug 17, 2026
@lstein lstein self-assigned this Aug 17, 2026
@lstein lstein moved this to 6.14.1: Bug fixes to 6.14.0 in Invoke - Community Roadmap Aug 17, 2026
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.
@github-actions github-actions Bot added the Root label Aug 17, 2026
# Conflicts:
#	invokeai/backend/model_manager/load/load_default.py
Pfannkuchensack and others added 3 commits August 19, 2026 03:38
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
@joshistoast

Copy link
Copy Markdown
Collaborator

This would allow me to run krea for once, please oh please put this in 6.14.0 👀 my dear @lstein

@lstein lstein added 6.14.0 and removed 6.14.1 labels Aug 24, 2026
@lstein lstein moved this from 6.14.1: Bug fixes to 6.14.0 to 6.14.x Theme: USER EXPERIENCE in Invoke - Community Roadmap Aug 24, 2026
lstein and others added 2 commits August 24, 2026 21:12
# 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 lstein left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, since Enum.__hash__ is hash(self._name_). It holds: the MRO is (ModelFormat, str, Enum, object), so str.__hash__ wins and hash(member) == hash(member.value).
  • Real GGMLTensor in a Linear via load_state_dict(assign=True): type(p) and type(p.data) both stay GGMLTensor and is_floating_point() is False — caught by both signals, so either one alone would still do it.
  • False positivesnn.Parameter(plain).data is torch.Tensor, not Parameter, so ordinary layers still cast. Zero params flagged across the real FLUX.1-dev transformer (780 params) and AnimaTransformer (601) built on meta. GGMLTensor and SDNQTensor are the only torch.Tensor subclasses 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_param each fails exactly one test. Nothing here is decorative.
  • FrontenduseMainModelDefaultSettings always supplies fp8Storage and RHF's shouldUnregister defaults to false, so data.fp8Storage.isEnabled in onSubmit still resolves for the hidden field. Same pattern as the existing isFluxFamily fields, and a persisted legacy true round-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.

@lstein
lstein merged commit 57c8534 into invoke-ai:main Aug 25, 2026
17 checks passed
Pfannkuchensack added a commit to Pfannkuchensack/InvokeAI that referenced this pull request Aug 25, 2026
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`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

6.14.0 backend PRs that change backend files frontend PRs that change frontend files python PRs that change python files python-tests PRs that change python tests Root

Projects

Status: 6.14.x Theme: USER EXPERIENCE

Development

Successfully merging this pull request may close these issues.

3 participants