Skip to content

Feat: fp8 compute raw - #9478

Open
Pfannkuchensack wants to merge 14 commits into
invoke-ai:mainfrom
Pfannkuchensack:feat/fp8_compute_raw
Open

Feat: fp8 compute raw#9478
Pfannkuchensack wants to merge 14 commits into
invoke-ai:mainfrom
Pfannkuchensack:feat/fp8_compute_raw

Conversation

@Pfannkuchensack

Copy link
Copy Markdown
Member

Summary

A checkpoint can ship fp8 weights with no weight_scale — plain float8_e4m3fn tensors. The runtime already handles those: scaled_mm_linear treats weight_scale as optional, and CustomLinear._can_use_fp8_matmul only requires the fp8 dtype. The loaders never let them through, though — FLUX, FLUX.2 and Z-Image cast the whole state dict to bf16 unconditionally, so both the VRAM saving and the tensor cores are discarded 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.

This adds cast_state_dict(), which casts everything except fp8 weights when the matmul is actually available, and wires it into all four loaders with a log line so the tensor-core path is visible.

Only nn.Linear.weight is 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'. A model's own _skip_layerwise_casting_patterns is honored on top, for Linears whose forward casts activations to their weight's dtype (Z-Image's TimestepEmbedder does exactly that).

float8_e5m2 is deliberately not preserved: _scaled_mm cannot take it as the weight operand on Ada, so keeping it quantized would buy VRAM at the cost of a per-forward dequantize.

Second change: 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.

Related Issues / Discussions

Stacked on feat/fp8_scaled_compute (shared fp8_scaled.py module + Krea-2 scaled support). Do not merge into main before that branch.

Overlaps with #9416 in _should_use_fp8 / _apply_fp8_layerwise_casting — the two guards are complementary but will conflict textually. #9416 catches GGUF/bnb payloads via not is_floating_point() or type(...) is not torch.Tensor; an fp8 weight is floating point and a plain tensor, so it falls through that check. Whichever merges second needs a trivial rebase.

QA Instructions

Requires an Ada (SM 8.9+) GPU and fp8_compute: true in invokeai.yaml.

  1. Load a checkpoint with raw fp8 weights — e.g. a Z-Image or FLUX.1 model whose file contains float8_e4m3fn tensors but no .weight_scale keys.
  2. Confirm the log shows Z-Image: kept N raw fp8 weight(s) quantized for the fp8 tensor cores.
  3. Confirm the transformer's Total model size in the [MODEL CACHE] Loaded model line is roughly half what it was before.
  4. Generate several images — one is not enough, the first run mixes in cold-load time — and compare s/it.
  5. Regression check: a scaled-fp8 checkpoint (one with weight_scale) and a GGUF checkpoint must behave exactly as before.

Measured on an RTX 4090, Z-Image unstableRevolution_V2Fp8, 1024×1024, 30 steps, 1 warm-up + 3 measured runs:

before after
transformer 11,740 MB 5,881 MB
residency 95.5 – 100 % 100 %
s/it (warm) 1.19 / 1.34 / 1.36 → 1.297 1.01 / 1.06 / 1.00 → 1.023
graph total (warm) 40.0 s 30.9 s

Note the drift on the before side: 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.

Merge Plan

Merge feat/fp8_scaled_compute first — this branch is stacked on it and its diff is meaningless standalone. Coordinate with #9416 (see above).

Checklist

  • The PR has a short but descriptive title, suitable for a changelog
  • Tests added / updated (if applicable)
  • ❗Changes to a redux slice have a corresponding migration
  • Documentation added / updated (if applicable)
  • Updated What's New copy (if doing a release after this PR)

…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%.
@github-actions github-actions Bot added python PRs that change python files invocations PRs that change invocations backend PRs that change backend files services PRs that change app services frontend PRs that change frontend files python-tests PRs that change python tests docs PRs that change docs labels Aug 7, 2026
@lstein lstein self-assigned this Aug 8, 2026
@lstein lstein added the 6.14.1 label Aug 8, 2026
@lstein lstein moved this to 6.14.1: Bug fixes to 6.14.0 in Invoke - Community Roadmap Aug 8, 2026

@lstein lstein left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review of the delta commit e93854e6ae against its stack base 49990d643e. Three blockers, one design gap, one accounting nit. The first three are reproduced below.


🔴 1. Krea-2 scaled-fp8 regression: skip_patterns silently discards weight_scale

