Skip to content

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

Open
Pfannkuchensack wants to merge 5 commits into
invoke-ai:mainfrom
Pfannkuchensack:feat/fp8_quantized_guard
Open

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

Conversation

@Pfannkuchensack

Copy link
Copy Markdown
Collaborator

Summary

Stacked on # (which is stacked on the Z-Image PR). No logical dependency — the guard stands alone — but all three touch _should_use_fp8, _apply_fp8_to_nn_module, MainModelDefaultSettings.tsx and the same test file, so merging in order avoids conflicts.

Enabling the fp8_storage toggle on an already-quantized model breaks it. Every quantized-format loader reaches _apply_fp8_layerwise_casting — FLUX/FLUX2/Krea-2/Qwen-Image/WAN/Z-Image GGUF, FLUX bnb-NF4, and the T5/Qwen3/Gemma2 encoders — and the cast there is not a no-op. Verified on real layers:

  • GGUF raises ValueError: Operation changed the dtype of GGMLTensor unexpectedly at load time.
  • 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.

Reproduce the silent one directly (no model download needed):

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

Guarded on two levels, because a format check alone is not enough — externally quantized weights can ship under a plain diffusers format (e.g. an SDNQ-quantized Z-Image):

  • _should_use_fp8 rejects gguf_quantized, bnb_quantized_nf4b and bnb_quantized_int8b.
  • _apply_fp8_to_nn_module skips any module whose params are non-floating-point (bnb's packed uint8) or a torch.Tensor subclass (GGUF's GGMLTensor), regardless of declared format. This also covers the case where a model already has a persisted fp8_storage=true from before this PR.

Frontend hides the toggle for quantized formats, so the UI stops offering a control the backend refuses.

Related Issues / Discussions

Follow-up to #8945 (FP8 storage). Same series as the Z-Image and Anima FP8 PRs.

QA Instructions

Needs a CUDA GPU.

Reproduce the bug on main (both cases need fp8_storage enabled in the model's Default Settings):

  1. Any GGUF main model → generation fails at load with Operation changed the dtype of GGMLTensor unexpectedly.
  2. A FLUX bnb-NF4 model → no error at all, output is garbage. Or run the snippet above.

Verify the fix:

  1. UI — open a GGUF or bnb 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. 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, no GGMLTensor error.

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

⚠️ If you test step 2 with a GGUF Z-Image model, denoise still fails afterwards with Multiple dispatch failed for 'torch._ops.aten.where.self' (torch.where(mask, pad_token, feats_cat) with a GGMLTensor pad token). That is a pre-existing, unrelated GGUF/Z-Image incompatibility — it reproduces identically with fp8_storage off, and on main. Use a GGUF FLUX model if you want a run that completes.

Unit tests:

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

433 passed, 129 skipped locally. The new tests cover the format check (parametrized over all three quantized formats) and the param-level guard (both signals: non-floating-point payload and Tensor subclass), plus a control assertion that an ordinary layer in the same model is still cast.

Merge Plan

Merge after the Anima PR to avoid conflicts in load_default.py, MainModelDefaultSettings.tsx and test_load_default_fp8.py. If the earlier PRs in the series are dropped, this one rebases onto main cleanly on its own — it has no logical dependency on them.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant