feat(fp8): run scaled and raw fp8 checkpoints on the fp8 tensor cores - #9478
feat(fp8): run scaled and raw fp8 checkpoints on the fp8 tensor cores#9478Pfannkuchensack wants to merge 53 commits into
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.
…sor cores InvokeAI dequantized ComfyUI 'scaled fp8' checkpoints to bf16 at load time in three near-identical implementations, discarding both the VRAM saving and the ability to use the fp8 tensor cores. Measured on an RTX 4090: the dequantize round trip makes fp8 *slower* than bf16 (0.90x on FLUX.2 Klein 9B), while torch._scaled_mm reaches 1.29x vs bf16 and 1.63x vs the dequantized path on Krea-2 Turbo, at the same VRAM and with no visible quality loss. Adds a shared invokeai/backend/quantization/fp8_scaled.py that keeps the quantization intact (weight_scale, optional calibrated input_scale, and the per-layer full_precision_matrix_mult hints), and an fp8 branch in CustomLinear._autocast_forward that falls back to the dequantized path whenever any precondition fails, rather than raising mid-generation. Wires up the Krea-2 single-file loader as the first consumer. Remaining loaders (FLUX.2, Z-Image, Qwen-Image) still dequantize eagerly. Gated behind `fp8_compute` (default off): the fp8 matmul quantizes activations too, so images change at a fixed seed. The same flag also decides whether the weights stay quantized, since keeping them fp8 without the fp8 matmul would halve VRAM but run slower.
…em on fp8 tensor cores The Krea-2 single-file loader dequantized ComfyUI 'scaled fp8' checkpoints to bf16 at load, discarding both the VRAM saving and any chance of using the fp8 tensor cores. It now keeps the quantization and hands the scales to CustomLinear, which multiplies via torch._scaled_mm where the checkpoint allows it. Measured on an RTX 4090 with krea2TurboOfficialComfy_krea2TurboFp8 at 1024x1024: 884 vs 1107 ms/step (1.25x) and 12.24 GiB resident instead of ~25 GB in bf16, so the model is fully resident rather than streamed. Images are indistinguishable from the dequantized path (PSNR 29.95 dB). The checkpoint's _quantization_metadata marks 96 of 256 layers with full_precision_matrix_mult; those are honored and stay in bf16, which is what keeps fidelity in line with ComfyUI (and costs ~23% of the speedup). Two subtleties the measurements exposed, both covered by tests now: - apply_custom_layers_to_model leaves device autocasting disabled for fully resident models, so a check living only in _autocast_forward never runs. The fp8 branch is consulted before the autocasting split. - _quantization_metadata names layers in the native scheme while the scales are extracted after the native -> diffusers rename, so the per-layer flags matched nothing. The metadata paths are pushed through the same converter. Gated behind `fp8_compute` (default off): activations are quantized too, so images change at a fixed seed.
…data read fp8 weights are force-routed to sidecar patching, and the sidecar wrapper dispatches through _autocast_forward, so the fp8 branch has to survive that route with the LoRA residual added on top. Verified on Krea-2 Turbo fp8 with a 256-layer LoRA: 1008 vs 1241 ms/step (1.231x, against 1.251x without the LoRA), all 256 fp8 modules routed to sidecar, and images equivalent between both paths (PSNR 26.83 dB). Reading the safetensors header metadata no longer fails the load. It only enriches fp8 handling with the per-layer full_precision_matrix_mult hints, so an unreadable header now warns and continues rather than raising — but it does warn, because without the hints layers the quantizer marked unsafe would silently be multiplied in fp8.
Adds a settings matrix (only `fp8_compute` is needed; `fp8_storage` is bypassed on that path) and logs when a redundant fp8_storage setting was skipped, so the case is not silent.
The Qwen-Image i2l node hardcoded vae.disable_tiling(), so a full-frame encode was the only option. At 2560x1440 that peaks at 9.26 GiB — on top of a resident multi-GB transformer, which is what makes an upscale round-trip run out of headroom exactly at this node while every other node fits. Adds `tiled` / `tile_size` input fields following the SD/SDXL i2l node, OR'd with the global force_tiled_decode setting. Off by default, so behaviour is unchanged unless enabled. estimate_vae_working_memory_qwen_image gains a matching tile_size parameter. Without it the change would be inert: the cache would keep reserving the full-frame figure (10.99 GiB at 2560x1440) and evict models to honour it, no matter what the VAE actually does. Tiled, it budgets one tile plus 25% overlap plus the resident RGB image, mirroring estimate_vae_working_memory_wan. Measured through the node at 2560x1440: 10.99 -> 0.26 GiB reserved, 9.26 -> 0.17 GiB actual peak, identical latent shape. Tiled latents differ by ~1.4% relative L2 on noise input (worst case for tile blending; real images blend far better), which is why this stays opt-in.
Both nodes reserve working memory for a full-frame operation, which at high resolutions exceeds a 24 GB card, so the model cache evicts everything else to honour it. On CUDA at 2560x1440: 19.91 GiB for the decode and 10.99 GiB for the encode. Tiling is the intended escape hatch, but it did not work on either node: - qwen_image_i2l hardcoded vae.disable_tiling(), so it could not be enabled. - qwen_image_l2i honoured the global force_tiled_decode, but computed its working-memory estimate before and independently of that flag. Tiling bounded the VAE while the cache still reserved the full-frame figure, so the memory was never freed for anything else — effectively inert. Adds `tiled` / `tile_size` input fields to both nodes following the SD/SDXL i2l/l2i nodes, OR'd with force_tiled_decode. Off by default; behaviour is unchanged unless enabled. estimate_vae_working_memory_qwen_image gains a matching tile_size parameter, and both nodes resolve tile_size=0 to the VAE default (256px) before estimating. Tiled it budgets one tile plus 25% overlap plus the resident RGB image, mirroring estimate_vae_working_memory_wan. Without this the change would be cosmetic on i2l and remain inert on l2i. Measured through the i2l node at 2560x1440: 10.99 -> 0.26 GiB reserved, 9.26 -> 0.17 GiB actual peak, identical latent shape. Verified across eight resolutions that tiled and untiled encodes produce the same latent dimensions. Tiled latents differ by ~1.4% relative L2 on noise input (worst case for tile blending), which is why this stays opt-in. Also fixes a crash in qwen_image_i2l: `width`/`height` are `int | None`, but the workflow UI sends 0 for an unset number input, and `0 is not None` reached `image.resize((0, 0))` -> "height and width must be > 0". Non-positive values are now treated as unset, matching how tile_size uses 0.
Switching the encoder to fp8 compute left everything the checkpoint does not quantize in bf16, growing it from 4236MB to 4999MB. On a GPU already holding a ~12GB transformer that was enough to push the transformer out of a full VRAM load, which is far more expensive than the encoder change ever saved. Cast the remainder to fp8 storage, with two exclusions. Layers carrying a weight_scale keep it and go through _scaled_mm -- the cast hooks would upcast them without applying the scale. nn.Embedding is skipped because the token embedding table is the encoder's input representation: quantizing it doubles the error against bf16 (relative L2 0.0079 -> 0.0163) to save 371MiB, a bad trade for a model whose whole job is text fidelity. The old fp8_storage path did cast it, so this is strictly more accurate than what shipped before. fp8_storage 116.8 ms 4.14 GiB rel L2 0.0351 (original) fp8 compute 61.9 ms 4.88 GiB rel L2 0.0079 (previous commit) this 65.3 ms 4.50 GiB rel L2 0.0079
…llings A scaled-fp8 checkpoint may ship an input_scale of exactly 1.0, meaning the producer wrote the field without calibrating it. Taking it at face value replaces the per-forward amax scale with no scaling at all, so activations above the fp8 maximum saturate. Measured relative error against a bf16 reference, dynamic vs a 1.0 scale: |x|max 368 0.0274 0.0262 |x|max 1928 0.0277 0.4356 |x|max 30976 0.0253 0.9639 Inside +/-448 the two are equivalent -- fp8_e4m3 is a floating-point format, so a scale factor buys no relative precision the way it would for int8. Above it the unscaled path collapses. Non-finite and non-positive scales are rejected for the same reason: they cannot be a valid divisor. Also accept `.scale_input` as an alias for `.input_scale`, mirroring the `.scale_weight`/`.weight_scale` pair we already handle. Previously such a key was left in the state dict, and the Qwen3-VL loader deleted it outright, so a calibrated activation scale was silently discarded and every forward paid the amax reduction. That delete is now redundant and removed.
FP8 Compute had no user-facing documentation, and the existing FP8 Storage page actively claimed a compute path might arrive "later" — it is already here. The part worth writing down is the reproducibility constraint. torch._scaled_mm needs both operands on the same device, so a layer whose weights are still in RAM silently falls back to the dequantized BF16 path. Which layers that hits depends on how much of the model happened to fit, and that shifts between runs, so the same seed stops reproducing. Measured on a 24GB card with a ~12GB transformer at 88-95% residency: two runs with identical seed and settings differed in 98.7% of pixels; fully resident, repeated runs were bit-identical. Also corrects "FP8 + partial loading: fully supported" — true for Storage, but for Compute it costs 47% per step on top of the reproducibility loss. Regenerates settings.json, which predated both fp8 settings.
A checkpoint can ship fp8 weights with no weight_scale. The runtime already handles them — scaled_mm_linear treats weight_scale as optional — but the loaders never let them through: FLUX, FLUX.2 and Z-Image cast the whole state dict to bf16, discarding both the VRAM saving and the tensor cores. Krea-2 kept them by accident and said nothing about it. Only nn.Linear.weight is preserved. That restriction is not cosmetic: a Z-Image checkpoint quantized everything, 243 of its 453 fp8 tensors being 1-D biases, norm weights and a learned pad token. Keeping those fp8 saves nothing usable and breaks inference — the value reaches the activations and the next Linear gets an fp8 input, which dies in x.abs() with "abs_cuda" not implemented. A model's own _skip_layerwise_casting_patterns is honored on top, for Linears whose forward casts activations to their weight's dtype. Also stops fp8 storage from silently defeating fp8 compute: layerwise casting restores the compute dtype before every forward, so on an already-fp8 checkpoint the matmul would quietly fall back and the VRAM toggle would make the model slower with no indication why. Verified end-to-end on Z-Image unstableRevolution_V2Fp8, 1024x1024, 30 steps: transformer 11739MB -> 5881MB (both 100% resident), 1.60 -> 1.27 s/it. 1.297 -> 1.023 s/it (3 warm runs each), transformer 11740MB -> 5881MB, residency 95.5-100% -> 100%.
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
# Conflicts: # invokeai/app/invocations/qwen_image_image_to_latents.py # invokeai/app/invocations/qwen_image_latents_to_image.py # invokeai/backend/util/vae_working_memory.py # invokeai/frontend/web/openapi.json # invokeai/frontend/web/src/services/api/schema.ts # tests/app/invocations/test_qwen_image_working_memory.py
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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
# Conflicts: # invokeai/backend/model_manager/load/load_default.py # invokeai/backend/model_manager/load/model_loaders/z_image.py
…string The Attributes: block feeds the API schema description, so the setting was missing from the generated OpenAPI docs while every other field was listed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A ComfyUI scaled-fp8 FLUX.1 checkpoint did not load at all: the loader knew nothing about the scale side-channel, so its 629 scale and marker keys reached `load_state_dict` as unexpected keys and it raised. Verified against `comfyanonymous/flux_dev_scaled_fp8_test`, the reference export. FluxCheckpointModel now takes the same path as Krea-2 and Z-Image: extract the scaled layers, fold them when the matmul is unavailable, keep them and attach the scales when it is. FLUX.1 needs no key conversion -- the checkpoint is already in the BFL layout the model implements, and `qkv` stays one fused Linear, so a per-tensor scale attaches to exactly the module it was computed for. No split, no scale copying. Reading the scales runs after the bundle conversion, since they carry the same `model.diffusion_model.` prefix as the weights. Also stop that conversion from folding fp8 scales to bf16. `.weight_scale` ends in "scale" like the model's own RMSNorm parameters, so the unguarded test cost 8 mantissa bits on a value every quantized weight is multiplied by. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FLUX.2 folded every weight_scale into bf16 at load, so a scaled-fp8 checkpoint ran as fp8 *storage* at best and never reached the tensor cores. It now takes the same path as FLUX.1, Krea-2 and Z-Image. FLUX.2 is the hard case because the keys are renamed and the fused qkv is split into three projections. Both steps drop a scale silently: the weights then stay quantized but unscaled, off by 1/weight_scale, with nothing logged. The converter no longer routes side-channel keys through the weight renames. Those are substring tests, so `img_attn.proj.weight_scale` satisfies `"img_attn.proj.weight" in rest` and the scale would be written to the weight's destination key, overwriting the weight itself. Scales and markers are now placed by pushing a probe `<base>.weight` through the real converter and reading back where it landed, so the placement cannot drift from the rename rules. A fused qkv yields three destinations; a scalar scale is copied to each, a per-channel scale split like the weight. Layer hints get the same one-to-many rename. They name layers BFL-side while the scales are read diffusers-side, so without it `full_precision_matrix_mult` matches nothing -- the mistake that already cost a round on Krea-2. `_split_qkv_sidechannel` moves from z_image.py to fp8_scaled.py; it was never Z-Image specific. Verified against all three variants: Klein 4B (80 hints -> 100, 10 split qkv), Klein 9B (112 -> 144, 16 split) and dev (128, no fused qkv quantized). Every scaled layer recognized, every hint matched, no orphaned scales. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The FLUX.2 [dev] text encoder ships as ComfyUI scaled fp8 -- 210 quantized Linears with calibrated input scales -- and the loader folded every scale into bf16 at load. That doubles it: 16.8 GiB on disk becomes 32.3 GiB in memory, which does not fit on a 24 GB card even on its own. It did not fail fast either; the dequantization ran for 13 minutes before the OOM. Measured on an RTX 4090, fp8_compute enabled: before: OOM after 13 min, never loaded after: 17,180 MB, 100% resident, loaded in 5.8 s, 210 layers on the tensor cores This is the largest single VRAM item in the fp8-compute series so far -- 15.5 GiB, more than any transformer it feeds. Both key rewrites in this loader are plain prefix operations, so a sibling `.weight_scale` travels with its weight and there are no fused projections to split. The only work is carrying the recovered scales through the `model.` strip and replacing the blanket cast loop with `cast_state_dict(keep_fp8=...)`. Verified against `mistral_3_small_flux2_fp8`: 210 scaled layers recognized, 210/210 layer hints matched, every input scale calibrated, no orphaned scales, and all 210 still line up with their weights after the prefix strip. FLUX.2 [dev] now renders end to end with an fp8 encoder and an fp8 transformer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
16d894e to
27a138f
Compare
A scaled-fp8 Anima checkpoint did not load at all. `_filter_non_model_keys` only drops derived buffers and exporter metadata, so the scale and marker keys reached `load_state_dict` and the loader raised on them -- 500 unexpected keys on a plain scaled export, 749 on one that also ships `comfy_quant` markers. Anima is the easy shape: `q_proj`/`k_proj`/`v_proj` stay separate and the only key rewrite is a prefix strip, so a sibling scale travels with its weight and nothing has to be split. The work is extraction, `cast_state_dict(keep_fp8=...)` in place of the blanket cast loop, and attaching the scales after the load. One quiet trap fixed with it. `_quantization_metadata` names its layers `net.`- prefixed, in the checkpoint's own scheme, while the scales are read *after* `_strip_anima_bundle_prefix` has run. Read without renaming, the header matches nothing and every `full_precision_matrix_mult` flag is dropped silently -- the same mistake that already cost a debugging round on Krea-2. The names are pushed through the real strip function rather than restating the prefix list. Verified against two checkpoints. `anima-base-v1.0-fp8` carries only per-layer markers; `anima-preview_tcfp8_mixed` carries both transports at once, which no other captured checkpoint does, plus exactly one marked layer -- every other one marks either none or a large fraction, so the single-flag case was untested. Both: 250 scaled layers, 250/250 hints matched, no orphaned scales, and a clean `load_state_dict` with zero unexpected and zero missing keys. The marked layer turns out to be the one layer without an `input_scale`, which is coherent -- a layer excluded from the fp8 matmul has no activation scale to calibrate -- so "every layer has an input scale" is not the invariant to assert. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lstein
left a comment
There was a problem hiding this comment.
Adversarial review — round 2 (d8f16182dc)
All three round-1 blockers are fixed, and I verified each:
- Krea-2 skip-pattern regression —
split_fp8_scaled_layers()now folds the scale into any layer the cast would dequantize anyway, beforecast_state_dict()runs.time_embed.linear_1/2come out correct. - ROCm RDNA3 crash —
_probe_fp8_matmul()replaces the capability guess, andtorch.version.hip is not Noneforces the probe rather than short-circuiting it. gfx1100 fails the probe and falls back. - FLUX.2 dead branch —
_dequantize_fp8_weights(..., keep_fp8=...)now spares raw fp8, so the FLUX.2 half of the raw path is reachable.
The shared fp8_scaled.py is a real improvement over three divergent copies, and routing cast_state_dict / predict_cast_state_dict_size / split_fp8_scaled_layers through one can_stay_quantized() predicate closes the drift that caused round 1's blocker.
New round, new findings. Everything below is reproduced against d8f16182dc.
Blockers
1. Krea-2 native format: last.linear's weight_scale is dropped — regression vs main
The loader now converts native → diffusers keys before extracting the scales, on the stated grounds that "the renames are substring-based on .weight, so a sibling .weight_scale is carried to the same destination key automatically."
That holds for the str.replace() rules and the startswith prefix rules, but five branches in _convert_krea2_native_to_diffusers use exact key equality — k == "last.linear.weight", "last.linear.bias", "last.norm.scale", "last.modulation.lin", "txtmlp.0.scale". A sibling last.linear.weight_scale matches none of them and is left behind while its weight moves to final_layer.linear.weight.
Two further keys the substring rules also miss, for the same reason:
.scale_weightspelling —k.replace(".attn.wk.weight", ".attn.to_k.weight")does not match.attn.wk.scale_weight. On a native checkpoint using the old spelling, every within-block scale is orphaned..input_scale— likewise never renamed, so calibrated activation scales are silently discarded and every forward pays theamaxreduction.
In all three cases extract_fp8_scaled_layers() pops the orphan key (so nothing trips load_state_dict) and drops it, because sd["<native path>.weight"] no longer exists. The weight stays quantized with no scale attached, and since the layer never enters fp8_layers, warn_on_unattached_scales() cannot see it either. With fp8_compute on it runs through _scaled_mm unscaled; with it off it is cast to bf16 unscaled. Off by 1/weight_scale, silently, both ways.
Reproduced:
sd = {
"blocks.0.attn.wq.weight": fp8, "blocks.0.attn.wq.weight_scale": t(0.5),
"blocks.0.attn.wq.input_scale": t(0.25),
"blocks.0.attn.wk.weight": fp8, "blocks.0.attn.wk.scale_weight": t(0.5),
"last.linear.weight": fp8, "last.linear.weight_scale": t(0.5),
}
conv = _convert_krea2_native_to_diffusers(sd)
layers = extract_fp8_scaled_layers(conv)
# recovered: ['transformer_blocks.0.attn.to_q']
# UNSCALED : final_layer.linear.weight
# UNSCALED : transformer_blocks.0.attn.to_k.weight
# input_scale recovered for to_q: Nonemain gets last.linear right, because _dequantize_scaled_fp8() ran before the conversion:
final_layer.linear.weight: dtype=torch.bfloat16 val=0.5 # main
final_layer.linear.weight: dtype=float8, no scale # this PR
Related: _dequantize_scaled_fp8() is now dead code — nothing outside tests/ calls it. Its eight tests, including the new test_the_scale_weight_spelling_is_folded_too, assert a behaviour the shipping path no longer has.
2. float8_e5m2 scaled weights lose their scale entirely
extract_fp8_scaled_layers() pops every scale key up front, then keeps only the layers whose weight is float8_e4m3fn:
if weight is None or getattr(weight, "dtype", None) != FP8_DTYPE:
continueFor an e5m2 weight the scale has already been removed from sd and is now discarded. Nothing folds it — dequantize_fp8_scaled() never sees the layer, and cast_state_dict() does a plain .to(bf16):
sd = {"lin.weight": (ones(32,32)*2).to(torch.float8_e5m2), "lin.weight_scale": t(0.25)}
extract_fp8_scaled_layers(sd) # -> {} (and the scale key is gone)
# resulting weight: 2.0 expected: 0.5Excluding e5m2 from staying quantized is the right call and is well argued in the docstring; excluding it from scale recovery is not. On main, FLUX.2's and Krea-2's dequantizers folded the scale regardless of dtype, so this is a regression on both.
Suggested shape: extract for any float8* dtype, and let can_stay_quantized() keep the e4m3fn-only restriction — split_fp8_scaled_layers() then folds the e5m2 layers with the scale properly applied.
3. Block-wise (2-D) weight_scale now raises instead of being expanded
The FLUX.2 loader keeps _dequantize_fp8_weights() as a "Safety net for scale layouts the shared extractor does not model (block-wise scales whose shape has to be expanded to the weight's). It is a no-op … because extraction has already taken the scales it understood."
Extraction takes all scales, not only the ones it understands — the weight_scales[path] = sd.pop(key) loop is unconditional. So the repeat_interleave expansion is unreachable for exactly the layouts it was written for, and dequantize_fp8_scaled() has no equivalent:
sd = {"b.weight": ones(64,128).to(fp8), "b.weight_scale": full((64,2), 0.5)}
dequantize_fp8_scaled(sd, extract_fp8_scaled_layers(sd), torch.bfloat16)
# RuntimeError: The size of tensor a (64) must match the size of tensor b (128) at
# non-singleton dimension 0main expands this correctly. With fp8_compute off — the default — the model now fails to load; with it on, the mismatch surfaces later as a shape error in scaled_mm_linear's out * weight_scale.reshape(1, -1), mid-generation.
Major
4. _quantization_metadata header hints are dropped for any prefixed checkpoint
The PR fixes the "hints are named in the checkpoint's scheme" bug for the rename step (remap_flux2_layer_paths, _remap_native_layer_paths, _strip_anima_prefix_from_layer_paths) but not for the prefix strip that runs before it. The header is read from the file, so its layer names still carry model.diffusion_model. / diffusion_model. / net., while the state dict has had the prefix removed.
meta = {"_quantization_metadata": '{"layers": {"model.diffusion_model.blocks.0.attn.wq":
{"full_precision_matrix_mult": true}}}'}
# after _strip_comfyui_prefix + _remap_native_layer_paths:
# hint key : model.diffusion_model.blocks.0.attn.to_q
# layer : transformer_blocks.0.attn.to_q full_precision_matmul=FalseThe producer said "do not multiply this layer in fp8" and it is multiplied in fp8, silently — the precise failure the hint plumbing exists to prevent. Same structure in FluxCheckpointModel (bundled checkpoints, after convert_bundle_to_flux_transformer_checkpoint strips the prefix), Flux2CheckpointModel, and ZImageCheckpointModel. Invisible on the checkpoints you measured because PM_Krea2_turboV2FP8 ships the flags as .comfy_quant tensors, which ride through the strip inside the state dict.
Pushing the hint names through the prefix strip as well — the same "run it through the real function" trick already used for the renames — covers all four loaders.
5. FLUX.2: the adaLN scale/shift swap is not applied to a per-output-channel weight_scale
final_layer.adaLN_modulation.1.weight has its two halves swapped by _flux2_swap_scale_shift(). The side-channel is placed by probing the converter for the destination key, which is correct, but the value is copied verbatim:
weight rows after convert : [12, 16, 20, 0, 4, 8] # halves swapped
weight_scale after convert: [ 1, 2, 3, 4, 5, 6] # unchanged
Rows 0–2 now hold the original rows 3–5 but are scaled by the original rows 0–2's factors. Per-tensor scalars are unaffected, and split_qkv_sidechannel handles the fused-qkv case correctly — this is the one converter transform that reorders rows and is not mirrored onto the scale.
Minor / robustness
-
_strip_anima_prefix_from_layer_pathscan abort the load.zip(layer_names, stripped.keys(), strict=True)raisesValueErrorif_strip_anima_bundle_prefixdrops any name — which it does for any hint name outside the detected prefix. Uncaught, so the whole Anima load fails:_strip_anima_prefix_from_layer_paths(["net.blocks.0.attn.q_proj", "final_layer.linear"]) # ValueError: zip() argument 2 is shorter than argument 1
_remap_native_layer_pathsin krea2.py wraps the equivalent call intry/except; this one should too. -
A transient probe failure disables fp8 compute for the process.
_probe_fp8_matmulcatches every exception and the result is cached permanently (reset_fp8_matmul_support_cacheis documented "for tests"). The probe runs during model loading, i.e. under real VRAM pressure — a CUDA OOM on the 16×16 allocation or on cuBLAS workspace init sticks as "this GPU cannot do fp8". Worth distinguishing an allocation failure from a genuine unsupported-op error, or not caching the former. -
Mistral:
_drop_quantization_metadatais skipped on the fp8 path, so keys ending.scaleand startingscaled_fp8— which that function deliberately dropped andis_scale_metadata_keydoes not cover — now reachload_state_dict. Harmless withstrict=False, but they show up in the "ignored N unexpected keys" debug line. -
Anima lost its non-float guard. The old loop was
if sd[k].is_floating_point(): sd[k] = sd[k].to(model_dtype);cast_state_dict()casts unconditionally. No Anima checkpoint key appears to be non-float today (the integer-ish buffers are non-persistent and filtered by_NON_MODEL_KEY_SUFFIXES), so this is latent rather than live — but the guard was there on purpose. -
Partial-load accounting.
weight_scale/input_scaleare non-persistent buffers, so they are invisible toCachedModelWithPartialLoad._state_dict_bytes, and_move_non_persistent_buffers_to_devicere-copies all ~314 of them (buffer.to(device, copy=True), unconditionally) on everypartial_load_to_vram. Both are small; noting so the accounting gap is a known one. -
Doc/description drift. The
fp8_computetext in theInvokeAIAppConfigclass docstring omits the "reproducibility requires full residency" paragraph that theField(description=...)carries._QUANTIZED_MODEL_FORMATSand the matching list inMainModelDefaultSettings.tsxboth omitsdnq_quantized(the_is_quantized_parambackstop covers it, so this is cosmetic). -
CI.
python-checksandpy3.11: windows-cpuare both cancelled, not failed — worth a re-run so the rollup is clean.
Attacked and held
- In-place
sd[key] = ...while iteratingfor key in sd— no insert/delete, safe. - The three-way agreement between
cast_state_dict,predict_cast_state_dict_sizeandsplit_fp8_scaled_layers— all funnel throughcan_stay_quantized(); the round-1 drift is gone.predict_*no longer under-counts biases/norms. scaled_mm_linearoperand layout:weightis(out, in)contiguous, so.t()is genuinely column-major; the unpadout[: x.shape[0] - pad]is right becausexwas rebound to the padded tensor; the per-channel weight scale is applied after the unpad and before the reshape.- fp8 storage hooks vs. the matmul: the forward pre-hook restores the compute dtype before
CustomLinear.forwardruns, so_can_use_fp8_matmulnever sees an fp8 weight on a storage-cast layer. The Qwen3-VLskip=lambda keys offweight_scale, whichattach_fp8_scaleshas already set at that point. - LoRA over fp8:
_is_any_part_of_layer_fp8forces sidecar patching, the sidecar's base call routes to_autocast_forward→ fp8 matmul, and the non-LoRA residual branch goes through_cast_weight_bias_for_input, which now applies the scale rather than a bare.to(). weight_scalebuffers survivewrap_custom_layer(shared__dict__), are moved by_move_non_persistent_buffers_to_deviceunder partial loading and bymodel.to()on full load.should_keep_fp8_weightsshort-circuits onis_fp8_matmul_enabled(), so a default install never runs the CUDA probe during loading.get_config()islru_cached, so the per-forwardis_fp8_matmul_enabled()is cheap.transformer.dtypeleaking float8 intolatents.to(...): Z-Image's first parameter isx_pad_token(1-D → never kept fp8), Mistral's isembed_tokens(annn.Embedding), Anima has no.dtype. No float8 escapes.get_model_compute_dtype's "first non-fp8 float param" fallback also covers the loaders that now return early without setting the marker.- FLUX.1's
load_state_dict(..., strict=True): both scale spellings, both input-scale spellings,.comfy_quantand the strayscaled_fp8are all popped before the load. iter_weight_scale_pairsskipping pairs whose.weightis absent, andextract_fp8_scaled_layersdropping a scale with no fp8 weight — correct, and the reason findings 1 and 2 are silent rather than loud.
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`.
Round-2 review follow-ups for invoke-ai#9478. Three of these are silent wrong-weight bugs: the scale never reaches the layer, nothing logs it, and the weight runs off by exactly 1/weight_scale. Carry the quantization side channel across key renames. The Krea-2 native converter renames `.weight` keys by substring and five more by whole-key equality, so a sibling `.scale_weight`, `.input_scale`, or any scale on an equality-renamed key (`last.linear.weight_scale`) stayed at the old path while its weight moved. `extract_fp8_scaled_layers` then dropped the orphan, and because the layer never entered `fp8_layers`, `warn_on_unattached_scales` could not see it either. Rather than restating the rename rules, `detach_layer_sidechannel` takes the side channel out before the conversion and `reattach_layer_sidechannel` puts it back under the converted path, resolved through the real converter. A module the converter drops (`last.down`/`last.up`) is now reported rather than swallowed. Recover scales for `float8_e5m2` too. Extraction gated on e4m3fn and had already popped the scale key by then, so an e5m2 weight was cast to bf16 unscaled. Extraction now accepts any float8 dtype; the e4m3fn-only restriction stays where it belongs, in `can_stay_quantized`, and `split_fp8_scaled_layers` folds the scale in properly on the way out. Handle block-wise (2-D) `weight_scale` again. Extraction flattened it, destroying the block geometry, and no dequantize path expanded it — so with `fp8_compute` off, the default, the model failed to load outright. `expand_weight_scale` centralizes the `repeat_interleave` expansion the FLUX.2 loader used to carry. Such a layer is also dequantized up front now: `scaled_mm_linear` can apply a per-tensor or per-row scale and nothing else, so left quantized it would have failed inside the kernel mid-generation. That happens in `split_fp8_scaled_layers`, not `can_stay_quantized`, because every loader runs split before `predict_cast_state_dict_size` — so the RAM reservation stays honest. Strip the checkpoint prefix from `_quantization_metadata` layer names. The header is read from the file and still carries `model.diffusion_model.` / `net.` while the state dict has had it removed, so every `full_precision_matrix_mult` flag matched nothing and the producer's "do not multiply this in fp8" was disregarded. The new `strip_layer_path_prefix` covers Krea-2, FLUX.1, FLUX.2, Z-Image and Mistral. It also replaces Anima's `zip(..., strict=True)` remap, which raised `ValueError` and aborted the load on a partially-prefixed header. Mirror the adaLN scale/shift swap onto a per-output-channel weight scale. `final_layer.adaLN_modulation.1.weight` has its halves swapped by the FLUX.2 converter; the scale was copied verbatim, leaving each row scaled by another row's factor. Per-tensor scalars, `input_scale` and `comfy_quant` blobs describe the whole layer and are still copied as-is. Smaller fixes: do not cache an fp8-matmul probe that could not be carried out — it runs during a model load, under real VRAM pressure, and a momentary OOM disabled fp8 for the rest of the process. Drop `.scale` and `scaled_fp8*` on the Mistral fp8 path, which only the dequantizing path cleaned up. Restore the non-float guard Anima lost, centrally in `cast_state_dict` so the next loader to adopt it does not lose it again. Sync the `fp8_compute` class-docstring text with its `Field` description. Remove `_dequantize_scaled_fp8` and its eight tests: nothing outside `tests/` called it any more, and it asserted behaviour the shipping path no longer has. Add 25 regression tests, each written against the reproducer from the review.
The probe allocates real tensors on the device before it reaches `torch._scaled_mm`. On a CPU-only runner that allocation raises first, so the mocked matmul never ran and the test asserted against the wrong path: it passed on a CUDA box and failed all six python jobs on CI. Drop the device argument from the probe's allocations for the duration of these two tests. float8 tensors are allocatable on CPU, so the probe runs through and the mocked matmul decides the outcome again, which is what the tests are about - the caching policy, not the allocation. Verified by instrumenting the allocations rather than by trusting the environment: under the new fixture the probe allocates on ['cpu', 'cpu', 'cpu'] and touches CUDA nowhere. `CUDA_VISIBLE_DEVICES` does not hide the device on Windows, so it could not be used to reproduce the CI condition.
…ytes OCP Microscaling stores its per-block scale as an E8M0 exponent - byte `v` encodes `2**(v-127)` - and safetensors has no E8M0 dtype, so producers write a `uint8` tensor. `_normalize_weight_scale` called `.float()` on it and used the raw byte as a linear multiplier. Measured on a real MXFP8 Krea-2 checkpoint (`krea2TurboOfficialComfy_krea2TurboMxfp8`, `blocks.0.attn.wk`): the exponents run 112-120, so a true scale of `2**-12` (~2.4e-4) was applied as 115.0 - weights inflated by ~470,000x, absolute maximum 53,760 instead of 3.5. The model loads, generates, and produces garbage. This is a regression introduced by the block-wise expansion in the previous commit: before it, such a checkpoint died on a shape mismatch. Expanding the scale without decoding its encoding turned a loud failure into a silent one, so the two belong in the same release. Gate on the dtype rather than the per-layer `format` field: the metadata is optional and no other producer writes an integer weight scale. The reserved 0xFF stays NaN rather than decoding to 2**128 and quietly making the weights infinite. Verified against three real layers of that checkpoint - bit-exact, maximum deviation 0.00e+00. Those layers are still widened at load: `_scaled_mm` cannot apply a block scale before Blackwell, which is what the redistributor means by "RTX 3000/4000 runs via software scaling layers".
OCP Microscaling stores one E8M0 exponent per 32-element block, written as `uint8` because safetensors has no E8M0 dtype. The previous commit's block-wise expansion made such checkpoints *loadable* - and they load wrong: end to end they generate a pure-noise image with nothing in the log. Decoding the byte as `2**(v-127)` is not the fix. Verified against a real pair: the MXFP8 and scaled-fp8 builds of `krea2TurboOfficialComfy` share all 174 bf16 tensors bit-for-bit, so the scaled build is an exact reference for the same weights. Against it the decoded weights correlate only 0.60 - worse than the unscaled raw codes at 0.75 - while the block axis itself checks out (4% spread within a 32-block along dim 1, against 38% for the alternative). The measured per-block scale has no monotonic relation to the byte: 112 and 116 yield the same true scale. That rules out a wrong exponent bias and points at a swizzled scale layout, which is a de-swizzle to implement, not a constant to correct. So refuse the format at extraction, naming the layer and saying what would happen otherwise. A loud refusal beats both the shape error this used to raise and the silent garbage it would produce now.
OCP Microscaling stores one E8M0 exponent per 32-element block, written as `uint8` because safetensors has no E8M0 dtype. The previous commit's block-wise expansion made such checkpoints *loadable* - and they load wrong: end to end they generate a pure-noise image with nothing in the log. Decoding the byte as `2**(v-127)` is not the fix. Verified against a real pair: the MXFP8 and scaled-fp8 builds of `krea2TurboOfficialComfy` share all 174 bf16 tensors bit-for-bit, so the scaled build is an exact reference for the same weights. Against it the decoded weights correlate only 0.60 - worse than the unscaled raw codes at 0.75 - while the block axis itself checks out (4% spread within a 32-block along dim 1, against 38% for the alternative). The measured per-block scale has no monotonic relation to the byte: 112 and 116 yield the same true scale. That rules out a wrong exponent bias and points at a swizzled scale layout, which is a de-swizzle to implement, not a constant to correct. So refuse the format at extraction, naming the layer and saying what would happen otherwise. A loud refusal beats both the shape error this used to raise and the silent garbage it would produce now.
Summary
Adds FP8 Compute: running checkpoints that already ship FP8 weights directly on the FP8 tensor cores via
torch._scaled_mm, instead of unpacking them to BF16 at load. This is distinct from the existing FP8 Storage toggle, which quantizes a full-precision model yourself and still does the math in BF16. New global settingfp8_computeininvokeai.yaml, defaultfalse.Two kinds of checkpoint are covered, because both exist in the wild:
Scaled FP8 (FP8 weight + a
weight_scale, the ComfyUI convention). Previously these were dequantized at load. They now stay quantized, with the scale attached to the layer. Wired into FLUX.1, FLUX.2, Anima, Krea-2, Z-Image and the Qwen3-VL and Mistral encoders. Handling that turned out to need more than reading one key:full_precision_matrix_multhints arrive via two transports — the safetensors header_quantization_metadataand per-layer.comfy_quantuint8-JSON tensors. The latter marks 96 of 256 layers inPM_Krea2_turboV2FP8and was previously ignored entirely. Honored by default;fp8_compute_full_precision_hints: falseoverrides the producer.weight_scale/scale_weight,input_scale/scale_input) are read everywhere. Reading only one meant a checkpoint using the other spelling had its scales deleted without ever being applied, leaving every quantized weight off by1/weight_scalewith nothing logged.input_scaleplaceholders (exactly 1.0, non-finite, <= 0) are discarded rather than applied.FLUX.1 is the cheap case and the most rewarding one. The checkpoint is already in the BFL layout
Fluximplements andqkvstays one fused Linear, so no key conversion, no qkv split and no scale copying are needed -- the per-tensor scale attaches to exactly the module it was computed for. The model is 100%nn.Linearwith no shape outside the_scaled_mm16-divisibility rule, so every quantized layer reaches the tensor cores. Before this, a scaled-FP8 FLUX.1 checkpoint did not load at all: its 629 scale and marker keys reachedload_state_dictas unexpected keys.One fix falls out of that.
convert_bundle_to_flux_transformer_checkpointfolded anything ending inscaleto bf16 for the model's own RMSNorm parameters -- and.weight_scaleends inscaletoo, so a quantization scale lost 8 mantissa bits before the loader ever saw it.Raw FP8 (FP8 weight, no scale at all). The runtime already handled these —
scaled_mm_lineartreatsweight_scaleas optional — but the loaders never let them through: FLUX, FLUX.2 and Z-Image cast the whole state dict to BF16 unconditionally, discarding both the VRAM saving and the tensor cores before the model is built. Krea-2 kept them by accident, via a dtype check written for the scaled path, and logged nothing about it. A sharedcast_state_dict()now casts everything except qualifying FP8 weights when the matmul is actually available, wired into all four loaders with a log line.Only
nn.Linear.weightis preserved. That restriction is not cosmetic. A Z-Image checkpoint in the wild quantized everything: 243 of its 453 FP8 tensors are 1-D biases, norm weights and a learned pad token. Keeping those quantized saves nothing usable and breaks inference — the FP8 value reaches the activations, the next Linear receives an FP8 input, and the forward dies with"abs_cuda" not implemented for 'Float8_e4m3fn'. Skip patterns alone are not enough here; you end up chasing one layer after the next. The predicate is_is_fp8_matmul_weight(): suffix.weightanddim() >= 2and the module really is atorch.nn.Linear. A model's own_skip_layerwise_casting_patternsis honored on top, for Linears whoseforwardcasts activations to their weight's dtype (Z-Image'sTimestepEmbedderdoes exactly that).FLUX.2 is the hard one, and the only place the side-channel needs real work. Its keys are renamed BFL -> diffusers and its fused
qkvis split into three projections; both steps drop a scale silently, leaving the weights quantized but unscaled. Worse, the block renames are substring tests, soimg_attn.proj.weight_scalesatisfies"img_attn.proj.weight" in restand the scale would be written to the weight's destination key, replacing the weight itself. Side-channel keys are therefore no longer routed through the weight converter at all: each one is placed by pushing a probe<base>.weightthrough the real converter and reading back where it landed, so placement cannot drift from the rename rules. A fusedqkvyields three destinations — a scalar scale is copied to each, a per-channel scale split like the weight. Layer hints get the same one-to-many rename, since they name layers BFL-side while the scales are read diffusers-side.The FLUX.2 [dev] Mistral text encoder turned out to be the largest single item in this series. It ships as scaled FP8 — 210 quantized Linears — and the loader folded every scale into BF16 at load. That doubles it: 16.8 GiB on disk becomes 32.3 GiB in memory, which does not fit on a 24 GB card even on its own, and the dequantization ran for 13 minutes before OOMing. It now loads in 5.8 s at 17,180 MB, fully resident. That is 15.5 GiB saved — more than any transformer it feeds.
Anima did not load at all either. Its
_filter_non_model_keysdrops derived buffers and exporter metadata but not the quantization side-channel, and the loader raises on unexpected keys — 500 of them on a plain scaled export, 749 on one that also shipscomfy_quantmarkers. Anima itself is the easy shape (separateq_proj/k_proj/v_proj, one prefix strip), but it exposed a quieter bug shared with the FLUX.2 work:_quantization_metadatanames layers in the checkpoint's own scheme,net.-prefixed, while the scales are read after the prefix is stripped. Read without renaming, the header matches nothing and everyfull_precision_matrix_multflag is dropped silently. In both loaders the names are now pushed through the real rename rather than restated.float8_e5m2is deliberately not preserved:_scaled_mmcannot take it as the weight operand on Ada, so keeping it quantized would buy VRAM at the cost of a per-forward dequantize.FP8 storage no longer silently defeats FP8 compute. Layerwise casting installs hooks that restore the compute dtype before every forward, so on an already-FP8 checkpoint the matmul would quietly fall back to the dequantized path — the VRAM toggle would make the model slower with no indication why. It is now skipped, with a log line, when the weights are already quantized and the matmul is in use.
Device support is probed by actually attempting a small FP8 matmul, not inferred from the device name, so an unsupported build falls back cleanly instead of failing partway through a generation.
Not in scope
nn.Linearand every shape clears the_scaled_mm16-divisibility rule, so it would suit this well — but it loads through diffusers'from_single_file/from_pretrained, which owns dtype conversion and leaves no state-dict seam, and FP8 SDXL checkpoints barely exist. Not worth the loader surgery today.Related Issues / Discussions
Follow-up to #8945 (FP8 storage), #9231 (hook-based casting) and #9412 (compute-dtype resolution).
Stacked on #9416, which is stacked on #9415 -> #9414. All four touch
_should_use_fp8and_apply_fp8_to_nn_module; the overlap is already resolved on this branch, so merging in that order is conflict-free._apply_fp8_to_nn_modulenow carries both extension points:extra_skip_patterns(declarative, from #9414) andskip(programmatic, used here to leave scaled-FP8 layers alone). They are complementary — a scaled layer holds an FP8 weight plus aweight_scale, and the casting hooks would upcast it without applying that scale, i.e. a silently wrong weight.QA Instructions
Requires an FP8-matmul-capable GPU (SM 8.9+ — Ada/Hopper/Blackwell) and
fp8_compute: trueininvokeai.yaml.Raw FP8 — e.g. Z-Image
unstableRevolution_V2Fp8(FP8 tensors, no.weight_scalekeys):kept N raw fp8 weight(s)and noFP8 layerwise casting enabledline. If you see the casting line, storage won and the measurement is meaningless.Total model sizeshould roughly halve.s/it. One image is not enough — cold load and residency drift both hide in it.Scaled FP8, FLUX.1 —
comfyanonymous/flux_dev_scaled_fp8_test(needs a separate CLIP-L, T5 and VAE; the bundled ones in an all-in-one checkpoint are discarded by the loader and cannot be selected):FLUX: kept 314 layer(s) in fp8 (scaled fp8 checkpoint, fp8_compute enabled)and the transformer at ~11,350 MB. Onmainthis checkpoint fails to load with 629 unexpected keys.FP8 storage skipped ... 314 weight(s) are already fp8and unchanged timings — not a slowdown.Scaled FP8, Krea-2 — e.g.
PM_Krea2_turboV2FP8:kept N layer(s) in fp8 (scaled fp8 checkpoint, fp8_compute enabled)plus, on this checkpoint, the note that 96 of 256 layers are markedfull_precision_matrix_mult.fp8_compute_full_precision_hints: false— those layers then also run on the tensor cores, which is worth ~1.5x on a checkpoint with no marked layers (see below).Scaled FP8, FLUX.2 —
black-forest-labs/FLUX.2-klein-9b-fp8or-4b-fp8(4B needs a Qwen3-4B encoder; the loader rejects an 8B one):FLUX.2: kept N layer(s) in fp8 (scaled fp8 checkpoint, fp8_compute enabled)and noFP8 layerwise casting enabledline. Onmainthe scales are folded and the model runs as FP8 storage.Mistral encoder: kept 210 layer(s) in fp8. Onmainthat encoder OOMs on a 24 GB card after a ~13 minute dequantization; here it loads in seconds at ~17,180 MB.Scaled FP8, Anima — e.g.
Bedovyy/Anima-FP8orpachiiahri/anima-fp8-comfyui:Anima: kept 250 layer(s) in fp8 (scaled fp8 checkpoint, fp8_compute enabled)and the transformer at ~2,488 MB instead of ~3,988 MB. Onmainthe model fails to load with several hundred unexpected keys.Regressions:
fp8_compute: false— behavior unchanged, weights dequantize to BF16 as before.weight_scaleanywhere) must be unaffected by the scaled path: it still logskept N raw fp8 weight(s)and nokept N layer(s) in fp8line. Verified on the FLUX.1 raw bundle — 0 scaled layers detected, 314 weights kept withfp8_computeon, 0 with it off.model.diffusion_model.prefixed, text encoders and VAE inside) must still load its transformer. Note the bundled encoders and VAE are discarded by the loader and cannot be selected — that is pre-existing behaviour, not a regression.Measured — RTX 4090, torch 2.7.1+cu128
Z-Image
unstableRevolution_V2Fp8, 1024x1024, 30 steps, 1 warm-up + 3 measured:Note the drift on the
beforeside: at 11.7 GB the transformer no longer stays fully resident, so per-step time degrades run over run. The FP8 version is not just faster on average, it is stable.That 1.27x is bought by residency, not by the tensor cores — at 11.7 GB the BF16 transformer no longer stayed fully resident. How much the matmul itself buys depends on the checkpoint, and the spread is large.
FLUX.1 dev scaled FP8 — the same question with residency held constant
1024x1024, 20 steps, 1 warm-up + 3 measured. FP8 storage is the honest baseline here: at BF16 the transformer is 23.8 GB and does not fit on a 24 GB card alongside T5, so "turn FP8 off" is not an option a user has.
fp8_computefp8_storage1.57x, on the pinned torch 2.7.1. The two arms differ by 3 MB of VRAM and are both fully resident, so this is the matmul and nothing else. Arm C is the guard above doing its job — without it, storage would have taken the matmul away and landed on arm B's numbers.
Anima — no speedup, but the checkpoint loads at all
Anima base v1.0, 1024x1024, 35 steps, CFG 4.5, same seed both arms. The baseline here is BF16, since Anima fits either way.
38% less VRAM at identical speed. Same composition, same lighting, same framing at the same seed; the differences are in fine structure (spoke detail, stone texture) — not a degraded or dithered image. For Anima the point is not the matmul anyway: before this the checkpoint raised on 500 unexpected keys.
What the three profiles suggest
The trend is consistent with coverage driving the speedup, but it does not establish it — Anima is a 2 B model where the matmul is unlikely to be the bottleneck at this resolution regardless. Treated as an observation, not an explanation.
FLUX.2 Klein — a smaller win, and a VRAM trade-off worth knowing
Same conditions, 8 steps. FP8 storage is again the honest baseline, since these checkpoints ship quantized.
fp8_computefp8_storage1.10x on Klein 9B — well short of FLUX.1's 1.57x. The likely reason is coverage: the FLUX.1 checkpoint quantizes 314 of 314 Linears, Klein 9B only 144. The matmul can only act where FP8 actually is; the rest runs BF16 either way. Not separately measured.
Note the direction of the VRAM column. FP8 compute is larger than FP8 storage here — 9,030 MB vs 8,708 MB — because storage quantizes every eligible Linear while compute keeps only the ones the checkpoint shipped. Storage is smaller and slower, compute is bigger and faster. Anyone expecting "FP8 compute always shrinks the model further" will be surprised, so it is worth stating.
Cold load follows the usual pattern: 48.6 s with compute against 119.0 s with storage, the BF16 round trip again.
FLUX.2 [dev] renders end to end with an FP8 encoder and an FP8 transformer, but is not a valid timing subject on 24 GB: at 32.2 B parameters the transformer alone is 33.8 GB resident (60.0 GiB without FP8 compute), so it needs partial loading and the measurement rules above rule it out.
Why Krea-2 shows nothing and FLUX.1 shows 1.57x
Measured on Krea-2 at 100% residency in both arms (12,228 MB vs 12,533 MB):
The leading suspect is
full_precision_matrix_multcoverage.PM_Krea2_turboV2FP8marks 96 of its 256 layers, and those dequantize on every forward instead of using the tensor cores; the scaled FLUX.1 checkpoint marks none of its 314. That is not the whole story — turning the hints off on Krea-2 was separately measured at only ~10% (57% with LoRAs), which does not close a 1.00x-to-1.57x gap on its own, so architecture and layer shapes plausibly contribute. What the FLUX.1 arms do establish is the part that matters for reviewing this PR: the flat Krea-2 result is not a property of torch 2.7.1. On the pinned version, the same code path is worth 1.57x on a checkpoint that lets it run.cu130 changes nothing on Ada either way (0.08%).
What FP8 compute buys regardless of the matmul:
fp8_compute_full_precision_hints: truecosts ~10% without LoRAs (57% with) and is still the right default: it is the fidelity the quantizer asked for.Merge Plan
Merge after #9414 -> #9415 -> #9416. No DB migration, no redux slice, no new dependency. Adds two
invokeai.yamlsettings (fp8_compute,fp8_compute_full_precision_hints), both opt-in viafp8_computedefaulting tofalse, so existing installs are unaffected.Checklist
tests/backend/quantization/test_fp8_scaled.pyplus captured key-layout fixtures for scaled-FP8 Z-Image and mixed-FP8 FLUX.2docs/src/content/docs/configuration/fp8-storage.mdx, renamed to "FP8 Storage & Compute"What's Newcopy (if doing a release after this PR)