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