krea2.py:432-439 routes the scaled path through the same cast_state_dict call as the raw path (keep_fp8=bool(fp8_layers) or should_keep_fp8_weights(...)) — and now hands it skip_patterns=Krea2Transformer2DModel._skip_layerwise_casting_patterns, which is ['time_embed', 'norm'].

For any layer in fp8_layers matching a skip pattern, cast_state_dict does a plain tensor.to(model_dtype) — the raw fp8 codes, scale not applied. attach_fp8_scales then skips that module because weight.dtype != FP8_DTYPE (fp8_scaled.py:259), so the scale is dropped for good. The weight ends up off by 1/weight_scale.

Repro, using the real Krea2Transformer2DModel._skip_layerwise_casting_patterns with the real extract_fp8_scaled_layers / cast_state_dict / attach_fp8_scales:

fp8_layers extracted: ['time_embed.linear_2', 'transformer_blocks.attn']
cast_state_dict kept: 1 of 2 scaled layers
attach_fp8_scales attached: 1
  time_embed.linear_2      dtype=torch.bfloat16      scale=NONE  rel-err vs true weight = 12405.8379
  transformer_blocks.attn  dtype=torch.float8_e4m3fn scale=yes   rel-err vs true weight = 0.0261

This is live on real checkpoints, not a synthetic shape. Krea2Transformer2DModel has exactly two Linears matching those patterns — time_embed.linear_1 (6144×256) and time_embed.linear_2 (6144×6144, 37.7M params) — and _convert_krea2_native_to_diffusers maps tmlp.0 / tmlp.2 straight onto them (krea2.py:217-219), so they are ordinary quantized tensors in a ComfyUI export. This very likely fires on krea2TurboOfficialComfy_krea2TurboFp8, the checkpoint the base branch was benchmarked with.

This is a regression, not a pre-existing gap. The pre-PR line was if sd[k].dtype is not FP8_DTYPE: sd[k] = sd[k].to(model_dtype) — every fp8 tensor survived, so every scale got attached.

The same class of failure applies beyond skip patterns: any scaled layer that fails _is_fp8_matmul_weight (not resolvable via get_submodule, not an nn.Linear) is now dequantized without its scale.

Suggested fix: apply the skip_patterns / _is_fp8_matmul_weight filters to raw fp8 only. For scaled layers, either keep them fp8 unconditionally or fold them through dequantize_fp8_scaled first and drop them from fp8_layers. Independently, attached != len(fp8_layers) should warn rather than pass silently — that mismatch is the tell for exactly this bug, and today the log prints attached as if it were the whole story.


🔴 2. ROCm RDNA3: should_keep_fp8_weights returns True, then every forward raises

device_supports_fp8_matmul (base branch, fp8_scaled.py:341) tests torch.cuda.get_device_capability(index) >= (8, 9). On ROCm that returns the gfx arch, not an SM version. On a W7900 (gfx1100) it is (11, 0) → passes.

Verified on the card:

device_supports_fp8_matmul(W7900) -> True
should_keep_fp8_weights(W7900)    -> True
_can_use_fp8_matmul               -> True
FORWARD RAISED: RuntimeError torch._scaled_mm is only supported on CUDA devices
                with compute capability >= 9.0 or 8.9, or ROCm MI300+

The predicate is the base branch's, but this PR is what makes it reachable for FLUX / Z-Image / raw Krea-2: previously those loaders cast the whole state dict to bf16, so _can_use_fp8_matmul short-circuited on weight.dtype != FP8_DTYPE. Now the weights stay fp8 and every Linear forward throws. _can_use_fp8_matmul's stated contract — "falling back … rather than raising, because a mid-generation failure is far worse than losing the speedup" — is defeated, because the fallback gates on the same broken predicate.

Corroborating signal: 12 of the 49 tests in tests/backend/quantization/test_fp8_scaled.py fail (not skip) on such a box, because the cuda_fp8 skipif marker uses the same predicate:

12 failed, 37 passed, 17 warnings

Fix: exclude ROCm unless MI300+, e.g. if torch.version.hip is not None: return get_device_properties(i).gcnArchName.split(':')[0] in {"gfx942", "gfx950"} — or probe once with a 16×16 _scaled_mm in a try/except and cache that result.


🔴 3. The FLUX.2 half of the PR is dead code

Flux2CheckpointModel._load_from_singlefile calls self._dequantize_fp8_weights(sd) (flux.py:919) before cast_state_dict. That method's final loop (flux.py:1094-1101) converts every tensor whose dtype contains "float8" to bf16, unconditionally — it is not gated on should_keep_fp8_weights. Nothing fp8 survives to reach cast_state_dict:

