Skip to content

feat(fp8): run scaled and raw fp8 checkpoints on the fp8 tensor cores - #232

Merged
Pfannkuchensack merged 68 commits into
mainfrom
feat/fp8-compute
Sep 9, 2026
Merged

feat(fp8): run scaled and raw fp8 checkpoints on the fp8 tensor cores#232
Pfannkuchensack merged 68 commits into
mainfrom
feat/fp8-compute

Conversation

@Pfannkuchensack

Copy link
Copy Markdown
Member

Summary

Ports invoke-ai/InvokeAI#9478 into v7 ahead of its upstream merge, so the work stacked behind it can start here instead of waiting.

Adds FP8 Compute: checkpoints that already ship FP8 weights run directly on the FP8 tensor cores via torch._scaled_mm instead of being unpacked to BF16 at load. Distinct from the existing FP8 Storage toggle, which quantizes a full-precision model yourself and still does the math in BF16. New global setting fp8_compute in invokeai.yaml, default false, plus fp8_compute_full_precision_hints (default true).

Covers both kinds of checkpoint in the wild — scaled FP8 (FP8 weight plus weight_scale, the ComfyUI convention) and raw FP8 (no scale at all) — wired into FLUX.1, FLUX.2, Anima, Krea-2, Z-Image and the Qwen3-VL and Mistral encoders. The upstream description carries the full rationale and the measurements; this PR does not restate them.

Not in scope, unchanged from upstream: Qwen-Image and SDXL. The v7-only loaders (MiniMax H3, Wan, CogView4, ErnieImage, Ideogram 4) are likewise untouched here.

Related Issues / Discussions

QA Instructions

This is a merge of the upstream PR branch rather than a copied diff, so that git already knows these commits when invoke-ai#9478 lands upstream and main is synced.

Net effect against main is exactly the upstream PR: 33 files, +5103/-271, no unrelated content.

Two conflicts, both resolved:

  • krea2.py — import block only: this repo's normalize_qwen3vl_rope_config against the PR's fp8_scaled imports. Both kept; both are used (5 and 12 references respectively).
  • invokeai/frontend/web/openapi.json — regenerated rather than hand-merged, with pnpm typegen for schema.ts. Both new settings reach the generated schema.

config_default.py auto-merged cleanly with this repo's own options intact.

Checks run locally (Windows, RTX 4090, diffusers 0.40.0):

  • pytest tests/backend/quantization/ tests/backend/model_manager/load/ tests/test_config.py tests/backend/util/test_fp8.py965 passed, 150 skipped, 0 failed
  • ruff check and ruff format --check — clean
  • Smoke import of all five touched loaders plus the config defaults — clean

Not run: the full test suite, left to CI. Not run: the runtime QA from the upstream PR — no FP8 checkpoint was generated with on this machine, so the speed and VRAM claims are inherited from upstream, not reproduced here.

Review

Agent-assisted. Independent review subagents were not run; this had a self-review pass only, which is a weaker guarantee and is disclosed here rather than implied. The conflict resolutions and the regenerated artifacts are the parts worth a human eye.

Compatibility / Rollout

Both new settings default to the previous behaviour (fp8_compute: false), so nothing changes until a user opts in. openapi.json and schema.ts are regenerated and additive.

Checklist

  • The PR has a short but descriptive title, suitable for a changelog
  • Meaningful regression coverage added / updated where needed; obsolete tests/code removed
  • Persisted-state and API changes include required migrations / compatibility validation
  • Relevant performance/efficiency opportunities considered; material claims have evidence
  • Material review findings resolved and relevant checks rerun — self-review only, see Review
  • Documentation added / updated (if applicable)
  • Updated What's New copy (if doing a release after this PR)

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%.
Conflicts in the fp8 loaders:

- load_default.py: keep both guards. main added the idempotence early-return
  (the FP8_COMPUTE_DTYPE_ATTR marker), this branch added the "already fp8,
  leave it on the tensor cores" early-return. They gate different things, so
  both stay; the marker check runs first.
- krea2.py: the transformer loader no longer calls _dequantize_scaled_fp8 —
  extract_fp8_scaled_layers/dequantize_fp8_scaled replaces it and honours the
  fp8_compute setting. main's RAM-spike fix to that helper is still relevant
  for its remaining callers, so the helper keeps main's dtype-aware version.
  target_device/model_dtype now come from main's hoisted position.
