feat(quantization): share the int8_convrot scheme and read it for Krea-2 and Z-Image - #194
Merged
Conversation
… read it `int8_tensorwise` is a ComfyUI-wide scheme, not one architecture's format, but the implementation lived in `backend/minimax_h3/`. Krea-2's loaders could not reach it, so `*_int8_convrot.safetensors` loaded as confident nonsense: `_dequantize_scaled_fp8` keys off `.weight_scale` alone, scaled the int8 weights and never un-rotated them. No error, no NaN -- correlation against a correct decode is 0.06. Moves `int8_convrot.py` to `backend/quantization/` beside the other schemes (gguf, sdnq, bnb) and wires both Krea-2 loaders to it. The weights stay int8-resident: each quantized Linear becomes an `Int8ConvrotLinear` that dequantizes and derotates per forward, as MiniMax H3 already does. That is 12.3 GB instead of 24 GB, on every platform and without the fp8-storage opt-in -- which is off by default and unavailable outside CUDA/XPU. Ordering is load-bearing three times over, and none of it fails loudly: - before the fp8 fold, which would scale an int8 weight without un-rotating it; - before the native->diffusers key conversion, which renames `.attn.wq.weight` by substring, carrying `.weight_scale` along but orphaning `.comfy_quant`; - before the encoder's fp8 detection, which answers yes to any `.weight_scale` and would keep an int8 encoder "fp8-resident" over weights that were never fp8. Each is pinned by a test that shows the damage rather than asserting the order. Three things the real checkpoints forced. `last.linear` was renamed by exact match per suffix while everything else uses prefix slicing, so `last.linear.weight_scale` kept its old name -- invisible until now, because both the fp8 path and a dense int8 decode consume the scales before the rename. One Qwen3-VL repack ships 337 `input_scale` activation scales, under a spelling the existing filter did not match. And `model_is_quantized` checked only the config format, so LoRA would have been written directly into int8 buffers; it is now `requires_sidecar_patching()`, which consults the module tree and can be tested on its own. Verified against four real checkpoints: 430/430 key coverage for the int8 and fp8 Krea-2 builds and 357/337 swapped layers for the two Qwen3-VL encoders, all with no missing, extra or orphaned tensors; weights at corr 0.997-0.9999 against an independent quantization of the same model (0.06-0.10 without the un-rotation); and a generation that produces the same photograph as the fp8 build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Z-Image is the model this format is actually useful for. Krea-2 is 12.6 GiB as int8 -- too big for the 12 GB cards that fall back to GGUF today -- while Z-Image is 5.8 GiB, and measured here it drops a 1024px generation from 11.74 GiB resident to 6.04 GiB at 6 % more time. Moves the architecture-agnostic half of the Krea-2 work into `backend/quantization/int8_convrot.py` (resolve_quantized_module_paths, swap_in_int8_linears, cast_unquantized, drop_unconsumed_quantization_sidecars) so Z-Image imports it rather than copying it. No behaviour change. Z-Image's own hazard is the fused QKV: `attention.qkv.weight` is split into to_q/to_k/to_v, and the split already handled the weight but dropped the scale and the marker under the fused name -- 408 keys that then reached a strict `load_state_dict`. The per-output-channel scale now splits with its weight and the marker is copied to all three. That decision is made from the key suffix, not the tensor shape: a 72-byte JSON marker is also divisible by three, and cutting it into thirds yields three fragments of broken JSON. Because this converter carries `.comfy_quant` onto the final module names, the markers are read after the conversion and need no re-keying -- the opposite of Krea-2, whose converter orphans them. Adds a guard for an int8 weight with no marker: it would be handed to a float Linear and fail only at forward time, if at all. Verified against the real 5.75 GiB checkpoint and the bf16 diffusers release of the same model: 272 quantized modules with no orphans, no missing and no extra keys; weights at corr 0.99978-1.00000 against unquantized ground truth (0.054-0.065 without the un-rotation) at 0.85-1 % relative error; and a generation that produces the same photograph. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eat/int8-convrot-shared
Upstream added a Qwen3-VL vision device patch import next to the minimax_h3.int8_convrot import this branch moves to backend.quantization. Kept the new import, dropped the moved-away one; no reference to the old path survives anywhere in the tree.
Rebinding `sd` to the filtered copy left the checkpoint's own dict holding every param through `load_state_dict(assign=True)`, so the later `sd.clear()` freed nothing and peak RAM overshot the `make_room()` reservation. Restores the invariant `test_state_dict_is_released_before_the_fp8_cast` guards.
Upstream rewrote the same load path in z_image.py and krea2.py for scaled fp8 that this branch rebuilt for int8_convrot. The two formats are mutually exclusive, so the loaders now decide once on the `comfy_quant` marker -- before the key conversion, which carries `.weight_scale` along but not `.comfy_quant` and would otherwise separate an int8 weight from the marker saying it is rotated. The orphan check stays outside that branch: an int8 weight with no marker leaves the marker set empty, which is the case it exists for. Drops three helpers upstream now covers: _split_qkv_sidechannel (its QKV_SPLIT_SIDECHANNEL_SUFFIXES lists comfy_quant), _dequantize_scaled_fp8, and the inline Qwen3-VL key mapping -- the last keeping this branch's key_map recording, which resolve_quantized_module_paths needs.
- `check_int8_scale_layout` accepts a `[out]` scale and the docstring promises it, but both multiply sites left it to broadcasting, which aligns trailing dimensions and so scales along the *input* axis. Square weights decoded silently wrong — 0.82 correlation against the intended result, a model that loads and generates — and every other shape raised a bare size mismatch. Normalised at one helper both sites now use. - `parse_comfy_quant_marker` used a strict `json.loads` and now runs over every marker in the file, before the loader knows which format it is reading. A NUL-padded blob — the padding the fp8 reader documents and strips — turned into `JSONDecodeError: Extra data` out of the middle of a load, naming neither the file nor the key, so fp8 checkpoints that load today would stop. A malformed marker is a lost hint again; an int8 weight whose marker did not parse is still caught by the loaders' orphan check, which says what is wrong. - Z-Image decided sidecar-vs-direct LoRA patching from the config format alone, so a `checkpoint` that this PR now loads as `Int8ConvrotLinear` took the direct path. Those modules hold their weights as buffers, so the patcher's own fallbacks — which iterate `module.parameters()` — find none and agree. It now asks the loaded module tree, as Krea-2 already did; the helper moved into the shared module, since every architecture this scheme reaches needs the same answer, and gained SDNQ, which Z-Image treats as quantized and Krea-2's copy did not list. Each fix is mutation-tested: reverting one fails 4, 5 and 1 of the new tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…aths The int8 reservation charged `max(element_size, model_dtype.itemsize)`, which for an int8 tensor and a bf16 compute dtype is 2 — the exact doubling the comment directly above it says it avoids, since `max(1, 2)` is 2. Measured on a realistic int8_convrot state dict: 5.50 GiB reserved against 2.75 GiB actually occupied, a ratio of 2.000. `_make_room_internal` evicts until the ask is met, so a 12 GiB Krea-2 asked for ~24 GiB and typically flushed the whole cache on every int8 load. All three sites now use `predict_cast_state_dict_size`, which was already imported in both files and returns exactly the 2.75 GiB. Krea-2's two int8 paths had no orphan guard, so an int8 weight that no marker claims was cast to the compute dtype as raw codes — unscaled, un-derotated — into a model that loads clean and generates noise. Z-Image has always refused this. Making `parse_comfy_quant_marker` tolerant in the previous commit widened the case: its docstring promises that an unparseable marker is "caught by the orphan check in the loaders", which only Z-Image kept. The scan is now a shared helper both loaders call, so the promise holds. The `Krea2CheckpointModel` docstring claimed the int8 build "is decoded to dense weights rather than kept int8-resident", while `swap_in_int8_linears` keeps it int8-resident — as `int8_convrot.py` says two files away. Mutation-tested: restoring either the old reservation or the missing guard fails the new assertions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pfannkuchensack
marked this pull request as ready for review
September 10, 2026 20:52
Pfannkuchensack
requested review from
JPPhoto,
blessedcoolant and
lstein
as code owners
September 10, 2026 20:52
Pfannkuchensack
enabled auto-merge
September 10, 2026 20:53
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Bug fix, plus a memory saving.
int8_tensorwiseis a ComfyUI-wide quantization scheme, not one architecture's format — but the implementation lived inbackend/minimax_h3/, where no other loader could reach it. This moves it tobackend/quantization/besidegguf,sdnqandbnb, and teaches Krea-2 and Z-Image to read it. The weights stay int8-resident: each quantizednn.Linearbecomes anInt8ConvrotLinearthat dequantizes and derotates per forward, as MiniMax H3 already does.Neither architecture could load such a checkpoint, and they failed differently:
_dequantize_scaled_fp8keys off.weight_scalealone, so it scaled the int8 weights and never un-rotated them. No error, no NaN, no warning — correlation against a correct decode is 0.06. It loads, and it generates noise.load_state_dict.Z-Image is the model this format is useful for. Krea-2 is 12.6 GiB as int8 — still too big for the 12 GB cards that fall back to GGUF today. Z-Image is 5.8 GiB. Measured at 1024px: 11.74 GiB resident as bf16, 6.04 GiB as int8, at 6 % more time, with the same image.
What it is worth
The load time is not a trick: safetensors maps lazily, and with no decode at load there is nothing to read. The 13 % on the denoise is the per-forward dequantize and derotation — and it does not grow with resolution, because the derotation is a fixed
[out, in/256, 256] @ [256, 256]matmul while the layer's own matmul scales with sequence length. At 512px it is ~12 % of the layer's matmul; at 1024px, ~3 %.Why resident rather than a dense decode. Measured against enabled fp8 storage the two are equal, both at 12.0 GiB — but
_should_use_fp8requiresdefault_settings.fp8_storage is True, which is off by default and unavailable outside CUDA/XPU. Against the state a user actually gets, it is 12.3 GB versus 24 GB — and on Apple Silicon there is no fp8 alternative at all.Ordering is load-bearing three times over, and none of it fails loudly
.attn.wq.weightby substring — carrying.weight_scalealong but orphaning.comfy_quant. Module paths are therefore resolved by following the weight's own rename, not the marker's key..weight_scaleand would otherwise keep an int8 encoder "fp8-resident" over weights that were never fp8.Each is pinned by a test that shows the damage rather than asserting the order.
Three things the real checkpoints forced
A bug in the existing key converter.
last.linearwas renamed by exact match per suffix (k == "last.linear.weight") while everything else uses prefix slicing.last.linear.weight_scalefell through every rule and kept its old name. This was invisible until now because both the fp8 path and a dense int8 decode consume the scales before the rename. Now a prefix rule, with a test over all ten rename rules.input_scale. One Qwen3-VL repack ships 337 activation scales for W8A8 inference. This code dequantizes the weight and computes in bf16, so there is nothing to apply them to — and the existing filter spelled itscale_input, which never matched. Both spellings are now dropped, in both load paths.LoRA.
model_is_quantizedchecked onlyGGUFQuantized. An int8 Krea-2 is a plaincheckpointas far as the config knows, soLayerPatcherwould have written directly into int8 buffers. Extracted asrequires_sidecar_patching()and tested on its own — it was previously inline in a 600-line method and unreachable by any test.Z-Image: the fused QKV
attention.qkv.weightis split intoto_q/to_k/to_v. The split already handled the weight; the scale and the marker were left behind under a module name the model does not have. The per-output-channel scale now splits with its weight and the marker is copied to all three.That decision is made from the key suffix, not the tensor shape — a 72-byte JSON marker is also divisible by three, and a shape-based rule cuts it into three fragments of broken JSON. A test asserts the fixture reproduces that trap.
Z-Image's converter carries
.comfy_quantonto the final module names, so its markers are read after the conversion and need no re-keying — the opposite of Krea-2, whose converter orphans them. Both arrangements are pinned by tests.Also added: an int8 weight with no marker is refused at load. It would otherwise be handed to a float
Linearand fail only at forward time, if at all.Related Issues / Discussions
This is the storage side of int8: the checkpoint loads correctly and the weights stay int8-resident, with the math in bf16. Running the int8 tensor cores is a separate matter and is not part of this PR.
Note for whoever merges v6 into v7: invoke-ai#9478 touches
model_loaders/krea2.py(+261/−69) andtest_krea2_state_dict_utils.py(+87/−43) — the same two files this PR touches.QA Instructions
Verified against four real checkpoints on RTX 4090, torch 2.7.1+cu128, Windows.
Key coverage, driven through the real loader pipeline on meta tensors (no weight data allocated):
Krea2_Turbo_convrot_int8mixed.safetensors(12.0 GiB, 264 quantized layers)Krea2_Turbo_fp8mixed.safetensors(12.0 GiB)qwen3vl_4b_int8.safetensors(no convrot, 199 unquantized bf16 weights)qwen3vl_4b_uncensored_int8_convrot.safetensors(convrot, 219 unquantized)z_image_turbo_int8_convrot_bf16emixed.safetensors(5.75 GiB, 209 unquantized bf16)Numerics. Z-Image ships an unquantized bf16 release, so its decode is checked against ground truth rather than a second quantization. Over 12 layers spanning depth and both refiner stacks:
to_q,to_kandto_vall land at 0.9998+, which tests the fused split, the scale split and the rotation together.For Krea-2 no unquantized release was to hand, so its decode was checked against the fp8 build of the same repack (confirmed first to be the same weights: 159 of 166 unquantized tensors bit-identical, the 7 that differ all biases of quantized layers). Over 14 layers: decoded corr 0.997–0.9999, negative control 0.06–0.10, relative error a uniform 2.8 % with a structureless residual (corr to the reference ±0.009). That 2.8 % is mostly the reference's own error — the fp8 build carries one scale for the whole matrix where int8 carries one per output channel, and against true bf16 ground truth the same decode measures 0.9 %.
Generation. Same prompt, same seed, 8 steps. Z-Image int8 against the bf16 release at 1024px: the same photograph, mean absolute difference 8.61/255, correlation 0.950 — against 81/255 and −0.0004 for random noise. Krea-2 int8 against fp8 at 512px: likewise the same photograph, 12.25/255, correlation 0.948. A wrong rotation does not fail, it produces noise, so this is the end-to-end form of the negative control.
The resident path agrees with a dense decode at corr ≥ 0.999883, the difference being bf16 rather than fp32 arithmetic.
To verify by hand: load a
*_int8_convrot.safetensorsKrea-2 and generate. Then load the fp8 build of the same model at the same seed — the images should be the same picture. Before this PR the int8 file produced noise.Tests:
ruff check/format --checkclean. Noopenapi.jsonchange. Eleven mutations were verified as caught — seven on the Krea-2 side (removing either swap, letting the fp8 fold eat int8 scales, restoring thelast.linearexact match, neutralising the scale-layout guard, neutralising the sidecar detection, no longer dropping activation scales) and four on Z-Image (removing the swap, not carrying the marker through the qkv split, deciding the split by shape again, removing the orphan guard).Not verified: a mixed-precision Krea-2 transformer specifically. The repack tested here quantizes all 266 weights despite "mixed" in its name. The Z-Image checkpoint does exercise that path on a real transformer (209 unmarked bf16 weights beside 204 quantized), as do both Qwen3-VL encoders — the decision is made per tensor rather than per file — but no Krea-2 file to hand covers it.
Merge Plan
No DB schema, no redux slice, no API change, no dependency change. Expect a conflict in
krea2.pyagainst the v6→v7 merge that brings invoke-ai#9478.Checklist
What's Newcopy (if doing a release after this PR)🤖 Generated with Claude Code