input dtypes : {'...qkv.weight': 'torch.float8_e4m3fn', '...qkv.bias': 'torch.float32'}
after _dequantize_fp8_weights: {'...qkv.weight': 'torch.bfloat16', '...qkv.bias': 'torch.float32'}
any fp8 left for cast_state_dict to keep? -> False

kept is therefore always 0, and the "FLUX.2: kept N raw fp8 weight(s)" log can never fire. The PR body lists FLUX.2 among the loaders fixed; it isn't. The keys_to_convert loop needs the same keep_fp8 gate.


🟡 4. Z-Image cannot tell raw fp8 from scaled fp8

z_image.py:275-281 deletes .scale_weight keys and never applies them, before cast_state_dict runs. So on a scaled-fp8 Z-Image checkpoint the weights are now kept quantized and multiplied with weight_scale=None, while the new log announces "Z-Image: kept N raw fp8 weight(s) quantized for the fp8 tensor cores."

Not a regression — the scale was discarded pre-PR too, and weight.to(bf16) was equally wrong — but the PR's entire premise ("raw fp8 = fp8 tensors with no weight_scale") is never actually checked here, and the new log asserts something the loader has no evidence for. Separately, the other spelling .weight_scale is not in keys_to_remove at all, so such a checkpoint dies in load_state_dict(..., strict=True) on unexpected keys.

Z-Image should go through extract_fp8_scaled_layers + attach_fp8_scales the way Krea-2 does.


🟡 5. make_room under-reserves

flux.py:657-660 and z_image.py:288-292 charge 1 byte/element for every fp8 tensor when keep_fp8, but cast_state_dict only preserves 2-D nn.Linear weights that don't match a skip pattern. Everything else lands at 2 bytes. On the Z-Image checkpoint from the PR description that's the 243 one-dimensional tensors, x_pad_token / cap_pad_token, and the t_embedder / cap_embedder Linears the loader deliberately dequantizes (cap_embedder alone is 2560×3840 ≈ 9.8M params).

The reservation is short by that amount — small relative to 6 GB, but it is a silent under-count in the one call whose job is to avoid RAM pressure. Cheapest fix is to have cast_state_dict predict/return the byte total using the same predicate.


Attacks that came back clean

Recording these so the surface that was checked is explicit:

  • for key in sd: with in-place sd[key] = ... — reassignment only, no resize, no RuntimeError.
  • float8_e5m2 exclusion is correct and consistent: cast_state_dict never keeps it, and _can_use_fp8_matmul rejects it via != FP8_DTYPE.
  • .weight_scale / .scale_weight keys are not mistaken for .weight by key.endswith(".weight").
  • Linears whose in_features / out_features are not divisible by 16 fall back correctly via _can_use_fp8_matmul's alignment check, so keeping all Linears fp8 does not expose a _scaled_mm shape constraint.
  • The new load_default.py early return does not regress a3784eb — the Qwen3-VL encoder calls _apply_fp8_to_nn_module directly (krea2.py:760, 783), bypassing _apply_fp8_layerwise_casting.
  • Nor does it double-fire on the Krea-2 scaled path, which already returns at krea2.py:484 before reaching it.
  • get_submodule on a model built under init_empty_weights() resolves fine (meta params); AttributeError is the only exception it raises for a bad path.
  • FLUX's own modules do not cast activations to a weight's dtype (timestep_embedding keys off t, not weight), so omitting skip_patterns in the FLUX loader is not the Z-Image TimestepEmbedder hazard — a minor inconsistency only.

Verdict

Requesting changes. #1 is silent weight corruption on a path the base branch already shipped working; #2 turns fp8_compute into a hard mid-generation crash on RDNA3 and disables the very fallback meant to prevent that; #3 means roughly a third of the advertised scope never executes.

The direction is right and the measurements are convincing — this is about the filters being applied to the scaled path, and the device predicate being too permissive.

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.
@Pfannkuchensack
Pfannkuchensack requested a review from lstein August 8, 2026 17:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

6.14.1 backend PRs that change backend files docs PRs that change docs frontend PRs that change frontend files invocations PRs that change invocations python PRs that change python files python-tests PRs that change python tests services PRs that change app services

Projects

Status: 6.14.1: Bug fixes to 6.14.0

Development

Successfully merging this pull request may close these issues.

2 participants