- krea2.py Qwen3-VL encoder: same, plus main's .comfy_quant / scale_input key
  cleanup is already covered — extract_fp8_scaled_layers pops both.
…evice

Review follow-ups for invoke-ai#9478.

1. A scaled fp8 layer that matched a skip pattern (or was not an nn.Linear
   weight) went through cast_state_dict's plain .to(dtype), which drops the
   weight_scale; attach_fp8_scales then skipped it for no longer being fp8, so
   the scale was lost and the weight ended up off by 1/weight_scale. Krea-2 hits
   this on ordinary ComfyUI exports — time_embed.linear_1/linear_2 are quantized
   like any other Linear and match the model's `time_embed` pattern.
   split_fp8_scaled_layers() now folds exactly those layers first, with the scale
   applied, and drops them from the mapping. The predicate the three callers
   share lives in can_stay_quantized(). attach_fp8_scales returning fewer than
   len(layers) now warns instead of reading as success.

2. device_supports_fp8_matmul tested compute capability >= (8, 9). On ROCm that
   reports the gfx arch, so RDNA3 (gfx1100 -> (11, 0)) passed and every forward
   then raised. It now probes once per device with a real 16x16 _scaled_mm; the
   capability compare is only a pre-filter. Without this the fallback that exists
   to avoid a mid-generation crash was gated on the same wrong answer.

3. Flux2CheckpointModel._dequantize_fp8_weights converted every float8 tensor
   unconditionally, before cast_state_dict ever saw the state dict — so the
   FLUX.2 half of the raw-fp8 path never executed and its log line could not
   fire. It now takes the same keep_fp8 gate.

4. Z-Image deleted .scale_weight without applying it and could not tell a scaled
   checkpoint from a raw one. It goes through extract_fp8_scaled_layers +
   attach_fp8_scales like Krea-2 now, reads the full_precision_matrix_mult hints
   from both the header and the .comfy_quant markers, and accepts both scale
   spellings. The fused-QKV split carries the quantization side-channel with it:
   a scale left on `...attention.qkv` keys onto a module the diffusers model does
   not have, so all three split weights would stay quantized but unscaled.

5. make_room charged 1 byte/element for every fp8 tensor, but only 2-D Linear
   weights outside the skip patterns actually stay quantized. On a checkpoint
   that quantized all 453 of its tensors that is most of the reservation.
   predict_cast_state_dict_size() answers with the same predicate the cast uses.
ComfyUI-style scaled fp8 checkpoints spell the scale either `.weight_scale` or
`.scale_weight`; fp8_scaled.py has always known both, but four loaders carried
their own `.weight_scale`-only literals.

flux.py (FLUX.2, Qwen-Image) and the z_image text-encoder path applied one
spelling and stripped both, so a `.scale_weight` checkpoint lost its scales
without a word and every quantized weight came out off by 1/weight_scale.
mistral_encoder.py neither applied nor stripped it, so the leftover key tripped
load_state_dict(..., strict=True).

Adds iter_weight_scale_pairs() and is_scale_metadata_key() and routes all four
loaders through them.

Validated against five real checkpoints (Krea-2 x2, FLUX.2 Klein 9B, Qwen-Image
Edit 2511, Qwen2.5-VL 7B), folding real quantized layers through the real loader
code: results are bit-identical for the spelling each file already used, and
identical again once its scale keys are renamed to the other spelling. On the
previous code the renamed variants come out with a relative error of 530-1682.
zImageTurboFP8Kijai_fp8ScaledE4m3fn is ComfyUI scaled fp8 with the `.scale_weight`
spelling and scales between 1.5 and 7.6. Loading it on main drops the scales in
the transformer's keys_to_remove filter, leaving each weight at 1/scale of its
true magnitude -- 13% to 65%, layer by layer, silently.

Captures the key layout (76 keys, shapes and dtypes) as a fixture next to the
existing bf16 one, with tests asserting every `.scale_weight` is recognized, the
keys are consumed rather than left for a strict load, and the scale value is
carried through. One test guards the fixture itself, since a bf16 recapture would
make the rest vacuous.
Pfannkuchensack and others added 16 commits August 26, 2026 00:32
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.
…be caching

