feat(fp8): enable FP8 storage for Anima - #9415
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.
# 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>
lstein
left a comment
There was a problem hiding this comment.
Adversarial review at 8dbf0a4b61 (reviewable delta = the 3 Anima files; the other 3 in the diff belong to #9414). I ran the attacks against a real anima-base-v1.0.safetensors on a W7900 — ROCm reports device.type == "cuda", so _device_supports_fp8_storage returns True and this whole path is live there too.
No correctness bug found. Approving.
Attacks attempted, and why each failed
- Compute-dtype poisoning.
compute_dtype = next(model.parameters()).dtyperesolves tot_embedder.1.linear_1.weight— which is present in the checkpoint and is a skipped module, so it stays bf16. The onlystrict=Falsemissing keys are the 3 buffers, andinit_empty_weights(include_buffers=False)leaves those as real CPU tensors, so no meta/float32 parameter can reach the probe. - Unanchored regex over-match.
re.search("t_embedder", …)would also matchtext_embedder/context_embedder; this architecture has neither. On a meta-device instantiation the three patterns match exactly 6 modules (t_embedder.1.linear_{1,2},x_embedder.proj.1,final_layer.{linear,adaln_modulation.1,adaln_modulation.2}) and nothing else. - Z-Image's failure mode (a
weight.dtyperead that casts the input).AnimaTransformerhas no.dtypeattribute, soanima_denoise.py:777'shasattr(transformer, "dtype")falls through toinference_dtype; nothing underinvokeai/backend/anima/touches.weightdirectly. - Runtime integrations, all verified with real weights: fully-resident forward,
CachedModelWithPartialLoadat 50% VRAM, an LLLite ControlNet bound (adapter demonstrably active — it moves the output by 5.7%), and LoRA patching, where_is_any_part_of_layer_fp8correctly selects sidecar: zero param dtype changes during or after the patch context, model stays 2012 MB. Storage dtypes are restored to fp8 after every forward in every case. - Cache staleness on toggling the setting.
_LOAD_AFFECTING_SETTINGSeviction is base-agnostic, so Anima gets it for free.
The analysis in the description checks out
All four rows of your size table reproduce to within 0.1 MB (1994.2 / 2010.2 / 2012.0 / 2180.0). TimestepEmbedding.forward with use_adaln_lora=True returns (sample, emb), so t_emb is the raw sinusoidal embedding and those two Linears really do feed only adaln_lora — the comment is exactly right.
The case for t_embedder is in fact stronger than the PR states: 38.2% of its weights flush to zero under unscaled e4m3fn (next worst group is 24.9%), with a round-trip relative error of 0.068 vs 0.028–0.048 everywhere else. It is by a wide margin the most fp8-damaged module in the network. And x_embedder/final_layer are literally diffusers' CosmosTransformer3DModel._skip_layerwise_casting_patterns = ["patch_embed", "final_layer", "norm"], which is apt given Anima is the Cosmos-Predict2 DiT.
Three non-blocking findings
1. The fix has no regression guard. Deleting the model = self._apply_fp8_layerwise_casting(...) line from anima.py outright leaves all 1253 tests in tests/backend/model_manager + tests/backend/anima passing. The dead toggle this PR fixes can come straight back with CI green. (#9414 has the same gap for Z-Image.)
2. The new test doesn't pin the patterns to real module names. test_anima_transformer_declares_t_embedder_skip asserts a string is in a list, then re-tests the loader against a hand-built _Model; x_embedder and final_layer are never asserted at all. Renaming t_embedder in the transformer would silently disable the skip with tests green. Instantiating the real model under accelerate.init_empty_weights() takes ~2 s and pins all three to actual dotted module paths.
3. adaln_modulation "made no difference" isn't supported by measurement. Single-forward velocity error vs bf16, real checkpoint, same inputs each run:
| skip list | param_size | rel. L2 vs bf16 |
|---|---|---|
| none | 1994.2 MB | 0.15203 |
t_embedder |
2010.2 MB | 0.14378 |
| PR list | 2012.0 MB | 0.13421 |
PR list + adaln_modulation |
2180.0 MB | 0.09115 |
adaln_modulation is the largest remaining error source, and skipping t_embedder alone moves the total only 0.152 → 0.144 — the errors add in quadrature and no single group dominates. The decision is defensible (168 MB for a modest gain), but the comment will be read as a measurement and currently tells the next maintainer the opposite. Suggest softening to "no visible difference in a 35-step A/B". Related nit: final_layer is annotated "output projection", but 1.57 of the 1.70 M params that entry protects are final_layer.adaln_modulation.* — i.e. most of what it shields is the thing the comment two lines down says is deliberately not shielded.
One caveat on my own numbers: I also ran a 25-step CFG-4.5 trajectory to test compounding, and it is chaotic under synthetic conditioning (rel ≈ 0.6 for every config, ordering meaningless). It can't settle the perceptual claim in either direction, so I'm not resting anything on it — your visual A/B remains the evidence for that part.
Adjacent, out of scope
fp8_storage is rendered for ControlNet configs (ControlAdapterModelDefaultSettings.tsx, everything except control_lora), but AnimaControlNetLLLiteModel._load_model never calls the cast — the same dead toggle, still live for Anima LLLite. Those adapters are 16–63 MB, so hiding the toggle is probably a better fix than wiring it.
# Conflicts: # tests/backend/model_manager/load/test_load_default_fp8.py
Review follow-ups for invoke-ai#9415. Add `tests/.../test_anima_fp8_wiring.py`. Deleting the `_apply_fp8_layerwise_casting` call from the Anima single-file loader previously left the whole model_manager and anima suites green, so the dead `fp8_storage` toggle this PR fixes could come straight back with CI passing. The new boundary test fails on that mutation. The pattern test now instantiates the real `AnimaTransformer` under `accelerate.init_empty_weights()` and pins all three declared patterns to actual dotted module paths, instead of asserting a string is in a list against a hand-built stand-in. A second test records that `_FP8_DEFAULT_SKIP_PATTERNS` covers zero modules in this architecture, so the declared list is demonstrably not redundant. Lift the transformer kwargs to `ANIMA_TRANSFORMER_CONFIG` so tests build the real graph without duplicating them, mirroring `KREA2_TRANSFORMER_CONFIG`. Correct the skip-list comment. `adaln_modulation` "made no difference" was not supported by measurement: relative L2 against bf16 on a single forward goes 0.134 -> 0.091 when it is skipped, making it the largest remaining error source. The 168MB call still stands, but it rests on a 35-step A/B showing no visible difference, and the comment now says so. Also note that most of what the `final_layer` entry shields is `final_layer.adaln_modulation.*` (1.57 of 1.70M params). Stop offering FP8 storage for Anima LLLite ControlNets in the model manager. `AnimaControlNetLLLiteModel` never calls the layerwise cast, so the toggle was rendered and inert; at 16-63MB per adapter, hiding it beats wiring it.
Two fixes from an adversarial review of the merge: - `ControlAdapterModelDefaultSettings` hid the FP8 storage control for Anima LLLite adapters but kept sending its value. react-hook-form keeps unrendered fields in `defaultValues` (`shouldUnregister` defaults to false), so a value persisted before the control was hidden was re-sent verbatim on every save, with no UI left to clear it. Null it out wherever the control is hidden. - `test_single_file_loader_applies_fp8_layerwise_casting` passed `fp8_storage` as a top-level kwarg to `model_construct`. It is not a field of `Main_Checkpoint_Anima_Config` and the model has no `extra="allow"`, so pydantic silently discarded it and `default_settings` stayed `None` -- the toggle was off in the test that exists to prove the toggle is wired up. Build a real `MainModelDefaultSettings(fp8_storage=True)` instead.
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`.
…AM reservation (#9538) * fix(fp8): honor every declared skip list, and stop overshooting the RAM reservation Review follow-ups for #9414 and #9415. Depends on #9415: the docs below describe Anima's FP8 support, which lands there. Read `_keep_in_fp32_modules` alongside `_skip_layerwise_casting_patterns`. Diffusers' `enable_layerwise_casting()` unions both; we replaced that call with our own hook-based path and were reading only the first, so a model declaring the second would lose its exclusions silently. Verified to protect nothing extra today - on Krea-2, Wan 14B, Z-Image and FLUX.1 - so this changes nothing now and stops being a trap later. Release the state dict before the FP8 cast in the Z-Image and Krea-2 single-file loaders. `load_state_dict(..., assign=True)` aliases every param to its `sd` tensor, so the compute-dtype originals stayed reachable while `param.data.to(float8)` allocated the fp8 copies, putting peak RAM ~50% over what `make_room()` reserved (~17.4GB actual against ~11.5GB reserved for Z-Image). Nothing reads `sd` after the load. Add `test_z_image_fp8_wiring.py`. Deleting the cast call from the Z-Image single-file loader previously left the whole model_manager suite green. The new tests fail on that, on removing `sd.clear()`, and on the aliasing premise itself, should torch ever stop assigning by reference. Use `get_model_compute_dtype()` in the Z-Image denoise loop instead of `transformer.dtype`. It is correct today only because `x_pad_token` happens to be parameter zero and is never cast; move the pad tokens under a submodule and the loop starts feeding float8 into `F.linear`. Reword the comment above the Z-Image cast. Dropping `.scale_weight` / `scaled_fp8` is not "filtering out metadata" - for a ComfyUI scaled-fp8 checkpoint it loads unscaled weights. That bug is pre-existing and out of scope here, but the comment read as though the cast made it safe. Update the FP8 docs, which still said Z-Image was excluded for a dtype mismatch and listed it in the troubleshooting exclusion list - the opposite of what the code has done since #9414. Add Anima and its LLLite adapters, and document that a model's own declared exclusions are honored on top of the generic skip list, with the measured cost: Wan 14B gives up ~221 MiB of savings, Krea-2 ~38 MiB, Anima ~18 MiB, FLUX.1 and Qwen-Image nothing. * docs(fp8): correct the Wan rows — no Wan loader applies FP8 Storage The "What FP8 Storage applies to" table listed Wan under "Yes", and the skip-list cost table used Wan as its headline example, closing with advice to budget ~220 MiB of lost saving. None of the three Wan loaders reaches the cast: - `WanDiffusersModel._load_model` fully overrides `GenericDiffusersLoader._load_model` and returns without calling `_apply_fp8_layerwise_casting`. - `WanCheckpointModel._load_from_singlefile` never calls it. - `WanGGUFCheckpointModel` never calls it (and GGUF is excluded anyway). `MainModelDefaultSettings.tsx` renders the switch for any non-quantized main model, so a Wan user sees the toggle, sets it, and gets nothing — the same rendered-and-inert shape this PR's point 6 exists to stop documenting as working. Wan now has its own row saying the switch has no effect yet, the cost table keeps the measurement but labels it as not applied, and the troubleshooting bullet names Wan alongside the other cases where the `FP8 layerwise casting enabled` log line is absent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TfA5DYQDv45CncruoQgjAe --------- Co-authored-by: Lincoln Stein <lincoln.stein@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
The
fp8_storagetoggle was shown for Anima main models but did nothing:AnimaCheckpointModelnever called_apply_fp8_layerwise_casting. This wires it in — the state dict is already cast to a singlemodel_dtypebeforeload_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 at all. The cause is
t_embedder: it produces theadaln_loraconditioning consumed by every block, so casting it to FP8 corrupts every token everywhere. None of the generic skip patterns reach it — they target diffusers' module names (norm,pos_embed,patch_embed,proj_in/out) and this architecture names the equivalent modules differently (x_embedder,final_layer,adaln_modulation_*).AnimaTransformernow declares_skip_layerwise_casting_patterns, the same attribute diffusers models use, so the loader needs no special-casing.Note this is the same module as Z-Image's
t_embedder, broken through a different mechanism: there diffusers readweight.dtypeand cast the input to float8; here the plain precision loss is enough.Related Issues / Discussions
Follow-up to #8945 (FP8 storage), #9231 (hook-based casting) and the Z-Image PR this is stacked on.
QA Instructions
Needs a CUDA GPU. Model Manager → an Anima main model → Default Settings → enable FP8 Storage → Save.
Generate. Expect in the log:
and the transformer resident at ~2012MB instead of 3988MB. On
mainthere is no FP8 line at all — that is the dead toggle this PR fixes.Quality vs. bf16. Note a fixed seed, then run once with FP8 on and once off. Two gotchas that will otherwise give you a false result:
PUT /api/v1/app/invocation_cache/disable), or the second run returns the first run's image unchanged and the two look pixel-identical.Expect the same composition with slightly coarser fine structure under FP8 — not a different image, and definitely not a dithered mess. A dithered result means the skip patterns are not being applied.
Regression: with FP8 off, output must be unchanged from before this PR.
Measured during development, same seed/steps/CFG each run — this is what pins the skip list down:
t_embedderx_embedder,final_layeradaln_modulationSo
t_embedderis necessary and sufficient. The two I/O layers are kept as ~2MB of margin, matching what diffusers skips by default for comparable DiTs.adaln_modulationis deliberately not listed — it costs 168MB and made no difference.Unit tests:
Merge Plan
Merge after the Z-Image PR —
_apply_fp8_to_nn_module(..., extra_skip_patterns=...)does not exist without it. No DB schema, no redux slice, no API schema change otherwise.Checklist
What's Newcopy (if doing a release after this PR) — n/a