M1. The Qwen3-VL encoder was the one caller that never went through
`split_fp8_scaled_layers`. Its per-key `if dtype is not FP8_DTYPE` cast looks
equivalent to `cast_state_dict` and is not: it keeps *every* fp8 tensor, so a
1-D fp8 norm from a "quantizes everything" checkpoint kept its scale as an
unused buffer on a non-Linear and the forward computed on raw fp8 codes, off by
1/weight_scale with nothing logged. Two more facets of the same hole: an e5m2
scaled weight was cast without its scale, and a block-wise scale reached
`scaled_mm_linear` unchecked and raised mid-generation. Now routed through
extract -> split -> cast like every other loader; the `skip=` callback for the
storage pass is unchanged.

m1. A multi-element `input_scale` aborted the load with a raw shape error from
`reshape(())`. `scaled_mm_linear` scales activations by one value, so there is
nothing to do with a per-channel activation scale — it is dropped and the
dynamic amax path takes over, matching how every other malformed side-channel in
the module behaves.

m2. `reattach_layer_sidechannel` tested for `<destination>.weight`, which assumes
every quantizable module stores its parameter as `weight`. A quantized norm
writes `<path>.scale`, so the guard rejected a destination it could have placed
and dropped the scale. It now asks whether any tensor sits under the
destination. The orphan report at the krea2 call site moves from DEBUG to INFO:
a dropped scale has no other symptom.

m3. Mistral strips `text_encoder.`/`language_model.` on top of the generic
prefixes, but its hints only went through the generic tuple — so a prefixed
redistribution shipping `_quantization_metadata` had every
`full_precision_matrix_mult` silently ignored. The prefix list is now a named
constant used by both the sd strip and the hint rename.

m4. The two legacy folds multiplied `(out, in) * (out,)`, which broadcasts on the
last axis and therefore scales input channels: wrong on a square weight, a shape
error otherwise. Both now use `expand_weight_scale`, as the shared dequantizers
already did.

m5. `make_room` moved back ahead of `split_fp8_scaled_layers` at all five call
sites. The split dequantizes its unusable subset through fp32, so reserving
afterwards let that transient peak land on an unreserved cache. The prediction is
unaffected — it applies the same `can_stay_quantized` predicate.

m6. `_probe_fp8_matmul` cached every unrecognized `RuntimeError` as "this GPU
cannot do fp8", which is the failure mode the OOM branch exists to avoid, just
reached through a different wording. It now caches only errors that name the
support constraint and treats the open set as inconclusive; an inconclusive
answer costs one 16x16 matmul on the next load.

m7. Test gaps closed: the already-fp8 storage guard and the `skip` callback now
have tests that fail if reverted; `set_fp8_matmul_enabled` is restored to `None`
rather than left pinned to `False` after `TestCustomLinearIntegration`; the 1-D
wrong-length branch of `is_matmul_usable_scale` is pinned; and hint-rename
plumbing is pinned per loader, including FLUX.1, which had no test at all. The
Mistral case pins the failure mode as well as the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Picks up invoke-ai#9538, which lands in the same code this branch rewrites.

- krea2.py: keep both. main added `sd.clear()` after `load_state_dict` so the
  `assign=True` aliases free before the FP8 cast; it now runs ahead of this
  branch's fp8 logging block, which returns early on an fp8 checkpoint and would
  otherwise skip it.
- z_image.py: main's block was a comment recording the scaled-fp8 mis-load as a
  known caveat ("the raw fp8 codes are cast to model_dtype unscaled and the model
  loads with wrong weights"). That is the bug this branch fixes, so the caveat is
  obsolete rather than merged.
- test_load_default_fp8.py: additive on both sides, both kept.

Semantic follow-up, not a textual conflict: invoke-ai#9538 introduced
`_model_declared_skip_patterns`, which unions `_skip_layerwise_casting_patterns`
with `_keep_in_fp32_modules`. The five loaders in this branch still read the
first attribute directly, so the checkpoint load path would have honoured a
narrower skip list than the layerwise cast does. All five now call the shared
helper.

test_z_image_fp8_wiring.py builds its loader with `object.__new__` and stubs only
`_ram_cache`; the scaled-fp8 path also reads the safetensors header and asks the
device about the fp8 matmul, so the stub gained `_logger` and `_torch_device`.
…olds

Round-4 review follow-ups for invoke-ai#9478.

R1. `make_room` runs before `split_fp8_scaled_layers`, but the prediction applied
    only `can_stay_quantized` while the split applies `is_matmul_usable_scale` on
    top. A block-wise-scaled 2-D Linear weight therefore predicted at 1 byte per
    element and arrived at 2 — half the reservation, and on a checkpoint using
    that layout the shortfall is the whole quantized-Linear set. Both now go
    through one predicate, `survives_split_and_cast`, and
    `predict_cast_state_dict_size` takes the `scaled_layers` mapping the caller is
    about to hand the split. All five call sites pass it; the docstrings and the
    per-call-site comment that claimed the prediction was split-independent are
    corrected.

    Also found while verifying: a 1-D weight_scale whose length matches neither
    layout reaches the fold and dies in torch's broadcast, naming neither the
    layer nor the file. `expand_weight_scale` now reports it as the malformed
    quantization metadata it is.

r1. The `expand_weight_scale` call sites in the Mistral encoder and the Z-Image
    Qwen3 encoder had no test, so reverting either to its local multiply passed
    CI — the exact gap this fix exists to close, since a wrong scale axis is
    silent on a square weight. Both are pinned now. The Z-Image fold was a loop
    inside `_load_from_singlefile`, unreachable without a real checkpoint, so it
    is extracted as `_fold_comfy_scaled_weights`.

r2. The Qwen3-VL split fix is pinned at helper level, no transformers needed:
    a scaled 1-D norm is folded with its scale applied, a block-wise Linear drops
    out of the returned mapping, and an e5m2 weight keeps its scale on the way
    down — the three things the old per-key loop got wrong.

r3/r4. `_load_text_encoder` resolved `should_keep_fp8_weights` twice, which can
    disagree now that an inconclusive probe is deliberately not cached; it is
    resolved once. And `keep_fp8` now covers the storage path as well
    (`keep_matmul_fp8 or use_fp8_storage`), so the fp8_compute-off path no longer
    casts raw fp8 Linears to bf16 only for `_apply_fp8_to_nn_module` to
    re-quantize them — roughly 4.4 -> 8.9 GiB of reservation on the 4B encoder for
    a value-exact round trip.
Round-5 review follow-up for invoke-ai#9478.

Blocker. Extracting `_fold_comfy_scaled_weights` in 9cd24e0 inserted it
between `@ModelLoaderRegistry.register(...)` and the class it decorates, so the
registry bound the helper. `register` does no type check and returns its argument
unchanged, so nothing complained: the helper kept working at its call sites, the
module imported, ruff was clean and the suite passed. The only symptom was that
`ModelLoadService.load_model` instantiates the registered implementation, so
every Z-Image Qwen3 single-file encoder load died with
`TypeError: _fold_comfy_scaled_weights() got an unexpected keyword argument
'app_config'`. The helper now sits above the decorator.

Pinned by a registry-wide guard rather than a case for this one loader: every
entry must be a class and a `ModelLoaderBase` subclass. That covers the whole
failure class, including the loaders with no end-to-end test.

n1. `_apply_fp8_to_nn_module` skipped a module and left whatever dtype it arrived
with. That is fine for a full-precision weight and silently fatal for one a
loader kept in fp8: no cast back, no upcast pre-hook, and the forward runs on raw
fp8 codes. Until now it depended on the loader's own skip list and
`_FP8_DEFAULT_SKIP_PATTERNS` never naming the same 2-D Linear — true for Qwen3-VL
by coincidence, not by construction. A pattern skip now restores the compute
dtype.

The `skip=` callback deliberately keeps the old behaviour: its one caller excludes
scaled-fp8 layers, which are meant to stay quantized and carry their weight_scale
into `_scaled_mm`, so upcasting them would drop the scale.
…d raw checkpoints

Ports invoke-ai#9478 ahead of its upstream merge. Its three stacked
prerequisites (invoke-ai#9414, invoke-ai#9415, invoke-ai#9416) are already on main, and the fp8 files were
byte-identical to the PR's base, so only two conflicts arose: krea2's import
block (both sides kept) and the regenerated openapi.json.
The generator emits raw json.dumps output; both openapi-checks and the frontend
prettier check compare against a prettier-formatted file.
@Pfannkuchensack
Pfannkuchensack merged commit e5cb1bb into main Sep 9, 2026
24 of 25 checks passed
@Pfannkuchensack
Pfannkuchensack deleted the feat/fp8-compute branch September 9, 2026 01:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant