AlperKTS commited on
Commit
06a0bf4
·
verified ·
1 Parent(s): 5387b9d

Sync the bundled nodes, workflows and docs with GitHub master (099a051)

Browse files

The mirrored copy had drifted to roughly the 5f40849 era, before the all-in-one work existed. That left the all-in-one checkpoint in this repo unloadable by the code shipped next to it -- Krea2SVDQuantCheckpointLoader was not in it -- and kept the dynamic-VRAM pin that made iteration time jump to 30-100 s past ~12 GB.

Updates 12 files and adds 15, including build_all_in_one.py and the 12 workflow JSONs that were missing.

BENCHMARKS.md CHANGED
@@ -4,6 +4,15 @@ Two independent tests on **Krea 2 Turbo**, all checkpoints produced by `quantize
4
  in this repo. Same seed across every checkpoint in a given test so images are directly
5
  comparable. RTX 3090, cu130 torch build.
6
 
 
 
 
 
 
 
 
 
 
7
  > **The quality scores in Tests 1 and 2 have been withdrawn (2026-07-27).** They came from
8
  > an LLM judge (Gemini 3.5 Flash Lite) that turned out to be saturated: 9 of the 12 rows in
9
  > the LoRA table scored a flat 10.00/10, and the whole t2i table spanned 0.46 points. An
@@ -267,7 +276,7 @@ configuration in which the plain objective is the better choice.
267
 
268
  ```bash
269
  python quantize_krea2.py turbo.safetensors --format svdq --rank 256 \
270
- --act-stats krea2_act_stats.safetensors
271
  ```
272
 
273
  Ruled out as confounds: `r256` and `r256-actaware` share source, rank, `refine_iters=100`,
 
4
  in this repo. Same seed across every checkpoint in a given test so images are directly
5
  comparable. RTX 3090, cu130 torch build.
6
 
7
+ > **Which patcher these were measured on.** Every speed number here was taken while the
8
+ > svdq loader still pinned the model to the classic `ModelPatcher` (`disable_dynamic=True`).
9
+ > That pin is gone; the default is now ComfyUI's dynamic-VRAM patcher. On a card where the
10
+ > model fits, the patcher is not on the hot path and the numbers should be unaffected — a
11
+ > spot check at rank 256, 1024², under the dynamic patcher measured 0.794 s/step, which sits
12
+ > in the same range as the table below. But that is one point, not a re-run: **these tables
13
+ > have not been regenerated since the change.** `tools/speed_bench.py` produces them if you
14
+ > want them re-measured.
15
+
16
  > **The quality scores in Tests 1 and 2 have been withdrawn (2026-07-27).** They came from
17
  > an LLM judge (Gemini 3.5 Flash Lite) that turned out to be saturated: 9 of the 12 rows in
18
  > the LoRA table scored a flat 10.00/10, and the whole t2i table spanned 0.46 points. An
 
276
 
277
  ```bash
278
  python quantize_krea2.py turbo.safetensors --format svdq --rank 256 \
279
+ --act-stats krea2_act_stats_turbo.safetensors
280
  ```
281
 
282
  Ruled out as confounds: `r256` and `r256-actaware` share source, rank, `refine_iters=100`,
README.md CHANGED
@@ -1,122 +1,89 @@
1
- ---
2
- license: other
3
- license_name: krea-2-community-license
4
- license_link: https://www.krea.ai/krea-2-licensing
5
- library_name: diffusers
6
- tags:
7
- - image-generation
8
- - comfyui
9
- - quantization
10
- - int8
11
- - int4
12
- - svdquant
13
- - krea2
14
- - krea
15
- - diffusion
16
- - transformer
17
- - lowvram
18
- base_model: krea/krea-2
19
- pipeline_tag: text-to-image
20
- ---
21
 
22
- # Krea 2 SVDQuant W4A4 for ComfyUI
 
23
 
24
  Quantized **Krea 2** checkpoints for ComfyUI — about **2x faster** and **a third the size**
25
  of the usual FP8 version, with no calibration dataset needed, on both **Krea 2 Turbo**
26
- (distilled, 8 steps) and the **base** release. This repo holds the custom nodes and the
27
- `quantize_krea2.py` conversion script; the `.safetensors` files (7.5-9.1 GB each) live on
28
  Hugging Face.
29
 
30
  Works on **any modern NVIDIA GPU** — INT8/W4A4 tensor cores go back to Turing (RTX 20-series).
31
- Benchmarked on an RTX 3090 (Ampere), the case most Krea 2 quantization writeups skip, since
32
- that generation has no FP8 or NVFP4 tensor cores at all. Everything here was built from
33
- scratch against ComfyUI's own quantization backend and is reproducible: `quantize_krea2.py`
34
- regenerates any checkpoint from a BF16 source in 40-100 seconds, or ~6 minutes with the
35
- default low-rank refinement.
36
 
37
  > **Requires a cu130 (CUDA 13) or newer PyTorch build.** ComfyUI disables `comfy_kitchen`'s
38
  > CUDA backend on older torch builds, which silently drops every quantized checkpoint onto a
39
  > pure-Python fallback that is *slower than bf16*. If these checkpoints are slower than FP8
40
- > for you, this is almost certainly why — see
41
- > [TROUBLESHOOTING.md](TROUBLESHOOTING.md#its-slower-than-fp8--slower-than-bf16).
42
-
43
- This is a community-produced modification of Krea 2, not an official Krea product — license
44
- and attribution are at the [bottom of this page](#attribution). Read them before using these
45
- weights, in particular the revenue threshold on commercial use.
46
 
47
- > **Moved 2026-07-27.** The checkpoints now live under `checkpoints/`, with superseded
48
- > builds in `checkpoints/legacy/`. Direct links to the old repository-root paths will 404 --
49
- > the root had ten loose 8 GB files with no indication which one to take.
50
 
51
- ## Which checkpoint to download
52
 
53
- Everything lives under `checkpoints/` on Hugging Face.
 
54
 
55
- | file | rank | size | when |
56
- |---|---|---|---|
57
- | **`Krea2-Turbo-SVDQuant-W4A4-rank256-actaware.safetensors`** | 256 | 9.10 GB | **Start here.** Closest to BF16 of anything measured, same speed as any other rank-256 build |
58
- | `Krea2-Turbo-SVDQuant-W4A4-rank256.safetensors` | 256 | 9.10 GB | The same without the activation weighting |
59
- | `Krea2-Turbo-SVDQuant-W4A4-rank64.safetensors` | 64 | 7.90 GB | 1.2 GB smaller, and identical to rank 256 *if you never load a LoRA* |
60
- | `Krea2-Turbo-W4A4-noLowRank.safetensors` | — | 7.50 GB | Smallest and ~9% faster per step; loads with the stock **UNETLoader** |
61
- | `Krea2-Base-SVDQuant-W4A4-rank256-actaware.safetensors` | 256 | 9.10 GB | The **base** (non-distilled) model, not turbo. 50 steps, cfg 3.5, real negative prompt |
62
- | `checkpoints/legacy/` | 16, 128 | — | Superseded, kept because BENCHMARKS.md cites measurements from them |
63
-
64
- The `actaware` file is the same format, size and speed as plain rank 256 — the low-rank branch
65
- was just fitted against measured per-channel activation energy instead of assuming it is
66
- uniform ([`--act-stats`](#activation-aware-branch---act-stats)). Without a LoRA that moves
67
- LPIPS-to-BF16 from 0.3378 to **0.2825** (t=4.68 over 32 paired cells); with a LoRA it measures
68
- no worse. There is no case where the plain build is the better pick. The calibration file it
69
- was built from is in `calibration/`, so the build is reproducible.
70
-
71
- The base file is the same recipe applied to `raw.safetensors`, built from its own calibration
72
- pass (`calibration/krea2_act_stats_base.safetensors`, 8 prompts on the BF16 base model at its
73
- own sampler settings). **It has not been through the paired benchmark yet** — the act-aware
74
- numbers below are turbo measurements and are not evidence about the base build. Treat it as
75
- "the base equivalent of the recommended turbo file", not as a measured improvement over
76
- `Krea2-Base-SVDQuant-W4A4-rank256`.
77
-
78
- **Rank only matters if you load LoRAs.** Without one, ranks 64 / 128 / 256 are statistically
79
- indistinguishable. With one, rank 256 wins clearly and rank 64 loses most of its advantage
80
- over having no branch at all
81
- ([Test 3](BENCHMARKS.md#test-3--paired-lpips-fidelity-with-and-without-a-lora)).
82
-
83
- Every file records how it was built in its safetensors metadata, so you can check what you
84
- downloaded rather than trusting this table:
85
-
86
- ```python
87
- from safetensors import safe_open
88
- with safe_open("Krea2-Turbo-SVDQuant-W4A4-rank256-actaware.safetensors", framework="pt") as f:
89
- print(f.metadata())
90
- ```
91
 
92
- > The test that matters is `f.metadata() is None`, not the date: an early batch (published
93
- > before 2026-07-26) was built without refinement and carries no metadata at all. If yours
94
- > returns `None`, re-download — the whole rank ladder is flat without refinement.
95
 
96
- ## Quick start
 
97
 
98
- 1. **Install the nodes.** In your ComfyUI folder:
99
  ```bash
100
- git clone https://github.com/alperktt/Krea-2-SVDQuant-ComfyUI custom_nodes/krea2-svdquant
101
  ```
102
- (No git? Download the repo as a ZIP and unzip it into `ComfyUI/custom_nodes/`.) Restart
103
- ComfyUI.
 
 
 
 
 
 
 
 
104
 
105
- 2. **Download one checkpoint** from `checkpoints/` on Hugging Face into
106
- `ComfyUI/models/diffusion_models/`.
 
 
 
 
 
107
 
108
- 3. **Download the text encoder and VAE** — the same ones any Krea 2 Turbo workflow needs:
109
- - [`qwen3vl_4b_fp8_scaled.safetensors`](https://huggingface.co/Comfy-Org/Krea-2/resolve/main/text_encoders/qwen3vl_4b_fp8_scaled.safetensors) → `ComfyUI/models/text_encoders/`
110
- - [`qwen_image_vae.safetensors`](https://huggingface.co/Comfy-Org/Krea-2/resolve/main/vae/qwen_image_vae.safetensors) → `ComfyUI/models/vae/`
111
 
112
- 4. **Drag in a workflow** from `workflows/`. Each opens with a **READ ME FIRST** note covering
113
- the settings that matter.
114
- - `krea2_turbo_svdquant_w4a4_t2i.json` → **Turbo**: 8 steps, `cfg 1.0`, zeroed negative.
115
- - `krea2_base_svdquant_w4a4_t2i.json` → **base**: 50 steps, `cfg 3.5`, real negative prompt.
 
116
 
117
- Any `SVDQuant-W4A4-rank*` file needs the **Krea2 SVDQuant W4A4 Loader** node from this repo;
118
- `noLowRank` uses the stock **UNETLoader**. The `*_api.json` files are for POSTing to
119
- `/prompt` from a script — don't drag those in, they carry no layout.
 
 
 
 
 
 
 
120
 
121
  ## See it
122
 
@@ -200,13 +167,15 @@ a calibration pass if you have one, but the default path needs nothing.
200
 
201
  ## The nodes
202
 
203
- Installing this adds seven nodes, all under **Krea2/SVDQuant**:
204
 
205
  | node | what it is for |
206
  |---|---|
207
- | **Krea2 SVDQuant W4A4 Loader** | Loads an `svdq` checkpoint. Its `status` output names the kernel that will actually run — read it first if generation is slow |
208
- | **Krea2 SVDQuant LoRA Loader** | LoRAs and LoKrs on quantized blocks — see [LoRA](#lora) |
209
- | **Krea2 SVDQuant Quantize** | Builds a quantized checkpoint without leaving ComfyUI. Blocks the queue (54 s to ~6 min) and writes ~8 GB |
 
 
210
  | **Krea2 SVDQuant Diagnostics** | Backend dispatch, memory accounting, per-layer timings, profiler table |
211
  | **Krea2 SVDQuant Env Check** | Is the int4 kernel available at all? Needs no model, so you can ask before downloading 8 GB |
212
  | **Krea2 SVDQuant Capture Start** / **Capture Save** | Record activation statistics for an activation-aware build |
@@ -217,12 +186,13 @@ Installing this adds seven nodes, all under **Krea2/SVDQuant**:
217
  | file | what it is |
218
  |---|---|
219
  | `quantize_krea2.py` | Converts a BF16 Krea 2 checkpoint to int8, w4a4, or w4a4 + low-rank (svdq). Also the CLI |
220
- | `svdquant_w4a4.py` | The **W4A4 Loader** node — loads `--format svdq` checkpoints (self-contained, no base model needed) |
221
  | `svdquant_lora.py` | The **LoRA Loader** node — applies LoRAs as a parallel branch so the 4-bit weight is never dequantized |
222
  | `svdquant_quantize.py` | The **Quantize** node — the quantizer above, from inside ComfyUI |
223
  | `svdquant_capture.py` | The **Capture Start/Save** nodes — record per-channel activation RMS for `--act-stats` |
224
  | `svdquant_diag.py` | The **Diagnostics** and **Env Check** nodes |
225
  | `diagnose.py` | The same reports from a terminal, without starting ComfyUI (`--mode all` for everything) |
 
226
  | `tools/fidelity_bench.py` | Paired multi-seed multi-LoRA LPIPS harness. Every fidelity claim in this repo comes from it |
227
  | `tools/speed_bench.py` | Per-step speed, by timing each checkpoint at two step counts and taking the slope. Every s/step number in this repo comes from it |
228
  | `tools/contact_sheet.py` | Builds the contact sheets in [GALLERY.md](GALLERY.md) — for looking at, not scoring |
@@ -237,12 +207,37 @@ is **API format** (POST to `/prompt`, no layout). Both come out of `build_workfl
237
 
238
  ## Build your own checkpoint
239
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  From a terminal:
241
 
242
  ```bash
243
- cd ComfyUI/custom_nodes/krea2-svdquant
244
  python quantize_krea2.py /path/to/krea2_bf16.safetensors --format svdq --rank 256 \
245
- --act-stats krea2_act_stats.safetensors
246
  ```
247
 
248
  `--format int8` and `--format w4a4` also work (`int8` is the most faithful option and still
@@ -251,9 +246,16 @@ python quantize_krea2.py /path/to/krea2_bf16.safetensors --format svdq --rank 25
251
  the metadata — it does not change the quantization, since layer selection keys off block
252
  naming that Turbo and base share.
253
 
254
- Or use the **Krea2 SVDQuant Quantize** node, which calls the same code. Three things first: it
255
- blocks the queue for the whole run, it unloads any resident model to take the GPU, and it
256
- writes ~8 GB (refusing rather than overwriting unless you tick `overwrite`).
 
 
 
 
 
 
 
257
 
258
  **`rank` and `refine_iters` are one lever, not two.** With refinement off a rank sweep is flat
259
  — rank 16 and rank 256 land within noise of each other — so raising rank without
@@ -336,6 +338,25 @@ one that worked, and it optimises against something else.
336
 
337
  </details>
338
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
  ## What was measured
340
 
341
  Full tables, methodology and caveats: **[BENCHMARKS.md](BENCHMARKS.md)**. Images:
@@ -453,10 +474,12 @@ coverage.
453
 
454
  ## Troubleshooting
455
 
456
- **[TROUBLESHOOTING.md](TROUBLESHOOTING.md)** covers the five things that actually go wrong:
457
  generation slower than FP8 (nearly always a pre-cu130 torch build), no speedup at all on
458
- RTX 20-series, `Pin error.` in the console, out-of-memory on a small card, and "left over keys
459
- in diffusion model" after re-saving.
 
 
460
 
461
  Start with the **Krea2 SVDQuant Diagnostics** node between the loader and the KSampler, or:
462
 
 
1
+ # Krea 2 SVDQuant & Native Quantization for ComfyUI
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
+ **Weights and all example images:**
4
+ [huggingface.co/AlperKTS/Krea-2-SVDQuant-ComfyUI](https://huggingface.co/AlperKTS/Krea-2-SVDQuant-ComfyUI)
5
 
6
  Quantized **Krea 2** checkpoints for ComfyUI — about **2x faster** and **a third the size**
7
  of the usual FP8 version, with no calibration dataset needed, on both **Krea 2 Turbo**
8
+ (distilled, 8 steps) and the **base** release. This repo holds the custom nodes, the All-in-One
9
+ builder, and the `quantize_krea2.py` conversion script; the `.safetensors` files live on
10
  Hugging Face.
11
 
12
  Works on **any modern NVIDIA GPU** — INT8/W4A4 tensor cores go back to Turing (RTX 20-series).
13
+ Benchmarked on an RTX 3090 (Ampere). Everything here was built from scratch against ComfyUI's
14
+ own quantization backend and is 100% reproducible.
 
 
 
15
 
16
  > **Requires a cu130 (CUDA 13) or newer PyTorch build.** ComfyUI disables `comfy_kitchen`'s
17
  > CUDA backend on older torch builds, which silently drops every quantized checkpoint onto a
18
  > pure-Python fallback that is *slower than bf16*. If these checkpoints are slower than FP8
19
+ > for you, see [TROUBLESHOOTING.md](TROUBLESHOOTING.md#its-slower-than-fp8--slower-than-bf16).
 
 
 
 
 
20
 
21
+ ---
 
 
22
 
23
+ ## 🚀 Quick Start (Choose Your Setup)
24
 
25
+ ### Option 1: 🌟 1-Click All-in-One Checkpoint (Recommended)
26
+ > **Zero extra downloads!** DiT model, 4-bit Qwen3-VL 4B text encoder, and VAE are combined into **a single 12.77 GB file**.
27
 
28
+ 1. **Install custom nodes:**
29
+ ```bash
30
+ git clone https://github.com/alperktt/Krea-2-SVDQuant-ComfyUI custom_nodes/krea-2-svdquant
31
+ ```
32
+ 2. **Download the unified checkpoint:**
33
+ - Download `Krea2-Turbo-AllInOne-SVDQuant-W4A4-rank256-actaware-TEW4A4.safetensors` (12.77 GB) from [Hugging Face](https://huggingface.co/AlperKTS/Krea-2-SVDQuant-ComfyUI).
34
+ - Place it in: `ComfyUI/models/checkpoints/`
35
+ 3. **Run in ComfyUI:**
36
+ - Drag [`workflows/krea2_turbo_all_in_one_t2i.json`](workflows/krea2_turbo_all_in_one_t2i.json) into your ComfyUI canvas.
37
+ - It uses the **Krea2 SVDQuant Checkpoint Loader** node to load everything in 1 click. Press **Queue Prompt**!
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
+ ---
 
 
40
 
41
+ ### Option 2: 📦 Modular 3-File Setup
42
+ > If you prefer downloading the DiT, Text Encoder, and VAE separately:
43
 
44
+ 1. **Install custom nodes:**
45
  ```bash
46
+ git clone https://github.com/alperktt/Krea-2-SVDQuant-ComfyUI custom_nodes/krea-2-svdquant
47
  ```
48
+ 2. **Download the 3 files:**
49
+ - **DiT Checkpoint** → `ComfyUI/models/diffusion_models/` (from [Hugging Face](https://huggingface.co/AlperKTS/Krea-2-SVDQuant-ComfyUI))
50
+ - **Text Encoder** → [`qwen3vl_4b_fp8_scaled.safetensors`](https://huggingface.co/Comfy-Org/Krea-2/resolve/main/text_encoders/qwen3vl_4b_fp8_scaled.safetensors) → `ComfyUI/models/text_encoders/`
51
+ - **VAE** → [`qwen_image_vae.safetensors`](https://huggingface.co/Comfy-Org/Krea-2/resolve/main/vae/qwen_image_vae.safetensors) → `ComfyUI/models/vae/`
52
+ 3. **Run in ComfyUI:**
53
+ - Drag [`workflows/krea2_turbo_svdquant_w4a4_t2i.json`](workflows/krea2_turbo_svdquant_w4a4_t2i.json) into canvas.
54
+
55
+ ---
56
+
57
+ ## 📥 Which Checkpoint to Download
58
 
59
+ | Checkpoint File | Format & Type | Size | Destination Folder | Loader Node | Recommended For |
60
+ |---|---|---|---|---|---|
61
+ | **`Krea2-Turbo-AllInOne-SVDQuant-W4A4-rank256-actaware-TEW4A4.safetensors`** | **All-in-One** (DiT + 4-bit TE + VAE, rank 256 act-aware) | **12.77 GB** | `models/checkpoints/` | **Krea2 SVDQuant Checkpoint Loader** | **Best for 1-click generation. Easiest setup, and the rank that holds up under a LoRA.** |
62
+ | **`Krea2-Turbo-SVDQuant-W4A4-rank256-actaware.safetensors`** | Standalone DiT (svdq r256 actaware) | 9.10 GB | `models/diffusion_models/` | **Krea2 SVDQuant W4A4 Loader** | Best standalone DiT with LoRA support |
63
+ | **`Krea2-Turbo-SVDQuant-W4A4-rank64.safetensors`** | Standalone DiT (svdq r64) | 7.90 GB | `models/diffusion_models/` | **Krea2 SVDQuant W4A4 Loader** | Smaller footprint without LoRA |
64
+ | **`Krea2-Turbo-W4A4-noLowRank.safetensors`** | Standalone DiT (branchless w4a4) | 7.50 GB | `models/diffusion_models/` | Stock **UNETLoader** | Fastest (~9% faster per step) |
65
+ | **`Krea2-Base-SVDQuant-W4A4-rank256-actaware.safetensors`** | Standalone DiT (Base release) | 9.10 GB | `models/diffusion_models/` | **Krea2 SVDQuant W4A4 Loader** | Base (50 steps, cfg 3.5, real negative) |
66
 
67
+ > 💡 **Folder Tip:**
68
+ > - **All-in-One files** go into `ComfyUI/models/checkpoints/`.
69
+ > - **Standalone DiT files** go into `ComfyUI/models/diffusion_models/`.
70
 
71
+ ---
72
+
73
+ ## 📑 Workflows Overview
74
+
75
+ Eight ready-to-use workflows in [`workflows/`](workflows/):
76
 
77
+ | Workflow File | Purpose | Setup Type |
78
+ |---|---|---|
79
+ | [`krea2_turbo_all_in_one_t2i.json`](workflows/krea2_turbo_all_in_one_t2i.json) | **1-Click Generation** (single 12.77 GB file) | All-in-One |
80
+ | [`krea2_turbo_svdquant_w4a4_t2i.json`](workflows/krea2_turbo_svdquant_w4a4_t2i.json) | Standard Turbo text-to-image (8 steps, `cfg 1.0`) | 3-File Modular |
81
+ | [`krea2_turbo_svdquant_w4a4_lora.json`](workflows/krea2_turbo_svdquant_w4a4_lora.json) | Turbo with LoRA/LoKr support (`bypass`, `svd delta`, `bake`) | 3-File Modular |
82
+ | [`krea2_base_svdquant_w4a4_t2i.json`](workflows/krea2_base_svdquant_w4a4_t2i.json) | Base non-turbo model (50 steps, `cfg 3.5`) | 3-File Modular |
83
+ | [`krea2_quantize_all_in_one.json`](workflows/krea2_quantize_all_in_one.json) | **Bake your own All-in-One checkpoint** in ComfyUI | Quantizer |
84
+ | [`krea2_quantize.json`](workflows/krea2_quantize.json) | Quantize BF16 DiT to SVDQuant in ComfyUI | Quantizer |
85
+ | [`krea2_quantize_calibrated.json`](workflows/krea2_quantize_calibrated.json) | Live activation capture + quantization | Quantizer |
86
+ | [`krea2_svdquant_diagnostics.json`](workflows/krea2_svdquant_diagnostics.json) | Verify environment, CUDA kernel & memory stats | Diagnostics |
87
 
88
  ## See it
89
 
 
167
 
168
  ## The nodes
169
 
170
+ Installing this adds nine nodes, all under **Krea2/SVDQuant**:
171
 
172
  | node | what it is for |
173
  |---|---|
174
+ | **Krea2 SVDQuant W4A4 Loader** | Loads standalone `svdq` DiT diffusion models |
175
+ | **Krea2 SVDQuant Checkpoint Loader** | Loads unified **All-in-One** checkpoints (DiT + 4-bit Qwen3-VL 4B TE + VAE) |
176
+ | **Krea2 SVDQuant LoRA Loader** | LoRAs, LoKrs, and LoHas on quantized blocks — supports `bypass`, `svd delta`, and `bake` modes |
177
+ | **Krea2 SVDQuant Quantize** | Builds a quantized DiT checkpoint without leaving ComfyUI |
178
+ | **Krea2 SVDQuant Quantize All-in-One** | Bakes DiT, 4-bit text encoder, and VAE into a unified ~12 GB checkpoint directly from ComfyUI |
179
  | **Krea2 SVDQuant Diagnostics** | Backend dispatch, memory accounting, per-layer timings, profiler table |
180
  | **Krea2 SVDQuant Env Check** | Is the int4 kernel available at all? Needs no model, so you can ask before downloading 8 GB |
181
  | **Krea2 SVDQuant Capture Start** / **Capture Save** | Record activation statistics for an activation-aware build |
 
186
  | file | what it is |
187
  |---|---|
188
  | `quantize_krea2.py` | Converts a BF16 Krea 2 checkpoint to int8, w4a4, or w4a4 + low-rank (svdq). Also the CLI |
189
+ | `svdquant_w4a4.py` | The **W4A4 Loader** & **Checkpoint Loader** nodes — loads standalone or All-In-One `--format svdq` checkpoints |
190
  | `svdquant_lora.py` | The **LoRA Loader** node — applies LoRAs as a parallel branch so the 4-bit weight is never dequantized |
191
  | `svdquant_quantize.py` | The **Quantize** node — the quantizer above, from inside ComfyUI |
192
  | `svdquant_capture.py` | The **Capture Start/Save** nodes — record per-channel activation RMS for `--act-stats` |
193
  | `svdquant_diag.py` | The **Diagnostics** and **Env Check** nodes |
194
  | `diagnose.py` | The same reports from a terminal, without starting ComfyUI (`--mode all` for everything) |
195
+ | `tools/build_all_in_one.py` | Bakes DiT, 4-bit text encoder (Qwen3-VL 4B), and VAE into a single combined checkpoint (~12 GB) |
196
  | `tools/fidelity_bench.py` | Paired multi-seed multi-LoRA LPIPS harness. Every fidelity claim in this repo comes from it |
197
  | `tools/speed_bench.py` | Per-step speed, by timing each checkpoint at two step counts and taking the slope. Every s/step number in this repo comes from it |
198
  | `tools/contact_sheet.py` | Builds the contact sheets in [GALLERY.md](GALLERY.md) — for looking at, not scoring |
 
207
 
208
  ## Build your own checkpoint
209
 
210
+ **Without a terminal.** Drag `workflows/krea2_quantize.json` in, point `source_model` at your
211
+ BF16 checkpoint, press Queue. Every widget carries a tooltip and the note on the left covers
212
+ the choices that matter (which format, which rank, how much disk).
213
+
214
+ Fill in `act_stats` while you are there — it is the cheapest quality you will get here and you
215
+ do not have to measure anything for it. Download the file matching your variant from
216
+ [`calibration/`](https://huggingface.co/AlperKTS/Krea-2-SVDQuant-ComfyUI/tree/main/calibration)
217
+ (6.67 MB) into `ComfyUI/output/` and type its name in: `krea2_act_stats_turbo.safetensors` for Turbo,
218
+ `krea2_act_stats_base.safetensors` for base.
219
+
220
+ **You only need to capture your own statistics for weights those files do not describe** — a
221
+ finetune, a merge, anything that is not the released checkpoint. Activation statistics describe
222
+ the weights they came from, and sharing an architecture is not enough: Turbo's file loads into a
223
+ base build without complaint and then describes the wrong model. `check_act_stats_coverage`
224
+ catches a genuinely foreign model before any GPU work, but it matches on layer *names*, which
225
+ Turbo and base share — so it cannot catch that particular mistake for you. For that case,
226
+ `workflows/krea2_quantize_calibrated.json` runs the capture and the quantize in one Queue press:
227
+ the Capture Save node's `act_stats_path` output feeds the Quantize node, and that wire is also
228
+ what guarantees the calibration runs *first* — without it ComfyUI is free to quantize before
229
+ sampling and you get an uncalibrated file with no warning.
230
+
231
+ Three things to know before you press Queue, in either graph: it blocks the queue for the
232
+ whole run (54 s to ~6 min), it unloads any resident model to take the GPU, and it writes ~8 GB
233
+ (refusing rather than overwriting unless you tick `overwrite`).
234
+
235
  From a terminal:
236
 
237
  ```bash
238
+ cd ComfyUI/custom_nodes/krea-2-svdquant
239
  python quantize_krea2.py /path/to/krea2_bf16.safetensors --format svdq --rank 256 \
240
+ --act-stats krea2_act_stats_turbo.safetensors
241
  ```
242
 
243
  `--format int8` and `--format w4a4` also work (`int8` is the most faithful option and still
 
246
  the metadata — it does not change the quantization, since layer selection keys off block
247
  naming that Turbo and base share.
248
 
249
+ The **Krea2 SVDQuant Quantize** node calls this same function — the CLI and the graph are one
250
+ quantizer, not two that drift.
251
+
252
+ **Builds are reproducible, per device.** The low-rank split is a *randomized* SVD, so before
253
+ `--seed` existed two runs of the same command on the same GPU produced different checkpoints —
254
+ around 1e-4 per weight, which a sampler then compounds over 8–50 steps into a visibly different
255
+ image. The seed defaults to `0`; pass `--seed -1` (or `-1` in the node) for the old behaviour.
256
+ Across devices it still differs whatever the seed: CPU and CUDA neither draw the same numbers
257
+ nor reduce their GEMMs in the same order. Both the seed and the build device are recorded in
258
+ the checkpoint metadata (`krea2_svdquant_seed`, `krea2_svdquant_device`).
259
 
260
  **`rank` and `refine_iters` are one lever, not two.** With refinement off a rank sweep is flat
261
  — rank 16 and rank 256 land within noise of each other — so raising rank without
 
338
 
339
  </details>
340
 
341
+ ### All-in-One Checkpoint (`tools/build_all_in_one.py`)
342
+
343
+ Bake the diffusion model, text encoder, and VAE into a single file (~12 GB instead of 15.5 GB across three separate files):
344
+
345
+ ```bash
346
+ python tools/build_all_in_one.py \
347
+ --dit models/diffusion_models/Krea2-Turbo-SVDQuant-W4A4-rank64.safetensors \
348
+ --text-encoder models/text_encoders/qwen3vl_4b_bf16.safetensors \
349
+ --vae models/vae/Krea2-HD-vae.safetensors \
350
+ --te-format w4a4 --variant turbo
351
+ ```
352
+
353
+ * **Streaming Safetensors writer**: never holds the combined 12+ GB in memory simultaneously.
354
+ * **Quantized Text Encoder**: quantizes the 252 linear projections of Qwen3-VL 4B (`self_attn.*_proj`, `mlp.*_proj`) to 4-bit (`convrot_w4a4` or `int8`) while leaving the vision tower and embeddings untouched.
355
+ * **Loading**:
356
+ - `SVDQuant` all-in-one checkpoints load with the **Krea2 SVDQuant Checkpoint Loader** node.
357
+ - Branchless checkpoints (`--format w4a4` or `int8`) load with stock **CheckpointLoaderSimple**.
358
+ * **Workflow**: drag `workflows/krea2_turbo_all_in_one_t2i.json` into ComfyUI for a streamlined 1-loader generation graph.
359
+
360
  ## What was measured
361
 
362
  Full tables, methodology and caveats: **[BENCHMARKS.md](BENCHMARKS.md)**. Images:
 
474
 
475
  ## Troubleshooting
476
 
477
+ **[TROUBLESHOOTING.md](TROUBLESHOOTING.md)** covers the things that actually go wrong:
478
  generation slower than FP8 (nearly always a pre-cu130 torch build), no speedup at all on
479
+ RTX 20-series, `Pin error.` in the console, iteration time jumping to 30-100 s past ~12 GB
480
+ (fixed — update if you see it), **LoRA renders changing after this release** (measured, and
481
+ the old behaviour was the unstable one), out-of-memory on a small card, LoRAs that match no
482
+ layer, sage-attention artifacts, and "left over keys in diffusion model" after re-saving.
483
 
484
  Start with the **Krea2 SVDQuant Diagnostics** node between the loader and the KSampler, or:
485
 
ROADMAP.md CHANGED
@@ -9,7 +9,78 @@ down gets run twice.
9
 
10
  ---
11
 
12
- ## 1. Act-aware at ranks other than 256
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
  **Status:** never tried. `--act-stats` shipped after the rank sweep was run, and only a
15
  rank-256 build was ever made. So "is act-aware rank 64 as good as act-aware rank 256" is
@@ -37,7 +108,7 @@ that rank 256 is "~4% slower than rank 16" — it is 8.2%.)
37
 
38
  **Cost:** two builds (~14 min each) plus a `base`-arm fidelity run (~25 min).
39
 
40
- ## 2. Make `TorchCompileModel` work on branchless checkpoints
41
 
42
  **Status:** open bug, one failed attempt.
43
 
@@ -67,49 +138,102 @@ see whether Dynamo honours an instance-level `forward` on a *child* module in th
67
  That separates "Dynamo ignores instance patches on children" from "something in ComfyUI
68
  replaces the module".
69
 
70
- ## 3. Fold LoKr/LoHa/OFT deltas into the low-rank branch by SVD
71
 
72
- **Status:** idea, not started. Would become a third value for the LoRA node's `adapters`
73
- input, alongside `bypass` and `bake`.
74
 
75
- **The problem it solves.** Today's two options both cost something:
76
-
77
- | mode | s/step (3090, 1440x1920, r256 + LoRA + LoKr + a `.diff`) | what it costs you |
78
- |---|---|---|
79
- | `bypass` (default) | 5.21 | exact, but the adapter runs every forward |
80
- | `bake` | 3.55 | fast, but the LoKr delta is requantized to **4 bits** |
81
- | stock loader | 3.22 | same, and the plain LoRA is requantized too |
82
-
83
- The bypass cost is inherent, not an implementation flaw — ComfyUI's `h()` already does the
84
- efficient nested contraction and never materialises the Kronecker product
85
- (`comfy/weight_adapter/lokr.py:146-163`). For a 6144→6144 layer with 4 groups it is ~38.7
86
- GMAC per layer in **bf16**, against the base layer's 154 GMAC in **int4**. A quarter of the
87
- arithmetic on hardware that is ~8x slower for it, which is why it roughly doubles the layer.
88
-
89
- **The idea.** At load time, extract the adapter's weight delta via ComfyUI's own
90
- `calculate_weight` (applied to a zero weight, so the result is the pure ΔW), take a truncated
91
- SVD with `torch.svd_lowrank`, and concatenate the factors onto the layer's existing
92
- `svdq_l1`/`svdq_l2`. Runtime cost then drops to *zero* beyond a slightly wider branch, and the
93
- delta is **truncated rather than quantized to 4 bits** — the same trade already accepted for
94
- the base weight, and a much gentler one than `bake` makes.
95
-
96
- **Open questions:** what rank the delta needs (a Kronecker product is not inherently
97
- low-rank, so this may need measuring per adapter); ~15 s of SVD at load across 224 layers;
98
- and whether the widened branch's VRAM is acceptable, given the branch is already 24% of a step
99
- at rank 256. Wants a fidelity run against `bypass` before it could become the default.
100
-
101
- ## 4. Mixed precision: keep the first and last blocks at W8A8
102
 
103
  **Status:** standard SVDQuant lever, never tried here. Costs file size, buys fidelity. No
104
  estimate — nothing has been measured, which is exactly why it is on this list rather than
105
  above the items that have been.
106
 
107
- ## 5. Benchmark the base checkpoint
108
 
109
  `Krea2-Base-SVDQuant-W4A4-rank256-actaware` is published and has **never been through the
110
  paired benchmark** (README says so). `tools/fidelity_bench.py --variant base` already
111
  supports it. Not a quality lever — a claim with no evidence behind it.
112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  ---
114
 
115
  ## Measured and rejected — do not redo these
 
9
 
10
  ---
11
 
12
+ ## 1. Let the svdq loader use ComfyUI's dynamic patcher
13
+
14
+ **Status: done.** `disable_dynamic=True` is gone from both loaders; they default to whatever
15
+ ComfyUI would do for any other model, which on an ordinary launch is `ModelPatcherDynamic`.
16
+ The `vram_management` input (and `KREA2_DISABLE_DYNAMIC=1`) restores the pin.
17
+
18
+ The pin was waiting on two conditions. Both hold on current ComfyUI, and both were
19
+ measured, not argued from the source:
20
+
21
+ 1. **The branch buffers survive the streaming patcher.** `ModelPatcherDynamic.load()` ends
22
+ by walking `self.model.named_buffers(recurse=True)`, moving every buffer to the load
23
+ device with `set_attr_buffer` and stashing the original in `self.backup_buffers`. Unload
24
+ is symmetric: `partially_unload()` falls through to `restore_loaded_backups()`.
25
+
26
+ Measured on a 3090 with `--reserve-vram 15` (~7 GB usable against an 8.51 GiB model),
27
+ read from the diagnostics node *after* a render — which is the only time it means
28
+ anything, since both arms show `cpu` at load time:
29
+
30
+ | `vram_management` | factor devices | weight devices | lowvram |
31
+ |---|---|---|---|
32
+ | `auto` | **448 × `cuda:0`** | 224 × `cpu` | False |
33
+ | `classic` | 368 × `cpu`, 80 × `cuda:0` | 184 × `cpu`, 40 × `cuda:0` | True |
34
+
35
+ So `add_low_rank`'s `cast_to` really is a no-op under `auto` — the opposite of the
36
+ feared per-step multi-MB staging copy — and the division of labour is the one you would
37
+ pick by hand: the small branch every step needs stays resident, the 4-bit weights
38
+ stream.
39
+ 2. **`module_size()` still counts them.** `_load_list()` derives its per-module budget from
40
+ `comfy.model_management.module_size`, which sums `state_dict()` — exactly what
41
+ `_publish_in_state_dict` exists to feed. The 1.6 GiB at rank 256 is visible to the
42
+ budget.
43
+
44
+ Two things that also had to be true and are:
45
+
46
+ * The compile fast path self-disables rather than reading a stale weight. Under the dynamic
47
+ patcher the resident copy lives in `_v_weight`/`_prefetch` while `module.weight` stays on
48
+ the host, so `_install_custom_op`'s per-call `weight._qdata.device != x.device` check
49
+ sends the call to the stock forward. Correct, at the cost of the in-graph op — which only
50
+ matters under `TorchCompileModel`.
51
+ * The LoRA node is unaffected: it works through `add_object_patch`, and
52
+ `ModelPatcherDynamic.patch_model` delegates object patches to `super()`.
53
+
54
+ **What is still open:** the buffers are moved with a plain device copy, outside the dynamic
55
+ allocator's `allocated_size` accounting. Real VRAM, invisible bookkeeping. That is why the
56
+ escape hatch shipped with the fix rather than the fix shipping alone.
57
+
58
+ ## 1b. Decide which patcher renders a LoRA *correctly*
59
+
60
+ **Status: open, and the only loose end left by the unpin.** Without a LoRA the two patchers
61
+ are bit-identical. With one they are not, and `classic` is additionally not reproducible --
62
+ its output moves with how much of the model was offloaded:
63
+
64
+ | pair | PSNR | SSIM |
65
+ |---|---|---|
66
+ | `auto` vs `auto`, three runs across two VRAM budgets | ∞ | 1.000000 |
67
+ | `classic` full-load vs `classic` under `--reserve-vram 15` | 17.86 dB | 0.712 |
68
+ | `classic` vs `auto` | 15.4-15.9 dB | 0.61-0.65 |
69
+
70
+ The branch bookkeeping is identical in both arms -- 224 quantized layers branched, 32
71
+ patched normally, logged the same either way -- so this is not the node failing to attach
72
+ something. The suspects are the 32 non-quantized layers, which go through ComfyUI's own
73
+ `add_patches`, and the fact that the classic path has two ways of applying a patch (in place
74
+ when resident, `LowVramPatch` at cast time when not) while the dynamic path has one.
75
+
76
+ `auto` being invariant is a good sign and not a proof. **Nothing here establishes which
77
+ image is the faithful one.** What would: apply the same LoRA to a BF16 Krea 2 and compare
78
+ both arms against it with `tools/fidelity_bench.py` -- the harness already does paired
79
+ LPIPS against a BF16 reference, so this is a run, not new machinery. Until that happens the
80
+ honest statement is the one in TROUBLESHOOTING.md: the default changed, the output changed,
81
+ and the new one is at least reproducible.
82
+
83
+ ## 2. Act-aware at ranks other than 256
84
 
85
  **Status:** never tried. `--act-stats` shipped after the rank sweep was run, and only a
86
  rank-256 build was ever made. So "is act-aware rank 64 as good as act-aware rank 256" is
 
108
 
109
  **Cost:** two builds (~14 min each) plus a `base`-arm fidelity run (~25 min).
110
 
111
+ ## 3. Make `TorchCompileModel` work on branchless checkpoints
112
 
113
  **Status:** open bug, one failed attempt.
114
 
 
138
  That separates "Dynamo ignores instance patches on children" from "something in ComfyUI
139
  replaces the module".
140
 
141
+ ## 4. Fold LoKr/LoHa/OFT deltas into the low-rank branch by SVD
142
 
143
+ **Status:** Completed in `experimental/all-in-one`. Added as the `svd delta` mode for `Krea2SVDQuantLoraLoader`, decomposing weight deltas at load time via `torch.svd_lowrank` and merging low-rank factors into the low-rank branch with zero per-step runtime overhead.
 
144
 
145
+ ## 5. Mixed precision: keep the first and last blocks at W8A8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
 
147
  **Status:** standard SVDQuant lever, never tried here. Costs file size, buys fidelity. No
148
  estimate — nothing has been measured, which is exactly why it is on this list rather than
149
  above the items that have been.
150
 
151
+ ## 6. Benchmark the base checkpoint
152
 
153
  `Krea2-Base-SVDQuant-W4A4-rank256-actaware` is published and has **never been through the
154
  paired benchmark** (README says so). `tools/fidelity_bench.py --variant base` already
155
  supports it. Not a quality lever — a claim with no evidence behind it.
156
 
157
+ ## 7. All-in-one checkpoint: diffusion + text encoder + VAE in one file
158
+
159
+ **Status:** Completed in `experimental/all-in-one`.
160
+ - Standalone builder: `tools/build_all_in_one.py` (streaming two-pass safetensors assembler).
161
+ - In-Graph node: `Krea2SVDQuantQuantizeAllInOne` in `svdquant_quantize.py`.
162
+ - Loader node: `Krea2SVDQuantCheckpointLoader` in `svdquant_w4a4.py`.
163
+ - Workflows: `workflows/krea2_turbo_all_in_one_t2i.json` and `workflows/krea2_quantize_all_in_one.json`.
164
+ - Quantizes the 252 language projections of Qwen3-VL 4B to 4-bit (`convrot_w4a4`, ~3.2 GB) while keeping the vision tower (315 tensors) and embeddings in native precision for 100% prompt fidelity. Total size: ~11.7–12.3 GB (down from 33.5 GB BF16 / 15.5 GB split).
165
+
166
+ **ComfyUI already supports this, which is the surprise.** `class Krea2` in
167
+ `comfy/supported_models.py` defines `vae_key_prefix = ["vae."]` and
168
+ `text_encoder_key_prefix = ["text_encoders."]`, and its `clip_target()` runs `llama_detect`
169
+ over `text_encoders.qwen3vl_4b.transformer.`, which resolves to
170
+ `comfy.utils.detect_layer_quantization` — a check for `.comfy_quant` markers that is entirely
171
+ format-agnostic. Any layer we quantize with the machinery already in `quantize_krea2.py`
172
+ switches the encoder onto `mixed_ops` and loads. So a **branchless** all-in-one file
173
+ (`--format w4a4` or `int8`) needs no ComfyUI change and no node from this repo: it loads with
174
+ the stock `CheckpointLoaderSimple`.
175
+
176
+ An **svdq** all-in-one does need a node, because the `svdq_l1`/`svdq_l2` buffers have to be
177
+ popped and attached the way `load_svdquant_w4a4` does. That is a wrapper around
178
+ `comfy.sd.load_state_dict_guess_config`, not new mechanism.
179
+
180
+ ### The size arithmetic
181
+
182
+ Measured file sizes, not estimates, except where marked:
183
+
184
+ | component | today | all-in-one target |
185
+ |---|---|---|
186
+ | diffusion | 9.78 GB (svdq rank 256) | 8.48 GB (rank 64) or 8.05 GB (noLowRank) |
187
+ | text encoder | 5.24 GB (`qwen3vl_4b_fp8_scaled`) | ~3.3 GB at 4-bit *(estimated)* |
188
+ | VAE | 0.51 GB | 0.51 GB, unquantized |
189
+ | **total** | **15.5 GB across 3 files** | **~11.9–12.3 GB in 1 file** |
190
+
191
+ The 4-bit encoder estimate scales the BF16 encoder (8.88 GB) by the ratio the diffusion model
192
+ gets (24 GB → 8.05 GB, ~34%), and is the number most likely to be wrong: a 4B LLM spends a much
193
+ larger share of itself on embeddings than a DiT does, and embeddings do not quantize here.
194
+
195
+ Note what this does **not** buy: the text encoder and the diffusion model are never resident at
196
+ the same time — ComfyUI encodes, unloads, then samples. So this is a disk, download and
197
+ correctness win, and a VRAM win during the encode pass only. Sampling speed and sampling VRAM
198
+ do not move. Worth saying out loud before anyone reads "12 GB instead of 15.5 GB" as a VRAM
199
+ figure.
200
+
201
+ ### What has to be built
202
+
203
+ 1. **A second layer set.** `_QUANT_SUFFIXES` targets `blocks.N.{attn,mlp}.*`, which is the DiT.
204
+ The encoder is a Qwen3 LLM: `layers.N.self_attn.{q,k,v,o}_proj`,
205
+ `layers.N.mlp.{gate,up,down}_proj`. `is_target` has to take the set rather than close over
206
+ one.
207
+ 2. **Leave the vision tower alone**, at least at first. Qwen3-VL carries one, krea2edit's image
208
+ conditioning path may use it, and quantizing something to find out whether it is dead weight
209
+ is the wrong order of operations.
210
+ 3. **A combiner**, streaming three state dicts into one with the prefixes above. The BF16 source
211
+ is 24 GB and the encoder another 8.88 GB, so it has to stream the way `convert()` already
212
+ does rather than build the dict in memory.
213
+ 4. **An svdq checkpoint loader node**, if the svdq variant is wanted.
214
+
215
+ ### The risk, and how to measure it
216
+
217
+ 4-bit is a much bigger ask of an LLM than of this DiT. The DiT tolerates it because convrot
218
+ spreads the outliers and the low-rank branch absorbs what is left; LLM activations have
219
+ outlier channels severe enough that a whole literature (SmoothQuant, AWQ) exists about them.
220
+ INT8 is not an alternative — it is the same byte count as the FP8 encoder we already ship, so
221
+ 4-bit is the only setting that changes the number.
222
+
223
+ Failure will not look like noise. It will look like **prompt adherence quietly getting worse**:
224
+ a dropped clause, a colour that drifts, a count that stops being respected. That is exactly the
225
+ kind of regression the existing harness catches, because it is measured against a BF16
226
+ reference at fixed seeds — hold the diffusion model constant, swap only the encoder, and run
227
+ `tools/fidelity_bench.py`. The dense-text and counting prompts in the benchmark set are the
228
+ ones to read first.
229
+
230
+ Act-aware calibration on the encoder is the natural follow-up if plain 4-bit is close but not
231
+ close enough. It needs its own capture: the hooks would ride on `CLIPTextEncode`, not on the
232
+ sampler, so `svdquant_capture.py` needs a second pair of nodes rather than a flag. Do not build
233
+ that before the plain 4-bit measurement says whether it is needed.
234
+
235
+ ---
236
+
237
  ---
238
 
239
  ## Measured and rejected — do not redo these
TROUBLESHOOTING.md CHANGED
@@ -122,20 +122,109 @@ locked pages aggressively — `MAX_PINNED_MEMORY` there is 40% of system RAM —
122
  routinely with a model this size. It is not specific to `svdq`; INT8 checkpoints trigger it
123
  too. The diagnostics node prints your pinned-memory budget under `mode=env`.
124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  ## Out of memory on a small card (and int8 works fine)
126
 
127
  Fixed. The low-rank factors were attached as non-persistent buffers, which ComfyUI's
128
  `module_size()` — the basis of every VRAM decision, including the lowvram split — could
129
  not see, while `.to(device)` moved them anyway. Worse, the old branch cached its own
130
  device move back onto the module, so once ComfyUI offloaded a layer the factors quietly
131
- came back to the GPU and stayed there, outside all accounting. About 645 MB at rank 64,
132
- which is the difference between fitting and not on an 8 GB card. INT8 checkpoints carry no
133
- branch, so they were never affected.
134
 
135
  They are now published into `state_dict()` under their own `svdq_l1` / `svdq_l2` keys and
136
  staged per call via `comfy.model_management.cast_to`, so they are budgeted and offloaded
137
- like any other weight. `mode=env` on the diagnostics node reports the factor devices — under
138
- lowvram they should sit on `cpu` between steps, not `cuda`.
 
 
 
 
 
 
 
 
 
 
 
 
139
 
140
  One gap remains and it is upstream, not here: `QuantizedTensor.nbytes` reports only the
141
  packed weight, so the W4A4 `weight_scale` (~3 MB/layer) is still invisible to ComfyUI's
 
122
  routinely with a model this size. It is not specific to `svdq`; INT8 checkpoints trigger it
123
  too. The diagnostics node prints your pinned-memory budget under `mode=env`.
124
 
125
+ ## Iteration time jumps to 30-100 s once VRAM passes ~12 GB (and FP8/INT8 do not)
126
+
127
+ **Fixed.** If you are on a build that still does this, update.
128
+
129
+ The `svdq` loader used to opt the model out of ComfyUI's dynamic VRAM management:
130
+ `load_svdquant_w4a4` passed `disable_dynamic=True`, which pinned it to the classic
131
+ `ModelPatcher`. The stock `UNETLoader` does not do that, so the FP8, INT8 and `noLowRank`
132
+ checkpoints got the streaming patcher and the `svdq` ones did not. Past the point where the
133
+ model no longer fits, the pinned path falls back to streaming weights per module per step,
134
+ and a ~1 s iteration becomes 30-100 s. Nothing about the quantization was involved — it was
135
+ which patcher the loader asked for.
136
+
137
+ Both loaders now default to whatever ComfyUI would do for any other model, which on an
138
+ ordinary launch is the dynamic patcher. The **status output** on the loader node says which
139
+ one you got:
140
+
141
+ ```
142
+ w4a4 + low-rank: attached 224 branches (rank 256, variant turbo), model_size 9.10 GiB,
143
+ ModelPatcherDynamic (dynamic vram: on), compile: 224 layers in-graph via krea2::w4a4_linear
144
+ ```
145
+
146
+ `ModelPatcher (dynamic vram: off)` there means you are on the old path — either you set
147
+ `vram_management` to `classic`, or ComfyUI itself has dynamic VRAM off (`--highvram`,
148
+ `--novram`, `--gpu-only`, `--cpu`, `--disable-dynamic-vram`, or no working `comfy-aimdo`
149
+ install; the startup log says `DynamicVRAM support detected and enabled` when it is on).
150
+
151
+ ### If you need the old behaviour back
152
+
153
+ One thing here is genuinely unmeasured: the streaming patcher moves the low-rank branch
154
+ buffers with a plain device copy rather than through its own allocator, so at rank 256 there
155
+ is 1.6 GiB of real VRAM its internal bookkeeping does not account for. If that turns out to
156
+ bite on your card, set the loader's **`vram_management`** input to `classic`, or run the
157
+ whole server with `KREA2_DISABLE_DYNAMIC=1`. Please open an issue if you have to — that is
158
+ the case this escape hatch exists to hear about.
159
+
160
+ ### Still tight on a 12 GB card
161
+
162
+ The branch is real memory on top of the checkpoint — measured off the published files,
163
+ 1.6 GiB at rank 256 and 0.4 GiB at rank 64 across the 224 blocks — and the rank-256 build is
164
+ 9.10 GB against 7.50 GB for `noLowRank`. So the older advice still holds as a *size*
165
+ argument, just no longer as a workaround for a 30x slowdown:
166
+
167
+ 1. **`Krea2-Turbo-W4A4-noLowRank.safetensors`** (7.50 GB) is ~9% faster per step than any
168
+ rank, and gives up the low-rank branch's fidelity.
169
+ 2. **`Krea2-Turbo-SVDQuant-W4A4-rank64.safetensors`** (7.90 GB) is 1.2 GB smaller than rank
170
+ 256 and statistically identical to it *as long as you never load a LoRA*.
171
+ 3. Watch the loader's `model_size` line — it reports the real number, branch included.
172
+
173
+ ## My LoRA renders changed after updating (and the image moves when VRAM gets tight)
174
+
175
+ Measured, real, and only half explained. Read this before concluding a LoRA is broken.
176
+
177
+ With **no LoRA loaded**, the two patchers are bit-identical — same seed, same graph, same
178
+ pixels, verified at three different VRAM budgets. With a **LoRA loaded** they are not:
179
+
180
+ | pair | PSNR | SSIM |
181
+ |---|---|---|
182
+ | `auto` vs `auto`, different VRAM budgets, three runs | ∞ | 1.000000 |
183
+ | `classic` full-load vs `classic` under pressure | 17.86 dB | 0.712 |
184
+ | `classic` vs `auto` | 15.4-15.9 dB | 0.61-0.65 |
185
+
186
+ So the instability is on the `classic` side: it renders a LoRA differently depending on how
187
+ much of the model got offloaded, because ComfyUI's classic path applies patches through
188
+ `LowVramPatch` at cast time when a layer is not resident and in place when it is. The
189
+ dynamic patcher has one path and uses it always, which is why all three `auto` runs agree
190
+ to the bit.
191
+
192
+ Until this release the loader pinned to `classic`, so **your previous LoRA output was the
193
+ classic one** — and, if you were near your VRAM limit, not necessarily reproducible either.
194
+ Now the default is `auto` and the result is stable. It is also a different image.
195
+
196
+ **What has not been established: which one is numerically right.** Determinism is not
197
+ correctness. Settling it needs the same LoRA applied to a reference the quantized path can
198
+ be compared against, and that reference does not exist here yet — see
199
+ [ROADMAP.md](ROADMAP.md). If you preferred the old look, `vram_management=classic` on the
200
+ loader node reproduces it, with the pressure-dependence that comes with it.
201
+
202
  ## Out of memory on a small card (and int8 works fine)
203
 
204
  Fixed. The low-rank factors were attached as non-persistent buffers, which ComfyUI's
205
  `module_size()` — the basis of every VRAM decision, including the lowvram split — could
206
  not see, while `.to(device)` moved them anyway. Worse, the old branch cached its own
207
  device move back onto the module, so once ComfyUI offloaded a layer the factors quietly
208
+ came back to the GPU and stayed there, outside all accounting. 0.4 GiB at rank 64 and
209
+ 1.6 GiB at rank 256, which is the difference between fitting and not on an 8 GB card. INT8
210
+ checkpoints carry no branch, so they were never affected.
211
 
212
  They are now published into `state_dict()` under their own `svdq_l1` / `svdq_l2` keys and
213
  staged per call via `comfy.model_management.cast_to`, so they are budgeted and offloaded
214
+ like any other weight.
215
+
216
+ `mode=env` on the diagnostics node reports the factor devices, and **what you should see
217
+ depends on which patcher you are on** — read it after a render, not before, because at load
218
+ time both arms say `cpu`:
219
+
220
+ | `vram_management` | healthy `factor devices` |
221
+ |---|---|
222
+ | `auto` (dynamic, the default) | all on `cuda:0` — 448 of them at rank 256 |
223
+ | `classic` | mostly `cpu`, staged per call |
224
+
225
+ `cuda` under `auto` is not the old bug coming back; it is the streaming patcher keeping the
226
+ small always-needed branch resident and streaming the 4-bit weights instead, which is what
227
+ makes it fast. Measured both ways on a 3090 under `--reserve-vram 15`.
228
 
229
  One gap remains and it is upstream, not here: `QuantizedTensor.nbytes` reports only the
230
  packed weight, so the W4A4 `weight_scale` (~3 MB/layer) is still invisible to ComfyUI's
__init__.py CHANGED
@@ -1,27 +1,27 @@
1
- from .svdquant_capture import (
2
- NODE_CLASS_MAPPINGS as _CAPTURE_NODES,
3
- NODE_DISPLAY_NAME_MAPPINGS as _CAPTURE_NAMES,
4
- )
5
- from .svdquant_diag import (
6
- NODE_CLASS_MAPPINGS as _DIAG_NODES,
7
- NODE_DISPLAY_NAME_MAPPINGS as _DIAG_NAMES,
8
- )
9
- from .svdquant_lora import (
10
- NODE_CLASS_MAPPINGS as _LORA_NODES,
11
- NODE_DISPLAY_NAME_MAPPINGS as _LORA_NAMES,
12
- )
13
- from .svdquant_quantize import (
14
- NODE_CLASS_MAPPINGS as _QUANT_NODES,
15
- NODE_DISPLAY_NAME_MAPPINGS as _QUANT_NAMES,
16
- )
17
- from .svdquant_w4a4 import (
18
- NODE_CLASS_MAPPINGS as _W4A4_NODES,
19
- NODE_DISPLAY_NAME_MAPPINGS as _W4A4_NAMES,
20
- )
21
-
22
- NODE_CLASS_MAPPINGS = {**_LORA_NODES, **_W4A4_NODES, **_DIAG_NODES, **_QUANT_NODES,
23
- **_CAPTURE_NODES}
24
- NODE_DISPLAY_NAME_MAPPINGS = {**_LORA_NAMES, **_W4A4_NAMES, **_DIAG_NAMES, **_QUANT_NAMES,
25
- **_CAPTURE_NAMES}
26
-
27
- __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
 
1
+ from .svdquant_capture import (
2
+ NODE_CLASS_MAPPINGS as _CAPTURE_NODES,
3
+ NODE_DISPLAY_NAME_MAPPINGS as _CAPTURE_NAMES,
4
+ )
5
+ from .svdquant_diag import (
6
+ NODE_CLASS_MAPPINGS as _DIAG_NODES,
7
+ NODE_DISPLAY_NAME_MAPPINGS as _DIAG_NAMES,
8
+ )
9
+ from .svdquant_lora import (
10
+ NODE_CLASS_MAPPINGS as _LORA_NODES,
11
+ NODE_DISPLAY_NAME_MAPPINGS as _LORA_NAMES,
12
+ )
13
+ from .svdquant_quantize import (
14
+ NODE_CLASS_MAPPINGS as _QUANT_NODES,
15
+ NODE_DISPLAY_NAME_MAPPINGS as _QUANT_NAMES,
16
+ )
17
+ from .svdquant_w4a4 import (
18
+ NODE_CLASS_MAPPINGS as _W4A4_NODES,
19
+ NODE_DISPLAY_NAME_MAPPINGS as _W4A4_NAMES,
20
+ )
21
+
22
+ NODE_CLASS_MAPPINGS = {**_LORA_NODES, **_W4A4_NODES, **_DIAG_NODES, **_QUANT_NODES,
23
+ **_CAPTURE_NODES}
24
+ NODE_DISPLAY_NAME_MAPPINGS = {**_LORA_NAMES, **_W4A4_NAMES, **_DIAG_NAMES, **_QUANT_NAMES,
25
+ **_CAPTURE_NAMES}
26
+
27
+ __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
build_all_in_one.py ADDED
@@ -0,0 +1,435 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bake the diffusion model, the text encoder and the VAE into one checkpoint.
2
+
3
+ The mechanism. `tools/build_all_in_one.py` is the command-line front end for it and
4
+ `Krea2SVDQuantQuantizeAllInOne` (svdquant_quantize.py) is the in-graph one; this module
5
+ lives beside `quantize_krea2.py` rather than under `tools/` because `.comfyignore`
6
+ keeps `tools/` out of the published package and the node has to be able to import it.
7
+
8
+ python tools/build_all_in_one.py \\
9
+ --dit D:/ComfyUI/models/diffusion_models/turbo.safetensors \\
10
+ --text-encoder D:/ComfyUI/models/text_encoders/qwen3vl_4b_bf16.safetensors \\
11
+ --vae D:/ComfyUI/models/vae/Krea2-HD-vae.safetensors \\
12
+ --format svdq --rank 64 --te-format w4a4 --variant turbo \\
13
+ --act-stats calibration/krea2_act_stats_turbo.safetensors
14
+
15
+ Three downloads that have to match becomes one file. `--dry-run` does the whole key mapping
16
+ and prints the size arithmetic without quantizing anything or touching the GPU, which is the
17
+ right way to check the layout before spending ten minutes on a build.
18
+
19
+ WHAT COMFYUI EXPECTS, AND WHY THIS IS MOSTLY A RENAMING PROBLEM
20
+
21
+ `comfy/supported_models.py`'s `Krea2` already declares `vae_key_prefix = ["vae."]` and
22
+ `text_encoder_key_prefix = ["text_encoders."]`, and its `clip_target()` runs
23
+ `detect_layer_quantization` over the encoder prefix -- a format-agnostic scan for
24
+ `.comfy_quant` markers. So a quantized text encoder inside a combined checkpoint is a case
25
+ ComfyUI already handles; nothing upstream needs changing. The three maps:
26
+
27
+ diffusion blocks.N... -> model.diffusion_model.blocks.N...
28
+ vae conv1... / encoder... -> vae.conv1... / vae.encoder...
29
+ encoder model.language_model.X -> text_encoders.qwen3vl_4b.transformer.model.X
30
+ model.visual.X -> text_encoders.qwen3vl_4b.transformer.visual.X
31
+ lm_head.X -> text_encoders.qwen3vl_4b.transformer.model.lm_head.X
32
+
33
+ The encoder map is not invented here: it is the same `state_dict_prefix_replace` call
34
+ `comfy/sd.py` makes in `load_text_encoder_state_dicts` when you load that file through
35
+ `CLIPLoader`. Standalone loading applies it at load time; a combined checkpoint has to carry
36
+ the keys already converted, because `load_state_dict_guess_config` does not repeat it.
37
+
38
+ WHICH FORMATS
39
+
40
+ The diffusion side takes any `--format` `quantize_krea2.py` supports. `svdq` gives the best
41
+ fidelity but the `svdq_l1`/`svdq_l2` buffers need this repo's loader to attach them, so an
42
+ svdq all-in-one needs the Krea2 SVDQuant Checkpoint Loader node rather than the stock
43
+ `CheckpointLoaderSimple`. Build with `--format w4a4` or `int8` for a file that loads with
44
+ stock ComfyUI and no custom node at all.
45
+
46
+ The text encoder takes `--te-format w4a4` or `int8` only, and never a low-rank branch. Not a
47
+ limitation of the quantizer -- `load_state_dict_guess_config` builds the CLIP through
48
+ ComfyUI's own path, which has nowhere to hang a branch. 4-bit is the only setting that
49
+ changes the number worth changing: INT8 is one byte per weight, the same as the FP8 encoder
50
+ already published.
51
+
52
+ WHAT THIS DOES NOT BUY
53
+
54
+ The text encoder and the diffusion model are never resident at the same time -- ComfyUI
55
+ encodes the prompt, unloads, then samples. This is a disk, download and don't-mismatch-your-
56
+ files win, plus a VRAM win during the encode pass. Sampling speed and sampling VRAM do not
57
+ move at all.
58
+ """
59
+ from __future__ import annotations
60
+
61
+ import json
62
+ import os
63
+ import struct
64
+ import time
65
+
66
+ import torch
67
+ from safetensors import safe_open
68
+
69
+ # Relative when imported as part of the pack, absolute when `tools/build_all_in_one.py` runs
70
+ # this as a plain script. The relative arm is the one that matters: importing
71
+ # `quantize_krea2` absolutely from inside the package creates a *second* top-level copy of
72
+ # it, with its own module-level state, alongside the pack's own `.quantize_krea2`.
73
+ #
74
+ # Branching on `__package__` rather than try/except ImportError so that a real failure
75
+ # inside `quantize_krea2` -- a missing comfy, a broken quant backend -- surfaces as itself
76
+ # instead of being retried down the other path and reported as the wrong problem.
77
+ if __package__:
78
+ from .quantize_krea2 import (
79
+ DEFAULT_SEED,
80
+ REFINE_TOL,
81
+ __version__,
82
+ conf_tensor,
83
+ detect_prefix,
84
+ quantize_weight,
85
+ resolve_format,
86
+ )
87
+ else: # run outside the package by tools/build_all_in_one.py, which puts it on sys.path
88
+ from quantize_krea2 import (
89
+ DEFAULT_SEED,
90
+ REFINE_TOL,
91
+ __version__,
92
+ conf_tensor,
93
+ detect_prefix,
94
+ quantize_weight,
95
+ resolve_format,
96
+ )
97
+
98
+ # `model.` here is the encoder's own `model.`, i.e. what `model.language_model.` becomes.
99
+ TE_PREFIX = "text_encoders.qwen3vl_4b.transformer."
100
+ DIT_PREFIX = "model.diffusion_model."
101
+ VAE_PREFIX = "vae."
102
+
103
+ # Exactly `comfy/sd.py`'s `load_text_encoder_state_dicts` mapping for a Qwen3-VL file. Order
104
+ # matters: `model.language_model.` has to be tried before any shorter `model.` rule would be.
105
+ TE_KEY_MAP = (
106
+ ("model.language_model.", "model."),
107
+ ("model.visual.", "visual."),
108
+ ("lm_head.", "model.lm_head."),
109
+ )
110
+
111
+ # The 7 projections in a Qwen3 decoder layer. Deliberately not the DiT's `_QUANT_SUFFIXES`:
112
+ # different architecture, different names, and sharing one tuple between them would make a
113
+ # typo in either silently quantize nothing in the other.
114
+ TE_QUANT_SUFFIXES = ("self_attn.q_proj", "self_attn.k_proj", "self_attn.v_proj",
115
+ "self_attn.o_proj", "mlp.gate_proj", "mlp.up_proj", "mlp.down_proj")
116
+ TE_LAYERS = 36
117
+ TE_EXPECTED = TE_LAYERS * len(TE_QUANT_SUFFIXES)
118
+
119
+ # Formats with no low-rank branch. The text encoder loads through ComfyUI's own CLIP path,
120
+ # which has nowhere to attach one.
121
+ TE_FORMATS = {"w4a4": "convrot_w4a4", "int8": "int8_tensorwise"}
122
+
123
+
124
+ def te_is_target(key: str) -> bool:
125
+ """True for a decoder-layer projection weight, in *converted* key space.
126
+
127
+ Converted, not raw: `model.visual.` becomes `visual.`, and the vision tower has layers
128
+ and projections of its own. Matching after the rename is what keeps them out -- Krea 2's
129
+ text encoder instantiates the vision tower (315 tensors, 0.83 GB) and krea2edit's image
130
+ conditioning may go through it, so it is carried unquantized rather than dropped.
131
+ """
132
+ if not key.startswith("model.layers.") or not key.endswith(".weight"):
133
+ return False
134
+ return key[: -len(".weight")].endswith(TE_QUANT_SUFFIXES)
135
+
136
+
137
+ def remap_te_key(key: str) -> str:
138
+ for old, new in TE_KEY_MAP:
139
+ if key.startswith(old):
140
+ return new + key[len(old):]
141
+ return key
142
+
143
+
144
+ _DTYPE_STR = {
145
+ torch.float32: "F32", torch.float16: "F16", torch.bfloat16: "BF16",
146
+ torch.float64: "F64", torch.int64: "I64", torch.int32: "I32", torch.int16: "I16",
147
+ torch.int8: "I8", torch.uint8: "U8", torch.bool: "BOOL",
148
+ torch.float8_e4m3fn: "F8_E4M3", torch.float8_e5m2: "F8_E5M2",
149
+ }
150
+
151
+
152
+ class StreamingWriter:
153
+ """A safetensors writer that never holds the whole checkpoint in memory.
154
+
155
+ `safetensors.torch.save_file` takes a dict, so writing a 12 GB combined checkpoint that
156
+ way needs 12 GB of RAM on top of whatever the quantizer is using. The format does not
157
+ require that: it is an 8-byte header length, a JSON header of name -> {dtype, shape,
158
+ offsets}, then the raw buffers back to back.
159
+
160
+ So this is two passes. `plan()` collects names, dtypes and shapes -- which are known
161
+ without materialising anything -- and `write()` streams the tensors past in the same
162
+ order. The cost is that every tensor is produced twice or held once; here the shapes come
163
+ from safetensors headers and quantizer outputs we already have, so it is neither.
164
+ """
165
+
166
+ def __init__(self, path: str, metadata: dict | None = None):
167
+ self.path = path
168
+ self.metadata = metadata or {}
169
+ self.entries = [] # (name, dtype_str, shape, nbytes)
170
+ self._names = set()
171
+
172
+ def plan(self, name: str, dtype, shape) -> None:
173
+ if name in self._names:
174
+ raise RuntimeError("duplicate key in the combined checkpoint: {}".format(name))
175
+ self._names.add(name)
176
+ dtype_str = _DTYPE_STR.get(dtype)
177
+ if dtype_str is None:
178
+ raise RuntimeError("no safetensors name for dtype {}".format(dtype))
179
+ nbytes = 1
180
+ for d in shape:
181
+ nbytes *= d
182
+ nbytes *= torch.empty((), dtype=dtype).element_size()
183
+ self.entries.append((name, dtype_str, list(shape), nbytes))
184
+
185
+ def header(self) -> bytes:
186
+ header, offset = {}, 0
187
+ for name, dtype_str, shape, nbytes in self.entries:
188
+ header[name] = {"dtype": dtype_str, "shape": shape,
189
+ "data_offsets": [offset, offset + nbytes]}
190
+ offset += nbytes
191
+ if self.metadata:
192
+ header["__metadata__"] = {k: str(v) for k, v in self.metadata.items()}
193
+ blob = json.dumps(header, separators=(",", ":")).encode("utf-8")
194
+ # The spec wants the buffer 8-byte aligned; pad the header with spaces, which JSON
195
+ # ignores, rather than the buffer, whose offsets are already committed.
196
+ pad = (-len(blob)) % 8
197
+ return blob + b" " * pad
198
+
199
+ def __enter__(self):
200
+ self._blob = self.header()
201
+ self._fh = open(self.path, "wb")
202
+ self._fh.write(struct.pack("<Q", len(self._blob)))
203
+ self._fh.write(self._blob)
204
+ self._i = 0
205
+ return self
206
+
207
+ def write(self, name: str, tensor: torch.Tensor) -> None:
208
+ expected = self.entries[self._i]
209
+ if name != expected[0]:
210
+ raise RuntimeError("write order does not match the plan: expected {!r}, got {!r}"
211
+ .format(expected[0], name))
212
+ t = tensor.contiguous().cpu()
213
+ # Shape and dtype, not just the byte count. The header is written from the *plan*,
214
+ # so a plan that disagrees with the tensor produces a file whose data is right and
215
+ # whose header lies -- and readers believe the header. A byte check does not catch
216
+ # it: [4096] and [4096, 1] are the same 16384 bytes, which is exactly how a
217
+ # text encoder shipped with its scales declared 2-D when convrot_w4a4 returns them
218
+ # 1-D, and failed only later, inside a kernel, as "scales: shape [4096, 1] fails:
219
+ # exactly 1D".
220
+ if list(t.shape) != expected[2]:
221
+ raise RuntimeError("{}: planned shape {}, got {}".format(
222
+ name, expected[2], list(t.shape)))
223
+ planned_dtype = _DTYPE_STR.get(t.dtype)
224
+ if planned_dtype != expected[1]:
225
+ raise RuntimeError("{}: planned dtype {}, got {}".format(
226
+ name, expected[1], planned_dtype))
227
+ buf = t.view(torch.uint8).reshape(-1) if t.dtype not in (torch.uint8,) else t.reshape(-1)
228
+ data = buf.numpy().tobytes()
229
+ if len(data) != expected[3]:
230
+ raise RuntimeError("{}: planned {} bytes, got {}".format(name, expected[3], len(data)))
231
+ self._fh.write(data)
232
+ self._i += 1
233
+
234
+ def __exit__(self, *exc):
235
+ self._fh.close()
236
+ if exc[0] is None and self._i != len(self.entries):
237
+ raise RuntimeError("planned {} tensors but wrote {}".format(len(self.entries), self._i))
238
+
239
+
240
+ def plan_dit(handle, keys, prefix):
241
+ """(combined key, source key) for every diffusion tensor, already prefixed."""
242
+ for key in keys:
243
+ bare = key[len(prefix):] if prefix and key.startswith(prefix) else key
244
+ yield DIT_PREFIX + bare, key
245
+
246
+
247
+ def plan_vae(keys):
248
+ for key in keys:
249
+ yield VAE_PREFIX + key, key
250
+
251
+
252
+ def quantize_text_encoder(path: str, fmt: str, groupsize: int, device: str, writer,
253
+ dry_run: bool):
254
+ """Plan (and on the second pass, write) the text encoder side.
255
+
256
+ Returns (planned entries, quantized layer count). The two passes run the same key walk so
257
+ the plan and the write cannot disagree about order -- which is the one way a streaming
258
+ writer produces a file that is subtly, unreadably wrong.
259
+ """
260
+ algo = TE_FORMATS[fmt]
261
+ quantized = 0
262
+ with safe_open(path, framework="pt", device="cpu") as handle:
263
+ keys = list(handle.keys())
264
+ for key in keys:
265
+ converted = remap_te_key(key)
266
+ out_key = TE_PREFIX + converted
267
+ if not te_is_target(converted):
268
+ if dry_run:
269
+ slice_ = handle.get_slice(key)
270
+ writer.plan(out_key, _slice_dtype(slice_), slice_.get_shape())
271
+ else:
272
+ writer.write(out_key, handle.get_tensor(key))
273
+ continue
274
+
275
+ layer = out_key[: -len(".weight")]
276
+ if dry_run:
277
+ slice_ = handle.get_slice(key)
278
+ shape = slice_.get_shape()
279
+ # int4 packs two values per byte along the input dimension; int8 is 1:1.
280
+ packed = [shape[0], shape[1] // 2] if fmt == "w4a4" else list(shape)
281
+ writer.plan(out_key, torch.int8, packed)
282
+ writer.plan(layer + ".weight_scale", torch.float32,
283
+ _scale_shape(algo, shape[0]))
284
+ writer.plan(layer + ".comfy_quant", torch.uint8, [_conf_len(algo, groupsize)])
285
+ else:
286
+ w = handle.get_tensor(key).to(device=device, dtype=torch.bfloat16)
287
+ qdata, scales, conf = quantize_weight(w, algo, groupsize)
288
+ writer.write(out_key, qdata)
289
+ for name, value in scales.items():
290
+ writer.write("{}.{}".format(layer, name), value)
291
+ writer.write(layer + ".comfy_quant", conf_tensor(conf))
292
+ del w, qdata, scales
293
+ if quantized % 32 == 0:
294
+ torch.cuda.empty_cache()
295
+ quantized += 1
296
+ return quantized
297
+
298
+
299
+ def _slice_dtype(slice_):
300
+ return {"F32": torch.float32, "F16": torch.float16, "BF16": torch.bfloat16,
301
+ "I8": torch.int8, "U8": torch.uint8, "I64": torch.int64,
302
+ "F8_E4M3": torch.float8_e4m3fn}[slice_.get_dtype()]
303
+
304
+
305
+ def _scale_shape(algo: str, out_features: int) -> list[int]:
306
+ """Shape of `weight_scale`, as the layout actually returns it, without quantizing.
307
+
308
+ Both algos carry one scale per output channel; they disagree only on the rank.
309
+ `convrot_w4a4` hands back a 1-D `[out_features]`, and its dequantize kernel constrains
310
+ the argument to exactly 1-D. `int8_tensorwise` hands back `[out_features, 1]`.
311
+
312
+ Hardcoded because the plan pass must not quantize anything -- that is what makes
313
+ `--dry-run` free and what keeps the two-pass writer honest. The risk of hardcoding is
314
+ that it drifts from what the layout does; `StreamingWriter.write` closes that by
315
+ comparing this against the real tensor, so a wrong entry here is a loud failure on the
316
+ first write rather than a quietly mis-headered checkpoint.
317
+ """
318
+ if algo == "convrot_w4a4":
319
+ return [out_features]
320
+ if algo == "int8_tensorwise":
321
+ return [out_features, 1]
322
+ raise ValueError("no known weight_scale shape for {!r}".format(algo))
323
+
324
+
325
+ def _conf_len(algo: str, groupsize: int) -> int:
326
+ """Byte length of the comfy_quant marker, without quantizing a weight to find out."""
327
+ if algo == "convrot_w4a4":
328
+ conf = {"format": "convrot_w4a4", "convrot_groupsize": groupsize,
329
+ "linear_dtype": "int4"}
330
+ else:
331
+ conf = {"format": "int8_tensorwise", "convrot": True, "convrot_groupsize": groupsize}
332
+ return len(conf_tensor(conf))
333
+
334
+
335
+ def build_all_in_one_checkpoint(dit_path: str, text_encoder_path: str, vae_path: str,
336
+ out_path: str, fmt_name: str = "svdq", te_format: str = "w4a4",
337
+ rank: int = 64, variant: str = "unknown", groupsize: int = 256,
338
+ device: str = "cuda", dry_run: bool = False,
339
+ progress_cb=None) -> str:
340
+ """Combine DiT, quantized text encoder, and VAE into a single checkpoint."""
341
+ fmt, rank = resolve_format(fmt_name, rank, rank_was_set=False)
342
+
343
+ with safe_open(dit_path, framework="pt", device="cpu") as handle:
344
+ dit_keys = list(handle.keys())
345
+ dit_prefix = detect_prefix(dit_keys, default="")
346
+ already = any(k.endswith(".comfy_quant") for k in dit_keys)
347
+
348
+ if not already and not dry_run:
349
+ raise RuntimeError(
350
+ "{} is a BF16 source. Quantize it first with quantize_krea2.py (or the "
351
+ "Quantize node) and pass the result as dit_path.".format(dit_path))
352
+
353
+ metadata = {
354
+ "krea2_svdquant_tool_version": __version__,
355
+ "krea2_allinone": "1",
356
+ "krea2_allinone_dit": os.path.basename(dit_path),
357
+ "krea2_allinone_te": os.path.basename(text_encoder_path),
358
+ "krea2_allinone_te_format": te_format,
359
+ "krea2_allinone_vae": os.path.basename(vae_path),
360
+ "krea2_svdquant_variant": variant,
361
+ }
362
+
363
+ writer = StreamingWriter(out_path, metadata)
364
+ with safe_open(dit_path, framework="pt", device="cpu") as dh:
365
+ for out_key, src in plan_dit(dh, dit_keys, dit_prefix):
366
+ s = dh.get_slice(src)
367
+ writer.plan(out_key, _slice_dtype(s), s.get_shape())
368
+ dit_planned = len(writer.entries)
369
+
370
+ with safe_open(vae_path, framework="pt", device="cpu") as vh:
371
+ for out_key, src in plan_vae(list(vh.keys())):
372
+ s = vh.get_slice(src)
373
+ writer.plan(out_key, _slice_dtype(s), s.get_shape())
374
+ vae_planned = len(writer.entries) - dit_planned
375
+
376
+ te_quantized = quantize_text_encoder(text_encoder_path, te_format, groupsize,
377
+ device, writer, dry_run=True)
378
+ te_planned = len(writer.entries) - dit_planned - vae_planned
379
+
380
+ if te_quantized != TE_EXPECTED:
381
+ raise RuntimeError(
382
+ "expected {} quantizable text-encoder linears ({} layers x {}), matched {}. "
383
+ "Layer naming in {} is not recognized.".format(
384
+ TE_EXPECTED, TE_LAYERS, len(TE_QUANT_SUFFIXES), te_quantized, text_encoder_path))
385
+
386
+ total = sum(e[3] for e in writer.entries)
387
+ by = lambda pred: sum(e[3] for e in writer.entries if pred(e[0])) / 1024 ** 3
388
+
389
+ stats = (
390
+ "planned {} tensors, {:.2f} GiB (diffusion: {:.2f} GiB, encoder: {:.2f} GiB [{} linears -> {}], vae: {:.2f} GiB)"
391
+ .format(len(writer.entries), total / 1024 ** 3, by(lambda k: k.startswith(DIT_PREFIX)),
392
+ by(lambda k: k.startswith(TE_PREFIX)), te_quantized, te_format,
393
+ by(lambda k: k.startswith(VAE_PREFIX)))
394
+ )
395
+
396
+ if dry_run:
397
+ return stats + "\n[dry-run: nothing written, GPU untouched]"
398
+
399
+ t0 = time.time()
400
+ total_steps = len(writer.entries)
401
+ step = 0
402
+
403
+ with writer as w:
404
+ with safe_open(dit_path, framework="pt", device="cpu") as dh:
405
+ for out_key, src in plan_dit(dh, dit_keys, dit_prefix):
406
+ w.write(out_key, dh.get_tensor(src))
407
+ step += 1
408
+ if progress_cb:
409
+ progress_cb(step, total_steps, "Copying DiT")
410
+ with safe_open(vae_path, framework="pt", device="cpu") as vh:
411
+ for out_key, src in plan_vae(list(vh.keys())):
412
+ w.write(out_key, vh.get_tensor(src))
413
+ step += 1
414
+ if progress_cb:
415
+ progress_cb(step, total_steps, "Copying VAE")
416
+ quantize_text_encoder(text_encoder_path, te_format, groupsize, device,
417
+ w, dry_run=False)
418
+ if progress_cb:
419
+ progress_cb(total_steps, total_steps, "Complete")
420
+
421
+ elapsed = time.time() - t0
422
+ final_size = os.path.getsize(out_path) / 1024 ** 3
423
+ summary = (
424
+ "baked all-in-one checkpoint: {} ({:.2f} GiB, {:.0f}s)\n"
425
+ " diffusion : {} (format {})\n"
426
+ " encoder : {} (quantized to {})\n"
427
+ " vae : {}\n"
428
+ "{}".format(
429
+ os.path.basename(out_path), final_size, elapsed,
430
+ os.path.basename(dit_path), fmt_name,
431
+ os.path.basename(text_encoder_path), te_format,
432
+ os.path.basename(vae_path), stats
433
+ )
434
+ )
435
+ return summary
quantize_krea2.py CHANGED
@@ -1,844 +1,923 @@
1
- """Quantize a BF16 Krea2 checkpoint into a ComfyUI-native quantized checkpoint.
2
-
3
- ComfyUI already ships native kernels for these formats via comfy_kitchen, so the output
4
- loads with a plain ``UNETLoader`` -- no custom node, and ordinary LoRA loaders work.
5
-
6
- Four targets:
7
-
8
- * ``int8`` -> ``int8_tensorwise`` with per-channel scales + convrot (W8A8).
9
- Natively accelerated on Ampere INT8 tensor cores.
10
- * ``w4a4`` -> ``convrot_w4a4`` (W4A4), smaller and potentially faster, 4-bit quality.
11
- * ``svdq`` -> the same W4A4 residual plus an SVDQuant low-rank bf16 branch, which
12
- absorbs the outlier directions 4 bits handle worst. Needs this repo's
13
- loader node; a plain ``UNETLoader`` cannot see the branch.
14
- * ``fp8`` -> ``float8_e4m3fn``, a single per-tensor scale, no convrot, no calibration.
15
- Lightest touch of the four -- best fidelity, smallest speedup, and the
16
- only one that needs no rotation or outlier handling because 8-bit float
17
- already has enough dynamic range for these weights.
18
-
19
- None of them need a calibration dataset: int8/w4a4/svdq spread outliers analytically via
20
- the convrot (group-wise Hadamard) rotation, and activations are quantized by the kernel at
21
- run time; fp8 just needs one abs-max scale per tensor.
22
-
23
- Only the 224 transformer-block linears are quantized, matching the layer set used by the
24
- reference ``krea2_raw_int8_convrot`` checkpoint. Norms, modulation, the text-fusion stack
25
- and the final layer stay in their original precision -- they are small and quantization
26
- noise there is disproportionately visible.
27
-
28
- The layer selection is variant-agnostic: Krea 2 Turbo and the base (non-distilled)
29
- release share the same block naming, so the same command converts either. What differs is
30
- how you sample afterwards, not how you quantize -- pass ``--variant`` so the output is
31
- named accordingly and the file records which one it came from.
32
-
33
- python quantize_krea2.py <bf16-model.safetensors> [--format int8|w4a4|svdq|fp8]
34
- [--rank 64] [--variant turbo|base] [--out PATH]
35
- """
36
-
37
- from __future__ import annotations
38
-
39
- import argparse
40
- import gc
41
- import importlib.util
42
- import json
43
- import math
44
- import os
45
- import sys
46
- import time
47
-
48
- __version__ = "0.2.0"
49
-
50
- import torch
51
- from safetensors import safe_open
52
- from safetensors.torch import save_file
53
-
54
- def _find_comfyui_root() -> str | None:
55
- """Locate the ComfyUI root so `comfy.*` imports work regardless of cwd.
56
-
57
- Preference order: $COMFYUI_PATH, then walking up from this file (this script
58
- lives in ComfyUI/custom_nodes/<pkg>/, so comfy/ is two levels up). None if
59
- neither resolves -- deliberately *not* cwd. This module is imported by the
60
- Quantize node inside ComfyUI's own process, and putting cwd at sys.path[0]
61
- there lets any file in the working directory shadow a real import.
62
- """
63
- env = os.environ.get("COMFYUI_PATH")
64
- if env and os.path.isdir(os.path.join(env, "comfy")):
65
- return env
66
- here = os.path.dirname(os.path.abspath(__file__))
67
- candidate = os.path.abspath(os.path.join(here, "..", ".."))
68
- if os.path.isdir(os.path.join(candidate, "comfy")):
69
- return candidate
70
- return None
71
-
72
-
73
- # Running inside ComfyUI, `comfy` already imports and the path needs no help.
74
- if importlib.util.find_spec("comfy") is None:
75
- _root = _find_comfyui_root()
76
- if _root is not None:
77
- sys.path.insert(0, _root)
78
-
79
- # Guarded because __init__.py imports this module transitively: an unguarded failure
80
- # here would deregister every node in the pack, including Env Check, whose whole job is
81
- # diagnosing a broken quantization backend. Degrade to "no formats available" instead.
82
- QUANT_UNAVAILABLE = ""
83
- try:
84
- from comfy.quant_ops import QUANT_ALGOS, get_layout_class # noqa: E402
85
- except Exception as exc: # pragma: no cover - depends on the ComfyUI build
86
- QUANT_ALGOS = {}
87
- get_layout_class = None
88
- QUANT_UNAVAILABLE = "{}: {}".format(type(exc).__name__, exc)
89
-
90
- # Relative improvement the refinement loop must keep making to earn another iteration. See
91
- # `svdquant_split` for the traced numbers this default comes from.
92
- REFINE_TOL = 0.001
93
-
94
- _QUANT_SUFFIXES = ("attn.wq", "attn.wk", "attn.wv", "attn.gate", "attn.wo",
95
- "mlp.gate", "mlp.up", "mlp.down")
96
-
97
-
98
- # Checkpoints ship either bare ("blocks.0...") or prefixed ("model.diffusion_model.blocks.0...").
99
- LAYER_PREFIXES = ("model.diffusion_model.", "diffusion_model.", "")
100
-
101
- # Formats we know how to reconstruct back to BF16 from disk alone. FP8 storage is one
102
- # byte per element with no packing or pre-rotation, so `qdata * scale` recovers the
103
- # original tensor exactly as it was cast -- no architecture knowledge needed beyond
104
- # what's already in the file. INT8/W4A4 sources pack multiple values per byte and are
105
- # rotated (convrot) before quantization; unpacking those correctly needs the exact
106
- # in/out feature counts from the live nn.Linear, which isn't recoverable from the
107
- # checkpoint alone, so those are rejected instead.
108
- _DEQUANTIZABLE_FORMATS = ("float8_e4m3fn", "float8_e5m2")
109
-
110
-
111
- def detect_prefix(keys, default: str | None = None) -> str:
112
- """Return the prefix the transformer blocks live under.
113
-
114
- Raises `RuntimeError` if no prefix matches and no `default` was given. Not
115
- `SystemExit`: that is a `BaseException`, and `convert()` runs inside ComfyUI's
116
- executor via the Quantize node, where a BaseException escapes error handling
117
- entirely. `main()` catches RuntimeError and prints it as a clean CLI line.
118
- """
119
- for prefix in LAYER_PREFIXES:
120
- if any(k.startswith("{}blocks.".format(prefix)) for k in keys):
121
- return prefix
122
- if default is not None:
123
- return default
124
- raise RuntimeError(
125
- "Could not find transformer blocks in this checkpoint. Expected keys like\n"
126
- " blocks.0.attn.wq.weight or model.diffusion_model.blocks.0.attn.wq.weight\n"
127
- "This does not look like a Krea 2 diffusion model."
128
- )
129
-
130
-
131
- def check_requantizable(handle, keys, prefix: str) -> None:
132
- """Make sure every target layer that's already quantized is something we can
133
- dequantize back to BF16 (see `_DEQUANTIZABLE_FORMATS`). Anything else -- most
134
- importantly INT8/W4A4 -- needs the original BF16 (or FP16) release instead.
135
- """
136
- for key in keys:
137
- if not key.endswith(".comfy_quant"):
138
- continue
139
- layer = key[: -len(".comfy_quant")]
140
- if not is_target(layer, prefix):
141
- continue
142
- conf = json.loads(bytes(handle.get_tensor(key).tolist()))
143
- fmt = conf.get("format")
144
- if fmt not in _DEQUANTIZABLE_FORMATS:
145
- raise RuntimeError(
146
- "Layer {} is already quantized as '{}'. Only FP8-quantized layers can be "
147
- "automatically reconstructed and re-quantized; for INT8/W4A4 sources, use "
148
- "the original BF16 (or FP16) release of the model instead.".format(layer, fmt)
149
- )
150
-
151
-
152
- def dequantize_target_weight(handle, layer: str, device: str) -> torch.Tensor:
153
- """Load a target layer's weight as BF16, dequantizing it first if it's FP8.
154
-
155
- Scaled FP8 (has a `comfy_quant` marker + `weight_scale`): ``qdata.float() * scale``.
156
- Unscaled FP8 (a bare ``.to(float8_e4m3fn)`` cast, no marker): ``qdata`` as-is.
157
- Anything else is already ruled out by `check_requantizable`.
158
- """
159
- weight = handle.get_tensor("{}.weight".format(layer)).to(device=device)
160
- conf_key = "{}.comfy_quant".format(layer)
161
- if conf_key in handle.keys():
162
- scale = handle.get_tensor("{}.weight_scale".format(layer)).to(device=device).float()
163
- return (weight.to(torch.float32) * scale).to(torch.bfloat16)
164
- return weight.to(torch.bfloat16)
165
-
166
-
167
- def is_target(layer: str, prefix: str) -> bool:
168
- """Only the transformer blocks; txtfusion and friends stay high precision."""
169
- if not layer.startswith("{}blocks.".format(prefix)):
170
- return False
171
- return any(layer.endswith(s) for s in _QUANT_SUFFIXES)
172
-
173
-
174
- def leaf_name(layer: str, prefix: str) -> str | None:
175
- """``blocks.7.attn.wq`` -> ``attn.wq``. None if the layer is not inside a block.
176
-
177
- Only used to make a "nothing matched" failure self-diagnosing: a Krea 2 variant that
178
- keeps the ``blocks.`` container but renames its leaves would otherwise quantize zero
179
- layers and still write a full-size file.
180
- """
181
- head = "{}blocks.".format(prefix)
182
- if not layer.startswith(head):
183
- return None
184
- rest = layer[len(head):]
185
- return rest.split(".", 1)[1] if "." in rest else None
186
-
187
-
188
- # Rank is bought in multiples of this, so a reallocated budget lands on the same grid the
189
- # `rank` widget and the CLI already use.
190
- _RANK_STEP = 8
191
-
192
- # Per-leaf rank multipliers, relative to a uniform budget.
193
- #
194
- # Uniform rank is the obvious default and the wrong one. Measured on Krea2 Turbo at rank 64,
195
- # error removed per million branch parameters spans 6.9x across the eight leaves:
196
- #
197
- # attn.wk 0.0992 (1536x6144, branch 491K params, absorbs 30.1% of the error)
198
- # attn.wv 0.0844 (1536x6144, 491K, 25.6%)
199
- # attn.wq 0.0445 (6144x6144, 786K, 21.6%)
200
- # attn.wo 0.0411 (6144x6144, 786K, 17.6%)
201
- # attn.gate 0.0384 (6144x6144, 786K, 18.7%)
202
- # mlp.down 0.0169 (6144x16384, 1.44M, 14.3%)
203
- # mlp.gate 0.0155 (16384x6144, 1.44M, 13.8%)
204
- # mlp.up 0.0143 (16384x6144, 1.44M, 12.7%)
205
- #
206
- # The cause is GQA: Krea 2 has 12 kv heads against 48 query heads, so wk/wv are 1536-wide and
207
- # their branch costs a third of an MLP branch -- while absorbing twice as much error. Uniform
208
- # rank therefore spends most of the budget on the layers that reward it least.
209
- #
210
- # These multipliers come from a greedy allocation under a fixed byte budget (branch cost is
211
- # exactly linear in rank, so scaling all of them preserves byte-neutrality at any base rank).
212
- # At base rank 64 they reproduce the solved table: wk 360, wv 256, wq 72, wo 64, gate 56,
213
- # mlp 8, and the checkpoint comes out 0.02% smaller than uniform rank 64.
214
- #
215
- # MEASURED, and it does not deliver. The greedy solve predicted 6% less mean layer weight
216
- # error; the images say otherwise. 10 prompts, seed 987654321, against the same BF16 reference:
217
- #
218
- # uniform rank 64 LPIPS 0.3403 +-0.1019
219
- # gqa rank 64 LPIPS 0.3523 +-0.0473
220
- #
221
- # gqa is closer on 5 of 10 prompts, paired t = +0.55 on 9 df. No mean effect in either
222
- # direction. Two things did not survive scrutiny and are recorded so nobody re-derives them:
223
- # the -6% rests on an absorption ~ sqrt(rank) model fitted to nothing stronger than "returns
224
- # are sublinear", and the tempting pattern that gqa wins on the hard prompts is an artifact --
225
- # re-pairing the gqa scores at random reproduces the same -0.92 correlation, because the
226
- # baseline appears on both sides of the difference.
227
- #
228
- # What is left is real but narrow: the spread across prompts shrinks (variance ratio 4.64,
229
- # F-test p = 0.032) and the worst prompt improves, 0.4975 -> 0.4470. That is one nominally
230
- # significant result among several things tried on the same 10 images, so it is a lead for a
231
- # larger measurement, not a reason to change the default. `uniform` stays the default.
232
- #
233
- # The 6.9x spread itself is solid -- it is measured weight error over real shapes. The lesson
234
- # is that weight error is a poor proxy for image outcome, which is now the third time this
235
- # has held on this model (the refinement objective and the depth hypothesis were the others).
236
- _GQA_ALLOC = {
237
- "attn.wk": 5.625, "attn.wv": 4.0, "attn.wq": 1.125, "attn.wo": 1.0, "attn.gate": 0.875,
238
- "mlp.gate": 0.125, "mlp.up": 0.125, "mlp.down": 0.125,
239
- }
240
-
241
- RANK_ALLOCATIONS: dict[str, dict[str, float] | None] = {
242
- "uniform": None,
243
- "gqa": _GQA_ALLOC,
244
- }
245
-
246
-
247
- def leaf_ranks(rank: int, rank_alloc: str = "uniform") -> dict[str, int]:
248
- """Expand a rank budget into a per-leaf rank, one entry per name in `_QUANT_SUFFIXES`.
249
-
250
- `uniform` gives every leaf the same rank -- the historical behaviour. Other allocations
251
- redistribute the same total branch bytes according to `RANK_ALLOCATIONS`.
252
- """
253
- if rank_alloc not in RANK_ALLOCATIONS:
254
- raise RuntimeError("unknown rank allocation {!r}; expected one of {}".format(
255
- rank_alloc, ", ".join(sorted(RANK_ALLOCATIONS))))
256
- mults = RANK_ALLOCATIONS[rank_alloc]
257
- if mults is None:
258
- return {leaf: rank for leaf in _QUANT_SUFFIXES}
259
-
260
- # Byte-neutrality is the whole point of a non-uniform allocation, and it only survives
261
- # while every leaf can be expressed on the step-8 grid. Below that the smallest leaves get
262
- # rounded *up* to the floor and the file grows: at budget 16 the MLP leaves want rank 2,
263
- # take 8, and the checkpoint comes out 20% larger than uniform -- which would make any
264
- # comparison against uniform meaningless. Refuse rather than quietly change the size.
265
- smallest = min(mults.values())
266
- minimum = int(math.ceil(_RANK_STEP / smallest / _RANK_STEP)) * _RANK_STEP
267
- if rank < minimum:
268
- raise RuntimeError(
269
- "rank allocation {!r} needs a budget of at least {} (you asked for {}). Its "
270
- "smallest leaf gets {}x the budget, which below that rounds up to the step-{} "
271
- "floor and makes the checkpoint *larger* than uniform instead of the same size."
272
- .format(rank_alloc, minimum, rank, smallest, _RANK_STEP))
273
-
274
- out = {}
275
- for leaf in _QUANT_SUFFIXES:
276
- out[leaf] = int(round(rank * mults[leaf] / _RANK_STEP)) * _RANK_STEP
277
- return out
278
-
279
-
280
- def rank_for_leaf(ranks: dict[str, int], leaf: str | None) -> int:
281
- """The branch rank for a target layer, given its leaf name. 0 means "no branch".
282
-
283
- Matches by suffix because `is_target` does. An exact-key lookup would disagree with it
284
- on any variant that nested the leaves one level deeper (`blocks.0.wrapper.attn.wq`):
285
- such a layer is a quantization target, so it would be quantized, but would find no rank
286
- entry and be written without a low-rank branch -- an svdq checkpoint silently missing
287
- branches on part of the network, which is the class of quiet wrongness the zero-layer
288
- check below exists to prevent.
289
- """
290
- if not ranks or leaf is None:
291
- return 0
292
- if leaf in ranks:
293
- return ranks[leaf]
294
- for suffix, value in ranks.items():
295
- if leaf.endswith(suffix):
296
- return value
297
- return 0
298
-
299
-
300
- def svd_lowrank(weight: torch.Tensor, rank: int, oversample: int = 16, niter: int = 2):
301
- """Randomized truncated SVD: return (L1 [out, rank], L2 [rank, in]) with W ~ L1 @ L2.
302
-
303
- Randomized rather than exact because these are 6144x16384 matrices and only the top
304
- few dozen singular directions matter here; `oversample` extra probe columns plus a
305
- couple of power iterations recover them to well past the precision 4-bit quantization
306
- cares about.
307
- """
308
- w = weight.float()
309
- min_dim = min(w.shape)
310
- rank = max(1, min(int(rank), min_dim))
311
- q = min(rank + max(0, int(oversample)), min_dim)
312
-
313
- if min_dim <= max(q, 32):
314
- u, s, vh = torch.linalg.svd(w, full_matrices=False)
315
- u_r, s_r, vh_r = u[:, :rank], s[:rank], vh[:rank, :]
316
- else:
317
- u, s, v = torch.svd_lowrank(w, q=q, niter=max(0, int(niter)))
318
- u_r, s_r, vh_r = u[:, :rank], s[:rank], v[:, :rank].transpose(-2, -1)
319
-
320
- return (u_r * s_r.unsqueeze(0)).contiguous(), vh_r.contiguous()
321
-
322
-
323
- def _act_weighting(act_rms: torch.Tensor | None, in_features: int, device, floor: float = 0.05):
324
- """Turn a captured activation RMS vector into the column weighting `d`, or None.
325
-
326
- Normalised to mean 1 so the weighting only redistributes emphasis and never rescales the
327
- problem -- otherwise the error metric would no longer be comparable across layers and the
328
- refinement loop's stopping threshold would mean something different in each one.
329
-
330
- The floor matters more than it looks. `l2` is recovered by dividing by `d`, so a channel
331
- whose activation is ~0 would divide a finite number by ~0 and put an enormous row into the
332
- branch -- a branch that then dominates the output for a channel the model never actually
333
- drives. Clamping to 5% of the mean bounds that amplification at 20x.
334
- """
335
- if act_rms is None:
336
- return None
337
- d = act_rms.to(device=device, dtype=torch.float32).flatten()
338
- if d.numel() != in_features:
339
- raise RuntimeError("activation stats have {} channels but the weight has {}"
340
- .format(d.numel(), in_features))
341
- mean = d.mean()
342
- if not torch.isfinite(mean) or mean <= 0:
343
- return None
344
- d = d / mean
345
- return d.clamp(min=floor)
346
-
347
-
348
- def svdquant_split(weight: torch.Tensor, rank: int, fmt: str, groupsize: int,
349
- refine_iters: int = 100, act_rms: torch.Tensor | None = None,
350
- refine_tol: float = REFINE_TOL):
351
- """SVDQuant ordering: pull a low-rank bf16 branch out of W, quantize the residual.
352
-
353
- The low-rank branch absorbs the outlier-heavy directions, so the part that has to
354
- survive 4 bits is better conditioned.
355
-
356
- A single SVD of W is only the first guess: it picks the directions that are largest
357
- in W, which is not the same as the directions the quantizer handles worst. So this
358
- iterates the way deepcompressor does -- re-fit the branch to whatever the quantizer
359
- is *currently* getting wrong, requantize, repeat -- and keeps the best result. Pass
360
- ``refine_iters=0`` for the plain single-shot split.
361
-
362
- Iteration one is exactly that single-shot split and the best is kept, so refining
363
- can only match or beat it. Measured on Krea2 Turbo, rank 64: ~10% less
364
- reconstruction error, every layer improving.
365
-
366
- `refine_tol` is what stops it, and it is *relative* for a reason. It used to be an
367
- absolute `1e-6` against an error of order 1e-1, so the loop only stopped when an
368
- iteration failed to improve by one part in a hundred thousand -- which measured at a mean
369
- of **79.8 of the 100 iterations** across a sample of twelve layers at rank 256. What those
370
- iterations buy, traced per iteration on the same layers:
371
-
372
- stop at iterations reconstruction error
373
- 1e-6 absolute (old) ~80 baseline
374
- 0.5% relative ~9 +4.3%
375
- 0.1% relative (now) ~22 +2.0%
376
-
377
- Conversion goes from ~14 minutes to ~4. The 2% is reconstruction error, which this repo
378
- has three times measured to be a poor predictor of image outcome (see `--rank-alloc` in
379
- the README), so read it as a conversion-time change rather than a quality one.
380
-
381
- The objective is weight reconstruction error, which needs no calibration data.
382
- That is the true output error under the assumption that the input covariance is
383
- identity -- and spreading outliers with the convrot rotation is what makes that
384
- assumption a reasonable one. Closing the remaining gap to deepcompressor means
385
- measuring the real covariance, which is what makes their conversion take hours.
386
-
387
- Returns None for a degenerate weight (all-zero, or one that makes the error metric
388
- non-finite); the caller quantizes those without a branch rather than aborting.
389
- """
390
- w = weight.float()
391
- d = _act_weighting(act_rms, w.shape[1], w.device)
392
- # With a weighting, both the SVD and the error metric work in the scaled space: minimising
393
- # ||(W - W_hat) diag(d)|| approximates the error the layer's real inputs would produce,
394
- # which is what actually reaches the image. Without one this is the identity and the whole
395
- # function reduces to exactly the old behaviour.
396
- w_norm = torch.linalg.matrix_norm(w if d is None else w * d).item()
397
- if not math.isfinite(w_norm) or w_norm == 0.0:
398
- return None
399
- qw = torch.zeros((), device=w.device, dtype=torch.float32)
400
-
401
- best = None
402
- best_err = float("inf")
403
- for _ in range(max(1, refine_iters)):
404
- target = w - qw
405
- if d is None:
406
- l1, l2 = svd_lowrank(target, rank, oversample=16, niter=2)
407
- else:
408
- # Fit in the scaled space so the retained directions are the ones that matter
409
- # once real activations hit them, then undo the scale on `l2` so `l1 @ l2` is
410
- # still an approximation of the *unscaled* residual.
411
- l1, l2s = svd_lowrank(target * d, rank, oversample=16, niter=2)
412
- l2 = l2s / d
413
- del l2s
414
- l1 = l1.to(torch.bfloat16)
415
- l2 = l2.to(torch.bfloat16)
416
- lw = l1.float() @ l2.float()
417
- # The quantizer must see the unscaled residual: the scale is an analysis device, and
418
- # baking it into what gets written would change what the kernel reconstructs.
419
- residual = (w - lw).to(torch.bfloat16)
420
-
421
- qdata, params, layout, _ = _quantize_raw(residual, fmt, groupsize)
422
- qw = layout.dequantize(qdata, params).float()
423
- del qdata, params
424
-
425
- left = w - (lw + qw)
426
- err = (torch.linalg.matrix_norm(left if d is None else left * d) / w_norm).item()
427
- del lw, left
428
- # NaN fails every comparison, so without this the loop would neither break nor
429
- # ever update `best` -- it would burn all `refine_iters` and return the last
430
- # split rather than the best one.
431
- if not math.isfinite(err):
432
- break
433
- if best is not None and err >= best_err * (1.0 - refine_tol):
434
- break # refinement has stopped paying off
435
- best_err, best = err, (residual, l1, l2)
436
-
437
- return best
438
-
439
-
440
- def _quantize_raw(weight: torch.Tensor, fmt: str, groupsize: int):
441
- """Quantize to `fmt`, handing back the layout and params too.
442
-
443
- Split out from `quantize_weight` so the refinement loop can round-trip a weight
444
- (quantize then dequantize) to see what the quantizer is actually getting wrong.
445
- """
446
- layout = get_layout_class(QUANT_ALGOS[fmt]["comfy_tensor_layout"])
447
- if fmt == "int8_tensorwise":
448
- qdata, params = layout.quantize(
449
- weight, is_weight=True, per_channel=True,
450
- convrot=True, convrot_groupsize=groupsize,
451
- )
452
- conf = {"format": "int8_tensorwise", "convrot": True, "convrot_groupsize": groupsize}
453
- elif fmt == "convrot_w4a4":
454
- qdata, params = layout.quantize(weight, convrot_groupsize=groupsize)
455
- conf = {"format": "convrot_w4a4", "convrot_groupsize": groupsize,
456
- "linear_dtype": getattr(params, "linear_dtype", "int4")}
457
- elif fmt == "float8_e4m3fn":
458
- qdata, params = layout.quantize(weight, scale="recalculate")
459
- conf = {"format": "float8_e4m3fn"}
460
- else:
461
- raise ValueError(fmt)
462
- return qdata, params, layout, conf
463
-
464
-
465
- def quantize_weight(weight: torch.Tensor, fmt: str, groupsize: int):
466
- qdata, params, _, conf = _quantize_raw(weight, fmt, groupsize)
467
- return qdata, {"weight_scale": params.scale}, conf
468
-
469
-
470
- def conf_tensor(conf: dict) -> torch.Tensor:
471
- return torch.tensor(list(json.dumps(conf).encode("utf-8")), dtype=torch.uint8)
472
-
473
-
474
- def load_act_stats(path: str) -> dict[str, torch.Tensor]:
475
- """Read the file the capture nodes write: {layer name: per-input-channel RMS}."""
476
- stats = {}
477
- with safe_open(path, framework="pt", device="cpu") as handle:
478
- for key in handle.keys():
479
- stats[key] = handle.get_tensor(key)
480
- if not stats:
481
- raise RuntimeError("{} contains no activation statistics".format(path))
482
- return stats
483
-
484
-
485
- def check_act_stats_coverage(stats: dict, keys, prefix: str, ranks: dict) -> None:
486
- """Fail before any GPU work if the statistics don't cover every layer that will branch.
487
-
488
- Partial statistics are the quiet-wrongness case: the covered layers would get an
489
- activation-aware split and the rest a plain one, producing a checkpoint that is neither,
490
- and no measurement of it would mean anything.
491
-
492
- Deliberately key-only. Which layers branch depends on `is_target` plus a positive
493
- `rank_for_leaf`, both of which read the layer *name*; the one weight-dependent step in
494
- the loop (`min(wanted, min(w.shape))`) can only lower a rank, never raise a zero above
495
- it, so this pre-pass sees exactly the set the loop will branch. Doing it here instead of
496
- after the loop turns a ~6 minute mistake into a two-second one.
497
- """
498
- covered, missing = 0, []
499
- for key in keys:
500
- if not key.endswith(".weight"):
501
- continue
502
- layer = key[: -len(".weight")]
503
- if not is_target(layer, prefix):
504
- continue
505
- if rank_for_leaf(ranks, leaf_name(layer, prefix)) <= 0:
506
- continue
507
- if stats.get(layer[len(prefix):] if prefix else layer) is None:
508
- missing.append(layer)
509
- else:
510
- covered += 1
511
- if missing:
512
- raise RuntimeError(
513
- "activation stats cover {} of {} branched layers; {} are missing, e.g. {}.\n"
514
- "The capture run did not see every layer -- usually because sampling was "
515
- "interrupted, or the statistics came from a different model. Re-capture rather "
516
- "than building a checkpoint that is activation-aware in some layers and not "
517
- "others.".format(covered, covered + len(missing), len(missing),
518
- ", ".join(missing[:3])))
519
-
520
-
521
- def convert(src: str, dst: str, fmt: str, groupsize: int, device: str = "cuda", rank: int = 0,
522
- refine_iters: int = 100, variant: str = "unknown", progress_cb=None,
523
- rank_alloc: str = "uniform", act_stats: str | None = None,
524
- weight_patch=None, refine_tol: float = REFINE_TOL):
525
- """Quantize `src` into `dst`. Returns the summary line it printed.
526
-
527
- `rank` is a budget, `rank_alloc` decides how it is spread across the eight projection
528
- types -- see `RANK_ALLOCATIONS`. Non-uniform allocations keep the same total branch bytes.
529
-
530
- `weight_patch(key, tensor) -> tensor` runs on every tensor as it is read, before anything
531
- is quantized. `tools/bake_adapter.py` uses it to add a LoRA delta into the high-precision
532
- weight, so the low-rank branch is fitted against the *merged* weight rather than the LoRA
533
- being requantized on top of a finished checkpoint. Streaming it here rather than writing a
534
- merged source out first matters in practice: the bf16 source is 24 GB.
535
-
536
- `progress_cb(done, total, message)` is called as layers complete, for callers with a
537
- progress bar to drive (the ComfyUI node). Errors are `RuntimeError`, never `SystemExit`:
538
- `SystemExit` derives from `BaseException`, so raising it from inside a node would slip
539
- past ComfyUI's executor instead of being reported as a failed node. `main()` translates
540
- them back for the CLI.
541
- """
542
- out: dict[str, torch.Tensor] = {}
543
- quantized = 0
544
- kept = 0
545
- branched = 0
546
- observed_leaves: set[str] = set()
547
- ranks = leaf_ranks(rank, rank_alloc) if rank > 0 else {}
548
- used_ranks: set[int] = set()
549
- clamped: dict[str, int] = {}
550
- stats = load_act_stats(act_stats) if act_stats else {}
551
- t0 = time.time()
552
-
553
- with safe_open(src, framework="pt", device="cpu") as handle:
554
- keys = list(handle.keys())
555
- prefix = detect_prefix(keys)
556
- check_requantizable(handle, keys, prefix)
557
- if stats:
558
- check_act_stats_coverage(stats, keys, prefix, ranks)
559
-
560
- # Companion keys (old scales/markers) for target layers get regenerated fresh
561
- # when we process the ".weight" key below -- skip the stale copies on disk,
562
- # otherwise they'd overwrite our new ones later in iteration order.
563
- stale_companions = set()
564
- for key in keys:
565
- if key.endswith(".weight"):
566
- layer = key[: -len(".weight")]
567
- if is_target(layer, prefix):
568
- for suffix in ("weight_scale", "weight_scale_2", "input_scale", "comfy_quant"):
569
- stale_companions.add("{}.{}".format(layer, suffix))
570
-
571
- for i, key in enumerate(keys):
572
- if key.endswith(".weight"):
573
- layer = key[: -len(".weight")]
574
- leaf = leaf_name(layer, prefix)
575
- if leaf is not None:
576
- observed_leaves.add(leaf)
577
- if is_target(layer, prefix):
578
- w = dequantize_target_weight(handle, layer, device)
579
- if weight_patch is not None:
580
- w = weight_patch(key, w)
581
- # A reallocated budget can ask for more rank than the weight has singular
582
- # directions (attn.wk is only 1536 wide). `svd_lowrank` clamps internally,
583
- # but clamping here too keeps the reported rank honest.
584
- wanted = rank_for_leaf(ranks, leaf)
585
- leaf_rank = min(wanted, min(w.shape))
586
- if leaf and leaf_rank < wanted:
587
- clamped[leaf] = leaf_rank
588
- if leaf_rank > 0:
589
- # Stats are keyed by module path (`blocks.0.attn.wq`); checkpoint
590
- # keys may carry a prefix, so strip it to look them up.
591
- # Coverage was already proven complete before the loop started, so
592
- # a None here cannot happen for a branched layer.
593
- act = stats.get(layer[len(prefix):] if prefix else layer)
594
- split = svdquant_split(w, leaf_rank, fmt, groupsize, refine_iters,
595
- act_rms=act, refine_tol=refine_tol)
596
- if split is None:
597
- print(" warning: {} is degenerate (zero or non-finite); "
598
- "quantizing it without a low-rank branch".format(layer),
599
- flush=True)
600
- else:
601
- w, l1, l2 = split
602
- out["{}.svdq_l1".format(layer)] = l1.cpu()
603
- out["{}.svdq_l2".format(layer)] = l2.cpu()
604
- branched += 1
605
- used_ranks.add(int(l1.shape[1]))
606
- del l1, l2
607
- qdata, scales, conf = quantize_weight(w, fmt, groupsize)
608
- out[key] = qdata.cpu()
609
- for name, value in scales.items():
610
- out["{}.{}".format(layer, name)] = value.cpu()
611
- out["{}.comfy_quant".format(layer)] = conf_tensor(conf)
612
- del w, qdata, scales
613
- quantized += 1
614
- if progress_cb is not None:
615
- progress_cb(quantized, _EXPECTED_LAYERS,
616
- "quantized {} layers ({:.0f}s)".format(
617
- quantized, time.time() - t0))
618
- if quantized % 32 == 0:
619
- torch.cuda.empty_cache()
620
- print(f" [{i + 1}/{len(keys)}] quantized {quantized} layers "
621
- f"({time.time() - t0:.0f}s)", flush=True)
622
- continue
623
- if key in stale_companions:
624
- continue
625
- value = handle.get_tensor(key)
626
- # The non-quantized layers (txtfusion, embeddings) can carry a LoRA too, and they
627
- # are the half a bake would otherwise silently drop.
628
- out[key] = value if weight_patch is None else weight_patch(key, value)
629
- kept += 1
630
-
631
- gc.collect()
632
- torch.cuda.empty_cache()
633
-
634
- if quantized == 0:
635
- raise RuntimeError(
636
- "Found the transformer blocks but quantized nothing: no layer under "
637
- "'{}blocks.' ends in one of the expected leaf names.\n"
638
- " expected: {}\n"
639
- " observed: {}\n"
640
- "This is a Krea 2 variant with different layer naming; quantize_krea2.py "
641
- "would otherwise have written a full-size file that does nothing."
642
- .format(prefix, ", ".join(sorted(_QUANT_SUFFIXES)),
643
- ", ".join(sorted(observed_leaves)) or "none")
644
- )
645
-
646
- # A leaf can only be clamped when the allocation asked for more rank than the weight is
647
- # wide, which means part of the budget went unspent and the file is *smaller* than the
648
- # uniform one -- so a comparison against uniform at this budget is no longer like-for-like.
649
- if clamped:
650
- print("warning: rank allocation {!r} asked for more rank than these layers are wide, "
651
- "so they were capped and part of the budget went unspent: {}. The checkpoint is "
652
- "smaller than uniform rank {}, not the same size -- lower the budget to compare "
653
- "them fairly.".format(
654
- rank_alloc, ", ".join("{}={}".format(k, v) for k, v in sorted(clamped.items())),
655
- rank), flush=True)
656
-
657
- created = len(out) - kept - quantized
658
- factors = "" if not rank else " + {} low-rank factors".format(branched * 2)
659
- print(f"quantized {quantized} layers; {kept} tensors passed through; "
660
- f"{created} tensors created (qdata/scale/marker{factors}); "
661
- f"{len(out)} tensors total; writing {dst} ...", flush=True)
662
-
663
- # Recorded so the loader (and anyone inspecting the file) can tell what produced it.
664
- # safetensors metadata is str -> str only. The loader treats all of this as optional:
665
- # checkpoints published before this existed still load, with the rank recovered from
666
- # the svdq_l1 shape as before.
667
- # The loader trusts the factor shapes over `krea2_svdquant_rank`, and under a non-uniform
668
- # allocation no single number is the rank -- so record the budget, the allocation name and
669
- # the full per-leaf map, and leave `rank` meaning "budget" for older loaders reading it.
670
- metadata = {
671
- "krea2_svdquant_tool_version": __version__,
672
- "krea2_svdquant_format": fmt,
673
- "krea2_svdquant_rank": str(rank),
674
- "krea2_svdquant_rank_alloc": rank_alloc,
675
- "krea2_svdquant_rank_map": json.dumps(ranks, sort_keys=True) if ranks else "",
676
- "krea2_svdquant_groupsize": str(groupsize),
677
- "krea2_svdquant_refine_iters": str(refine_iters if rank else 0),
678
- "krea2_svdquant_variant": variant,
679
- "krea2_svdquant_quantized_layers": str(quantized),
680
- "krea2_svdquant_branched_layers": str(branched),
681
- "krea2_svdquant_source_name": os.path.basename(src),
682
- "krea2_svdquant_source_bytes": str(os.path.getsize(src)),
683
- # Two checkpoints with identical rank/refine settings are still different models if
684
- # one was activation-weighted, so this has to be recoverable from the file.
685
- "krea2_svdquant_act_stats": os.path.basename(act_stats) if act_stats else "",
686
- }
687
- if progress_cb is not None:
688
- progress_cb(quantized, _EXPECTED_LAYERS, "writing {:.2f} GB ...".format(
689
- sum(t.numel() * t.element_size() for t in out.values()) / 1024 ** 3))
690
- save_file(out, dst, metadata=metadata)
691
- size = os.path.getsize(dst) / 1024 ** 3
692
- rank_desc = ""
693
- if used_ranks:
694
- rank_desc = (" rank {}".format(next(iter(used_ranks))) if len(used_ranks) == 1
695
- else " rank {}-{} ({})".format(min(used_ranks), max(used_ranks),
696
- rank_alloc))
697
- summary = ("quantized {} layers ({} branched{}) in {:.0f}s -> {} ({:.2f} GB, source "
698
- "{:.2f} GB)".format(quantized, branched, rank_desc, time.time() - t0, dst, size,
699
- os.path.getsize(src) / 1024 ** 3))
700
- print("done in {:.0f}s -> {} ({:.2f} GB, source {:.2f} GB)".format(
701
- time.time() - t0, dst, size, os.path.getsize(src) / 1024 ** 3))
702
- return summary
703
-
704
-
705
- # The Krea 2 block count, used only to scale a progress bar. A variant with a different
706
- # depth still quantizes correctly; the bar is just less accurate.
707
- _EXPECTED_LAYERS = 28 * len(_QUANT_SUFFIXES)
708
-
709
- _FORMAT_ALIASES = {
710
- "int8": "int8_tensorwise",
711
- "w4a4": "convrot_w4a4",
712
- "svdq": "convrot_w4a4",
713
- "fp8": "float8_e4m3fn",
714
- }
715
-
716
- _GENERIC_STEMS = ("raw", "model", "diffusion_pytorch_model", "turbo")
717
-
718
- SAMPLER_HINTS = {
719
- "base": ("Base (non-distilled) model: start from ~50 steps, cfg 3.5, euler/simple, and a "
720
- "real negative prompt. See workflows/krea2_base_svdquant_w4a4_t2i.json."),
721
- "turbo": ("Turbo (distilled) model: 8 steps, cfg 1.0, euler/simple. "
722
- "See workflows/krea2_turbo_svdquant_w4a4_t2i.json."),
723
- }
724
-
725
-
726
- def resolve_format(fmt_name: str, rank: int, rank_was_set: bool = True) -> tuple[str, int]:
727
- """Map a CLI/node format name to a comfy_kitchen format, validating the rank with it.
728
-
729
- Shared by `main()` and the ComfyUI node so the two cannot drift apart on which
730
- combinations are legal.
731
- """
732
- fmt = _FORMAT_ALIASES[fmt_name]
733
- if fmt not in QUANT_ALGOS:
734
- raise RuntimeError("{} is not available in this ComfyUI build".format(fmt))
735
- # Silently zeroing the rank here used to make `--format w4a4 --rank 128` look like it had
736
- # done something it had not.
737
- if fmt_name != "svdq" and rank_was_set:
738
- raise RuntimeError(
739
- "rank only applies to format 'svdq' (you asked for '{}'). The low-rank branch is "
740
- "what distinguishes svdq from plain w4a4.".format(fmt_name))
741
- if fmt_name == "svdq" and rank <= 0:
742
- raise RuntimeError("format 'svdq' needs rank > 0; use format 'w4a4' for no branch")
743
- return fmt, (rank if fmt_name == "svdq" else 0)
744
-
745
-
746
- def derive_out_path(src: str, fmt_name: str, rank: int, variant: str,
747
- rank_alloc: str = "uniform", act_stats: str | None = None
748
- ) -> tuple[str, str | None]:
749
- """The default output path, plus a note to show when the source name is uninformative."""
750
- stem = os.path.splitext(os.path.basename(src))[0]
751
- note = None
752
- if variant != "unknown":
753
- stem = "Krea2-{}".format(variant.capitalize())
754
- elif stem.lower() in _GENERIC_STEMS:
755
- note = ("note: '{}' is a generic filename. Set the variant (or an explicit output "
756
- "name) to get a checkpoint name you will still recognise next month."
757
- .format(stem))
758
- # The allocation goes in the filename because two checkpoints with the same budget and
759
- # different allocations are the same size -- nothing else would tell them apart.
760
- alloc_tag = "" if rank_alloc == "uniform" else "-{}".format(rank_alloc)
761
- # Same reasoning as alloc_tag: an activation-weighted build is byte-identical in shape to
762
- # a plain one, so without a tag the two are indistinguishable on disk.
763
- act_tag = "-actaware" if act_stats else ""
764
- suffix = ("SVDQuant-W4A4-rank{}{}{}".format(rank, alloc_tag, act_tag) if rank else
765
- ("{}-convrot".format(fmt_name.upper()) if fmt_name != "fp8" else "FP8"))
766
- return os.path.join(os.path.dirname(src), "{}-{}.safetensors".format(stem, suffix)), note
767
-
768
-
769
- def main():
770
- ap = argparse.ArgumentParser()
771
- ap.add_argument("src")
772
- ap.add_argument("--format", choices=["int8", "w4a4", "svdq", "fp8"], default="int8",
773
- help="svdq = w4a4 residual + SVDQuant low-rank bf16 branch; "
774
- "fp8 = float8_e4m3fn, no convrot, no low-rank branch")
775
- ap.add_argument("--groupsize", type=int, default=256, help="unused for fp8")
776
- ap.add_argument("--rank", type=int, default=64, help="low-rank branch budget, svdq only")
777
- ap.add_argument("--rank-alloc", choices=sorted(RANK_ALLOCATIONS), default="uniform",
778
- help="how to spread the rank budget across the eight projection types. "
779
- "uniform = same rank everywhere. gqa = byte-neutral reallocation "
780
- "towards the GQA kv projections, which absorb ~2x the error at a "
781
- "third of the branch cost (see RANK_ALLOCATIONS)")
782
- ap.add_argument("--refine-iters", type=int, default=100,
783
- help="svdq only: refine the low-rank branch against the quantization "
784
- "error, keeping the best (0 = plain single-shot SVD, much faster "
785
- "but ~10%% more reconstruction error). This is a cap; --refine-tol "
786
- "is what usually stops the loop first")
787
- ap.add_argument("--refine-tol", type=float, default=REFINE_TOL, metavar="FRACTION",
788
- help="svdq only: stop refining a layer once an iteration improves its "
789
- "reconstruction error by less than this fraction (default %(default)s "
790
- "= 0.1%%). Lower means more iterations for less return: 0.001 takes "
791
- "~22 iterations per layer, 0.005 takes ~9 for 2%% more error, and 0 "
792
- "restores the old behaviour of running nearly all --refine-iters")
793
- ap.add_argument("--variant", choices=["turbo", "base", "unknown"], default="unknown",
794
- help="which Krea 2 release this is. Only affects the output filename "
795
- "and the recorded metadata -- quantization is identical for both, "
796
- "the difference is the sampler settings you run afterwards")
797
- ap.add_argument("--act-stats", default=None, metavar="PATH",
798
- help="svdq only: activation statistics from the Krea2 SVDQuant Capture "
799
- "nodes. Weights the low-rank split by per-input-channel activation "
800
- "RMS, so the branch spends its capacity on the directions the model "
801
- "actually drives instead of on the largest weights. Costs nothing "
802
- "at runtime -- same rank, format, size and kernel")
803
- ap.add_argument("--out", default=None)
804
- ap.add_argument("--device", default="cuda")
805
- args = ap.parse_args()
806
-
807
- if args.act_stats:
808
- if args.format != "svdq":
809
- raise SystemExit("--act-stats only applies to format 'svdq': it weights the "
810
- "low-rank split, and the other formats have no branch")
811
- if not os.path.exists(args.act_stats):
812
- raise SystemExit("--act-stats file not found: {}".format(args.act_stats))
813
-
814
- # RuntimeError is the shared failure type (see `convert`); the CLI wants SystemExit so it
815
- # prints one clean line instead of a traceback.
816
- try:
817
- fmt, rank = resolve_format(args.format, args.rank,
818
- rank_was_set=args.rank != ap.get_default("rank"))
819
- except RuntimeError as exc:
820
- raise SystemExit(str(exc)) from None
821
-
822
- if args.rank_alloc != "uniform" and not rank:
823
- raise SystemExit("--rank-alloc only applies to format 'svdq'")
824
-
825
- out = args.out
826
- if out is None:
827
- out, note = derive_out_path(args.src, args.format, rank, args.variant, args.rank_alloc,
828
- args.act_stats)
829
- if note:
830
- print(note, flush=True)
831
- try:
832
- convert(args.src, out, fmt, args.groupsize, args.device, rank, args.refine_iters,
833
- variant=args.variant, rank_alloc=args.rank_alloc, act_stats=args.act_stats,
834
- refine_tol=args.refine_tol)
835
- except RuntimeError as exc:
836
- raise SystemExit(str(exc)) from None
837
-
838
- hint = SAMPLER_HINTS.get(args.variant)
839
- if hint:
840
- print("\n" + hint)
841
-
842
-
843
- if __name__ == "__main__":
844
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Quantize a BF16 Krea2 checkpoint into a ComfyUI-native quantized checkpoint.
2
+
3
+ ComfyUI already ships native kernels for these formats via comfy_kitchen, so the output
4
+ loads with a plain ``UNETLoader`` -- no custom node, and ordinary LoRA loaders work.
5
+
6
+ Four targets:
7
+
8
+ * ``int8`` -> ``int8_tensorwise`` with per-channel scales + convrot (W8A8).
9
+ Natively accelerated on Ampere INT8 tensor cores.
10
+ * ``w4a4`` -> ``convrot_w4a4`` (W4A4), smaller and potentially faster, 4-bit quality.
11
+ * ``svdq`` -> the same W4A4 residual plus an SVDQuant low-rank bf16 branch, which
12
+ absorbs the outlier directions 4 bits handle worst. Needs this repo's
13
+ loader node; a plain ``UNETLoader`` cannot see the branch.
14
+ * ``fp8`` -> ``float8_e4m3fn``, a single per-tensor scale, no convrot, no calibration.
15
+ Lightest touch of the four -- best fidelity, smallest speedup, and the
16
+ only one that needs no rotation or outlier handling because 8-bit float
17
+ already has enough dynamic range for these weights.
18
+
19
+ None of them need a calibration dataset: int8/w4a4/svdq spread outliers analytically via
20
+ the convrot (group-wise Hadamard) rotation, and activations are quantized by the kernel at
21
+ run time; fp8 just needs one abs-max scale per tensor.
22
+
23
+ Only the 224 transformer-block linears are quantized, matching the layer set used by the
24
+ reference ``krea2_raw_int8_convrot`` checkpoint. Norms, modulation, the text-fusion stack
25
+ and the final layer stay in their original precision -- they are small and quantization
26
+ noise there is disproportionately visible.
27
+
28
+ The layer selection is variant-agnostic: Krea 2 Turbo and the base (non-distilled)
29
+ release share the same block naming, so the same command converts either. What differs is
30
+ how you sample afterwards, not how you quantize -- pass ``--variant`` so the output is
31
+ named accordingly and the file records which one it came from.
32
+
33
+ python quantize_krea2.py <bf16-model.safetensors> [--format int8|w4a4|svdq|fp8]
34
+ [--rank 64] [--variant turbo|base] [--out PATH]
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import argparse
40
+ import gc
41
+ import importlib.util
42
+ import json
43
+ import math
44
+ import os
45
+ import sys
46
+ import time
47
+ import zlib
48
+
49
+ __version__ = "0.2.0"
50
+
51
+ import torch
52
+ from safetensors import safe_open
53
+ from safetensors.torch import save_file
54
+
55
+ def _find_comfyui_root() -> str | None:
56
+ """Locate the ComfyUI root so `comfy.*` imports work regardless of cwd.
57
+
58
+ Preference order: $COMFYUI_PATH, then walking up from this file (this script
59
+ lives in ComfyUI/custom_nodes/<pkg>/, so comfy/ is two levels up). None if
60
+ neither resolves -- deliberately *not* cwd. This module is imported by the
61
+ Quantize node inside ComfyUI's own process, and putting cwd at sys.path[0]
62
+ there lets any file in the working directory shadow a real import.
63
+ """
64
+ env = os.environ.get("COMFYUI_PATH")
65
+ if env and os.path.isdir(os.path.join(env, "comfy")):
66
+ return env
67
+ here = os.path.dirname(os.path.abspath(__file__))
68
+ candidate = os.path.abspath(os.path.join(here, "..", ".."))
69
+ if os.path.isdir(os.path.join(candidate, "comfy")):
70
+ return candidate
71
+ for extra in ("D:/ComfyUI", "C:/ComfyUI", os.path.abspath(os.path.join(here, "..", "ComfyUI"))):
72
+ if os.path.isdir(os.path.join(extra, "comfy")):
73
+ return extra
74
+ return None
75
+
76
+
77
+ # Running inside ComfyUI, `comfy` already imports and the path needs no help.
78
+ if importlib.util.find_spec("comfy") is None:
79
+ _root = _find_comfyui_root()
80
+ if _root is not None:
81
+ sys.path.insert(0, _root)
82
+
83
+ # Guarded because __init__.py imports this module transitively: an unguarded failure
84
+ # here would deregister every node in the pack, including Env Check, whose whole job is
85
+ # diagnosing a broken quantization backend. Degrade to "no formats available" instead.
86
+ QUANT_UNAVAILABLE = ""
87
+ try:
88
+ from comfy.quant_ops import QUANT_ALGOS, get_layout_class # noqa: E402
89
+ except Exception as exc: # pragma: no cover - depends on the ComfyUI build
90
+ QUANT_ALGOS = {}
91
+ get_layout_class = None
92
+ QUANT_UNAVAILABLE = "{}: {}".format(type(exc).__name__, exc)
93
+
94
+ # Relative improvement the refinement loop must keep making to earn another iteration. See
95
+ # `svdquant_split` for the traced numbers this default comes from.
96
+ REFINE_TOL = 0.001
97
+
98
+ # Deterministic by default. The low-rank split is a *randomized* SVD, so an unseeded build is
99
+ # irreproducible even on the same machine -- two files that differ by ~1e-4 per weight and by
100
+ # a visibly different image after 8 steps of sampling. `None` restores the old ambient-RNG
101
+ # behaviour for anyone who wants to sample the spread rather than pin it.
102
+ DEFAULT_SEED = 0
103
+
104
+ _QUANT_SUFFIXES = ("attn.wq", "attn.wk", "attn.wv", "attn.gate", "attn.wo",
105
+ "mlp.gate", "mlp.up", "mlp.down")
106
+
107
+
108
+ # Checkpoints ship either bare ("blocks.0...") or prefixed ("model.diffusion_model.blocks.0...").
109
+ LAYER_PREFIXES = ("model.diffusion_model.", "diffusion_model.", "")
110
+
111
+ # Formats we know how to reconstruct back to BF16 from disk alone. FP8 storage is one
112
+ # byte per element with no packing or pre-rotation, so `qdata * scale` recovers the
113
+ # original tensor exactly as it was cast -- no architecture knowledge needed beyond
114
+ # what's already in the file. INT8/W4A4 sources pack multiple values per byte and are
115
+ # rotated (convrot) before quantization; unpacking those correctly needs the exact
116
+ # in/out feature counts from the live nn.Linear, which isn't recoverable from the
117
+ # checkpoint alone, so those are rejected instead.
118
+ _DEQUANTIZABLE_FORMATS = ("float8_e4m3fn", "float8_e5m2")
119
+
120
+
121
+ def detect_prefix(keys, default: str | None = None) -> str:
122
+ """Return the prefix the transformer blocks live under.
123
+
124
+ Raises `RuntimeError` if no prefix matches and no `default` was given. Not
125
+ `SystemExit`: that is a `BaseException`, and `convert()` runs inside ComfyUI's
126
+ executor via the Quantize node, where a BaseException escapes error handling
127
+ entirely. `main()` catches RuntimeError and prints it as a clean CLI line.
128
+ """
129
+ for prefix in LAYER_PREFIXES:
130
+ if any(k.startswith("{}blocks.".format(prefix)) for k in keys):
131
+ return prefix
132
+ if default is not None:
133
+ return default
134
+ raise RuntimeError(
135
+ "Could not find transformer blocks in this checkpoint. Expected keys like\n"
136
+ " blocks.0.attn.wq.weight or model.diffusion_model.blocks.0.attn.wq.weight\n"
137
+ "This does not look like a Krea 2 diffusion model."
138
+ )
139
+
140
+
141
+ def check_requantizable(handle, keys, prefix: str) -> None:
142
+ """Make sure every target layer that's already quantized is something we can
143
+ dequantize back to BF16 (see `_DEQUANTIZABLE_FORMATS`). Anything else -- most
144
+ importantly INT8/W4A4 -- needs the original BF16 (or FP16) release instead.
145
+ """
146
+ for key in keys:
147
+ if not key.endswith(".comfy_quant"):
148
+ continue
149
+ layer = key[: -len(".comfy_quant")]
150
+ if not is_target(layer, prefix):
151
+ continue
152
+ conf = json.loads(bytes(handle.get_tensor(key).tolist()))
153
+ fmt = conf.get("format")
154
+ if fmt not in _DEQUANTIZABLE_FORMATS:
155
+ raise RuntimeError(
156
+ "Layer {} is already quantized as '{}'. Only FP8-quantized layers can be "
157
+ "automatically reconstructed and re-quantized; for INT8/W4A4 sources, use "
158
+ "the original BF16 (or FP16) release of the model instead.".format(layer, fmt)
159
+ )
160
+
161
+
162
+ def dequantize_target_weight(handle, layer: str, device: str) -> torch.Tensor:
163
+ """Load a target layer's weight as BF16, dequantizing it first if it's FP8.
164
+
165
+ Scaled FP8 (has a `comfy_quant` marker + `weight_scale`): ``qdata.float() * scale``.
166
+ Unscaled FP8 (a bare ``.to(float8_e4m3fn)`` cast, no marker): ``qdata`` as-is.
167
+ Anything else is already ruled out by `check_requantizable`.
168
+ """
169
+ weight = handle.get_tensor("{}.weight".format(layer)).to(device=device)
170
+ conf_key = "{}.comfy_quant".format(layer)
171
+ if conf_key in handle.keys():
172
+ scale = handle.get_tensor("{}.weight_scale".format(layer)).to(device=device).float()
173
+ return (weight.to(torch.float32) * scale).to(torch.bfloat16)
174
+ return weight.to(torch.bfloat16)
175
+
176
+
177
+ def is_target(layer: str, prefix: str) -> bool:
178
+ """Only the transformer blocks; txtfusion and friends stay high precision."""
179
+ if not layer.startswith("{}blocks.".format(prefix)):
180
+ return False
181
+ return any(layer.endswith(s) for s in _QUANT_SUFFIXES)
182
+
183
+
184
+ def leaf_name(layer: str, prefix: str) -> str | None:
185
+ """``blocks.7.attn.wq`` -> ``attn.wq``. None if the layer is not inside a block.
186
+
187
+ Only used to make a "nothing matched" failure self-diagnosing: a Krea 2 variant that
188
+ keeps the ``blocks.`` container but renames its leaves would otherwise quantize zero
189
+ layers and still write a full-size file.
190
+ """
191
+ head = "{}blocks.".format(prefix)
192
+ if not layer.startswith(head):
193
+ return None
194
+ rest = layer[len(head):]
195
+ return rest.split(".", 1)[1] if "." in rest else None
196
+
197
+
198
+ # Rank is bought in multiples of this, so a reallocated budget lands on the same grid the
199
+ # `rank` widget and the CLI already use.
200
+ _RANK_STEP = 8
201
+
202
+ # Per-leaf rank multipliers, relative to a uniform budget.
203
+ #
204
+ # Uniform rank is the obvious default and the wrong one. Measured on Krea2 Turbo at rank 64,
205
+ # error removed per million branch parameters spans 6.9x across the eight leaves:
206
+ #
207
+ # attn.wk 0.0992 (1536x6144, branch 491K params, absorbs 30.1% of the error)
208
+ # attn.wv 0.0844 (1536x6144, 491K, 25.6%)
209
+ # attn.wq 0.0445 (6144x6144, 786K, 21.6%)
210
+ # attn.wo 0.0411 (6144x6144, 786K, 17.6%)
211
+ # attn.gate 0.0384 (6144x6144, 786K, 18.7%)
212
+ # mlp.down 0.0169 (6144x16384, 1.44M, 14.3%)
213
+ # mlp.gate 0.0155 (16384x6144, 1.44M, 13.8%)
214
+ # mlp.up 0.0143 (16384x6144, 1.44M, 12.7%)
215
+ #
216
+ # The cause is GQA: Krea 2 has 12 kv heads against 48 query heads, so wk/wv are 1536-wide and
217
+ # their branch costs a third of an MLP branch -- while absorbing twice as much error. Uniform
218
+ # rank therefore spends most of the budget on the layers that reward it least.
219
+ #
220
+ # These multipliers come from a greedy allocation under a fixed byte budget (branch cost is
221
+ # exactly linear in rank, so scaling all of them preserves byte-neutrality at any base rank).
222
+ # At base rank 64 they reproduce the solved table: wk 360, wv 256, wq 72, wo 64, gate 56,
223
+ # mlp 8, and the checkpoint comes out 0.02% smaller than uniform rank 64.
224
+ #
225
+ # MEASURED, and it does not deliver. The greedy solve predicted 6% less mean layer weight
226
+ # error; the images say otherwise. 10 prompts, seed 987654321, against the same BF16 reference:
227
+ #
228
+ # uniform rank 64 LPIPS 0.3403 +-0.1019
229
+ # gqa rank 64 LPIPS 0.3523 +-0.0473
230
+ #
231
+ # gqa is closer on 5 of 10 prompts, paired t = +0.55 on 9 df. No mean effect in either
232
+ # direction. Two things did not survive scrutiny and are recorded so nobody re-derives them:
233
+ # the -6% rests on an absorption ~ sqrt(rank) model fitted to nothing stronger than "returns
234
+ # are sublinear", and the tempting pattern that gqa wins on the hard prompts is an artifact --
235
+ # re-pairing the gqa scores at random reproduces the same -0.92 correlation, because the
236
+ # baseline appears on both sides of the difference.
237
+ #
238
+ # What is left is real but narrow: the spread across prompts shrinks (variance ratio 4.64,
239
+ # F-test p = 0.032) and the worst prompt improves, 0.4975 -> 0.4470. That is one nominally
240
+ # significant result among several things tried on the same 10 images, so it is a lead for a
241
+ # larger measurement, not a reason to change the default. `uniform` stays the default.
242
+ #
243
+ # The 6.9x spread itself is solid -- it is measured weight error over real shapes. The lesson
244
+ # is that weight error is a poor proxy for image outcome, which is now the third time this
245
+ # has held on this model (the refinement objective and the depth hypothesis were the others).
246
+ _GQA_ALLOC = {
247
+ "attn.wk": 5.625, "attn.wv": 4.0, "attn.wq": 1.125, "attn.wo": 1.0, "attn.gate": 0.875,
248
+ "mlp.gate": 0.125, "mlp.up": 0.125, "mlp.down": 0.125,
249
+ }
250
+
251
+ RANK_ALLOCATIONS: dict[str, dict[str, float] | None] = {
252
+ "uniform": None,
253
+ "gqa": _GQA_ALLOC,
254
+ }
255
+
256
+
257
+ def leaf_ranks(rank: int, rank_alloc: str = "uniform") -> dict[str, int]:
258
+ """Expand a rank budget into a per-leaf rank, one entry per name in `_QUANT_SUFFIXES`.
259
+
260
+ `uniform` gives every leaf the same rank -- the historical behaviour. Other allocations
261
+ redistribute the same total branch bytes according to `RANK_ALLOCATIONS`.
262
+ """
263
+ if rank_alloc not in RANK_ALLOCATIONS:
264
+ raise RuntimeError("unknown rank allocation {!r}; expected one of {}".format(
265
+ rank_alloc, ", ".join(sorted(RANK_ALLOCATIONS))))
266
+ mults = RANK_ALLOCATIONS[rank_alloc]
267
+ if mults is None:
268
+ return {leaf: rank for leaf in _QUANT_SUFFIXES}
269
+
270
+ # Byte-neutrality is the whole point of a non-uniform allocation, and it only survives
271
+ # while every leaf can be expressed on the step-8 grid. Below that the smallest leaves get
272
+ # rounded *up* to the floor and the file grows: at budget 16 the MLP leaves want rank 2,
273
+ # take 8, and the checkpoint comes out 20% larger than uniform -- which would make any
274
+ # comparison against uniform meaningless. Refuse rather than quietly change the size.
275
+ smallest = min(mults.values())
276
+ minimum = int(math.ceil(_RANK_STEP / smallest / _RANK_STEP)) * _RANK_STEP
277
+ if rank < minimum:
278
+ raise RuntimeError(
279
+ "rank allocation {!r} needs a budget of at least {} (you asked for {}). Its "
280
+ "smallest leaf gets {}x the budget, which below that rounds up to the step-{} "
281
+ "floor and makes the checkpoint *larger* than uniform instead of the same size."
282
+ .format(rank_alloc, minimum, rank, smallest, _RANK_STEP))
283
+
284
+ out = {}
285
+ for leaf in _QUANT_SUFFIXES:
286
+ out[leaf] = int(round(rank * mults[leaf] / _RANK_STEP)) * _RANK_STEP
287
+ return out
288
+
289
+
290
+ def rank_for_leaf(ranks: dict[str, int], leaf: str | None) -> int:
291
+ """The branch rank for a target layer, given its leaf name. 0 means "no branch".
292
+
293
+ Matches by suffix because `is_target` does. An exact-key lookup would disagree with it
294
+ on any variant that nested the leaves one level deeper (`blocks.0.wrapper.attn.wq`):
295
+ such a layer is a quantization target, so it would be quantized, but would find no rank
296
+ entry and be written without a low-rank branch -- an svdq checkpoint silently missing
297
+ branches on part of the network, which is the class of quiet wrongness the zero-layer
298
+ check below exists to prevent.
299
+ """
300
+ if not ranks or leaf is None:
301
+ return 0
302
+ if leaf in ranks:
303
+ return ranks[leaf]
304
+ for suffix, value in ranks.items():
305
+ if leaf.endswith(suffix):
306
+ return value
307
+ return 0
308
+
309
+
310
+ def svd_lowrank(weight: torch.Tensor, rank: int, oversample: int = 16, niter: int = 2):
311
+ """Randomized truncated SVD: return (L1 [out, rank], L2 [rank, in]) with W ~ L1 @ L2.
312
+
313
+ Randomized rather than exact because these are 6144x16384 matrices and only the top
314
+ few dozen singular directions matter here; `oversample` extra probe columns plus a
315
+ couple of power iterations recover them to well past the precision 4-bit quantization
316
+ cares about.
317
+
318
+ "Randomized" is load-bearing for reproducibility: `torch.svd_lowrank` draws its probe
319
+ matrix from the *ambient* RNG, so seeding is the caller's job (`svdquant_split` does it
320
+ per layer). Left unseeded, two runs of this script on the same GPU with identical
321
+ arguments produce different checkpoints -- close, but not equal, and visibly so once a
322
+ sampler amplifies them.
323
+ """
324
+ w = weight.float()
325
+ min_dim = min(w.shape)
326
+ rank = max(1, min(int(rank), min_dim))
327
+ q = min(rank + max(0, int(oversample)), min_dim)
328
+
329
+ if min_dim <= max(q, 32):
330
+ u, s, vh = torch.linalg.svd(w, full_matrices=False)
331
+ u_r, s_r, vh_r = u[:, :rank], s[:rank], vh[:rank, :]
332
+ else:
333
+ u, s, v = torch.svd_lowrank(w, q=q, niter=max(0, int(niter)))
334
+ u_r, s_r, vh_r = u[:, :rank], s[:rank], v[:, :rank].transpose(-2, -1)
335
+
336
+ return (u_r * s_r.unsqueeze(0)).contiguous(), vh_r.contiguous()
337
+
338
+
339
+ def _act_weighting(act_rms: torch.Tensor | None, in_features: int, device, floor: float = 0.05):
340
+ """Turn a captured activation RMS vector into the column weighting `d`, or None.
341
+
342
+ Normalised to mean 1 so the weighting only redistributes emphasis and never rescales the
343
+ problem -- otherwise the error metric would no longer be comparable across layers and the
344
+ refinement loop's stopping threshold would mean something different in each one.
345
+
346
+ The floor matters more than it looks. `l2` is recovered by dividing by `d`, so a channel
347
+ whose activation is ~0 would divide a finite number by ~0 and put an enormous row into the
348
+ branch -- a branch that then dominates the output for a channel the model never actually
349
+ drives. Clamping to 5% of the mean bounds that amplification at 20x.
350
+ """
351
+ if act_rms is None:
352
+ return None
353
+ d = act_rms.to(device=device, dtype=torch.float32).flatten()
354
+ if d.numel() != in_features:
355
+ raise RuntimeError("activation stats have {} channels but the weight has {}"
356
+ .format(d.numel(), in_features))
357
+ mean = d.mean()
358
+ if not torch.isfinite(mean) or mean <= 0:
359
+ return None
360
+ d = d / mean
361
+ return d.clamp(min=floor)
362
+
363
+
364
+ def svdquant_split(weight: torch.Tensor, rank: int, fmt: str, groupsize: int,
365
+ refine_iters: int = 100, act_rms: torch.Tensor | None = None,
366
+ refine_tol: float = REFINE_TOL, seed: int | None = None):
367
+ """SVDQuant ordering: pull a low-rank bf16 branch out of W, quantize the residual.
368
+
369
+ The low-rank branch absorbs the outlier-heavy directions, so the part that has to
370
+ survive 4 bits is better conditioned.
371
+
372
+ A single SVD of W is only the first guess: it picks the directions that are largest
373
+ in W, which is not the same as the directions the quantizer handles worst. So this
374
+ iterates the way deepcompressor does -- re-fit the branch to whatever the quantizer
375
+ is *currently* getting wrong, requantize, repeat -- and keeps the best result. Pass
376
+ ``refine_iters=0`` for the plain single-shot split.
377
+
378
+ Iteration one is exactly that single-shot split and the best is kept, so refining
379
+ can only match or beat it. Measured on Krea2 Turbo, rank 64: ~10% less
380
+ reconstruction error, every layer improving.
381
+
382
+ `refine_tol` is what stops it, and it is *relative* for a reason. It used to be an
383
+ absolute `1e-6` against an error of order 1e-1, so the loop only stopped when an
384
+ iteration failed to improve by one part in a hundred thousand -- which measured at a mean
385
+ of **79.8 of the 100 iterations** across a sample of twelve layers at rank 256. What those
386
+ iterations buy, traced per iteration on the same layers:
387
+
388
+ stop at iterations reconstruction error
389
+ 1e-6 absolute (old) ~80 baseline
390
+ 0.5% relative ~9 +4.3%
391
+ 0.1% relative (now) ~22 +2.0%
392
+
393
+ Conversion goes from ~14 minutes to ~4. The 2% is reconstruction error, which this repo
394
+ has three times measured to be a poor predictor of image outcome (see `--rank-alloc` in
395
+ the README), so read it as a conversion-time change rather than a quality one.
396
+
397
+ The objective is weight reconstruction error, which needs no calibration data.
398
+ That is the true output error under the assumption that the input covariance is
399
+ identity -- and spreading outliers with the convrot rotation is what makes that
400
+ assumption a reasonable one. Closing the remaining gap to deepcompressor means
401
+ measuring the real covariance, which is what makes their conversion take hours.
402
+
403
+ `seed` makes the run reproducible. The refinement draws a fresh random probe matrix on
404
+ every iteration through `svd_lowrank`, from the ambient RNG, so without this two builds
405
+ of the same checkpoint with the same arguments are merely *similar*: measured at ~1e-4
406
+ relative on the dequantized weights, which is small until a sampler spends 8-50 steps
407
+ compounding it and you get a visibly different image. Seeding once here rather than per
408
+ iteration is enough -- the whole sequence of draws then follows from one number. Pass
409
+ `None` for the old ambient-RNG behaviour.
410
+
411
+ Reproducibility is per device, not across devices: CPU and CUDA generate different
412
+ numbers from the same seed, and their GEMM reduction orders differ too, so a CPU build
413
+ and a GPU build of the same checkpoint will never be equal. `convert` records the device
414
+ in the metadata for that reason.
415
+
416
+ Returns None for a degenerate weight (all-zero, or one that makes the error metric
417
+ non-finite); the caller quantizes those without a branch rather than aborting.
418
+ """
419
+ if seed is not None:
420
+ # fork_rng so a caller's RNG state survives: this runs inside somebody else's
421
+ # process (ComfyUI), where silently reseeding the global generator would make every
422
+ # later sampler node in the same session reproduce a different image.
423
+ with torch.random.fork_rng(devices=_rng_devices(weight.device)):
424
+ torch.manual_seed(seed)
425
+ return _svdquant_split(weight, rank, fmt, groupsize, refine_iters, act_rms,
426
+ refine_tol)
427
+ return _svdquant_split(weight, rank, fmt, groupsize, refine_iters, act_rms, refine_tol)
428
+
429
+
430
+ def _layer_seed(seed: int | None, layer: str) -> int | None:
431
+ """A per-layer seed derived from the run's seed and the layer name.
432
+
433
+ Per layer rather than one seed for the whole run so that the result does not depend on
434
+ the order layers happen to be visited in, nor on how many refinement iterations the
435
+ previous layer took before its tolerance stopped it. Either would make a rebuild with a
436
+ different `--refine-tol` diverge everywhere instead of only where it should.
437
+
438
+ `zlib.crc32` rather than `hash()`: Python randomises string hashing per process, so
439
+ `hash()` here would be reproducible within a run and useless across runs -- the exact
440
+ failure this is meant to fix, hidden one level deeper.
441
+ """
442
+ if seed is None:
443
+ return None
444
+ return (seed + zlib.crc32(layer.encode("utf-8"))) % (2 ** 63)
445
+
446
+
447
+ def _rng_devices(device: torch.device) -> list:
448
+ """Which generators `fork_rng` has to save and restore for work on `device`."""
449
+ return [device] if device.type == "cuda" else []
450
+
451
+
452
+ def _svdquant_split(weight: torch.Tensor, rank: int, fmt: str, groupsize: int,
453
+ refine_iters: int, act_rms, refine_tol: float):
454
+ w = weight.float()
455
+ d = _act_weighting(act_rms, w.shape[1], w.device)
456
+ # With a weighting, both the SVD and the error metric work in the scaled space: minimising
457
+ # ||(W - W_hat) diag(d)|| approximates the error the layer's real inputs would produce,
458
+ # which is what actually reaches the image. Without one this is the identity and the whole
459
+ # function reduces to exactly the old behaviour.
460
+ w_norm = torch.linalg.matrix_norm(w if d is None else w * d).item()
461
+ if not math.isfinite(w_norm) or w_norm == 0.0:
462
+ return None
463
+ qw = torch.zeros((), device=w.device, dtype=torch.float32)
464
+
465
+ best = None
466
+ best_err = float("inf")
467
+ for _ in range(max(1, refine_iters)):
468
+ target = w - qw
469
+ if d is None:
470
+ l1, l2 = svd_lowrank(target, rank, oversample=16, niter=2)
471
+ else:
472
+ # Fit in the scaled space so the retained directions are the ones that matter
473
+ # once real activations hit them, then undo the scale on `l2` so `l1 @ l2` is
474
+ # still an approximation of the *unscaled* residual.
475
+ l1, l2s = svd_lowrank(target * d, rank, oversample=16, niter=2)
476
+ l2 = l2s / d
477
+ del l2s
478
+ l1 = l1.to(torch.bfloat16)
479
+ l2 = l2.to(torch.bfloat16)
480
+ lw = l1.float() @ l2.float()
481
+ # The quantizer must see the unscaled residual: the scale is an analysis device, and
482
+ # baking it into what gets written would change what the kernel reconstructs.
483
+ residual = (w - lw).to(torch.bfloat16)
484
+
485
+ qdata, params, layout, _ = _quantize_raw(residual, fmt, groupsize)
486
+ qw = layout.dequantize(qdata, params).float()
487
+ del qdata, params
488
+
489
+ left = w - (lw + qw)
490
+ err = (torch.linalg.matrix_norm(left if d is None else left * d) / w_norm).item()
491
+ del lw, left
492
+ # NaN fails every comparison, so without this the loop would neither break nor
493
+ # ever update `best` -- it would burn all `refine_iters` and return the last
494
+ # split rather than the best one.
495
+ if not math.isfinite(err):
496
+ break
497
+ if best is not None and err >= best_err * (1.0 - refine_tol):
498
+ break # refinement has stopped paying off
499
+ best_err, best = err, (residual, l1, l2)
500
+
501
+ return best
502
+
503
+
504
+ def _quantize_raw(weight: torch.Tensor, fmt: str, groupsize: int):
505
+ """Quantize to `fmt`, handing back the layout and params too.
506
+
507
+ Split out from `quantize_weight` so the refinement loop can round-trip a weight
508
+ (quantize then dequantize) to see what the quantizer is actually getting wrong.
509
+ """
510
+ layout = get_layout_class(QUANT_ALGOS[fmt]["comfy_tensor_layout"])
511
+ if fmt == "int8_tensorwise":
512
+ qdata, params = layout.quantize(
513
+ weight, is_weight=True, per_channel=True,
514
+ convrot=True, convrot_groupsize=groupsize,
515
+ )
516
+ conf = {"format": "int8_tensorwise", "convrot": True, "convrot_groupsize": groupsize}
517
+ elif fmt == "convrot_w4a4":
518
+ qdata, params = layout.quantize(weight, convrot_groupsize=groupsize)
519
+ conf = {"format": "convrot_w4a4", "convrot_groupsize": groupsize,
520
+ "linear_dtype": getattr(params, "linear_dtype", "int4")}
521
+ elif fmt == "float8_e4m3fn":
522
+ qdata, params = layout.quantize(weight, scale="recalculate")
523
+ conf = {"format": "float8_e4m3fn"}
524
+ else:
525
+ raise ValueError(fmt)
526
+ return qdata, params, layout, conf
527
+
528
+
529
+ def quantize_weight(weight: torch.Tensor, fmt: str, groupsize: int):
530
+ qdata, params, _, conf = _quantize_raw(weight, fmt, groupsize)
531
+ return qdata, {"weight_scale": params.scale}, conf
532
+
533
+
534
+ def conf_tensor(conf: dict) -> torch.Tensor:
535
+ return torch.tensor(list(json.dumps(conf).encode("utf-8")), dtype=torch.uint8)
536
+
537
+
538
+ def load_act_stats(path: str) -> dict[str, torch.Tensor]:
539
+ """Read the file the capture nodes write: {layer name: per-input-channel RMS}."""
540
+ stats = {}
541
+ with safe_open(path, framework="pt", device="cpu") as handle:
542
+ for key in handle.keys():
543
+ stats[key] = handle.get_tensor(key)
544
+ if not stats:
545
+ raise RuntimeError("{} contains no activation statistics".format(path))
546
+ return stats
547
+
548
+
549
+ def check_act_stats_coverage(stats: dict, keys, prefix: str, ranks: dict) -> None:
550
+ """Fail before any GPU work if the statistics don't cover every layer that will branch.
551
+
552
+ Partial statistics are the quiet-wrongness case: the covered layers would get an
553
+ activation-aware split and the rest a plain one, producing a checkpoint that is neither,
554
+ and no measurement of it would mean anything.
555
+
556
+ Deliberately key-only. Which layers branch depends on `is_target` plus a positive
557
+ `rank_for_leaf`, both of which read the layer *name*; the one weight-dependent step in
558
+ the loop (`min(wanted, min(w.shape))`) can only lower a rank, never raise a zero above
559
+ it, so this pre-pass sees exactly the set the loop will branch. Doing it here instead of
560
+ after the loop turns a ~6 minute mistake into a two-second one.
561
+ """
562
+ covered, missing = 0, []
563
+ for key in keys:
564
+ if not key.endswith(".weight"):
565
+ continue
566
+ layer = key[: -len(".weight")]
567
+ if not is_target(layer, prefix):
568
+ continue
569
+ if rank_for_leaf(ranks, leaf_name(layer, prefix)) <= 0:
570
+ continue
571
+ if stats.get(layer[len(prefix):] if prefix else layer) is None:
572
+ missing.append(layer)
573
+ else:
574
+ covered += 1
575
+ if missing:
576
+ raise RuntimeError(
577
+ "activation stats cover {} of {} branched layers; {} are missing, e.g. {}.\n"
578
+ "The capture run did not see every layer -- usually because sampling was "
579
+ "interrupted, or the statistics came from a different model. Re-capture rather "
580
+ "than building a checkpoint that is activation-aware in some layers and not "
581
+ "others.".format(covered, covered + len(missing), len(missing),
582
+ ", ".join(missing[:3])))
583
+
584
+
585
+ def convert(src: str, dst: str, fmt: str, groupsize: int, device: str = "cuda", rank: int = 0,
586
+ refine_iters: int = 100, variant: str = "unknown", progress_cb=None,
587
+ rank_alloc: str = "uniform", act_stats: str | None = None,
588
+ weight_patch=None, refine_tol: float = REFINE_TOL,
589
+ seed: int | None = DEFAULT_SEED):
590
+ """Quantize `src` into `dst`. Returns the summary line it printed.
591
+
592
+ `rank` is a budget, `rank_alloc` decides how it is spread across the eight projection
593
+ types -- see `RANK_ALLOCATIONS`. Non-uniform allocations keep the same total branch bytes.
594
+
595
+ `weight_patch(key, tensor) -> tensor` runs on every tensor as it is read, before anything
596
+ is quantized. `tools/bake_adapter.py` uses it to add a LoRA delta into the high-precision
597
+ weight, so the low-rank branch is fitted against the *merged* weight rather than the LoRA
598
+ being requantized on top of a finished checkpoint. Streaming it here rather than writing a
599
+ merged source out first matters in practice: the bf16 source is 24 GB.
600
+
601
+ `progress_cb(done, total, message)` is called as layers complete, for callers with a
602
+ progress bar to drive (the ComfyUI node). Errors are `RuntimeError`, never `SystemExit`:
603
+ `SystemExit` derives from `BaseException`, so raising it from inside a node would slip
604
+ past ComfyUI's executor instead of being reported as a failed node. `main()` translates
605
+ them back for the CLI.
606
+ """
607
+ out: dict[str, torch.Tensor] = {}
608
+ quantized = 0
609
+ kept = 0
610
+ branched = 0
611
+ observed_leaves: set[str] = set()
612
+ ranks = leaf_ranks(rank, rank_alloc) if rank > 0 else {}
613
+ used_ranks: set[int] = set()
614
+ clamped: dict[str, int] = {}
615
+ stats = load_act_stats(act_stats) if act_stats else {}
616
+ t0 = time.time()
617
+
618
+ with safe_open(src, framework="pt", device="cpu") as handle:
619
+ keys = list(handle.keys())
620
+ prefix = detect_prefix(keys)
621
+ check_requantizable(handle, keys, prefix)
622
+ if stats:
623
+ check_act_stats_coverage(stats, keys, prefix, ranks)
624
+
625
+ # Companion keys (old scales/markers) for target layers get regenerated fresh
626
+ # when we process the ".weight" key below -- skip the stale copies on disk,
627
+ # otherwise they'd overwrite our new ones later in iteration order.
628
+ stale_companions = set()
629
+ for key in keys:
630
+ if key.endswith(".weight"):
631
+ layer = key[: -len(".weight")]
632
+ if is_target(layer, prefix):
633
+ for suffix in ("weight_scale", "weight_scale_2", "input_scale", "comfy_quant"):
634
+ stale_companions.add("{}.{}".format(layer, suffix))
635
+
636
+ for i, key in enumerate(keys):
637
+ if key.endswith(".weight"):
638
+ layer = key[: -len(".weight")]
639
+ leaf = leaf_name(layer, prefix)
640
+ if leaf is not None:
641
+ observed_leaves.add(leaf)
642
+ if is_target(layer, prefix):
643
+ w = dequantize_target_weight(handle, layer, device)
644
+ if weight_patch is not None:
645
+ w = weight_patch(key, w)
646
+ # A reallocated budget can ask for more rank than the weight has singular
647
+ # directions (attn.wk is only 1536 wide). `svd_lowrank` clamps internally,
648
+ # but clamping here too keeps the reported rank honest.
649
+ wanted = rank_for_leaf(ranks, leaf)
650
+ leaf_rank = min(wanted, min(w.shape))
651
+ if leaf and leaf_rank < wanted:
652
+ clamped[leaf] = leaf_rank
653
+ if leaf_rank > 0:
654
+ # Stats are keyed by module path (`blocks.0.attn.wq`); checkpoint
655
+ # keys may carry a prefix, so strip it to look them up.
656
+ # Coverage was already proven complete before the loop started, so
657
+ # a None here cannot happen for a branched layer.
658
+ act = stats.get(layer[len(prefix):] if prefix else layer)
659
+ split = svdquant_split(w, leaf_rank, fmt, groupsize, refine_iters,
660
+ act_rms=act, refine_tol=refine_tol,
661
+ seed=_layer_seed(seed, layer))
662
+ if split is None:
663
+ print(" warning: {} is degenerate (zero or non-finite); "
664
+ "quantizing it without a low-rank branch".format(layer),
665
+ flush=True)
666
+ else:
667
+ w, l1, l2 = split
668
+ out["{}.svdq_l1".format(layer)] = l1.cpu()
669
+ out["{}.svdq_l2".format(layer)] = l2.cpu()
670
+ branched += 1
671
+ used_ranks.add(int(l1.shape[1]))
672
+ del l1, l2
673
+ qdata, scales, conf = quantize_weight(w, fmt, groupsize)
674
+ out[key] = qdata.cpu()
675
+ for name, value in scales.items():
676
+ out["{}.{}".format(layer, name)] = value.cpu()
677
+ out["{}.comfy_quant".format(layer)] = conf_tensor(conf)
678
+ del w, qdata, scales
679
+ quantized += 1
680
+ if progress_cb is not None:
681
+ progress_cb(quantized, _EXPECTED_LAYERS,
682
+ "quantized {} layers ({:.0f}s)".format(
683
+ quantized, time.time() - t0))
684
+ if quantized % 32 == 0:
685
+ torch.cuda.empty_cache()
686
+ print(f" [{i + 1}/{len(keys)}] quantized {quantized} layers "
687
+ f"({time.time() - t0:.0f}s)", flush=True)
688
+ continue
689
+ if key in stale_companions:
690
+ continue
691
+ value = handle.get_tensor(key)
692
+ # The non-quantized layers (txtfusion, embeddings) can carry a LoRA too, and they
693
+ # are the half a bake would otherwise silently drop.
694
+ out[key] = value if weight_patch is None else weight_patch(key, value)
695
+ kept += 1
696
+
697
+ gc.collect()
698
+ torch.cuda.empty_cache()
699
+
700
+ if quantized == 0:
701
+ raise RuntimeError(
702
+ "Found the transformer blocks but quantized nothing: no layer under "
703
+ "'{}blocks.' ends in one of the expected leaf names.\n"
704
+ " expected: {}\n"
705
+ " observed: {}\n"
706
+ "This is a Krea 2 variant with different layer naming; quantize_krea2.py "
707
+ "would otherwise have written a full-size file that does nothing."
708
+ .format(prefix, ", ".join(sorted(_QUANT_SUFFIXES)),
709
+ ", ".join(sorted(observed_leaves)) or "none")
710
+ )
711
+
712
+ # A leaf can only be clamped when the allocation asked for more rank than the weight is
713
+ # wide, which means part of the budget went unspent and the file is *smaller* than the
714
+ # uniform one -- so a comparison against uniform at this budget is no longer like-for-like.
715
+ if clamped:
716
+ print("warning: rank allocation {!r} asked for more rank than these layers are wide, "
717
+ "so they were capped and part of the budget went unspent: {}. The checkpoint is "
718
+ "smaller than uniform rank {}, not the same size -- lower the budget to compare "
719
+ "them fairly.".format(
720
+ rank_alloc, ", ".join("{}={}".format(k, v) for k, v in sorted(clamped.items())),
721
+ rank), flush=True)
722
+
723
+ created = len(out) - kept - quantized
724
+ factors = "" if not rank else " + {} low-rank factors".format(branched * 2)
725
+ print(f"quantized {quantized} layers; {kept} tensors passed through; "
726
+ f"{created} tensors created (qdata/scale/marker{factors}); "
727
+ f"{len(out)} tensors total; writing {dst} ...", flush=True)
728
+
729
+ # Recorded so the loader (and anyone inspecting the file) can tell what produced it.
730
+ # safetensors metadata is str -> str only. The loader treats all of this as optional:
731
+ # checkpoints published before this existed still load, with the rank recovered from
732
+ # the svdq_l1 shape as before.
733
+ # The loader trusts the factor shapes over `krea2_svdquant_rank`, and under a non-uniform
734
+ # allocation no single number is the rank -- so record the budget, the allocation name and
735
+ # the full per-leaf map, and leave `rank` meaning "budget" for older loaders reading it.
736
+ metadata = {
737
+ "krea2_svdquant_tool_version": __version__,
738
+ "krea2_svdquant_format": fmt,
739
+ "krea2_svdquant_rank": str(rank),
740
+ "krea2_svdquant_rank_alloc": rank_alloc,
741
+ "krea2_svdquant_rank_map": json.dumps(ranks, sort_keys=True) if ranks else "",
742
+ "krea2_svdquant_groupsize": str(groupsize),
743
+ "krea2_svdquant_refine_iters": str(refine_iters if rank else 0),
744
+ "krea2_svdquant_variant": variant,
745
+ "krea2_svdquant_quantized_layers": str(quantized),
746
+ "krea2_svdquant_branched_layers": str(branched),
747
+ "krea2_svdquant_source_name": os.path.basename(src),
748
+ "krea2_svdquant_source_bytes": str(os.path.getsize(src)),
749
+ # Two checkpoints with identical rank/refine settings are still different models if
750
+ # one was activation-weighted, so this has to be recoverable from the file.
751
+ "krea2_svdquant_act_stats": os.path.basename(act_stats) if act_stats else "",
752
+ # A build is reproducible only against the same seed AND the same device: the low-rank
753
+ # split draws a random probe matrix, and CPU and CUDA neither generate the same numbers
754
+ # from a seed nor reduce their GEMMs in the same order. Both go in the file so "why is
755
+ # my checkpoint not byte-identical to yours" is answerable from the file.
756
+ "krea2_svdquant_seed": "" if seed is None else str(seed),
757
+ "krea2_svdquant_device": str(device),
758
+ }
759
+ if progress_cb is not None:
760
+ progress_cb(quantized, _EXPECTED_LAYERS, "writing {:.2f} GB ...".format(
761
+ sum(t.numel() * t.element_size() for t in out.values()) / 1024 ** 3))
762
+ save_file(out, dst, metadata=metadata)
763
+ size = os.path.getsize(dst) / 1024 ** 3
764
+ rank_desc = ""
765
+ if used_ranks:
766
+ rank_desc = (" rank {}".format(next(iter(used_ranks))) if len(used_ranks) == 1
767
+ else " rank {}-{} ({})".format(min(used_ranks), max(used_ranks),
768
+ rank_alloc))
769
+ summary = ("quantized {} layers ({} branched{}) in {:.0f}s -> {} ({:.2f} GB, source "
770
+ "{:.2f} GB)".format(quantized, branched, rank_desc, time.time() - t0, dst, size,
771
+ os.path.getsize(src) / 1024 ** 3))
772
+ print("done in {:.0f}s -> {} ({:.2f} GB, source {:.2f} GB)".format(
773
+ time.time() - t0, dst, size, os.path.getsize(src) / 1024 ** 3))
774
+ return summary
775
+
776
+
777
+ # The Krea 2 block count, used only to scale a progress bar. A variant with a different
778
+ # depth still quantizes correctly; the bar is just less accurate.
779
+ _EXPECTED_LAYERS = 28 * len(_QUANT_SUFFIXES)
780
+
781
+ _FORMAT_ALIASES = {
782
+ "int8": "int8_tensorwise",
783
+ "w4a4": "convrot_w4a4",
784
+ "svdq": "convrot_w4a4",
785
+ "fp8": "float8_e4m3fn",
786
+ }
787
+
788
+ _GENERIC_STEMS = ("raw", "model", "diffusion_pytorch_model", "turbo")
789
+
790
+ SAMPLER_HINTS = {
791
+ "base": ("Base (non-distilled) model: start from ~50 steps, cfg 3.5, euler/simple, and a "
792
+ "real negative prompt. See workflows/krea2_base_svdquant_w4a4_t2i.json."),
793
+ "turbo": ("Turbo (distilled) model: 8 steps, cfg 1.0, euler/simple. "
794
+ "See workflows/krea2_turbo_svdquant_w4a4_t2i.json."),
795
+ }
796
+
797
+
798
+ def resolve_format(fmt_name: str, rank: int, rank_was_set: bool = True) -> tuple[str, int]:
799
+ """Map a CLI/node format name to a comfy_kitchen format, validating the rank with it.
800
+
801
+ Shared by `main()` and the ComfyUI node so the two cannot drift apart on which
802
+ combinations are legal.
803
+ """
804
+ fmt = _FORMAT_ALIASES[fmt_name]
805
+ if fmt not in QUANT_ALGOS:
806
+ raise RuntimeError("{} is not available in this ComfyUI build".format(fmt))
807
+ # Silently zeroing the rank here used to make `--format w4a4 --rank 128` look like it had
808
+ # done something it had not.
809
+ if fmt_name != "svdq" and rank_was_set:
810
+ raise RuntimeError(
811
+ "rank only applies to format 'svdq' (you asked for '{}'). The low-rank branch is "
812
+ "what distinguishes svdq from plain w4a4.".format(fmt_name))
813
+ if fmt_name == "svdq" and rank <= 0:
814
+ raise RuntimeError("format 'svdq' needs rank > 0; use format 'w4a4' for no branch")
815
+ return fmt, (rank if fmt_name == "svdq" else 0)
816
+
817
+
818
+ def derive_out_path(src: str, fmt_name: str, rank: int, variant: str,
819
+ rank_alloc: str = "uniform", act_stats: str | None = None
820
+ ) -> tuple[str, str | None]:
821
+ """The default output path, plus a note to show when the source name is uninformative."""
822
+ stem = os.path.splitext(os.path.basename(src))[0]
823
+ note = None
824
+ if variant != "unknown":
825
+ stem = "Krea2-{}".format(variant.capitalize())
826
+ elif stem.lower() in _GENERIC_STEMS:
827
+ note = ("note: '{}' is a generic filename. Set the variant (or an explicit output "
828
+ "name) to get a checkpoint name you will still recognise next month."
829
+ .format(stem))
830
+ # The allocation goes in the filename because two checkpoints with the same budget and
831
+ # different allocations are the same size -- nothing else would tell them apart.
832
+ alloc_tag = "" if rank_alloc == "uniform" else "-{}".format(rank_alloc)
833
+ # Same reasoning as alloc_tag: an activation-weighted build is byte-identical in shape to
834
+ # a plain one, so without a tag the two are indistinguishable on disk.
835
+ act_tag = "-actaware" if act_stats else ""
836
+ suffix = ("SVDQuant-W4A4-rank{}{}{}".format(rank, alloc_tag, act_tag) if rank else
837
+ ("{}-convrot".format(fmt_name.upper()) if fmt_name != "fp8" else "FP8"))
838
+ return os.path.join(os.path.dirname(src), "{}-{}.safetensors".format(stem, suffix)), note
839
+
840
+
841
+ def main():
842
+ ap = argparse.ArgumentParser()
843
+ ap.add_argument("src")
844
+ ap.add_argument("--format", choices=["int8", "w4a4", "svdq", "fp8"], default="int8",
845
+ help="svdq = w4a4 residual + SVDQuant low-rank bf16 branch; "
846
+ "fp8 = float8_e4m3fn, no convrot, no low-rank branch")
847
+ ap.add_argument("--groupsize", type=int, default=256, help="unused for fp8")
848
+ ap.add_argument("--rank", type=int, default=64, help="low-rank branch budget, svdq only")
849
+ ap.add_argument("--rank-alloc", choices=sorted(RANK_ALLOCATIONS), default="uniform",
850
+ help="how to spread the rank budget across the eight projection types. "
851
+ "uniform = same rank everywhere. gqa = byte-neutral reallocation "
852
+ "towards the GQA kv projections, which absorb ~2x the error at a "
853
+ "third of the branch cost (see RANK_ALLOCATIONS)")
854
+ ap.add_argument("--refine-iters", type=int, default=100,
855
+ help="svdq only: refine the low-rank branch against the quantization "
856
+ "error, keeping the best (0 = plain single-shot SVD, much faster "
857
+ "but ~10%% more reconstruction error). This is a cap; --refine-tol "
858
+ "is what usually stops the loop first")
859
+ ap.add_argument("--refine-tol", type=float, default=REFINE_TOL, metavar="FRACTION",
860
+ help="svdq only: stop refining a layer once an iteration improves its "
861
+ "reconstruction error by less than this fraction (default %(default)s "
862
+ "= 0.1%%). Lower means more iterations for less return: 0.001 takes "
863
+ "~22 iterations per layer, 0.005 takes ~9 for 2%% more error, and 0 "
864
+ "restores the old behaviour of running nearly all --refine-iters")
865
+ ap.add_argument("--variant", choices=["turbo", "base", "unknown"], default="unknown",
866
+ help="which Krea 2 release this is. Only affects the output filename "
867
+ "and the recorded metadata -- quantization is identical for both, "
868
+ "the difference is the sampler settings you run afterwards")
869
+ ap.add_argument("--act-stats", default=None, metavar="PATH",
870
+ help="svdq only: activation statistics from the Krea2 SVDQuant Capture "
871
+ "nodes. Weights the low-rank split by per-input-channel activation "
872
+ "RMS, so the branch spends its capacity on the directions the model "
873
+ "actually drives instead of on the largest weights. Costs nothing "
874
+ "at runtime -- same rank, format, size and kernel")
875
+ ap.add_argument("--out", default=None)
876
+ ap.add_argument("--device", default="cuda")
877
+ ap.add_argument("--seed", type=int, default=DEFAULT_SEED,
878
+ help="seed for the randomized low-rank SVD, so a build is reproducible "
879
+ "(default %(default)s). Reproducible on the same device only -- a "
880
+ "CPU build and a CUDA build of the same checkpoint differ by ~1e-4 "
881
+ "per weight whatever the seed. Pass -1 for the old unseeded "
882
+ "behaviour, where even two runs on one GPU differ")
883
+ args = ap.parse_args()
884
+
885
+ if args.act_stats:
886
+ if args.format != "svdq":
887
+ raise SystemExit("--act-stats only applies to format 'svdq': it weights the "
888
+ "low-rank split, and the other formats have no branch")
889
+ if not os.path.exists(args.act_stats):
890
+ raise SystemExit("--act-stats file not found: {}".format(args.act_stats))
891
+
892
+ # RuntimeError is the shared failure type (see `convert`); the CLI wants SystemExit so it
893
+ # prints one clean line instead of a traceback.
894
+ try:
895
+ fmt, rank = resolve_format(args.format, args.rank,
896
+ rank_was_set=args.rank != ap.get_default("rank"))
897
+ except RuntimeError as exc:
898
+ raise SystemExit(str(exc)) from None
899
+
900
+ if args.rank_alloc != "uniform" and not rank:
901
+ raise SystemExit("--rank-alloc only applies to format 'svdq'")
902
+
903
+ out = args.out
904
+ if out is None:
905
+ out, note = derive_out_path(args.src, args.format, rank, args.variant, args.rank_alloc,
906
+ args.act_stats)
907
+ if note:
908
+ print(note, flush=True)
909
+ try:
910
+ convert(args.src, out, fmt, args.groupsize, args.device, rank, args.refine_iters,
911
+ variant=args.variant, rank_alloc=args.rank_alloc, act_stats=args.act_stats,
912
+ refine_tol=args.refine_tol,
913
+ seed=None if args.seed < 0 else args.seed)
914
+ except RuntimeError as exc:
915
+ raise SystemExit(str(exc)) from None
916
+
917
+ hint = SAMPLER_HINTS.get(args.variant)
918
+ if hint:
919
+ print("\n" + hint)
920
+
921
+
922
+ if __name__ == "__main__":
923
+ main()
svdquant_capture.py CHANGED
@@ -228,9 +228,14 @@ class Krea2SVDQuantCaptureSave:
228
  }
229
  }
230
 
231
- RETURN_TYPES = ("LATENT", "STRING")
232
- RETURN_NAMES = ("latent", "status")
233
- OUTPUT_TOOLTIPS = ("Passed through unchanged.", "Where the file went and what is in it.")
 
 
 
 
 
234
  OUTPUT_NODE = True
235
  FUNCTION = "save"
236
  CATEGORY = _CATEGORY
@@ -243,7 +248,24 @@ class Krea2SVDQuantCaptureSave:
243
  return float("nan")
244
 
245
  def save(self, latent, filename, keep_capturing):
246
- path = os.path.join(folder_paths.get_output_directory(), filename)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  info = write(path)
248
  if not keep_capturing:
249
  detach()
@@ -251,7 +273,7 @@ class Krea2SVDQuantCaptureSave:
251
  "tokens per layer: {min_tokens}-{max_tokens}{more}").format(
252
  more="" if keep_capturing else "\nhooks detached", **info)
253
  logging.info("[krea2-svdquant] %s", status.replace("\n", " "))
254
- return {"ui": {"text": [status]}, "result": (latent, status)}
255
 
256
 
257
  NODE_CLASS_MAPPINGS = {
 
228
  }
229
  }
230
 
231
+ # `act_stats_path` is appended rather than inserted so workflows saved against the
232
+ # two-output version keep their links: slot indices 0 and 1 still mean what they did.
233
+ RETURN_TYPES = ("LATENT", "STRING", "STRING")
234
+ RETURN_NAMES = ("latent", "status", "act_stats_path")
235
+ OUTPUT_TOOLTIPS = ("Passed through unchanged.", "Where the file went and what is in it.",
236
+ "Absolute path of the file just written. Wire it into the Quantize "
237
+ "node's act_stats: that is also what forces the quantizer to run "
238
+ "after the calibration pass rather than before it.")
239
  OUTPUT_NODE = True
240
  FUNCTION = "save"
241
  CATEGORY = _CATEGORY
 
248
  return float("nan")
249
 
250
  def save(self, latent, filename, keep_capturing):
251
+ # Relative to ComfyUI/output/, which is what the tooltip promises. `os.path.join`
252
+ # alone does not keep that promise: hand it an absolute path and it discards the
253
+ # first argument, and `write()` then happily creates whatever directory it names.
254
+ # The sibling Quantize node already resolves `act_stats` this way, so make the two
255
+ # agree about what a bare filename means.
256
+ filename = filename.strip()
257
+ if not filename:
258
+ raise ValueError("filename is empty")
259
+ if os.path.isabs(filename) or os.path.splitdrive(filename)[0]:
260
+ raise ValueError(
261
+ "filename must be relative to ComfyUI/output/, got an absolute path: {}"
262
+ .format(filename))
263
+ out_dir = folder_paths.get_output_directory()
264
+ path = os.path.normpath(os.path.join(out_dir, filename))
265
+ if os.path.commonpath([os.path.abspath(out_dir), os.path.abspath(path)]) != \
266
+ os.path.abspath(out_dir):
267
+ raise ValueError(
268
+ "filename must stay under ComfyUI/output/, got: {}".format(filename))
269
  info = write(path)
270
  if not keep_capturing:
271
  detach()
 
273
  "tokens per layer: {min_tokens}-{max_tokens}{more}").format(
274
  more="" if keep_capturing else "\nhooks detached", **info)
275
  logging.info("[krea2-svdquant] %s", status.replace("\n", " "))
276
+ return {"ui": {"text": [status]}, "result": (latent, status, info["path"])}
277
 
278
 
279
  NODE_CLASS_MAPPINGS = {
svdquant_lora.py CHANGED
@@ -54,12 +54,12 @@ from .svdquant_w4a4 import add_low_rank, has_branch
54
  _PREFIX = "diffusion_model."
55
  _ALT_PREFIX = "transformer."
56
 
57
- # What to do with an adapter that cannot fold into the low-rank branch (LoKr, LoHa, OFT).
58
- # `bypass` computes it every forward and never touches the 4-bit weight; `bake` hands it to
59
- # ComfyUI, which rewrites the weight once and pays nothing per step. Measured on a 3090 at
60
- # 1440x1920 with a LoKr on all 224 blocks: bypass costs +1.8 s per model call, bake costs
61
- # nothing and quantizes the delta to 4 bits along with the weight.
62
  ADAPTER_BYPASS = "bypass (exact, slower)"
 
63
  ADAPTER_BAKE = "bake into the weight (fast, requantizes the delta)"
64
 
65
  # Reloading a 300 MB LoRA off disk on every graph execution is pure latency when the
@@ -135,6 +135,59 @@ def _plain_lora_factors(adapter, strength: float, device, dtype):
135
  return l1.contiguous(), l2.contiguous()
136
 
137
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  def _supports_bypass(adapter) -> bool:
139
  """True when the adapter can run without touching the base weight.
140
 
@@ -244,6 +297,17 @@ def collect_svdquant_lora(patcher, lora_sd, strength: float, quant_layers: set[s
244
  # A plain LoRA folds into the branch for free in either mode -- one pair of
245
  # GEMMs for the whole stack -- so `bake` has nothing to win here.
246
  into_branch.setdefault(layer, []).append(factors)
 
 
 
 
 
 
 
 
 
 
 
247
  elif (adapter_mode == ADAPTER_BYPASS
248
  and isinstance(patch, comfy.weight_adapter.WeightAdapterBase)
249
  and _supports_bypass(patch)):
@@ -293,15 +357,14 @@ class Krea2SVDQuantLoraLoader:
293
  }),
294
  },
295
  "optional": {
296
- "adapters": ([ADAPTER_BYPASS, ADAPTER_BAKE], {
297
  "default": ADAPTER_BYPASS,
298
- "tooltip": "Only affects LoRAs that cannot fold into the low-rank branch "
299
- "(LoKr, LoHa, OFT); a plain LoRA is free either way. 'bypass' "
300
- "computes the adapter every forward and never touches the "
301
- "4-bit weight. 'bake' hands it to ComfyUI, which rewrites the "
302
- "weight once: no per-step cost, but the delta is quantized to "
303
- "4 bits with it. Measured with a LoKr at 1440x1920: bypass "
304
- "+1.8 s per model call, bake +0.",
305
  }),
306
  },
307
  }
 
54
  _PREFIX = "diffusion_model."
55
  _ALT_PREFIX = "transformer."
56
 
57
+ # What to do with an adapter that cannot fold directly into the low-rank branch (LoKr, LoHa, OFT).
58
+ # `bypass` computes it every forward and never touches the 4-bit weight; `svd delta` decomposes
59
+ # the weight delta with torch.svd_lowrank and merges it into the low-rank branch (zero per-step
60
+ # overhead); `bake` hands it to ComfyUI, which rewrites the weight once and pays nothing per step.
 
61
  ADAPTER_BYPASS = "bypass (exact, slower)"
62
+ ADAPTER_SVD = "svd delta (fast, low-rank SVD, no per-step overhead)"
63
  ADAPTER_BAKE = "bake into the weight (fast, requantizes the delta)"
64
 
65
  # Reloading a 300 MB LoRA off disk on every graph execution is pure latency when the
 
135
  return l1.contiguous(), l2.contiguous()
136
 
137
 
138
+ def _svd_adapter_factors(adapter, strength: float, device, dtype, max_rank: int = 64):
139
+ """Decompose an arbitrary adapter's weight delta into low-rank factors (l1, l2) via SVD.
140
+
141
+ For LoKr, LoHa, OFT, GFS, etc. This extracts the weight delta, computes a
142
+ truncated SVD using torch.svd_lowrank, and produces (l1, l2) where l1 @ l2 ≈ delta * strength.
143
+ Running this in the low-rank branch completely eliminates the per-step runtime overhead
144
+ of complex adapter contractions while preserving 4-bit base weights untouched.
145
+ """
146
+ try:
147
+ delta = None
148
+ # Case 1: ComfyUI WeightAdapterBase with calculate_weight
149
+ if hasattr(adapter, "calculate_weight"):
150
+ shape = getattr(adapter, "shape", None)
151
+ if shape is not None and len(shape) == 2:
152
+ zeros = torch.zeros(shape, dtype=torch.float32, device="cpu")
153
+ delta = adapter.calculate_weight(zeros)
154
+ # Case 2: Tuple / Diff / weights directly
155
+ if delta is None and hasattr(adapter, "weights"):
156
+ weights = adapter.weights
157
+ if weights and isinstance(adapter, comfy.weight_adapter.DiffAdapter):
158
+ delta = weights[0]
159
+ elif isinstance(adapter, comfy.weight_adapter.LoKrAdapter) and len(weights) >= 2:
160
+ w1, w2 = weights[0], weights[1]
161
+ if w1 is not None and w2 is not None:
162
+ delta = torch.kron(w1.to(torch.float32), w2.to(torch.float32))
163
+ elif isinstance(adapter, comfy.weight_adapter.LoHaAdapter) and len(weights) >= 4:
164
+ w1a, w1b, w2a, w2b = weights[:4]
165
+ if all(w is not None for w in (w1a, w1b, w2a, w2b)):
166
+ delta = (w1a.to(torch.float32) @ w1b.to(torch.float32)) * (w2a.to(torch.float32) @ w2b.to(torch.float32))
167
+ elif isinstance(adapter, tuple) and len(adapter) >= 1 and torch.is_tensor(adapter[0]):
168
+ delta = adapter[0]
169
+
170
+ if delta is None or delta.ndim != 2:
171
+ return None
172
+
173
+ out_dim, in_dim = delta.shape
174
+ k = min(max_rank, min(out_dim, in_dim))
175
+ if k <= 0:
176
+ return None
177
+
178
+ delta_f32 = delta.to(device="cpu", dtype=torch.float32)
179
+ # SVD: delta_f32 ≈ U @ diag(S) @ V.T
180
+ U, S, V = torch.svd_lowrank(delta_f32, q=k)
181
+ sqrt_s = torch.sqrt(torch.clamp(S, min=0.0))
182
+ # l1 is (out_dim, k), l2 is (k, in_dim)
183
+ l1 = (U * sqrt_s.unsqueeze(0)).to(device=device, dtype=dtype)
184
+ l2 = (sqrt_s.unsqueeze(1) * V.T * strength).to(device=device, dtype=dtype)
185
+ return l1.contiguous(), l2.contiguous()
186
+ except Exception as exc:
187
+ logging.warning("[krea2-svdquant] SVD adapter decomposition skipped: %s", exc)
188
+ return None
189
+
190
+
191
  def _supports_bypass(adapter) -> bool:
192
  """True when the adapter can run without touching the base weight.
193
 
 
297
  # A plain LoRA folds into the branch for free in either mode -- one pair of
298
  # GEMMs for the whole stack -- so `bake` has nothing to win here.
299
  into_branch.setdefault(layer, []).append(factors)
300
+ elif adapter_mode == ADAPTER_SVD:
301
+ # Attempt SVD decomposition into low-rank factors
302
+ svd_factors = _svd_adapter_factors(patch, strength, device, dtype)
303
+ if svd_factors is not None:
304
+ into_branch.setdefault(layer, []).append(svd_factors)
305
+ elif isinstance(patch, comfy.weight_adapter.WeightAdapterBase) and _supports_bypass(patch):
306
+ into_adapters.setdefault(layer, []).append((patch, strength))
307
+ else:
308
+ leftover[model_key] = patch
309
+ dequantizing += 1
310
+ continue
311
  elif (adapter_mode == ADAPTER_BYPASS
312
  and isinstance(patch, comfy.weight_adapter.WeightAdapterBase)
313
  and _supports_bypass(patch)):
 
357
  }),
358
  },
359
  "optional": {
360
+ "adapters": ([ADAPTER_BYPASS, ADAPTER_SVD, ADAPTER_BAKE], {
361
  "default": ADAPTER_BYPASS,
362
+ "tooltip": "Only affects LoRAs that cannot fold directly into the low-rank "
363
+ "branch (LoKr, LoHa, OFT); a plain LoRA is free in all modes. "
364
+ "'bypass' computes the adapter every forward without touching "
365
+ "the 4-bit weight. 'svd delta' decomposes the delta into low-rank "
366
+ "factors (zero per-step overhead). 'bake' rewrites the weight once: "
367
+ "fast, but quantizes the delta to 4 bits.",
 
368
  }),
369
  },
370
  }
svdquant_quantize.py CHANGED
@@ -35,6 +35,13 @@ from .svdquant_diag import _CATEGORY
35
  # the ~8 GB output rather than exactly it.
36
  _MIN_FREE_BYTES = 12 * 1024 ** 3
37
 
 
 
 
 
 
 
 
38
 
39
  def _free_bytes(path: str) -> int:
40
  return shutil.disk_usage(os.path.dirname(os.path.abspath(path))).free
@@ -116,9 +123,64 @@ class Krea2SVDQuantQuantize:
116
  "inference and the best-measured setting here - LPIPS to BF16 "
117
  "0.3378 to 0.2825 with no LoRA. Empty means the plain objective.",
118
  }),
 
 
 
 
 
 
 
 
 
 
 
119
  },
120
  }
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  @classmethod
123
  def IS_CHANGED(cls, *args, **kwargs):
124
  # Writes an ~8 GB file as its side effect, so a cached summary would claim a file
@@ -139,7 +201,7 @@ class Krea2SVDQuantQuantize:
139
  "Krea2 SVDQuant W4A4 Loader (svdq) or the stock UNETLoader (w4a4/int8/fp8).")
140
 
141
  def run(self, source_model, format, rank, rank_alloc, refine_iters, groupsize, variant,
142
- output_name, overwrite, act_stats=""):
143
  src = folder_paths.get_full_path_or_raise("diffusion_models", source_model)
144
 
145
  # `rank` always carries a value from the widget, so "was it set?" cannot be inferred
@@ -196,12 +258,13 @@ class Krea2SVDQuantQuantize:
196
  pbar.update_absolute(done, total)
197
 
198
  logging.info("[krea2-svdquant] quantizing %s -> %s (format %s, rank %s/%s, "
199
- "refine_iters %s, act_stats %s)", src, dst, fmt, rank, rank_alloc,
200
- refine_iters if rank else 0, stats_path or "none")
 
201
  summary = convert(src, dst, fmt, groupsize, "cuda", rank, refine_iters,
202
  variant=variant, progress_cb=progress,
203
  rank_alloc=rank_alloc if rank else "uniform",
204
- act_stats=stats_path)
205
 
206
  hint = SAMPLER_HINTS.get(variant)
207
  loader = ("Krea2 SVDQuant W4A4 Loader" if rank else "the stock UNETLoader")
@@ -211,5 +274,246 @@ class Krea2SVDQuantQuantize:
211
  return {"ui": {"text": [text]}, "result": (text,)}
212
 
213
 
214
- NODE_CLASS_MAPPINGS = {"Krea2SVDQuantQuantize": Krea2SVDQuantQuantize}
215
- NODE_DISPLAY_NAME_MAPPINGS = {"Krea2SVDQuantQuantize": "Krea2 SVDQuant Quantize"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  # the ~8 GB output rather than exactly it.
36
  _MIN_FREE_BYTES = 12 * 1024 ** 3
37
 
38
+ # All-in-one: the combined file is ~12 GB, and building one from a BF16 DiT writes an ~8 GB
39
+ # intermediate first. Those two can land on different drives (the intermediate goes to
40
+ # ComfyUI's temp directory), so they are checked separately rather than summed -- the old
41
+ # single 15 GB check against the output drive was under the real peak either way.
42
+ _MIN_FREE_ALLINONE = 15 * 1024 ** 3
43
+ _MIN_FREE_INTERMEDIATE = 11 * 1024 ** 3
44
+
45
 
46
  def _free_bytes(path: str) -> int:
47
  return shutil.disk_usage(os.path.dirname(os.path.abspath(path))).free
 
123
  "inference and the best-measured setting here - LPIPS to BF16 "
124
  "0.3378 to 0.2825 with no LoRA. Empty means the plain objective.",
125
  }),
126
+ # Appended after act_stats rather than inserted among the required inputs:
127
+ # ComfyUI matches widgets_values positionally, so anywhere else would shift
128
+ # every value in a workflow saved before this input existed.
129
+ "seed": ("INT", {
130
+ "default": 0, "min": -1, "max": 0xFFFFFFFF,
131
+ "tooltip": "svdq only: seed for the randomized low-rank SVD. Quantizing "
132
+ "twice with the same seed on the same GPU now gives identical "
133
+ "files; -1 restores the old unseeded behaviour, where it did "
134
+ "not. The same seed on a different device still differs (~1e-4 "
135
+ "per weight) - CPU and CUDA do not draw the same numbers.",
136
+ }),
137
  },
138
  }
139
 
140
+ @classmethod
141
+ def VALIDATE_INPUTS(cls, source_model, format, variant, rank, rank_alloc, output_name,
142
+ overwrite):
143
+ """Refuse the obviously-doomed run before the graph starts, not after.
144
+
145
+ Everything checked here is a widget, so ComfyUI has it at validation time. That
146
+ matters most in the calibrated graph, where this node sits behind a full sampling
147
+ pass: without this, "that file already exists" arrives several minutes after you
148
+ pressed Queue, having burned the calibration run to tell you.
149
+
150
+ Deliberately not checked here: `act_stats`, which can be wired from the Capture Save
151
+ node and therefore has no value yet at validation time. Its checks stay in `run`.
152
+ """
153
+ try:
154
+ src = folder_paths.get_full_path_or_raise("diffusion_models", source_model)
155
+ except Exception as exc:
156
+ return str(exc)
157
+ try:
158
+ _, rank = resolve_format(format, rank, rank_was_set=False)
159
+ except Exception as exc:
160
+ return str(exc)
161
+
162
+ if output_name.strip():
163
+ name = output_name.strip()
164
+ if not name.endswith(".safetensors"):
165
+ name += ".safetensors"
166
+ dst = os.path.join(os.path.dirname(src), name)
167
+ else:
168
+ # An act-aware build lands on a different filename, and act_stats is unknowable
169
+ # here. Pass None and the check is against the un-tagged name: it can miss a
170
+ # collision, never invent one, which is the right way for a pre-flight check to
171
+ # be wrong.
172
+ dst, _note = derive_out_path(src, format, rank, variant, rank_alloc, None)
173
+
174
+ if os.path.exists(dst) and not overwrite:
175
+ return ("{} already exists. Enable 'overwrite', or set a different output_name."
176
+ .format(dst))
177
+ free = _free_bytes(dst)
178
+ if free < _MIN_FREE_BYTES:
179
+ return ("only {:.1f} GB free on the drive holding {}; quantizing needs roughly "
180
+ "{:.0f} GB of headroom.".format(free / 1024 ** 3, os.path.dirname(dst),
181
+ _MIN_FREE_BYTES / 1024 ** 3))
182
+ return True
183
+
184
  @classmethod
185
  def IS_CHANGED(cls, *args, **kwargs):
186
  # Writes an ~8 GB file as its side effect, so a cached summary would claim a file
 
201
  "Krea2 SVDQuant W4A4 Loader (svdq) or the stock UNETLoader (w4a4/int8/fp8).")
202
 
203
  def run(self, source_model, format, rank, rank_alloc, refine_iters, groupsize, variant,
204
+ output_name, overwrite, act_stats="", seed=0):
205
  src = folder_paths.get_full_path_or_raise("diffusion_models", source_model)
206
 
207
  # `rank` always carries a value from the widget, so "was it set?" cannot be inferred
 
258
  pbar.update_absolute(done, total)
259
 
260
  logging.info("[krea2-svdquant] quantizing %s -> %s (format %s, rank %s/%s, "
261
+ "refine_iters %s, act_stats %s, seed %s)", src, dst, fmt, rank,
262
+ rank_alloc, refine_iters if rank else 0, stats_path or "none",
263
+ "unseeded" if seed < 0 else seed)
264
  summary = convert(src, dst, fmt, groupsize, "cuda", rank, refine_iters,
265
  variant=variant, progress_cb=progress,
266
  rank_alloc=rank_alloc if rank else "uniform",
267
+ act_stats=stats_path, seed=None if seed < 0 else int(seed))
268
 
269
  hint = SAMPLER_HINTS.get(variant)
270
  loader = ("Krea2 SVDQuant W4A4 Loader" if rank else "the stock UNETLoader")
 
274
  return {"ui": {"text": [text]}, "result": (text,)}
275
 
276
 
277
+ class Krea2SVDQuantQuantizeAllInOne:
278
+ @classmethod
279
+ def INPUT_TYPES(cls):
280
+ return {
281
+ "required": {
282
+ "source_dit": (folder_paths.get_filename_list("diffusion_models"), {
283
+ "tooltip": "The Krea 2 DiT model: either a BF16 source (~24 GB, will be "
284
+ "quantized first) or an already-quantized SVDQuant/W4A4/INT8 file.",
285
+ }),
286
+ "text_encoder": (folder_paths.get_filename_list("text_encoders"), {
287
+ "tooltip": "The Qwen3-VL 4B BF16 text encoder safetensors (~8.88 GB). "
288
+ "Its 252 linear projections will be quantized to te_format, "
289
+ "reducing it to ~3.2 GB without losing the vision tower.",
290
+ }),
291
+ "vae": (folder_paths.get_filename_list("vae"), {
292
+ "tooltip": "The Krea 2 VAE (~0.51 GB, kept unquantized).",
293
+ }),
294
+ "format": (["svdq", "w4a4", "int8", "fp8"], {
295
+ "default": "svdq",
296
+ "tooltip": "Diffusion model format. svdq: 4-bit weights and activations "
297
+ "plus a low-rank bf16 correction branch.",
298
+ }),
299
+ "te_format": (["w4a4", "int8"], {
300
+ "default": "w4a4",
301
+ "tooltip": "Text encoder format: w4a4 (convrot_w4a4, ~3.2 GB) or int8 "
302
+ "(int8_tensorwise, ~5.2 GB).",
303
+ }),
304
+ "rank": ("INT", {
305
+ "default": 64, "min": 8, "max": 1024, "step": 8,
306
+ "tooltip": "svdq only: size of the low-rank branch on the diffusion model.",
307
+ }),
308
+ "refine_iters": ("INT", {
309
+ "default": 100, "min": 0, "max": 200,
310
+ "tooltip": "svdq only (if source_dit is BF16). Refines the branch against "
311
+ "quantization error. Ignored if source_dit is already quantized.",
312
+ }),
313
+ "groupsize": ("INT", {
314
+ "default": 256, "min": 32, "max": 1024, "step": 32,
315
+ "tooltip": "convrot rotation group size.",
316
+ }),
317
+ "variant": (["turbo", "base", "unknown"], {
318
+ "default": "turbo",
319
+ "tooltip": "Which Krea 2 release this is.",
320
+ }),
321
+ "output_name": ("STRING", {
322
+ "default": "",
323
+ "tooltip": "Filename in models/checkpoints/. Leave empty to derive automatically.",
324
+ }),
325
+ "overwrite": ("BOOLEAN", {
326
+ "default": False,
327
+ "tooltip": "Allow overwriting existing output checkpoint.",
328
+ }),
329
+ },
330
+ "optional": {
331
+ "act_stats": ("STRING", {
332
+ "default": "",
333
+ "tooltip": "svdq only (if source_dit is BF16): activation statistics file.",
334
+ }),
335
+ "seed": ("INT", {
336
+ "default": 0, "min": -1, "max": 0xFFFFFFFF,
337
+ "tooltip": "Random seed for reproducible SVD.",
338
+ }),
339
+ },
340
+ }
341
+
342
+ @staticmethod
343
+ def _out_path(output_name, variant, format, rank, te_format) -> str:
344
+ """Where this run will write. Shared, so validation checks the path `run` will use."""
345
+ ckpt_dir = folder_paths.get_folder_paths("checkpoints")[0]
346
+ if output_name.strip():
347
+ name = output_name.strip()
348
+ if not name.endswith(".safetensors"):
349
+ name += ".safetensors"
350
+ return os.path.join(ckpt_dir, name)
351
+ stem = "Krea2-{}".format(variant.capitalize()) if variant != "unknown" else "Krea2"
352
+ tag = "SVDQuant-W4A4-rank{}".format(rank) if rank else format.upper()
353
+ return os.path.join(ckpt_dir, "{}-AllInOne-{}-TE{}.safetensors".format(
354
+ stem, tag, te_format.upper()))
355
+
356
+ @classmethod
357
+ def VALIDATE_INPUTS(cls, source_dit, text_encoder, vae, output_name, overwrite,
358
+ variant, format, te_format, rank):
359
+ """Same pre-flight as the sibling node, for the same reason.
360
+
361
+ This one writes ~12 GB and, from a BF16 source, spends ten minutes quantizing a DiT
362
+ before it reaches the combiner -- so "that file already exists" has to arrive at
363
+ Queue time rather than after the expensive part.
364
+ """
365
+ for folder, name in (("diffusion_models", source_dit),
366
+ ("text_encoders", text_encoder), ("vae", vae)):
367
+ try:
368
+ folder_paths.get_full_path_or_raise(folder, name)
369
+ except Exception as exc:
370
+ return str(exc)
371
+ try:
372
+ _, rank = resolve_format(format, rank, rank_was_set=False)
373
+ except Exception as exc:
374
+ return str(exc)
375
+
376
+ dst = cls._out_path(output_name, variant, format, rank, te_format)
377
+ if os.path.exists(dst) and not overwrite:
378
+ return ("{} already exists. Enable 'overwrite', or set a different output_name."
379
+ .format(dst))
380
+ free = _free_bytes(dst)
381
+ if free < _MIN_FREE_ALLINONE:
382
+ return ("only {:.1f} GB free on the drive holding {}; the combined checkpoint "
383
+ "needs roughly {:.0f} GB.".format(free / 1024 ** 3, os.path.dirname(dst),
384
+ _MIN_FREE_ALLINONE / 1024 ** 3))
385
+ return True
386
+
387
+ @classmethod
388
+ def IS_CHANGED(cls, *args, **kwargs):
389
+ # ~12 GB written as a side effect. Same reasoning as the sibling node: a cached
390
+ # summary claims a file exists that the user may since have deleted.
391
+ return float("nan")
392
+
393
+ RETURN_TYPES = ("STRING",)
394
+ RETURN_NAMES = ("summary",)
395
+ OUTPUT_TOOLTIPS = ("Size, tensor counts and loader instructions for the baked checkpoint.",)
396
+ OUTPUT_NODE = True
397
+ FUNCTION = "run"
398
+ CATEGORY = _CATEGORY
399
+ TITLE = "Krea2 SVDQuant Quantize All-in-One"
400
+ DESCRIPTION = ("Bakes DiT, 4-bit quantized Qwen3-VL 4B text encoder, and VAE into a single "
401
+ "combined checkpoint in ComfyUI/models/checkpoints/ (~12 GB instead of 15.5 GB "
402
+ "across three files).")
403
+
404
+ def run(self, source_dit, text_encoder, vae, format, te_format, rank, refine_iters,
405
+ groupsize, variant, output_name, overwrite, act_stats="", seed=0):
406
+ from safetensors import safe_open
407
+ # `.build_all_in_one`, not `.tools.build_all_in_one`: `.comfyignore` keeps `tools/`
408
+ # out of the published package, so the old import raised ImportError for every
409
+ # Registry/Manager install and worked only for a git clone.
410
+ from .build_all_in_one import build_all_in_one_checkpoint
411
+
412
+ dit_src = folder_paths.get_full_path_or_raise("diffusion_models", source_dit)
413
+ te_src = folder_paths.get_full_path_or_raise("text_encoders", text_encoder)
414
+ vae_src = folder_paths.get_full_path_or_raise("vae", vae)
415
+
416
+ fmt, rank = resolve_format(format, rank, rank_was_set=False)
417
+
418
+ os.makedirs(folder_paths.get_folder_paths("checkpoints")[0], exist_ok=True)
419
+ dst = self._out_path(output_name, variant, format, rank, te_format)
420
+
421
+ if os.path.exists(dst) and not overwrite:
422
+ raise RuntimeError(
423
+ "{} already exists. Enable 'overwrite', or set a different output_name."
424
+ .format(dst))
425
+
426
+ free = _free_bytes(dst)
427
+ if free < _MIN_FREE_ALLINONE:
428
+ raise RuntimeError(
429
+ "only {:.1f} GB free on the drive holding {}; baking all-in-one needs "
430
+ "roughly {:.0f} GB headroom.".format(
431
+ free / 1024 ** 3, dst, _MIN_FREE_ALLINONE / 1024 ** 3))
432
+
433
+ comfy.model_management.unload_all_models()
434
+ comfy.model_management.soft_empty_cache()
435
+
436
+ # Check if DiT is already quantized or needs quantization
437
+ with safe_open(dit_src, framework="pt", device="cpu") as handle:
438
+ keys = list(handle.keys())
439
+ already_quantized = any(k.endswith(".comfy_quant") for k in keys)
440
+ temp_dit = None
441
+
442
+ if not already_quantized:
443
+ stats_path = act_stats.strip() or None
444
+ if stats_path is not None and not os.path.isabs(stats_path):
445
+ stats_path = os.path.join(folder_paths.get_output_directory(), stats_path)
446
+ # ComfyUI's temp directory rather than models/checkpoints/: a run killed
447
+ # mid-quantize leaves this ~8 GB file behind -- the `finally` below only covers
448
+ # a clean unwind -- and beside the real checkpoints it shows up in every loader
449
+ # dropdown as something that looks loadable and is not.
450
+ temp_dir = folder_paths.get_temp_directory()
451
+ os.makedirs(temp_dir, exist_ok=True)
452
+ temp_dit = os.path.join(temp_dir,
453
+ os.path.basename(dst) + ".tmp_dit.safetensors")
454
+ temp_free = _free_bytes(temp_dit)
455
+ if temp_free < _MIN_FREE_INTERMEDIATE:
456
+ raise RuntimeError(
457
+ "only {:.1f} GB free on the drive holding {}; quantizing the DiT first "
458
+ "writes an intermediate of roughly {:.0f} GB there.".format(
459
+ temp_free / 1024 ** 3, temp_dir,
460
+ _MIN_FREE_INTERMEDIATE / 1024 ** 3))
461
+ logging.info("[krea2-svdquant] quantizing DiT first: %s -> %s", dit_src, temp_dit)
462
+ convert(dit_src, temp_dit, fmt, groupsize, "cuda", rank, refine_iters,
463
+ variant=variant, rank_alloc="uniform", act_stats=stats_path,
464
+ seed=None if seed < 0 else int(seed))
465
+ active_dit = temp_dit
466
+ else:
467
+ active_dit = dit_src
468
+
469
+ pbar = comfy.utils.ProgressBar(1)
470
+ state = {"total": 0}
471
+
472
+ def progress(done, total, message):
473
+ if total != state["total"]:
474
+ state["total"] = total
475
+ pbar.total = total
476
+ pbar.update_absolute(done, total)
477
+
478
+ try:
479
+ summary = build_all_in_one_checkpoint(
480
+ dit_path=active_dit,
481
+ text_encoder_path=te_src,
482
+ vae_path=vae_src,
483
+ out_path=dst,
484
+ fmt_name=format,
485
+ te_format=te_format,
486
+ rank=rank,
487
+ variant=variant,
488
+ groupsize=groupsize,
489
+ device="cuda",
490
+ progress_cb=progress,
491
+ )
492
+ finally:
493
+ if temp_dit and os.path.exists(temp_dit):
494
+ try:
495
+ os.remove(temp_dit)
496
+ except Exception:
497
+ # Not worth failing the run over -- the checkpoint is already written --
498
+ # but ~8 GB staying behind in the temp directory is worth one line,
499
+ # because nothing else will ever mention it.
500
+ logging.warning(
501
+ "[krea2-svdquant] could not delete the intermediate %s; roughly "
502
+ "8 GB is still there", temp_dit, exc_info=True)
503
+
504
+ hint = SAMPLER_HINTS.get(variant)
505
+ loader = ("Krea2 SVDQuant Checkpoint Loader" if rank else "CheckpointLoaderSimple")
506
+ text = "\n".join(x for x in (
507
+ summary, "Load it with {}.".format(loader), hint) if x)
508
+ logging.info("[krea2-svdquant] %s", text)
509
+ return {"ui": {"text": [text]}, "result": (text,)}
510
+
511
+
512
+ NODE_CLASS_MAPPINGS = {
513
+ "Krea2SVDQuantQuantize": Krea2SVDQuantQuantize,
514
+ "Krea2SVDQuantQuantizeAllInOne": Krea2SVDQuantQuantizeAllInOne,
515
+ }
516
+ NODE_DISPLAY_NAME_MAPPINGS = {
517
+ "Krea2SVDQuantQuantize": "Krea2 SVDQuant Quantize",
518
+ "Krea2SVDQuantQuantizeAllInOne": "Krea2 SVDQuant Quantize All-in-One",
519
+ }
svdquant_w4a4.py CHANGED
@@ -33,6 +33,64 @@ from .svdquant_diag import BUF_L1, BUF_L2, _CATEGORY, branch_factors, log_dispat
33
  _L1 = "." + BUF_L1
34
  _L2 = "." + BUF_L2
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
  def has_branch(module: torch.nn.Module) -> bool:
38
  """True once this module carries a low-rank branch (i.e. it is a quantized linear)."""
@@ -61,9 +119,10 @@ def _publish_in_state_dict(module: torch.nn.Module) -> None:
61
  emits weight/scale/marker/bias and nothing else -- buffers never reach it. That
62
  matters because ``model_management.module_size()`` sums ``state_dict()``, and every
63
  per-module VRAM decision ComfyUI makes (the lowvram split in particular) is derived
64
- from that number. Left alone, ~2.9 MB per layer and ~645 MB across the 224 blocks
65
- would sit on the GPU while the budget believed it was free -- which is exactly how an
66
- 8 GB card OOMs on this checkpoint but not on the branch-free int8 one.
 
67
 
68
  Emitting them under their own names also means a model saved out of ComfyUI carries
69
  the same ``<layer>.svdq_l1`` keys quantize_krea2.py writes, so it round-trips.
@@ -97,9 +156,9 @@ def attach_branch(module: torch.nn.Module, l1: torch.Tensor, l2: torch.Tensor,
97
  by `_publish_in_state_dict` -- on these modules persistence alone is not enough, see
98
  that function for why. Both steps are needed to get them counted by
99
  ``model_management.module_size()``, which is what every VRAM decision (full load, the
100
- lowvram split, how much to free) is derived from. Uncounted, they are roughly 2.9 MB
101
- per layer and 645 MB across the 224 blocks: the difference between fitting and OOMing
102
- on an 8 GB card.
103
 
104
  `scale` is folded into l2 at attach time rather than applied to the output every step:
105
  l2 is [rank, in_features] while the output is [tokens, out_features], so this is about
@@ -339,39 +398,25 @@ def _install_custom_op(module: torch.nn.Module) -> bool:
339
  return True
340
 
341
 
342
- def load_svdquant_w4a4(path: str, model_options: dict | None = None,
343
- compile_safe: bool = True):
344
- sd, metadata = comfy.utils.load_torch_file(path, return_metadata=True)
345
- # `comfy.sd.load_diffusion_model_state_dict` strips this prefix internally when it builds
346
- # the module tree, but we walk that tree ourselves to attach branches, so we strip it too.
347
- # `default=""` rather than raising: a loader must not blow up on a checkpoint with no
348
- # blocks, and the "found no branches" error below says far more about what went wrong.
349
- layer_prefix = detect_prefix(sd.keys(), default="")
350
 
351
- branches: dict[str, dict[str, torch.Tensor]] = {}
352
- for key in list(sd.keys()):
353
- for suffix, slot in ((_L1, "l1"), (_L2, "l2")):
354
- if key.endswith(suffix):
355
- branches.setdefault(key[: -len(suffix)], {})[slot] = sd.pop(key)
356
- break
357
- if not branches:
358
- raise ValueError(
359
- "{} carries no svdq_l1/svdq_l2 tensors - it is a plain quantized checkpoint, "
360
- "load it with UNETLoader instead".format(path)
361
- )
362
 
363
- # `disable_dynamic=True` pins this to the classic ModelPatcher. On current ComfyUI
364
- # that is a no-op (`CoreModelPatcher is ModelPatcher`), but when upstream flips it to
365
- # ModelPatcherDynamic the streaming patcher takes ownership of the weights via
366
- # `load_model_weights(..., assign=patcher.is_dynamic())`, which we have not validated
367
- # against the branch buffers. Keep it pinned until that path is tested; the
368
- # diagnostics node reports which patcher is actually in use.
369
- patcher = comfy.sd.load_diffusion_model_state_dict(
370
- sd, model_options=model_options or {}, metadata=metadata, disable_dynamic=True
371
- )
372
- if patcher is None:
373
- raise RuntimeError("could not detect a model in {}".format(path))
374
 
 
 
 
375
  diffusion_model = patcher.model.diffusion_model
376
  model_dtype = patcher.model.get_dtype()
377
  attached = 0
@@ -453,9 +498,9 @@ def load_svdquant_w4a4(path: str, model_options: dict | None = None,
453
  compile_desc = "compile: all {} layers are graph breaks{}".format(
454
  attached, " ({})".format(_W4A4_OP_ERROR) if _W4A4_OP_ERROR else "")
455
  summary = ("w4a4 + low-rank: attached {} branches (rank {}, variant {}), "
456
- "model_size {:.2f} GiB, {}".format(
457
  attached, rank_desc, variant, patcher.model_size() / 1024 ** 3,
458
- compile_desc))
459
  logging.info("[krea2-svdquant] %s", summary)
460
 
461
  dispatch = log_dispatch(diffusion_model)
@@ -465,9 +510,95 @@ def load_svdquant_w4a4(path: str, model_options: dict | None = None,
465
  # matters most for the dispatch warning: buried in the console, the people who most need
466
  # to read it are exactly the ones who never see it.
467
  patcher.krea2_load_summary = "\n".join(x for x in (summary, dispatch) if x)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
468
  return patcher
469
 
470
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
471
  class Krea2SVDQuantW4A4Loader:
472
  @classmethod
473
  def INPUT_TYPES(cls):
@@ -479,7 +610,16 @@ class Krea2SVDQuantW4A4Loader:
479
  "checkpoints have no branch and load with the stock UNETLoader "
480
  "instead.",
481
  }),
482
- }
 
 
 
 
 
 
 
 
 
483
  }
484
 
485
  RETURN_TYPES = ("MODEL", "STRING")
@@ -495,12 +635,59 @@ class Krea2SVDQuantW4A4Loader:
495
  "no separate base model needed. The status output tells you whether the "
496
  "fast int4 kernel is in play.")
497
 
498
- def load(self, model_name):
499
  path = folder_paths.get_full_path_or_raise("diffusion_models", model_name)
500
- patcher = load_svdquant_w4a4(path)
501
  status = getattr(patcher, "krea2_load_summary", "")
502
  return {"ui": {"text": [status]}, "result": (patcher, status)}
503
 
504
 
505
- NODE_CLASS_MAPPINGS = {"Krea2SVDQuantW4A4Loader": Krea2SVDQuantW4A4Loader}
506
- NODE_DISPLAY_NAME_MAPPINGS = {"Krea2SVDQuantW4A4Loader": "Krea2 SVDQuant W4A4 Loader"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  _L1 = "." + BUF_L1
34
  _L2 = "." + BUF_L2
35
 
36
+ # How the two loaders answer `disable_dynamic=` when they build the patcher.
37
+ VRAM_MANAGEMENT = ["auto", "classic"]
38
+
39
+ # Both loaders offer the same choice, so the wording lives in one place. Long, because the
40
+ # question it answers -- "why is this suddenly 30 s an iteration" -- is one nobody arrives
41
+ # at the tooltip already understanding.
42
+ VRAM_TOOLTIP = (
43
+ 'Leave on "auto" unless something is wrong. "auto" lets ComfyUI manage this model the '
44
+ "way it manages any other, which on an ordinary launch means the dynamic-VRAM "
45
+ "streaming patcher -- that is what keeps iteration time flat once the model stops "
46
+ 'fitting in VRAM. "classic" pins it to the older patcher, where a model that no longer '
47
+ "fits falls back to streaming weights per module per step and an iteration goes from "
48
+ "~1 s to 30-100 s. KREA2_DISABLE_DYNAMIC=1 forces classic for a whole server run."
49
+ )
50
+
51
+
52
+ def _disable_dynamic(mode: str = "auto") -> bool:
53
+ """Whether to pin this model to the classic `ModelPatcher`.
54
+
55
+ `auto` means "whatever ComfyUI would do for a stock UNETLoader", which since the
56
+ dynamic-VRAM work means the streaming `ModelPatcherDynamic` on any ordinary launch
57
+ (`main.py` swaps `CoreModelPatcher` for it unless --highvram/--novram/--gpu-only/--cpu
58
+ or --disable-dynamic-vram is set). This loader used to hardcode the pin, and that is
59
+ the whole reason an svdq checkpoint fell to per-module weight streaming past the point
60
+ where it stopped fitting -- 30-100 s an iteration -- while the same-size FP8/INT8/
61
+ noLowRank files, which go through the stock loader, did not.
62
+
63
+ Both conditions the pin was waiting on hold on current ComfyUI, and both were
64
+ measured rather than argued from the source:
65
+
66
+ * the branch buffers survive the streaming patcher. Its `load()` walks
67
+ `named_buffers()`, moves every one to the load device and backs up the original.
68
+ Read off the diagnostics node after a render, under enough pressure to force
69
+ offloading: all 448 factors on `cuda:0` while all 224 quantized weights sit on `cpu`
70
+ and stream. So `add_low_rank`'s `cast_to` is a no-op, and the split is the one you
71
+ would choose -- the small branch every step needs stays resident, the big weights do
72
+ not. The same reading under `classic` is 368 factors on `cpu`, i.e. staged per call.
73
+ * `module_size()` still counts them, because `_load_list` derives its per-module
74
+ budget from `model_management.module_size`, which sums `state_dict()` -- exactly
75
+ what `_publish_in_state_dict` exists to feed.
76
+
77
+ `classic` restores the old behaviour. It is kept because one thing here *is*
78
+ unmeasured: the streaming patcher moves those buffers with a plain `.to()` rather than
79
+ through its own allocator, so 1.6 GiB at rank 256 is real VRAM its `allocated_size`
80
+ bookkeeping does not see. If that turns out to matter on some card, the way back should
81
+ be a dropdown, not a downgrade.
82
+
83
+ The env var is the same escape hatch `KREA2_W4A4_OP=0` is, and for the same reason:
84
+ the choice is made once per load, so A/B-ing it means two server runs that differ by
85
+ exactly this.
86
+ """
87
+ if mode == "classic":
88
+ return True
89
+ if mode != "auto":
90
+ raise ValueError("vram_management must be one of {}, got {!r}".format(
91
+ VRAM_MANAGEMENT, mode))
92
+ return os.environ.get("KREA2_DISABLE_DYNAMIC") == "1"
93
+
94
 
95
  def has_branch(module: torch.nn.Module) -> bool:
96
  """True once this module carries a low-rank branch (i.e. it is a quantized linear)."""
 
119
  emits weight/scale/marker/bias and nothing else -- buffers never reach it. That
120
  matters because ``model_management.module_size()`` sums ``state_dict()``, and every
121
  per-module VRAM decision ComfyUI makes (the lowvram split in particular) is derived
122
+ from that number. Left alone, 3.75-11 MiB per layer and 1.6 GiB across the 224 blocks
123
+ (measured off a rank-256 file; 0.4 GiB at rank 64) would sit on the GPU while the budget
124
+ believed it was free -- which is exactly how an 8 GB card OOMs on this checkpoint but
125
+ not on the branch-free int8 one.
126
 
127
  Emitting them under their own names also means a model saved out of ComfyUI carries
128
  the same ``<layer>.svdq_l1`` keys quantize_krea2.py writes, so it round-trips.
 
156
  by `_publish_in_state_dict` -- on these modules persistence alone is not enough, see
157
  that function for why. Both steps are needed to get them counted by
158
  ``model_management.module_size()``, which is what every VRAM decision (full load, the
159
+ lowvram split, how much to free) is derived from. Uncounted, they are 3.75-11 MiB per
160
+ layer and 1.6 GiB across the 224 blocks at rank 256 (0.4 GiB at rank 64): the difference
161
+ between fitting and OOMing on an 8 GB card.
162
 
163
  `scale` is folded into l2 at attach time rather than applied to the output every step:
164
  l2 is [rank, in_features] while the output is [tokens, out_features], so this is about
 
398
  return True
399
 
400
 
401
+ def _patcher_desc(patcher) -> str:
402
+ """Which patcher this model got, for the load summary.
 
 
 
 
 
 
403
 
404
+ Worth stating on every load rather than only when something goes wrong: "dynamic" and
405
+ "pinned" are the difference between a 1 s iteration and a 30 s one once the model stops
406
+ fitting, and the status output is the one place a user is told to look when generation
407
+ is slow. `is_dynamic` is wrapped because it is a newer ComfyUI API and a loader must not
408
+ fail to load a checkpoint over a status string.
409
+ """
410
+ try:
411
+ return "{} (dynamic vram: {})".format(
412
+ type(patcher).__name__, "on" if patcher.is_dynamic() else "off")
413
+ except Exception:
414
+ return type(patcher).__name__
415
 
 
 
 
 
 
 
 
 
 
 
 
416
 
417
+ def _attach_svdq_branches(patcher, branches: dict[str, dict[str, torch.Tensor]],
418
+ layer_prefix: str, metadata: dict | None, path: str,
419
+ compile_safe: bool = True) -> str:
420
  diffusion_model = patcher.model.diffusion_model
421
  model_dtype = patcher.model.get_dtype()
422
  attached = 0
 
498
  compile_desc = "compile: all {} layers are graph breaks{}".format(
499
  attached, " ({})".format(_W4A4_OP_ERROR) if _W4A4_OP_ERROR else "")
500
  summary = ("w4a4 + low-rank: attached {} branches (rank {}, variant {}), "
501
+ "model_size {:.2f} GiB, {}, {}".format(
502
  attached, rank_desc, variant, patcher.model_size() / 1024 ** 3,
503
+ _patcher_desc(patcher), compile_desc))
504
  logging.info("[krea2-svdquant] %s", summary)
505
 
506
  dispatch = log_dispatch(diffusion_model)
 
510
  # matters most for the dispatch warning: buried in the console, the people who most need
511
  # to read it are exactly the ones who never see it.
512
  patcher.krea2_load_summary = "\n".join(x for x in (summary, dispatch) if x)
513
+ return patcher.krea2_load_summary
514
+
515
+
516
+ def load_svdquant_w4a4(path: str, model_options: dict | None = None,
517
+ compile_safe: bool = True, vram_management: str = "auto"):
518
+ sd, metadata = comfy.utils.load_torch_file(path, return_metadata=True)
519
+ # `comfy.sd.load_diffusion_model_state_dict` strips this prefix internally when it builds
520
+ # the module tree, but we walk that tree ourselves to attach branches, so we strip it too.
521
+ # `default=""` rather than raising: a loader must not blow up on a checkpoint with no
522
+ # blocks, and the "found no branches" error below says far more about what went wrong.
523
+ layer_prefix = detect_prefix(sd.keys(), default="")
524
+
525
+ branches: dict[str, dict[str, torch.Tensor]] = {}
526
+ for key in list(sd.keys()):
527
+ for suffix, slot in ((_L1, "l1"), (_L2, "l2")):
528
+ if key.endswith(suffix):
529
+ branches.setdefault(key[: -len(suffix)], {})[slot] = sd.pop(key)
530
+ break
531
+ if not branches:
532
+ raise ValueError(
533
+ "{} carries no svdq_l1/svdq_l2 tensors - it is a plain quantized checkpoint, "
534
+ "load it with UNETLoader instead".format(path)
535
+ )
536
+
537
+ # See `_disable_dynamic` for why this is no longer hardcoded to True. The diagnostics
538
+ # node and the load summary both report which patcher the model actually got.
539
+ patcher = comfy.sd.load_diffusion_model_state_dict(
540
+ sd, model_options=model_options or {}, metadata=metadata,
541
+ disable_dynamic=_disable_dynamic(vram_management)
542
+ )
543
+ if patcher is None:
544
+ raise RuntimeError("could not detect a model in {}".format(path))
545
+
546
+ _attach_svdq_branches(patcher, branches, layer_prefix, metadata, path, compile_safe)
547
  return patcher
548
 
549
 
550
+ def load_svdquant_checkpoint(path: str, output_vae: bool = True, output_clip: bool = True,
551
+ model_options: dict | None = None,
552
+ te_model_options: dict | None = None,
553
+ compile_safe: bool = True, vram_management: str = "auto"):
554
+ """Load an all-in-one Krea 2 checkpoint (diffusion model + text encoder + VAE).
555
+
556
+ If the checkpoint carries SVDQuant branches (model.diffusion_model.blocks.N.*.svdq_l1/l2),
557
+ they are extracted, the model is built via ComfyUI's load_state_dict_guess_config, and
558
+ the low-rank branches are attached. If the checkpoint is branchless (e.g. w4a4 or int8),
559
+ it is loaded cleanly without branches.
560
+ """
561
+ sd, metadata = comfy.utils.load_torch_file(path, return_metadata=True)
562
+
563
+ branches: dict[str, dict[str, torch.Tensor]] = {}
564
+ for key in list(sd.keys()):
565
+ for suffix, slot in ((_L1, "l1"), (_L2, "l2")):
566
+ if key.endswith(suffix):
567
+ branches.setdefault(key[: -len(suffix)], {})[slot] = sd.pop(key)
568
+ break
569
+
570
+ out = comfy.sd.load_state_dict_guess_config(
571
+ sd,
572
+ output_vae=output_vae,
573
+ output_clip=output_clip,
574
+ output_clipvision=False,
575
+ embedding_directory=folder_paths.get_folder_paths("embeddings"),
576
+ model_options=model_options or {},
577
+ te_model_options=te_model_options or {},
578
+ metadata=metadata,
579
+ disable_dynamic=_disable_dynamic(vram_management),
580
+ )
581
+ if out is None:
582
+ raise RuntimeError("could not detect a valid checkpoint in {}".format(path))
583
+
584
+ patcher, clip, vae, _ = out
585
+
586
+ if branches:
587
+ layer_prefix = detect_prefix(branches.keys(), default="")
588
+ _attach_svdq_branches(patcher, branches, layer_prefix, metadata, path, compile_safe)
589
+ else:
590
+ diffusion_model = patcher.model.diffusion_model
591
+ install_mask_guard(patcher)
592
+ dispatch = log_dispatch(diffusion_model)
593
+ summary = "all-in-one checkpoint (branchless): model_size {:.2f} GiB, {}".format(
594
+ patcher.model_size() / 1024 ** 3, _patcher_desc(patcher))
595
+ logging.info("[krea2-svdquant] %s", summary)
596
+ patcher.krea2_load_summary = "\n".join(x for x in (summary, dispatch) if x)
597
+
598
+ status = getattr(patcher, "krea2_load_summary", "")
599
+ return patcher, clip, vae, status
600
+
601
+
602
  class Krea2SVDQuantW4A4Loader:
603
  @classmethod
604
  def INPUT_TYPES(cls):
 
610
  "checkpoints have no branch and load with the stock UNETLoader "
611
  "instead.",
612
  }),
613
+ },
614
+ # Optional and appended last: ComfyUI matches `widgets_values` positionally, so
615
+ # inserting a widget anywhere else shifts every value in a workflow saved
616
+ # before this input existed.
617
+ "optional": {
618
+ "vram_management": (VRAM_MANAGEMENT, {
619
+ "default": "auto",
620
+ "tooltip": VRAM_TOOLTIP,
621
+ }),
622
+ },
623
  }
624
 
625
  RETURN_TYPES = ("MODEL", "STRING")
 
635
  "no separate base model needed. The status output tells you whether the "
636
  "fast int4 kernel is in play.")
637
 
638
+ def load(self, model_name, vram_management="auto"):
639
  path = folder_paths.get_full_path_or_raise("diffusion_models", model_name)
640
+ patcher = load_svdquant_w4a4(path, vram_management=vram_management)
641
  status = getattr(patcher, "krea2_load_summary", "")
642
  return {"ui": {"text": [status]}, "result": (patcher, status)}
643
 
644
 
645
+ class Krea2SVDQuantCheckpointLoader:
646
+ @classmethod
647
+ def INPUT_TYPES(cls):
648
+ return {
649
+ "required": {
650
+ "ckpt_name": (folder_paths.get_filename_list("checkpoints"), {
651
+ "tooltip": "An all-in-one Krea2 checkpoint containing diffusion model, "
652
+ "quantized text encoder, and VAE. If it carries SVDQuant "
653
+ "branches (*.svdq_l1/*.svdq_l2), they will be attached automatically.",
654
+ }),
655
+ },
656
+ # See the sibling loader: appended last, for the same positional reason.
657
+ "optional": {
658
+ "vram_management": (VRAM_MANAGEMENT, {
659
+ "default": "auto",
660
+ "tooltip": VRAM_TOOLTIP,
661
+ }),
662
+ },
663
+ }
664
+
665
+ RETURN_TYPES = ("MODEL", "CLIP", "VAE", "STRING")
666
+ RETURN_NAMES = ("model", "clip", "vae", "status")
667
+ OUTPUT_TOOLTIPS = ("Wire this to a KSampler.",
668
+ "Wire this to CLIPTextEncode.",
669
+ "Wire this to VAEDecode.",
670
+ "Rank, variant, size and which kernel the quantized layers will "
671
+ "actually dispatch to.")
672
+ OUTPUT_NODE = True
673
+ FUNCTION = "load"
674
+ CATEGORY = _CATEGORY
675
+ TITLE = "Krea2 SVDQuant Checkpoint Loader"
676
+ DESCRIPTION = ("Loads an all-in-one Krea2 checkpoint containing diffusion model, text encoder, "
677
+ "and VAE in a single file. Attaches low-rank SVDQuant branches if present.")
678
+
679
+ def load(self, ckpt_name, vram_management="auto"):
680
+ path = folder_paths.get_full_path_or_raise("checkpoints", ckpt_name)
681
+ patcher, clip, vae, status = load_svdquant_checkpoint(
682
+ path, vram_management=vram_management)
683
+ return {"ui": {"text": [status]}, "result": (patcher, clip, vae, status)}
684
+
685
+
686
+ NODE_CLASS_MAPPINGS = {
687
+ "Krea2SVDQuantW4A4Loader": Krea2SVDQuantW4A4Loader,
688
+ "Krea2SVDQuantCheckpointLoader": Krea2SVDQuantCheckpointLoader,
689
+ }
690
+ NODE_DISPLAY_NAME_MAPPINGS = {
691
+ "Krea2SVDQuantW4A4Loader": "Krea2 SVDQuant W4A4 Loader",
692
+ "Krea2SVDQuantCheckpointLoader": "Krea2 SVDQuant Checkpoint Loader",
693
+ }
tools/bake_adapter.py CHANGED
@@ -28,14 +28,15 @@ import sys
28
 
29
  HERE = os.path.dirname(os.path.abspath(__file__))
30
  sys.path.insert(0, os.path.dirname(HERE)) # the node package
31
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(HERE)))) # ComfyUI root
32
 
33
  import torch # noqa: E402
34
 
35
- import comfy.lora # noqa: E402
36
- import comfy.utils # noqa: E402
37
- import comfy.weight_adapter # noqa: E402
38
-
 
 
39
  from quantize_krea2 import ( # noqa: E402
40
  LAYER_PREFIXES,
41
  convert,
@@ -44,6 +45,10 @@ from quantize_krea2 import ( # noqa: E402
44
  resolve_format,
45
  )
46
 
 
 
 
 
47
  _PREFIX = "diffusion_model."
48
  _ALT_PREFIX = "transformer."
49
 
 
28
 
29
  HERE = os.path.dirname(os.path.abspath(__file__))
30
  sys.path.insert(0, os.path.dirname(HERE)) # the node package
 
31
 
32
  import torch # noqa: E402
33
 
34
+ # `quantize_krea2` before `comfy.*`, and that order is the point. It runs
35
+ # `_find_comfyui_root()` at import -- $COMFYUI_PATH, then two levels up from itself, then a
36
+ # couple of fallbacks -- so importing it is what puts ComfyUI on the path. This file used to
37
+ # do that itself with a bare `dirname(dirname(dirname(HERE)))`, which is correct only when
38
+ # the pack sits in `ComfyUI/custom_nodes/`: from a working copy anywhere else, even `--help`
39
+ # died on `No module named 'comfy'` with nothing to say about why.
40
  from quantize_krea2 import ( # noqa: E402
41
  LAYER_PREFIXES,
42
  convert,
 
45
  resolve_format,
46
  )
47
 
48
+ import comfy.lora # noqa: E402
49
+ import comfy.utils # noqa: E402
50
+ import comfy.weight_adapter # noqa: E402
51
+
52
  _PREFIX = "diffusion_model."
53
  _ALT_PREFIX = "transformer."
54
 
tools/build_all_in_one.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Command-line front end for `build_all_in_one.py`.
2
+
3
+ The mechanism lives one directory up, beside `quantize_krea2.py`, because `.comfyignore`
4
+ keeps `tools/` out of the published package and the in-graph quantize node imports it.
5
+ This file is the part a terminal needs: the sys.path bootstrap that makes the package
6
+ importable when it is run as a plain script, and the argument parsing.
7
+
8
+ python tools/build_all_in_one.py --dit ... --text-encoder ... --vae ... --format svdq
9
+
10
+ Run `--help` for the full set. `--dry-run` does the whole key mapping and prints the size
11
+ arithmetic without quantizing anything or touching the GPU.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import os
17
+ import sys
18
+
19
+ # The package directory, so `import build_all_in_one` resolves to the module beside
20
+ # `quantize_krea2.py`. It in turn finds ComfyUI itself (`quantize_krea2._find_comfyui_root`),
21
+ # so there is nothing else to bootstrap here.
22
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
23
+
24
+ from build_all_in_one import TE_FORMATS, build_all_in_one_checkpoint # noqa: E402
25
+ from quantize_krea2 import DEFAULT_SEED, REFINE_TOL, resolve_format # noqa: E402
26
+
27
+
28
+ def main():
29
+ ap = argparse.ArgumentParser(description=__doc__,
30
+ formatter_class=argparse.RawDescriptionHelpFormatter)
31
+ ap.add_argument("--dit", required=True,
32
+ help="the diffusion model: a BF16 source, or one this tool already "
33
+ "quantized (detected, and passed through untouched)")
34
+ ap.add_argument("--text-encoder", required=True, help="qwen3vl_4b BF16 safetensors")
35
+ ap.add_argument("--vae", required=True)
36
+ ap.add_argument("--format", choices=["int8", "w4a4", "svdq", "fp8"], default="svdq",
37
+ help="diffusion side. svdq needs this repo's checkpoint loader node; "
38
+ "w4a4/int8 load with the stock CheckpointLoaderSimple")
39
+ ap.add_argument("--te-format", choices=sorted(TE_FORMATS), default="w4a4",
40
+ help="text encoder side. No low-rank branch either way")
41
+ ap.add_argument("--rank", type=int, default=64, help="diffusion side, svdq only")
42
+ ap.add_argument("--rank-alloc", default="uniform")
43
+ ap.add_argument("--refine-iters", type=int, default=100)
44
+ ap.add_argument("--refine-tol", type=float, default=REFINE_TOL)
45
+ ap.add_argument("--groupsize", type=int, default=256)
46
+ ap.add_argument("--act-stats", default=None, help="diffusion side only")
47
+ ap.add_argument("--variant", choices=["turbo", "base", "unknown"], default="unknown")
48
+ ap.add_argument("--seed", type=int, default=DEFAULT_SEED)
49
+ ap.add_argument("--device", default="cuda")
50
+ ap.add_argument("--out", default=None)
51
+ ap.add_argument("--keep-dit-temp", action="store_true",
52
+ help="do not delete the intermediate quantized diffusion file")
53
+ ap.add_argument("--dry-run", action="store_true",
54
+ help="map every key and print the size arithmetic. Quantizes nothing, "
55
+ "allocates nothing, never touches the GPU")
56
+ args = ap.parse_args()
57
+
58
+ fmt, rank = resolve_format(args.format, args.rank, rank_was_set=True)
59
+
60
+ out = args.out
61
+ if out is None:
62
+ stem = "Krea2-{}".format(args.variant.capitalize()) if args.variant != "unknown" \
63
+ else "Krea2"
64
+ tag = "SVDQuant-W4A4-rank{}".format(rank) if rank else args.format.upper()
65
+ dit_dir = os.path.dirname(os.path.abspath(args.dit))
66
+ ckpt_dir = os.path.join(os.path.dirname(dit_dir), "checkpoints")
67
+ target_dir = ckpt_dir if os.path.isdir(ckpt_dir) else dit_dir
68
+ out = os.path.join(target_dir,
69
+ "{}-AllInOne-{}-TE{}.safetensors".format(
70
+ stem, tag, args.te_format.upper()))
71
+
72
+ print("diffusion : {}".format(os.path.basename(args.dit)))
73
+ print("encoder : {} -> {}".format(os.path.basename(args.text_encoder), args.te_format))
74
+ print("vae : {} (unquantized)".format(os.path.basename(args.vae)))
75
+ print("out : {}".format(out))
76
+
77
+ summary = build_all_in_one_checkpoint(
78
+ dit_path=args.dit,
79
+ text_encoder_path=args.text_encoder,
80
+ vae_path=args.vae,
81
+ out_path=out,
82
+ fmt_name=args.format,
83
+ te_format=args.te_format,
84
+ rank=rank,
85
+ variant=args.variant,
86
+ groupsize=args.groupsize,
87
+ device=args.device,
88
+ dry_run=args.dry_run,
89
+ )
90
+ print("\n" + summary)
91
+ if not args.dry_run:
92
+ if rank:
93
+ print("svdq checkpoint: load it with the Krea2 SVDQuant Checkpoint Loader node, "
94
+ "not CheckpointLoaderSimple -- the low-rank branch needs attaching.")
95
+ else:
96
+ print("branchless: loads with the stock CheckpointLoaderSimple.")
97
+
98
+
99
+ if __name__ == "__main__":
100
+ main()
tools/build_workflows.py CHANGED
@@ -37,7 +37,12 @@ ROW = 200 # vertical gap between rows
37
  TEAL = ("#233", "#355") # loaders
38
  GREEN = ("#232", "#353") # prompts
39
  PURPLE = ("#323", "#535") # sampling
40
- YELLOW = ("#432", "#653") # notes / read me first
 
 
 
 
 
41
 
42
 
43
  class Graph:
@@ -56,7 +61,7 @@ class Graph:
56
  self._api = {} # node_id -> what the API dialect needs about that node
57
 
58
  def add(self, class_type, col, row, inputs=None, outputs=None, widgets=None,
59
- title=None, colour=None, size=None, api=True):
60
  """`widgets` is a list of (input_name, value).
61
 
62
  The name is what the API dialect needs and the UI dialect ignores; `None` marks a
@@ -66,7 +71,17 @@ class Graph:
66
 
67
  `api=False` drops the node from the API output: Notes and the optional Diagnostics
68
  node are editor furniture with nothing to execute.
 
 
 
 
 
 
 
 
69
  """
 
 
70
  nid = self._next_node
71
  self._next_node += 1
72
  inputs = inputs or []
@@ -83,8 +98,10 @@ class Graph:
83
  "size": size or [380, 100],
84
  "flags": {},
85
  "order": self._next_node - 2,
86
- "mode": 0,
87
- "inputs": [{"name": n, "type": t, "link": None} for n, t in inputs],
 
 
88
  "outputs": [{"name": n, "type": t, "links": [], "slot_index": i}
89
  for i, (n, t) in enumerate(outputs)],
90
  "properties": {"Node name for S&R": class_type},
@@ -191,6 +208,23 @@ TURBO_NOTE = """KREA 2 TURBO - SVDQuant W4A4
191
  5. LoRA: use the Krea2 SVDQuant LoRA Loader, not the stock one. The
192
  stock loader cannot patch a quantized weight and skips the blocks."""
193
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  BASE_NOTE = """KREA 2 BASE (non-turbo) - SVDQuant W4A4
195
 
196
  The loader node will be outlined in RED when you open this. That is
@@ -221,6 +255,68 @@ checkpoint, then reselect it in the loader.
221
  Same slow-generation checklist too: read the loader's status
222
  output first."""
223
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  PROMPT = ("A cluttered antique clockmaker's workshop seen through a cracked magnifying "
225
  "glass, brass gears laid out in a spiral on the workbench, a ginger cat asleep "
226
  "on a stack of leather-bound books, warm afternoon light with visible dust "
@@ -296,6 +392,66 @@ def build_turbo():
296
  return g.serialize(groups), g.serialize_api()
297
 
298
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
  def build_base():
300
  g = Graph()
301
  g.note(BASE_NOTE, 0, 0, size=(400, 560), title="READ ME FIRST")
@@ -356,6 +512,399 @@ def build_base():
356
  return g.serialize(groups), g.serialize_api()
357
 
358
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
  def _write(path, payload):
360
  with open(path, "w", encoding="utf-8", newline="\n") as fh:
361
  json.dump(payload, fh, indent=2, ensure_ascii=False)
@@ -363,9 +912,16 @@ def _write(path, payload):
363
 
364
 
365
  def main():
 
366
  os.makedirs(OUT_DIR, exist_ok=True)
367
  for name, build in (("krea2_turbo_svdquant_w4a4_t2i", build_turbo),
368
- ("krea2_base_svdquant_w4a4_t2i", build_base)):
 
 
 
 
 
 
369
  graph, api = build()
370
  ui_path = os.path.join(OUT_DIR, name + ".json")
371
  api_path = os.path.join(OUT_DIR, name + "_api.json")
 
37
  TEAL = ("#233", "#355") # loaders
38
  GREEN = ("#232", "#353") # prompts
39
  PURPLE = ("#323", "#535") # sampling
40
+ YELLOW = ("#432", "#653") # notes / read me first / diagnostics
41
+ ORANGE = ("#653", "#874") # LoRA
42
+
43
+ # Mirrors svdquant_lora.ADAPTER_BYPASS. Not imported: this script must run without ComfyUI
44
+ # on sys.path, and the loader module imports comfy at module level. Asserted in main().
45
+ ADAPTER_BYPASS = "bypass (exact, slower)"
46
 
47
 
48
  class Graph:
 
61
  self._api = {} # node_id -> what the API dialect needs about that node
62
 
63
  def add(self, class_type, col, row, inputs=None, outputs=None, widgets=None,
64
+ title=None, colour=None, size=None, api=True, mode=0, widget_inputs=()):
65
  """`widgets` is a list of (input_name, value).
66
 
67
  The name is what the API dialect needs and the UI dialect ignores; `None` marks a
 
71
 
72
  `api=False` drops the node from the API output: Notes and the optional Diagnostics
73
  node are editor furniture with nothing to execute.
74
+
75
+ `mode=4` mutes the node in the editor. A muted node must also be `api=False` -- the
76
+ API dialect has no mute flag, so leaving it in would execute something the editor
77
+ shows as off.
78
+
79
+ `widget_inputs` names inputs that are widgets on the node class but are being wired
80
+ from another node instead. LiteGraph needs the `widget` key on the slot or the editor
81
+ draws a plain input the frontend then refuses to reconnect to its widget.
82
  """
83
+ if mode == 4 and api:
84
+ raise ValueError("a muted node must be api=False: {}".format(class_type))
85
  nid = self._next_node
86
  self._next_node += 1
87
  inputs = inputs or []
 
98
  "size": size or [380, 100],
99
  "flags": {},
100
  "order": self._next_node - 2,
101
+ "mode": mode,
102
+ "inputs": [dict({"name": n, "type": t, "link": None},
103
+ **({"widget": {"name": n}} if n in widget_inputs else {}))
104
+ for n, t in inputs],
105
  "outputs": [{"name": n, "type": t, "links": [], "slot_index": i}
106
  for i, (n, t) in enumerate(outputs)],
107
  "properties": {"Node name for S&R": class_type},
 
208
  5. LoRA: use the Krea2 SVDQuant LoRA Loader, not the stock one. The
209
  stock loader cannot patch a quantized weight and skips the blocks."""
210
 
211
+ ALL_IN_ONE_TURBO_NOTE = """KREA 2 TURBO - ALL-IN-ONE CHECKPOINT
212
+
213
+ 1. Checkpoint goes in ComfyUI/models/checkpoints/.
214
+ This file is fully self-contained: it combines the diffusion model,
215
+ the 4-bit quantized text encoder (Qwen3-VL 4B), and the VAE into a
216
+ single safetensors file (~12 GB instead of 15.5 GB across 3 files).
217
+
218
+ 2. SVDQuant checkpoints (carrying low-rank factors) load with the
219
+ "Krea2 SVDQuant Checkpoint Loader" node. Branchless checkpoints
220
+ (--format w4a4 or int8) also load with stock CheckpointLoaderSimple.
221
+
222
+ 3. cfg MUST be 1.0. Krea 2 Turbo is cfg-distilled, so the negative
223
+ prompt is zeroed out (ConditioningZeroOut) rather than encoded.
224
+ 8 steps with euler / simple scheduler is what the model was distilled for.
225
+
226
+ 4. 1024x1024 native resolution. Latent size MUST be multiples of 16."""
227
+
228
  BASE_NOTE = """KREA 2 BASE (non-turbo) - SVDQuant W4A4
229
 
230
  The loader node will be outlined in RED when you open this. That is
 
255
  Same slow-generation checklist too: read the loader's status
256
  output first."""
257
 
258
+ LORA_NOTE = """KREA 2 TURBO + LoRA - SVDQuant W4A4
259
+
260
+ Same graph as the plain Turbo one, with LoRA loaders inserted between
261
+ the checkpoint loader and the sampler.
262
+
263
+ 1. USE THIS NODE, NOT THE STOCK ONE. On a quantized checkpoint the
264
+ stock LoraLoaderModelOnly has to dequantize the 4-bit weight, add
265
+ the LoRA, and requantize it -- which throws away the format and
266
+ quantizes the LoRA delta to 4 bits along with it. This node
267
+ attaches the LoRA as a parallel branch instead:
268
+ (W + BA)x == Wx + B(Ax), so the quantized weight is never touched.
269
+
270
+ 2. CHECK THE CONSOLE. The node prints what it matched, e.g.
271
+ 224 quantized layers, 32 normal layers
272
+ If it says 0 quantized layers, the LoRA does not target this model
273
+ and nothing will change. That line is the first thing to look at
274
+ when a LoRA "does nothing".
275
+
276
+ 3. STACKING. Chain more of these nodes, one per LoRA. The whole stack
277
+ is refolded each time, so strengths stay exact no matter how many
278
+ you chain, and the cost stays one pair of GEMMs. The second loader
279
+ here is bypassed (ctrl-B) -- unmute it to use it.
280
+
281
+ 4. RANK MATTERS HERE. Without a LoRA, branch ranks 64/128/256 measure
282
+ the same. With one, rank 256 wins clearly and rank 64 loses most of
283
+ its advantage. If you use LoRAs, download rank 256.
284
+
285
+ 5. adapters INPUT. Only affects LoKr / LoHa / OFT, which cannot fold
286
+ into the branch. "bypass" is exact and costs ~1.8s per model call
287
+ at 1440x1920; "bake" is free per-step but requantizes the delta.
288
+ A plain up/down LoRA ignores this setting entirely."""
289
+
290
+ DIAG_NOTE = """KREA 2 SVDQUANT - DIAGNOSTICS
291
+
292
+ Run this before reporting a bug, and before downloading 8 GB.
293
+ Nothing here generates an image.
294
+
295
+ ENV CHECK (left, no model needed)
296
+ Answers the single most common report: "the quantized checkpoint
297
+ is slower than fp8". Almost always the cause is that ComfyUI
298
+ disabled comfy_kitchen's CUDA backend because torch was built
299
+ against CUDA < 13, and the fallback dequantizes int4 in Python --
300
+ slower than bf16, not faster. Needs no model, so run it first.
301
+
302
+ DIAGNOSTICS (right, needs a checkpoint) - the mode widget:
303
+ dispatch which kernel actually runs. START HERE.
304
+ env versions + memory accounting, including where the
305
+ low-rank factors currently live. Under lowvram they
306
+ should read cpu between steps, not cuda.
307
+ bench quantized vs bf16 timing per layer.
308
+ profile torch.profiler table.
309
+ compile graph breaks a TorchCompileModel run would pay.
310
+
311
+ tokens = sequence length. 4096 is 1024x1024. Kernel selection is
312
+ shape-dependent, so match your real resolution.
313
+
314
+ WHAT TO PASTE INTO AN ISSUE
315
+ Your GPU, the checkpoint filename, and the report output of both
316
+ nodes. Both are OUTPUT_NODEs, so the text shows in the node itself
317
+ and in the console. Neither is cached -- they re-measure the live
318
+ process every run, which is the point."""
319
+
320
  PROMPT = ("A cluttered antique clockmaker's workshop seen through a cracked magnifying "
321
  "glass, brass gears laid out in a spiral on the workbench, a ginger cat asleep "
322
  "on a stack of leather-bound books, warm afternoon light with visible dust "
 
392
  return g.serialize(groups), g.serialize_api()
393
 
394
 
395
+ def build_all_in_one_turbo():
396
+ g = Graph()
397
+ g.note(ALL_IN_ONE_TURBO_NOTE, 0, 0, size=(400, 620), title="READ ME FIRST")
398
+
399
+ loader = g.add("Krea2SVDQuantCheckpointLoader", 1, 0,
400
+ outputs=[("MODEL", "MODEL"), ("CLIP", "CLIP"), ("VAE", "VAE"), ("STRING", "STRING")],
401
+ widgets=[("ckpt_name", "Krea2-Turbo-AllInOne-SVDQuant-W4A4-rank256-actaware-TEW4A4.safetensors")],
402
+ title="Krea2 SVDQuant Checkpoint Loader", colour=TEAL, size=[400, 140])
403
+
404
+ pos = g.add("CLIPTextEncode", 2, 0, inputs=[("clip", "CLIP")],
405
+ outputs=[("CONDITIONING", "CONDITIONING")], widgets=[("text", PROMPT)],
406
+ title="Prompt", colour=GREEN, size=[400, 220])
407
+ neg = g.add("ConditioningZeroOut", 2, 1.4, inputs=[("conditioning", "CONDITIONING")],
408
+ outputs=[("CONDITIONING", "CONDITIONING")],
409
+ title="Negative (zeroed - required at cfg 1.0)", colour=GREEN,
410
+ size=[400, 60])
411
+ latent = g.add("EmptySD3LatentImage", 2, 2.2, outputs=[("LATENT", "LATENT")],
412
+ widgets=[("width", 1024), ("height", 1024), ("batch_size", 1)],
413
+ title="Latent 1024x1024", colour=GREEN,
414
+ size=[400, 120])
415
+
416
+ sampler = g.add("KSampler", 3, 0,
417
+ inputs=[("model", "MODEL"), ("positive", "CONDITIONING"),
418
+ ("negative", "CONDITIONING"), ("latent_image", "LATENT")],
419
+ outputs=[("LATENT", "LATENT")],
420
+ widgets=[("seed", 987654321), (None, "randomize"), ("steps", 8), ("cfg", 1.0),
421
+ ("sampler_name", "euler"), ("scheduler", "simple"),
422
+ ("denoise", 1.0)],
423
+ title="KSampler - 8 steps, cfg 1.0", colour=PURPLE, size=[400, 280])
424
+ decode = g.add("VAEDecode", 4, 0, inputs=[("samples", "LATENT"), ("vae", "VAE")],
425
+ outputs=[("IMAGE", "IMAGE")], title="VAE Decode", colour=PURPLE,
426
+ size=[300, 60])
427
+ save = g.add("SaveImage", 4, 0.7, inputs=[("images", "IMAGE")],
428
+ widgets=[("filename_prefix", "krea2_turbo_aio_svdq")], title="Save", colour=PURPLE, size=[400, 300])
429
+
430
+ diag = g.add("Krea2SVDQuantDiagnostics", 3, 2.4, inputs=[("model", "MODEL")],
431
+ outputs=[("MODEL", "MODEL"), ("STRING", "STRING")],
432
+ widgets=[("mode", "dispatch"), ("tokens", 4096)],
433
+ title="Diagnostics (optional - run if slow)", colour=YELLOW,
434
+ size=[400, 130], api=False)
435
+
436
+ g.link(loader, "MODEL", sampler, "model")
437
+ g.link(loader, "CLIP", pos, "clip")
438
+ g.link(pos, "CONDITIONING", neg, "conditioning")
439
+ g.link(pos, "CONDITIONING", sampler, "positive")
440
+ g.link(neg, "CONDITIONING", sampler, "negative")
441
+ g.link(latent, "LATENT", sampler, "latent_image")
442
+ g.link(sampler, "LATENT", decode, "samples")
443
+ g.link(loader, "VAE", decode, "vae")
444
+ g.link(decode, "IMAGE", save, "images")
445
+ g.link(loader, "MODEL", diag, "model")
446
+
447
+ groups = [
448
+ g.group("Load All-in-One", (500, 20, 420, 280)),
449
+ g.group("Prompt", (940, 20, 420, 620)),
450
+ g.group("Sample", (1360, 20, 420, 640), colour="#8a4"),
451
+ ]
452
+ return g.serialize(groups), g.serialize_api()
453
+
454
+
455
  def build_base():
456
  g = Graph()
457
  g.note(BASE_NOTE, 0, 0, size=(400, 560), title="READ ME FIRST")
 
512
  return g.serialize(groups), g.serialize_api()
513
 
514
 
515
+ def build_lora():
516
+ """The Turbo graph with two LoRA loaders spliced in, the second one muted.
517
+
518
+ Two rather than one because stacking is the question people actually ask, and a muted
519
+ second node shows the answer (chain them) without changing what the graph does on first
520
+ run.
521
+ """
522
+ g = Graph()
523
+ g.note(LORA_NOTE, 0, 0, size=(400, 720), title="READ ME FIRST")
524
+
525
+ loader = g.add("Krea2SVDQuantW4A4Loader", 1, 0,
526
+ outputs=[("MODEL", "MODEL"), ("STRING", "STRING")],
527
+ widgets=[("model_name", "Krea2-Turbo-SVDQuant-W4A4-rank256.safetensors")],
528
+ title="Krea2 SVDQuant W4A4 Loader (rank 256 for LoRAs)", colour=TEAL,
529
+ size=[400, 120])
530
+ clip = g.add("CLIPLoader", 1, 1, outputs=[("CLIP", "CLIP")],
531
+ widgets=[("clip_name", "qwen3vl_4b_fp8_scaled.safetensors"), ("type", "krea2"),
532
+ ("device", "default")],
533
+ title="Text encoder (Qwen3-VL 4B)", colour=TEAL, size=[400, 120])
534
+ vae = g.add("VAELoader", 1, 2, outputs=[("VAE", "VAE")],
535
+ widgets=[("vae_name", "qwen_image_vae.safetensors")], title="VAE", colour=TEAL,
536
+ size=[400, 80])
537
+
538
+ lora1 = g.add("Krea2SVDQuantLoraLoader", 2, 0, inputs=[("model", "MODEL")],
539
+ outputs=[("model", "MODEL")],
540
+ widgets=[("lora_name", "your_krea2_lora.safetensors"), ("strength", 1.0),
541
+ ("adapters", ADAPTER_BYPASS)],
542
+ title="Krea2 SVDQuant LoRA Loader", colour=ORANGE, size=[400, 140])
543
+ lora2 = g.add("Krea2SVDQuantLoraLoader", 2, 1, inputs=[("model", "MODEL")],
544
+ outputs=[("model", "MODEL")],
545
+ widgets=[("lora_name", "your_krea2_lora.safetensors"), ("strength", 0.6),
546
+ ("adapters", ADAPTER_BYPASS)],
547
+ title="Second LoRA (muted - ctrl-B to enable)", colour=ORANGE,
548
+ size=[400, 140], mode=4, api=False)
549
+
550
+ pos = g.add("CLIPTextEncode", 3, 0, inputs=[("clip", "CLIP")],
551
+ outputs=[("CONDITIONING", "CONDITIONING")], widgets=[("text", PROMPT)],
552
+ title="Prompt (add your LoRA trigger words)", colour=GREEN, size=[400, 220])
553
+ neg = g.add("ConditioningZeroOut", 3, 1.4, inputs=[("conditioning", "CONDITIONING")],
554
+ outputs=[("CONDITIONING", "CONDITIONING")],
555
+ title="Negative (zeroed - required at cfg 1.0)", colour=GREEN, size=[400, 60])
556
+ latent = g.add("EmptySD3LatentImage", 3, 2.2, outputs=[("LATENT", "LATENT")],
557
+ widgets=[("width", 1024), ("height", 1024), ("batch_size", 1)],
558
+ title="Latent 1024x1024", colour=GREEN, size=[400, 120])
559
+
560
+ sampler = g.add("KSampler", 4, 0,
561
+ inputs=[("model", "MODEL"), ("positive", "CONDITIONING"),
562
+ ("negative", "CONDITIONING"), ("latent_image", "LATENT")],
563
+ outputs=[("LATENT", "LATENT")],
564
+ widgets=[("seed", 987654321), (None, "randomize"), ("steps", 8), ("cfg", 1.0),
565
+ ("sampler_name", "euler"), ("scheduler", "simple"), ("denoise", 1.0)],
566
+ title="KSampler - 8 steps, cfg 1.0", colour=PURPLE, size=[400, 280])
567
+ decode = g.add("VAEDecode", 5, 0, inputs=[("samples", "LATENT"), ("vae", "VAE")],
568
+ outputs=[("IMAGE", "IMAGE")], title="VAE Decode", colour=PURPLE, size=[300, 60])
569
+ save = g.add("SaveImage", 5, 0.7, inputs=[("images", "IMAGE")],
570
+ widgets=[("filename_prefix", "krea2_turbo_lora")], title="Save", colour=PURPLE,
571
+ size=[400, 300])
572
+
573
+ g.link(loader, "MODEL", lora1, "model")
574
+ g.link(lora1, "model", lora2, "model")
575
+ g.link(lora1, "model", sampler, "model")
576
+ g.link(clip, "CLIP", pos, "clip")
577
+ g.link(pos, "CONDITIONING", neg, "conditioning")
578
+ g.link(pos, "CONDITIONING", sampler, "positive")
579
+ g.link(neg, "CONDITIONING", sampler, "negative")
580
+ g.link(latent, "LATENT", sampler, "latent_image")
581
+ g.link(sampler, "LATENT", decode, "samples")
582
+ g.link(vae, "VAE", decode, "vae")
583
+ g.link(decode, "IMAGE", save, "images")
584
+
585
+ groups = [
586
+ g.group("Load", (500, 20, 420, 620)),
587
+ g.group("LoRA - chain one node per LoRA", (940, 20, 420, 420), colour="#a85"),
588
+ g.group("Prompt", (1360, 20, 420, 620)),
589
+ g.group("Sample", (1780, 20, 420, 640), colour="#8a4"),
590
+ ]
591
+ return g.serialize(groups), g.serialize_api()
592
+
593
+
594
+ def build_diagnostics():
595
+ """Env Check + Diagnostics. No sampler: nothing here generates an image."""
596
+ g = Graph()
597
+ g.note(DIAG_NOTE, 0, 0, size=(430, 700), title="READ ME FIRST")
598
+
599
+ env = g.add("Krea2SVDQuantEnvCheck", 1, 0, outputs=[("report", "STRING")],
600
+ title="1. Env Check - no model needed, run this first", colour=YELLOW,
601
+ size=[400, 80])
602
+ env_view = g.add("PreviewAny", 1, 0.6, inputs=[("source", "*")],
603
+ title="Env Check report", colour=YELLOW, size=[400, 300])
604
+
605
+ loader = g.add("Krea2SVDQuantW4A4Loader", 2, 0,
606
+ outputs=[("MODEL", "MODEL"), ("STRING", "STRING")],
607
+ widgets=[("model_name", "Krea2-Turbo-SVDQuant-W4A4-rank256-actaware.safetensors")],
608
+ title="2. Load the checkpoint you are diagnosing", colour=TEAL,
609
+ size=[400, 120])
610
+ loader_view = g.add("PreviewAny", 2, 0.7, inputs=[("source", "*")],
611
+ title="Loader status - names the kernel that will run", colour=TEAL,
612
+ size=[400, 200])
613
+
614
+ diag = g.add("Krea2SVDQuantDiagnostics", 3, 0, inputs=[("model", "MODEL")],
615
+ outputs=[("model", "MODEL"), ("report", "STRING")],
616
+ widgets=[("mode", "dispatch"), ("tokens", 4096)],
617
+ title="3. Diagnostics - start with mode=dispatch", colour=YELLOW,
618
+ size=[400, 130])
619
+ diag_view = g.add("PreviewAny", 3, 0.8, inputs=[("source", "*")],
620
+ title="Diagnostics report - paste this into an issue", colour=YELLOW,
621
+ size=[400, 400])
622
+
623
+ g.link(env, "report", env_view, "source")
624
+ g.link(loader, "STRING", loader_view, "source")
625
+ g.link(loader, "MODEL", diag, "model")
626
+ g.link(diag, "report", diag_view, "source")
627
+
628
+ groups = [
629
+ g.group("Step 1 - does the int4 kernel exist at all?", (500, 20, 420, 480),
630
+ colour="#a85"),
631
+ g.group("Step 2 - load it", (940, 20, 420, 480)),
632
+ g.group("Step 3 - what actually runs", (1360, 20, 420, 620), colour="#8a4"),
633
+ ]
634
+ return g.serialize(groups), g.serialize_api()
635
+
636
+
637
+ def _check_adapter_constant():
638
+ """The `adapters` widget value must match svdquant_lora.ADAPTER_BYPASS exactly.
639
+
640
+ ComfyUI matches combo widgets by string. A drifted copy here would write a workflow whose
641
+ dropdown silently falls back to the first option, so read it out of the source rather than
642
+ trusting that two literals stayed equal.
643
+ """
644
+ src = os.path.join(os.path.dirname(HERE), "svdquant_lora.py")
645
+ with open(src, encoding="utf-8") as fh:
646
+ for line in fh:
647
+ if line.startswith("ADAPTER_BYPASS"):
648
+ actual = line.split("=", 1)[1].strip().strip('"')
649
+ if actual != ADAPTER_BYPASS:
650
+ raise SystemExit(
651
+ "ADAPTER_BYPASS drifted: svdquant_lora.py has {!r}, this script has "
652
+ "{!r}".format(actual, ADAPTER_BYPASS))
653
+ return
654
+ raise SystemExit("could not find ADAPTER_BYPASS in " + src)
655
+
656
+
657
+ QUANTIZE_NOTE = """MAKE YOUR OWN QUANTIZED CHECKPOINT - no terminal needed
658
+
659
+ This graph is the whole quantizer. Set the widgets, press Queue,
660
+ wait. It does exactly what `python quantize_krea2.py` does (same
661
+ function, not a copy), and writes the result next to the source in
662
+ ComfyUI/models/diffusion_models/.
663
+
664
+ BEFORE YOU START
665
+ - You need the BF16 Krea 2 checkpoint (~24 GB) in
666
+ ComfyUI/models/diffusion_models/. FP8 also works as a source; any
667
+ other already-quantized file does not.
668
+ - ~12 GB free on that drive. The output is ~8 GB.
669
+ - Run the Env Check node first (it needs nothing wired). If it says
670
+ the cuda backend is not live, the file you are about to build will
671
+ run SLOWER than fp8 - fix torch before spending the disk.
672
+
673
+ WHICH FORMAT
674
+ svdq 4-bit + a low-rank bf16 correction branch. Best quality of
675
+ the 4-bit options. Loads with the Krea2 SVDQuant W4A4 Loader.
676
+ w4a4 same without the branch. Smaller, ~9% faster per step.
677
+ int8 most faithful, still ~2x fp8 on Ampere. Stock UNETLoader.
678
+ fp8 storage only, no speedup here.
679
+ w4a4 / int8 / fp8 ignore rank, rank_alloc and refine_iters.
680
+
681
+ WHICH RANK (svdq only)
682
+ No LoRA planned -> 64. 128 and 256 measured the same.
683
+ LoRAs planned -> 256. It wins clearly there; 64 loses most of
684
+ its advantage once a LoRA is patched in.
685
+ Keep refine_iters at 100. At 0 the split is single-shot (~54s
686
+ instead of ~5.7min) and raising rank then buys nothing.
687
+
688
+ WHAT TO EXPECT WHILE IT RUNS
689
+ - The queue is BLOCKED. Nothing else generates. 54s to ~6 min.
690
+ - Every loaded model is unloaded first, so your next generation
691
+ pays a reload. This is deliberate: the dequantize step needs the
692
+ card to itself or it OOMs.
693
+ - overwrite is off on purpose. An existing file of the same name is
694
+ an error, not 8 GB written over last night's run.
695
+
696
+ The summary appears on the node when it finishes, and says which
697
+ loader to use and what sampler settings that variant wants.
698
+
699
+ FREE QUALITY: FILL IN act_stats (svdq only)
700
+ Download the calibration file for your variant from
701
+ huggingface.co/AlperKTS/Krea-2-SVDQuant-ComfyUI/tree/main/calibration
702
+ into ComfyUI/output/, then type its name into act_stats:
703
+
704
+ Turbo -> krea2_act_stats_turbo.safetensors
705
+ base -> krea2_act_stats_base.safetensors
706
+
707
+ 6.67 MB each. They fit the low-rank branch against activations
708
+ measured from a real sampling pass instead of assuming every input
709
+ channel matters equally. LPIPS to BF16 0.3378 -> 0.2825 at rank 64.
710
+ Same rank, same file size, same kernel, same speed. There is no
711
+ reason to skip this.
712
+
713
+ Take the file that matches the model you are quantizing. A mismatch
714
+ is caught before any GPU work only if the layer NAMES differ, and
715
+ Turbo and base share their names - so the check will not save you
716
+ from feeding Turbo's statistics to a base build. Read the filename.
717
+
718
+ If you are quantizing a finetune, a merge, or anything these two
719
+ files do not describe, capture your own:
720
+ workflows/krea2_quantize_calibrated.json does the capture and the
721
+ quantize in one Queue press."""
722
+
723
+ CALIB_NOTE = """CALIBRATED QUANTIZE - measure your own activations
724
+
725
+ YOU PROBABLY DO NOT NEED THIS GRAPH.
726
+ Calibration files for stock Krea 2 Turbo and base are published at
727
+ huggingface.co/AlperKTS/Krea-2-SVDQuant-ComfyUI/tree/main/calibration
728
+ - 6.67 MB each. Download the one matching your variant, drop it in
729
+ ComfyUI/output/, name it in the act_stats box of the plain
730
+ krea2_quantize.json graph, and you get the identical quality lever
731
+ without running a sampling pass.
732
+
733
+ This graph is for the case those files do not cover: a finetune, a
734
+ merge, a model whose weights are not the released ones. Activation
735
+ statistics describe the WEIGHTS they were captured from. Same
736
+ architecture is not the same model - Turbo's file loads cleanly into
737
+ a base build and quietly describes the wrong thing.
738
+
739
+ WHAT IT BUYS
740
+ The low-rank branch is fitted against activations measured from a
741
+ real sampling pass instead of assuming every input channel matters
742
+ equally. Measured, no LoRA, rank 64: LPIPS to BF16 0.3378 -> 0.2825.
743
+ Same rank, same file size, same kernel, same speed.
744
+
745
+ WHAT RUNS, IN ORDER
746
+ 1. The BF16 model is loaded with the stock UNETLoader and sampled
747
+ once. Capture Start hooks every layer that will be quantized and
748
+ records the RMS of its inputs; Capture Save writes them out.
749
+ 2. Capture Save's act_stats_path feeds the Quantize node. That wire
750
+ is not cosmetic - it is what stops ComfyUI running the quantizer
751
+ first, which would silently produce an uncalibrated file.
752
+
753
+ So one Queue press does calibration AND quantization. Expect the
754
+ sampling pass plus 54s-6min of quantizing, with the queue blocked.
755
+
756
+ SET THIS BEFORE YOU RUN
757
+ - UNETLoader: the BF16 checkpoint. Statistics from an already
758
+ quantized model describe the wrong thing.
759
+ - Quantize node: the SAME file as source_model. It is a separate
760
+ dropdown, and no, it cannot be wired - the loader hands over a
761
+ MODEL object, not a filename.
762
+ - variant: turbo or base, so the output gets the right name.
763
+ - KSampler: 20 steps / cfg 3.5 suits base. For Turbo use 8 steps
764
+ and cfg 1.0, and swap the negative prompt for ConditioningZeroOut.
765
+
766
+ CALIBRATING ON SEVERAL PROMPTS (better, optional)
767
+ The defaults calibrate on one prompt, which is enough to beat no
768
+ calibration. To use more: set keep_capturing = true on Capture Save,
769
+ mute the Quantize node (select it, Ctrl+M), queue as many prompts as
770
+ you like with reset = false on Capture Start after the first, then
771
+ unmute Quantize and queue once more. Prompts should look like what
772
+ you actually generate.
773
+
774
+ Only svdq uses act_stats. The other formats have no branch to fit,
775
+ and wiring act_stats into them is an error rather than a no-op."""
776
+
777
+
778
+ def build_quantize():
779
+ g = Graph()
780
+ g.note(QUANTIZE_NOTE, 0, 0, size=(460, 1360), title="READ ME FIRST")
781
+
782
+ g.add("Krea2SVDQuantEnvCheck", 1, 0, outputs=[("report", "STRING")],
783
+ title="1. Env Check - run this first, it needs nothing", colour=YELLOW,
784
+ size=[420, 120])
785
+ g.add("Krea2SVDQuantQuantize", 1, 0.9,
786
+ outputs=[("summary", "STRING")],
787
+ widgets=[("source_model", "krea2_bf16.safetensors"), ("format", "svdq"),
788
+ ("rank", 64), ("rank_alloc", "uniform"), ("refine_iters", 100),
789
+ ("groupsize", 256), ("variant", "turbo"), ("output_name", ""),
790
+ ("overwrite", False), ("act_stats", ""), ("seed", 0)],
791
+ title="2. Quantize - set source_model and act_stats, then Queue", colour=TEAL,
792
+ size=[420, 460])
793
+
794
+ groups = [g.group("Quantize", (500, 20, 460, 640), colour="#8a4")]
795
+ return g.serialize(groups), g.serialize_api()
796
+
797
+
798
+ def build_quantize_all_in_one():
799
+ g = Graph()
800
+ note = """QUANTIZE ALL-IN-ONE - Bake DiT + Text Encoder + VAE
801
+
802
+ This graph bakes three files into one unified checkpoint in models/checkpoints/:
803
+ 1. source_dit (in models/diffusion_models/): BF16 or already quantized.
804
+ 2. text_encoder (in models/text_encoders/): Qwen3-VL 4B BF16 (~8.88 GB -> ~3.2 GB W4A4).
805
+ 3. vae (in models/vae/): Krea2 VAE (~0.51 GB unquantized).
806
+
807
+ Resulting file: ~12 GB in models/checkpoints/, loaded with Krea2 SVDQuant Checkpoint Loader."""
808
+ g.note(note, 0, 0, size=(460, 600), title="READ ME FIRST")
809
+
810
+ g.add("Krea2SVDQuantEnvCheck", 1, 0, outputs=[("report", "STRING")],
811
+ title="1. Env Check - verify CUDA kernel", colour=YELLOW,
812
+ size=[420, 120])
813
+ g.add("Krea2SVDQuantQuantizeAllInOne", 1, 0.9,
814
+ outputs=[("summary", "STRING")],
815
+ widgets=[("source_dit", "turbo.safetensors"),
816
+ ("text_encoder", "qwen3vl_4b_bf16.safetensors"),
817
+ ("vae", "Krea2-HD-vae.safetensors"),
818
+ ("format", "svdq"),
819
+ ("te_format", "w4a4"),
820
+ ("rank", 64),
821
+ ("refine_iters", 100),
822
+ ("groupsize", 256),
823
+ ("variant", "turbo"),
824
+ ("output_name", ""),
825
+ ("overwrite", False),
826
+ ("act_stats", "krea2_act_stats_turbo.safetensors"),
827
+ ("seed", 0)],
828
+ title="2. Quantize & Bake All-in-One Checkpoint", colour=TEAL,
829
+ size=[420, 520])
830
+
831
+ groups = [g.group("All-in-One Baker", (500, 20, 460, 700), colour="#8a4")]
832
+ return g.serialize(groups), g.serialize_api()
833
+
834
+
835
+ def build_quantize_calibrated():
836
+ g = Graph()
837
+ g.note(CALIB_NOTE, 0, 0, size=(460, 1420), title="READ ME FIRST")
838
+
839
+ unet = g.add("UNETLoader", 1, 0, outputs=[("MODEL", "MODEL")],
840
+ widgets=[("unet_name", "krea2_bf16.safetensors"),
841
+ ("weight_dtype", "default")],
842
+ title="BF16 source (stock loader)", colour=TEAL, size=[400, 120])
843
+ clip = g.add("CLIPLoader", 1, 1, outputs=[("CLIP", "CLIP")],
844
+ widgets=[("clip_name", "qwen3vl_4b_fp8_scaled.safetensors"), ("type", "krea2"),
845
+ ("device", "default")],
846
+ title="Text encoder (Qwen3-VL 4B)", colour=TEAL, size=[400, 120])
847
+
848
+ start = g.add("Krea2SVDQuantCaptureStart", 2, 0, inputs=[("model", "MODEL")],
849
+ outputs=[("model", "MODEL"), ("status", "STRING")],
850
+ widgets=[("reset", True)],
851
+ title="Capture Start - before the sampler", colour=GREEN, size=[400, 100])
852
+ pos = g.add("CLIPTextEncode", 2, 0.8, inputs=[("clip", "CLIP")],
853
+ outputs=[("CONDITIONING", "CONDITIONING")], widgets=[("text", PROMPT)],
854
+ title="Calibration prompt", colour=GREEN, size=[400, 200])
855
+ neg = g.add("CLIPTextEncode", 2, 2.0, inputs=[("clip", "CLIP")],
856
+ outputs=[("CONDITIONING", "CONDITIONING")], widgets=[("text", NEGATIVE)],
857
+ title="Negative (Turbo: replace with ConditioningZeroOut)", colour=GREEN,
858
+ size=[400, 140])
859
+ latent = g.add("EmptySD3LatentImage", 2, 3.0, outputs=[("LATENT", "LATENT")],
860
+ widgets=[("width", 1024), ("height", 1024), ("batch_size", 1)],
861
+ title="Latent 1024x1024", colour=GREEN, size=[400, 120])
862
+
863
+ sampler = g.add("KSampler", 3, 0,
864
+ inputs=[("model", "MODEL"), ("positive", "CONDITIONING"),
865
+ ("negative", "CONDITIONING"), ("latent_image", "LATENT")],
866
+ outputs=[("LATENT", "LATENT")],
867
+ widgets=[("seed", 987654321), (None, "randomize"), ("steps", 20),
868
+ ("cfg", 3.5), ("sampler_name", "euler"), ("scheduler", "simple"),
869
+ ("denoise", 1.0)],
870
+ title="Calibration pass - base 20/3.5, Turbo 8/1.0", colour=PURPLE,
871
+ size=[400, 280])
872
+
873
+ save = g.add("Krea2SVDQuantCaptureSave", 4, 0, inputs=[("latent", "LATENT")],
874
+ outputs=[("latent", "LATENT"), ("status", "STRING"),
875
+ ("act_stats_path", "STRING")],
876
+ widgets=[("filename", "krea2_act_stats.safetensors"),
877
+ ("keep_capturing", False)],
878
+ title="Capture Save - after the sampler", colour=GREEN, size=[400, 120])
879
+
880
+ quant = g.add("Krea2SVDQuantQuantize", 5, 0,
881
+ inputs=[("act_stats", "STRING")],
882
+ outputs=[("summary", "STRING")],
883
+ widgets=[("source_model", "krea2_bf16.safetensors"), ("format", "svdq"),
884
+ ("rank", 64), ("rank_alloc", "uniform"), ("refine_iters", 100),
885
+ ("groupsize", 256), ("variant", "turbo"), ("output_name", ""),
886
+ ("overwrite", False), ("act_stats", ""), ("seed", 0)],
887
+ title="Quantize - source_model must match the loader above", colour=TEAL,
888
+ size=[420, 440], widget_inputs=("act_stats",))
889
+
890
+ g.link(unet, "MODEL", start, "model")
891
+ g.link(start, "model", sampler, "model")
892
+ g.link(clip, "CLIP", pos, "clip")
893
+ g.link(clip, "CLIP", neg, "clip")
894
+ g.link(pos, "CONDITIONING", sampler, "positive")
895
+ g.link(neg, "CONDITIONING", sampler, "negative")
896
+ g.link(latent, "LATENT", sampler, "latent_image")
897
+ g.link(sampler, "LATENT", save, "latent")
898
+ g.link(save, "act_stats_path", quant, "act_stats")
899
+
900
+ groups = [
901
+ g.group("Load BF16", (500, 20, 420, 420)),
902
+ g.group("Calibration pass", (940, 20, 420, 800)),
903
+ g.group("Quantize", (2200, 20, 460, 520), colour="#8a4"),
904
+ ]
905
+ return g.serialize(groups), g.serialize_api()
906
+
907
+
908
  def _write(path, payload):
909
  with open(path, "w", encoding="utf-8", newline="\n") as fh:
910
  json.dump(payload, fh, indent=2, ensure_ascii=False)
 
912
 
913
 
914
  def main():
915
+ _check_adapter_constant()
916
  os.makedirs(OUT_DIR, exist_ok=True)
917
  for name, build in (("krea2_turbo_svdquant_w4a4_t2i", build_turbo),
918
+ ("krea2_turbo_all_in_one_t2i", build_all_in_one_turbo),
919
+ ("krea2_base_svdquant_w4a4_t2i", build_base),
920
+ ("krea2_turbo_svdquant_w4a4_lora", build_lora),
921
+ ("krea2_svdquant_diagnostics", build_diagnostics),
922
+ ("krea2_quantize", build_quantize),
923
+ ("krea2_quantize_all_in_one", build_quantize_all_in_one),
924
+ ("krea2_quantize_calibrated", build_quantize_calibrated)):
925
  graph, api = build()
926
  ui_path = os.path.join(OUT_DIR, name + ".json")
927
  api_path = os.path.join(OUT_DIR, name + "_api.json")
workflows/README.md ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Eight graphs. Drag the plain `.json` into the ComfyUI canvas — each opens with a **READ ME
2
+ FIRST** note carrying the settings that matter. The `*_api.json` twins are for POSTing to
3
+ `/prompt` from a script; they carry no layout, so don't drag those in.
4
+
5
+ | file | what it's for |
6
+ |---|---|
7
+ | `krea2_svdquant_diagnostics.json` | **Start here if anything is wrong.** No sampler, generates no image |
8
+ | `krea2_turbo_svdquant_w4a4_t2i.json` | Krea 2 **Turbo**, 8 steps, cfg 1.0 (3 separate files) |
9
+ | `krea2_turbo_all_in_one_t2i.json` | Krea 2 **Turbo All-in-One**, 8 steps, cfg 1.0 (single ~12 GB checkpoint) |
10
+ | `krea2_turbo_svdquant_w4a4_lora.json` | Turbo **with LoRAs**, chained through this repo's LoRA node |
11
+ | `krea2_base_svdquant_w4a4_t2i.json` | Krea 2 **base** (non-turbo), 50 steps, cfg 3.5 |
12
+ | `krea2_quantize.json` | **Build your own quantized DiT checkpoint** from a BF16 one, no terminal |
13
+ | `krea2_quantize_all_in_one.json` | **Build your own All-in-One checkpoint** (DiT + 4-bit TE + VAE), no terminal |
14
+ | `krea2_quantize_calibrated.json` | The same, preceded by an activation-calibration pass |
15
+
16
+ Everything under `workflows/` is generated by [`tools/build_workflows.py`](../tools/build_workflows.py).
17
+ Edit that script, not the JSON.
18
+
19
+ ## What every graph needs
20
+
21
+ For standard workflows, three files:
22
+
23
+ 1. **A checkpoint** in `ComfyUI/models/diffusion_models/`, from
24
+ [Hugging Face](https://huggingface.co/AlperKTS/Krea-2-SVDQuant-ComfyUI).
25
+ 2. **Text encoder** `qwen3vl_4b_fp8_scaled.safetensors` in `ComfyUI/models/text_encoders/`,
26
+ loaded with `CLIPLoader` type `krea2`.
27
+ 3. **VAE** `qwen_image_vae.safetensors` in `ComfyUI/models/vae/`.
28
+
29
+ For **All-in-One** checkpoints, only **1 file** in `ComfyUI/models/checkpoints/`:
30
+ - Combines the quantized DiT, 4-bit quantized text encoder (Qwen3-VL 4B), and VAE into one ~12 GB file.
31
+
32
+ **Which loader node** depends on which checkpoint you downloaded:
33
+
34
+ | checkpoint | loader node |
35
+ |---|---|
36
+ | `Krea2-Turbo-W4A4-noLowRank` | the **stock UNETLoader** |
37
+ | any `SVDQuant-W4A4-rank*` | **Krea2 SVDQuant W4A4 Loader** (this repo) |
38
+ | All-in-One `SVDQuant-*-AllInOne-*` | **Krea2 SVDQuant Checkpoint Loader** (this repo) |
39
+ | All-in-One branchless (`W4A4` / `INT8`) | **CheckpointLoaderSimple** (stock) or **Krea2 SVDQuant Checkpoint Loader** |
40
+
41
+ The `svdq` files carry extra `*.svdq_l1` / `*.svdq_l2` tensors that the stock loader does not
42
+ know about, which is why they need their own node.
43
+
44
+ A **red outline** on a loader means ComfyUI cannot find the filename in the dropdown — the
45
+ graph ships a suggested name, and yours may differ. Click the widget and pick your file.
46
+
47
+ ## Diagnostics — run this before reporting anything
48
+
49
+ `krea2_svdquant_diagnostics.json`. Three steps, left to right, and the first one needs no
50
+ model at all so you can run it *before* downloading 8 GB.
51
+
52
+ **1. Env Check.** Answers the single most common report against this pack: *"the quantized
53
+ checkpoint is slower than fp8"*. Almost always the cause is that ComfyUI disabled
54
+ `comfy_kitchen`'s CUDA backend because torch was built against CUDA < 13 — and the fallback
55
+ dequantizes int4 in Python, so the checkpoint ends up slower than bf16, not faster. You want
56
+ to see:
57
+
58
+ ```
59
+ cuda backend is live -- the int4 tensor-core kernel is available.
60
+ ```
61
+
62
+ If you don't, install a **cu130 (CUDA 13) or newer** PyTorch build. Nothing else in this repo
63
+ will behave until that line is green.
64
+
65
+ **2. Load the checkpoint** you're diagnosing. Its `status` output names the kernel that will
66
+ actually run.
67
+
68
+ **3. Diagnostics**, `mode` widget:
69
+
70
+ | mode | what it tells you |
71
+ |---|---|
72
+ | `dispatch` | which kernel actually runs, per weight shape. **Start here** |
73
+ | `env` | versions and memory accounting, including where the low-rank factors currently live — under lowvram they should read `cpu` between steps, not `cuda` |
74
+ | `bench` | quantized vs bf16 timing per layer |
75
+ | `profile` | a `torch.profiler` table |
76
+ | `compile` | graph breaks a `TorchCompileModel` run would pay |
77
+
78
+ `tokens` is the sequence length to probe with — 4096 is 1024x1024. Kernel selection is
79
+ shape-dependent, so match your real resolution.
80
+
81
+ Neither node is cached: they re-measure the live process every run, which is the entire
82
+ point. **Paste both reports, your GPU, and the checkpoint filename into any bug report.**
83
+
84
+ ## LoRAs
85
+
86
+ `krea2_turbo_svdquant_w4a4_lora.json`.
87
+
88
+ **Use Krea2 SVDQuant LoRA Loader, not the stock `LoraLoaderModelOnly`.** On a quantized
89
+ checkpoint the stock loader has to dequantize the 4-bit weight, add the LoRA, and requantize
90
+ it — which discards the format and quantizes the LoRA delta to 4 bits along with it. This
91
+ node attaches the LoRA as a parallel branch instead: `(W + BA)x == Wx + B(Ax)` is exact for a
92
+ linear layer, and the quantized weight is never touched.
93
+
94
+ **If a LoRA seems to do nothing, read the console.** The node prints what it matched:
95
+
96
+ ```
97
+ 224 quantized layers, 32 normal layers
98
+ ```
99
+
100
+ `0 quantized layers` means the LoRA does not target this model, and nothing will change no
101
+ matter what strength you set. That line is the first thing to check.
102
+
103
+ **Stacking.** Chain one node per LoRA — the shipped graph has a second one muted, press
104
+ `ctrl-B` on it to enable. The whole stack is refolded on each node, so strengths stay exact
105
+ however many you chain, and the runtime cost stays one pair of GEMMs rather than one per LoRA.
106
+
107
+ **Download rank 256 if you use LoRAs.** Without a LoRA, branch ranks 64 / 128 / 256 are
108
+ statistically indistinguishable. With one, rank 256 wins clearly and rank 64 loses most of its
109
+ advantage over having no branch at all — measured over 16 prompts x 2 seeds x 4 adapters in
110
+ [BENCHMARKS.md](../BENCHMARKS.md#test-3--paired-lpips-fidelity-with-and-without-a-lora).
111
+
112
+ **The `adapters` input** only affects LoRAs that cannot fold into the branch — LoKr, LoHa,
113
+ OFT. A plain up/down LoRA ignores it.
114
+
115
+ | value | cost | what happens |
116
+ |---|---|---|
117
+ | `bypass` (default) | ~1.8 s per model call at 1440x1920 | exact; the 4-bit weight is never touched |
118
+ | `bake` | none per step | ComfyUI rewrites the weight once and the adapter delta is quantized to 4 bits with it |
119
+
120
+ ## Building your own checkpoint
121
+
122
+ `krea2_quantize.json` is the whole quantizer as a graph: point `source_model` at a BF16 Krea 2
123
+ checkpoint, pick a format and a rank, press Queue. It calls the same `convert()` function
124
+ `quantize_krea2.py` does, so there is one quantizer and not two that drift.
125
+
126
+ It blocks the queue for the whole run (54 s to ~6 min), unloads any resident model to take the
127
+ GPU, and writes ~8 GB. `overwrite` is off by default, so an existing file of the same name is
128
+ an error rather than 8 GB written over your last run.
129
+
130
+ **Fill in `act_stats`.** It is the largest fidelity gain in this repo that costs nothing at
131
+ runtime — same rank, same file size, same kernel, same speed — and for the released weights you
132
+ do not have to measure anything. Download the file for your variant from
133
+ [`calibration/`](https://huggingface.co/AlperKTS/Krea-2-SVDQuant-ComfyUI/tree/main/calibration)
134
+ (6.67 MB) into `ComfyUI/output/` and type its name:
135
+
136
+ | you are quantizing | act_stats file |
137
+ |---|---|
138
+ | stock Krea 2 **Turbo** | `krea2_act_stats_turbo.safetensors` |
139
+ | stock Krea 2 **base** | `krea2_act_stats_base.safetensors` |
140
+ | a finetune, a merge, anything else | capture your own — see below |
141
+
142
+ `krea2_act_stats_turbo_lora_bloomgirls.safetensors` is also published. Do **not** reach for it because you use
143
+ LoRAs: it is the capture from the `bloomgirls` arm of
144
+ [Test 5](../BENCHMARKS.md), kept so that experiment is reproducible. Calibrating with an
145
+ adapter loaded measured marginally *worse* than calibrating without one, and the recommendation
146
+ is the plain file whether or not you load LoRAs.
147
+
148
+ ## Capturing your own activation statistics
149
+
150
+ Only needed for weights the published files do not describe — a finetune, a merge, a model
151
+ whose weights are not the released ones. Activation statistics describe the **weights** they
152
+ were captured from, and sharing an architecture is not enough: Turbo's file loads into a base
153
+ build without a word of complaint and then describes the wrong model. The coverage check that
154
+ runs before quantizing matches on layer *names*, which Turbo and base share, so it cannot catch
155
+ that for you.
156
+
157
+ `krea2_quantize_calibrated.json` does it in one Queue press:
158
+
159
+ ```
160
+ UNETLoader (BF16) → Capture Start → KSampler → Capture Save → Quantize
161
+ ```
162
+
163
+ * **Capture Start** goes *before* the sampler. It hooks all 224 linears that will be quantized
164
+ and records the RMS of their inputs. Feed it the **BF16** model — statistics taken from an
165
+ already-quantized model describe the thing being corrected.
166
+ * **Capture Save** goes *after* the sampler. It takes the sampler's `LATENT`, which is what
167
+ forces it to run after denoising rather than before it; if it is not downstream of the
168
+ sampler, ComfyUI is free to execute it first and you get an empty capture.
169
+ * Its **`act_stats_path`** output feeds the Quantize node. That wire is not cosmetic — it is
170
+ what stops ComfyUI quantizing before the calibration pass has run and handing back an
171
+ uncalibrated file with no warning.
172
+ * Set the Quantize node's `source_model` to the **same file** the UNETLoader has. It is a
173
+ separate dropdown and cannot be wired: the loader hands over a MODEL object, not a filename.
174
+
175
+ **Calibrating on several prompts** (better, and what the published files did — 8 prompts each):
176
+
177
+ 1. Set `keep_capturing` = true on Capture Save.
178
+ 2. Mute the Quantize node (select it, Ctrl+M).
179
+ 3. Queue your first prompt with `reset` = true on Capture Start.
180
+ 4. Queue more prompts with `reset` = **false**. Use prompts that look like what you actually
181
+ generate, and *not* the ones you plan to judge the checkpoint with.
182
+ 5. Unmute Quantize, set `keep_capturing` = false, queue once more.
183
+
184
+ Watch the Capture Save status: a clean run ends with every layer on the same token count (ours
185
+ was 264200/264200 across 224 layers). A spread means the hooks stayed live through something
186
+ other than your calibration run — usually a graph that errored between Start and Save — and the
187
+ statistics now describe more than you meant. Re-run with `reset` = true.
188
+
189
+ **One LoRA at a time is not a thing to calibrate for.** The statistics are per model, not per
190
+ LoRA: capture from the BF16 weights you are about to quantize, with no adapter loaded, and use
191
+ the result for every LoRA you later run on top of it. That is not a shortcut — it measured
192
+ better than capturing with the adapter loaded.
193
+
194
+ ## Turbo vs base
195
+
196
+ **Turbo** is cfg-distilled: `cfg` **must** be 1.0 and the negative prompt is zeroed
197
+ (`ConditioningZeroOut`) rather than encoded. Raising cfg degrades the image, it does not
198
+ sharpen it. 8 steps is what the checkpoint was distilled for.
199
+
200
+ **Base** is not distilled, so it uses real classifier-free guidance — cfg 3.5, a real negative
201
+ prompt through a second `CLIPTextEncode`, ~50 steps. There is no pre-built base checkpoint to
202
+ download; build one:
203
+
204
+ ```bash
205
+ python quantize_krea2.py raw.safetensors --format svdq --rank 256 --variant base
206
+ ```
207
+
208
+ That is roughly 12x the sampling work of the 8-step Turbo graph. Every benchmark number in
209
+ the README is Turbo at 8 steps — the ratios carry over, the absolute seconds do not.
workflows/krea2_quantize.json ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "00000000-0000-4000-8000-000000000000",
3
+ "revision": 0,
4
+ "last_node_id": 3,
5
+ "last_link_id": 0,
6
+ "nodes": [
7
+ {
8
+ "id": 1,
9
+ "type": "Note",
10
+ "pos": [
11
+ 60,
12
+ 60
13
+ ],
14
+ "size": [
15
+ 460,
16
+ 1360
17
+ ],
18
+ "flags": {},
19
+ "order": 0,
20
+ "mode": 0,
21
+ "inputs": [],
22
+ "outputs": [],
23
+ "properties": {
24
+ "Node name for S&R": "Note"
25
+ },
26
+ "widgets_values": [
27
+ "MAKE YOUR OWN QUANTIZED CHECKPOINT - no terminal needed\n\nThis graph is the whole quantizer. Set the widgets, press Queue,\nwait. It does exactly what `python quantize_krea2.py` does (same\nfunction, not a copy), and writes the result next to the source in\nComfyUI/models/diffusion_models/.\n\nBEFORE YOU START\n- You need the BF16 Krea 2 checkpoint (~24 GB) in\n ComfyUI/models/diffusion_models/. FP8 also works as a source; any\n other already-quantized file does not.\n- ~12 GB free on that drive. The output is ~8 GB.\n- Run the Env Check node first (it needs nothing wired). If it says\n the cuda backend is not live, the file you are about to build will\n run SLOWER than fp8 - fix torch before spending the disk.\n\nWHICH FORMAT\n svdq 4-bit + a low-rank bf16 correction branch. Best quality of\n the 4-bit options. Loads with the Krea2 SVDQuant W4A4 Loader.\n w4a4 same without the branch. Smaller, ~9% faster per step.\n int8 most faithful, still ~2x fp8 on Ampere. Stock UNETLoader.\n fp8 storage only, no speedup here.\n w4a4 / int8 / fp8 ignore rank, rank_alloc and refine_iters.\n\nWHICH RANK (svdq only)\n No LoRA planned -> 64. 128 and 256 measured the same.\n LoRAs planned -> 256. It wins clearly there; 64 loses most of\n its advantage once a LoRA is patched in.\n Keep refine_iters at 100. At 0 the split is single-shot (~54s\n instead of ~5.7min) and raising rank then buys nothing.\n\nWHAT TO EXPECT WHILE IT RUNS\n- The queue is BLOCKED. Nothing else generates. 54s to ~6 min.\n- Every loaded model is unloaded first, so your next generation\n pays a reload. This is deliberate: the dequantize step needs the\n card to itself or it OOMs.\n- overwrite is off on purpose. An existing file of the same name is\n an error, not 8 GB written over last night's run.\n\nThe summary appears on the node when it finishes, and says which\nloader to use and what sampler settings that variant wants.\n\nFREE QUALITY: FILL IN act_stats (svdq only)\nDownload the calibration file for your variant from\nhuggingface.co/AlperKTS/Krea-2-SVDQuant-ComfyUI/tree/main/calibration\ninto ComfyUI/output/, then type its name into act_stats:\n\n Turbo -> krea2_act_stats_turbo.safetensors\n base -> krea2_act_stats_base.safetensors\n\n6.67 MB each. They fit the low-rank branch against activations\nmeasured from a real sampling pass instead of assuming every input\nchannel matters equally. LPIPS to BF16 0.3378 -> 0.2825 at rank 64.\nSame rank, same file size, same kernel, same speed. There is no\nreason to skip this.\n\nTake the file that matches the model you are quantizing. A mismatch\nis caught before any GPU work only if the layer NAMES differ, and\nTurbo and base share their names - so the check will not save you\nfrom feeding Turbo's statistics to a base build. Read the filename.\n\nIf you are quantizing a finetune, a merge, or anything these two\nfiles do not describe, capture your own:\nworkflows/krea2_quantize_calibrated.json does the capture and the\nquantize in one Queue press."
28
+ ],
29
+ "title": "READ ME FIRST",
30
+ "color": "#432",
31
+ "bgcolor": "#653"
32
+ },
33
+ {
34
+ "id": 2,
35
+ "type": "Krea2SVDQuantEnvCheck",
36
+ "pos": [
37
+ 480,
38
+ 60
39
+ ],
40
+ "size": [
41
+ 420,
42
+ 120
43
+ ],
44
+ "flags": {},
45
+ "order": 1,
46
+ "mode": 0,
47
+ "inputs": [],
48
+ "outputs": [
49
+ {
50
+ "name": "report",
51
+ "type": "STRING",
52
+ "links": [],
53
+ "slot_index": 0
54
+ }
55
+ ],
56
+ "properties": {
57
+ "Node name for S&R": "Krea2SVDQuantEnvCheck"
58
+ },
59
+ "widgets_values": [],
60
+ "title": "1. Env Check - run this first, it needs nothing",
61
+ "color": "#432",
62
+ "bgcolor": "#653"
63
+ },
64
+ {
65
+ "id": 3,
66
+ "type": "Krea2SVDQuantQuantize",
67
+ "pos": [
68
+ 480,
69
+ 240.0
70
+ ],
71
+ "size": [
72
+ 420,
73
+ 460
74
+ ],
75
+ "flags": {},
76
+ "order": 2,
77
+ "mode": 0,
78
+ "inputs": [],
79
+ "outputs": [
80
+ {
81
+ "name": "summary",
82
+ "type": "STRING",
83
+ "links": [],
84
+ "slot_index": 0
85
+ }
86
+ ],
87
+ "properties": {
88
+ "Node name for S&R": "Krea2SVDQuantQuantize"
89
+ },
90
+ "widgets_values": [
91
+ "krea2_bf16.safetensors",
92
+ "svdq",
93
+ 64,
94
+ "uniform",
95
+ 100,
96
+ 256,
97
+ "turbo",
98
+ "",
99
+ false,
100
+ "",
101
+ 0
102
+ ],
103
+ "title": "2. Quantize - set source_model and act_stats, then Queue",
104
+ "color": "#233",
105
+ "bgcolor": "#355"
106
+ }
107
+ ],
108
+ "links": [],
109
+ "groups": [
110
+ {
111
+ "title": "Quantize",
112
+ "bounding": [
113
+ 500,
114
+ 20,
115
+ 460,
116
+ 640
117
+ ],
118
+ "color": "#8a4",
119
+ "font_size": 24,
120
+ "flags": {}
121
+ }
122
+ ],
123
+ "config": {},
124
+ "extra": {},
125
+ "version": 0.4
126
+ }
workflows/krea2_quantize_all_in_one.json ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "00000000-0000-4000-8000-000000000000",
3
+ "revision": 0,
4
+ "last_node_id": 3,
5
+ "last_link_id": 0,
6
+ "nodes": [
7
+ {
8
+ "id": 1,
9
+ "type": "Note",
10
+ "pos": [
11
+ 60,
12
+ 60
13
+ ],
14
+ "size": [
15
+ 460,
16
+ 600
17
+ ],
18
+ "flags": {},
19
+ "order": 0,
20
+ "mode": 0,
21
+ "inputs": [],
22
+ "outputs": [],
23
+ "properties": {
24
+ "Node name for S&R": "Note"
25
+ },
26
+ "widgets_values": [
27
+ "QUANTIZE ALL-IN-ONE - Bake DiT + Text Encoder + VAE\n\nThis graph bakes three files into one unified checkpoint in models/checkpoints/:\n1. source_dit (in models/diffusion_models/): BF16 or already quantized.\n2. text_encoder (in models/text_encoders/): Qwen3-VL 4B BF16 (~8.88 GB -> ~3.2 GB W4A4).\n3. vae (in models/vae/): Krea2 VAE (~0.51 GB unquantized).\n\nResulting file: ~12 GB in models/checkpoints/, loaded with Krea2 SVDQuant Checkpoint Loader."
28
+ ],
29
+ "title": "READ ME FIRST",
30
+ "color": "#432",
31
+ "bgcolor": "#653"
32
+ },
33
+ {
34
+ "id": 2,
35
+ "type": "Krea2SVDQuantEnvCheck",
36
+ "pos": [
37
+ 480,
38
+ 60
39
+ ],
40
+ "size": [
41
+ 420,
42
+ 120
43
+ ],
44
+ "flags": {},
45
+ "order": 1,
46
+ "mode": 0,
47
+ "inputs": [],
48
+ "outputs": [
49
+ {
50
+ "name": "report",
51
+ "type": "STRING",
52
+ "links": [],
53
+ "slot_index": 0
54
+ }
55
+ ],
56
+ "properties": {
57
+ "Node name for S&R": "Krea2SVDQuantEnvCheck"
58
+ },
59
+ "widgets_values": [],
60
+ "title": "1. Env Check - verify CUDA kernel",
61
+ "color": "#432",
62
+ "bgcolor": "#653"
63
+ },
64
+ {
65
+ "id": 3,
66
+ "type": "Krea2SVDQuantQuantizeAllInOne",
67
+ "pos": [
68
+ 480,
69
+ 240.0
70
+ ],
71
+ "size": [
72
+ 420,
73
+ 520
74
+ ],
75
+ "flags": {},
76
+ "order": 2,
77
+ "mode": 0,
78
+ "inputs": [],
79
+ "outputs": [
80
+ {
81
+ "name": "summary",
82
+ "type": "STRING",
83
+ "links": [],
84
+ "slot_index": 0
85
+ }
86
+ ],
87
+ "properties": {
88
+ "Node name for S&R": "Krea2SVDQuantQuantizeAllInOne"
89
+ },
90
+ "widgets_values": [
91
+ "turbo.safetensors",
92
+ "qwen3vl_4b_bf16.safetensors",
93
+ "Krea2-HD-vae.safetensors",
94
+ "svdq",
95
+ "w4a4",
96
+ 64,
97
+ 100,
98
+ 256,
99
+ "turbo",
100
+ "",
101
+ false,
102
+ "krea2_act_stats_turbo.safetensors",
103
+ 0
104
+ ],
105
+ "title": "2. Quantize & Bake All-in-One Checkpoint",
106
+ "color": "#233",
107
+ "bgcolor": "#355"
108
+ }
109
+ ],
110
+ "links": [],
111
+ "groups": [
112
+ {
113
+ "title": "All-in-One Baker",
114
+ "bounding": [
115
+ 500,
116
+ 20,
117
+ 460,
118
+ 700
119
+ ],
120
+ "color": "#8a4",
121
+ "font_size": 24,
122
+ "flags": {}
123
+ }
124
+ ],
125
+ "config": {},
126
+ "extra": {},
127
+ "version": 0.4
128
+ }
workflows/krea2_quantize_all_in_one_api.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "1": {
3
+ "class_type": "Krea2SVDQuantEnvCheck",
4
+ "inputs": {},
5
+ "_meta": {
6
+ "title": "1. Env Check - verify CUDA kernel"
7
+ }
8
+ },
9
+ "2": {
10
+ "class_type": "Krea2SVDQuantQuantizeAllInOne",
11
+ "inputs": {
12
+ "source_dit": "turbo.safetensors",
13
+ "text_encoder": "qwen3vl_4b_bf16.safetensors",
14
+ "vae": "Krea2-HD-vae.safetensors",
15
+ "format": "svdq",
16
+ "te_format": "w4a4",
17
+ "rank": 64,
18
+ "refine_iters": 100,
19
+ "groupsize": 256,
20
+ "variant": "turbo",
21
+ "output_name": "",
22
+ "overwrite": false,
23
+ "act_stats": "krea2_act_stats_turbo.safetensors",
24
+ "seed": 0
25
+ },
26
+ "_meta": {
27
+ "title": "2. Quantize & Bake All-in-One Checkpoint"
28
+ }
29
+ }
30
+ }
workflows/krea2_quantize_api.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "1": {
3
+ "class_type": "Krea2SVDQuantEnvCheck",
4
+ "inputs": {},
5
+ "_meta": {
6
+ "title": "1. Env Check - run this first, it needs nothing"
7
+ }
8
+ },
9
+ "2": {
10
+ "class_type": "Krea2SVDQuantQuantize",
11
+ "inputs": {
12
+ "source_model": "krea2_bf16.safetensors",
13
+ "format": "svdq",
14
+ "rank": 64,
15
+ "rank_alloc": "uniform",
16
+ "refine_iters": 100,
17
+ "groupsize": 256,
18
+ "variant": "turbo",
19
+ "output_name": "",
20
+ "overwrite": false,
21
+ "act_stats": "",
22
+ "seed": 0
23
+ },
24
+ "_meta": {
25
+ "title": "2. Quantize - set source_model and act_stats, then Queue"
26
+ }
27
+ }
28
+ }
workflows/krea2_quantize_calibrated.json ADDED
@@ -0,0 +1,557 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "00000000-0000-4000-8000-000000000000",
3
+ "revision": 0,
4
+ "last_node_id": 10,
5
+ "last_link_id": 9,
6
+ "nodes": [
7
+ {
8
+ "id": 1,
9
+ "type": "Note",
10
+ "pos": [
11
+ 60,
12
+ 60
13
+ ],
14
+ "size": [
15
+ 460,
16
+ 1420
17
+ ],
18
+ "flags": {},
19
+ "order": 0,
20
+ "mode": 0,
21
+ "inputs": [],
22
+ "outputs": [],
23
+ "properties": {
24
+ "Node name for S&R": "Note"
25
+ },
26
+ "widgets_values": [
27
+ "CALIBRATED QUANTIZE - measure your own activations\n\nYOU PROBABLY DO NOT NEED THIS GRAPH.\nCalibration files for stock Krea 2 Turbo and base are published at\nhuggingface.co/AlperKTS/Krea-2-SVDQuant-ComfyUI/tree/main/calibration\n- 6.67 MB each. Download the one matching your variant, drop it in\nComfyUI/output/, name it in the act_stats box of the plain\nkrea2_quantize.json graph, and you get the identical quality lever\nwithout running a sampling pass.\n\nThis graph is for the case those files do not cover: a finetune, a\nmerge, a model whose weights are not the released ones. Activation\nstatistics describe the WEIGHTS they were captured from. Same\narchitecture is not the same model - Turbo's file loads cleanly into\na base build and quietly describes the wrong thing.\n\nWHAT IT BUYS\nThe low-rank branch is fitted against activations measured from a\nreal sampling pass instead of assuming every input channel matters\nequally. Measured, no LoRA, rank 64: LPIPS to BF16 0.3378 -> 0.2825.\nSame rank, same file size, same kernel, same speed.\n\nWHAT RUNS, IN ORDER\n1. The BF16 model is loaded with the stock UNETLoader and sampled\n once. Capture Start hooks every layer that will be quantized and\n records the RMS of its inputs; Capture Save writes them out.\n2. Capture Save's act_stats_path feeds the Quantize node. That wire\n is not cosmetic - it is what stops ComfyUI running the quantizer\n first, which would silently produce an uncalibrated file.\n\nSo one Queue press does calibration AND quantization. Expect the\nsampling pass plus 54s-6min of quantizing, with the queue blocked.\n\nSET THIS BEFORE YOU RUN\n- UNETLoader: the BF16 checkpoint. Statistics from an already\n quantized model describe the wrong thing.\n- Quantize node: the SAME file as source_model. It is a separate\n dropdown, and no, it cannot be wired - the loader hands over a\n MODEL object, not a filename.\n- variant: turbo or base, so the output gets the right name.\n- KSampler: 20 steps / cfg 3.5 suits base. For Turbo use 8 steps\n and cfg 1.0, and swap the negative prompt for ConditioningZeroOut.\n\nCALIBRATING ON SEVERAL PROMPTS (better, optional)\nThe defaults calibrate on one prompt, which is enough to beat no\ncalibration. To use more: set keep_capturing = true on Capture Save,\nmute the Quantize node (select it, Ctrl+M), queue as many prompts as\nyou like with reset = false on Capture Start after the first, then\nunmute Quantize and queue once more. Prompts should look like what\nyou actually generate.\n\nOnly svdq uses act_stats. The other formats have no branch to fit,\nand wiring act_stats into them is an error rather than a no-op."
28
+ ],
29
+ "title": "READ ME FIRST",
30
+ "color": "#432",
31
+ "bgcolor": "#653"
32
+ },
33
+ {
34
+ "id": 2,
35
+ "type": "UNETLoader",
36
+ "pos": [
37
+ 480,
38
+ 60
39
+ ],
40
+ "size": [
41
+ 400,
42
+ 120
43
+ ],
44
+ "flags": {},
45
+ "order": 1,
46
+ "mode": 0,
47
+ "inputs": [],
48
+ "outputs": [
49
+ {
50
+ "name": "MODEL",
51
+ "type": "MODEL",
52
+ "links": [
53
+ 1
54
+ ],
55
+ "slot_index": 0
56
+ }
57
+ ],
58
+ "properties": {
59
+ "Node name for S&R": "UNETLoader"
60
+ },
61
+ "widgets_values": [
62
+ "krea2_bf16.safetensors",
63
+ "default"
64
+ ],
65
+ "title": "BF16 source (stock loader)",
66
+ "color": "#233",
67
+ "bgcolor": "#355"
68
+ },
69
+ {
70
+ "id": 3,
71
+ "type": "CLIPLoader",
72
+ "pos": [
73
+ 480,
74
+ 260
75
+ ],
76
+ "size": [
77
+ 400,
78
+ 120
79
+ ],
80
+ "flags": {},
81
+ "order": 2,
82
+ "mode": 0,
83
+ "inputs": [],
84
+ "outputs": [
85
+ {
86
+ "name": "CLIP",
87
+ "type": "CLIP",
88
+ "links": [
89
+ 3,
90
+ 4
91
+ ],
92
+ "slot_index": 0
93
+ }
94
+ ],
95
+ "properties": {
96
+ "Node name for S&R": "CLIPLoader"
97
+ },
98
+ "widgets_values": [
99
+ "qwen3vl_4b_fp8_scaled.safetensors",
100
+ "krea2",
101
+ "default"
102
+ ],
103
+ "title": "Text encoder (Qwen3-VL 4B)",
104
+ "color": "#233",
105
+ "bgcolor": "#355"
106
+ },
107
+ {
108
+ "id": 4,
109
+ "type": "Krea2SVDQuantCaptureStart",
110
+ "pos": [
111
+ 900,
112
+ 60
113
+ ],
114
+ "size": [
115
+ 400,
116
+ 100
117
+ ],
118
+ "flags": {},
119
+ "order": 3,
120
+ "mode": 0,
121
+ "inputs": [
122
+ {
123
+ "name": "model",
124
+ "type": "MODEL",
125
+ "link": 1
126
+ }
127
+ ],
128
+ "outputs": [
129
+ {
130
+ "name": "model",
131
+ "type": "MODEL",
132
+ "links": [
133
+ 2
134
+ ],
135
+ "slot_index": 0
136
+ },
137
+ {
138
+ "name": "status",
139
+ "type": "STRING",
140
+ "links": [],
141
+ "slot_index": 1
142
+ }
143
+ ],
144
+ "properties": {
145
+ "Node name for S&R": "Krea2SVDQuantCaptureStart"
146
+ },
147
+ "widgets_values": [
148
+ true
149
+ ],
150
+ "title": "Capture Start - before the sampler",
151
+ "color": "#232",
152
+ "bgcolor": "#353"
153
+ },
154
+ {
155
+ "id": 5,
156
+ "type": "CLIPTextEncode",
157
+ "pos": [
158
+ 900,
159
+ 220.0
160
+ ],
161
+ "size": [
162
+ 400,
163
+ 200
164
+ ],
165
+ "flags": {},
166
+ "order": 4,
167
+ "mode": 0,
168
+ "inputs": [
169
+ {
170
+ "name": "clip",
171
+ "type": "CLIP",
172
+ "link": 3
173
+ }
174
+ ],
175
+ "outputs": [
176
+ {
177
+ "name": "CONDITIONING",
178
+ "type": "CONDITIONING",
179
+ "links": [
180
+ 5
181
+ ],
182
+ "slot_index": 0
183
+ }
184
+ ],
185
+ "properties": {
186
+ "Node name for S&R": "CLIPTextEncode"
187
+ },
188
+ "widgets_values": [
189
+ "A cluttered antique clockmaker's workshop seen through a cracked magnifying glass, brass gears laid out in a spiral on the workbench, a ginger cat asleep on a stack of leather-bound books, warm afternoon light with visible dust motes, photorealistic, 85mm lens, shallow depth of field"
190
+ ],
191
+ "title": "Calibration prompt",
192
+ "color": "#232",
193
+ "bgcolor": "#353"
194
+ },
195
+ {
196
+ "id": 6,
197
+ "type": "CLIPTextEncode",
198
+ "pos": [
199
+ 900,
200
+ 460.0
201
+ ],
202
+ "size": [
203
+ 400,
204
+ 140
205
+ ],
206
+ "flags": {},
207
+ "order": 5,
208
+ "mode": 0,
209
+ "inputs": [
210
+ {
211
+ "name": "clip",
212
+ "type": "CLIP",
213
+ "link": 4
214
+ }
215
+ ],
216
+ "outputs": [
217
+ {
218
+ "name": "CONDITIONING",
219
+ "type": "CONDITIONING",
220
+ "links": [
221
+ 6
222
+ ],
223
+ "slot_index": 0
224
+ }
225
+ ],
226
+ "properties": {
227
+ "Node name for S&R": "CLIPTextEncode"
228
+ },
229
+ "widgets_values": [
230
+ "blurry, low resolution, jpeg artifacts, watermark, deformed, extra limbs"
231
+ ],
232
+ "title": "Negative (Turbo: replace with ConditioningZeroOut)",
233
+ "color": "#232",
234
+ "bgcolor": "#353"
235
+ },
236
+ {
237
+ "id": 7,
238
+ "type": "EmptySD3LatentImage",
239
+ "pos": [
240
+ 900,
241
+ 660.0
242
+ ],
243
+ "size": [
244
+ 400,
245
+ 120
246
+ ],
247
+ "flags": {},
248
+ "order": 6,
249
+ "mode": 0,
250
+ "inputs": [],
251
+ "outputs": [
252
+ {
253
+ "name": "LATENT",
254
+ "type": "LATENT",
255
+ "links": [
256
+ 7
257
+ ],
258
+ "slot_index": 0
259
+ }
260
+ ],
261
+ "properties": {
262
+ "Node name for S&R": "EmptySD3LatentImage"
263
+ },
264
+ "widgets_values": [
265
+ 1024,
266
+ 1024,
267
+ 1
268
+ ],
269
+ "title": "Latent 1024x1024",
270
+ "color": "#232",
271
+ "bgcolor": "#353"
272
+ },
273
+ {
274
+ "id": 8,
275
+ "type": "KSampler",
276
+ "pos": [
277
+ 1320,
278
+ 60
279
+ ],
280
+ "size": [
281
+ 400,
282
+ 280
283
+ ],
284
+ "flags": {},
285
+ "order": 7,
286
+ "mode": 0,
287
+ "inputs": [
288
+ {
289
+ "name": "model",
290
+ "type": "MODEL",
291
+ "link": 2
292
+ },
293
+ {
294
+ "name": "positive",
295
+ "type": "CONDITIONING",
296
+ "link": 5
297
+ },
298
+ {
299
+ "name": "negative",
300
+ "type": "CONDITIONING",
301
+ "link": 6
302
+ },
303
+ {
304
+ "name": "latent_image",
305
+ "type": "LATENT",
306
+ "link": 7
307
+ }
308
+ ],
309
+ "outputs": [
310
+ {
311
+ "name": "LATENT",
312
+ "type": "LATENT",
313
+ "links": [
314
+ 8
315
+ ],
316
+ "slot_index": 0
317
+ }
318
+ ],
319
+ "properties": {
320
+ "Node name for S&R": "KSampler"
321
+ },
322
+ "widgets_values": [
323
+ 987654321,
324
+ "randomize",
325
+ 20,
326
+ 3.5,
327
+ "euler",
328
+ "simple",
329
+ 1.0
330
+ ],
331
+ "title": "Calibration pass - base 20/3.5, Turbo 8/1.0",
332
+ "color": "#323",
333
+ "bgcolor": "#535"
334
+ },
335
+ {
336
+ "id": 9,
337
+ "type": "Krea2SVDQuantCaptureSave",
338
+ "pos": [
339
+ 1740,
340
+ 60
341
+ ],
342
+ "size": [
343
+ 400,
344
+ 120
345
+ ],
346
+ "flags": {},
347
+ "order": 8,
348
+ "mode": 0,
349
+ "inputs": [
350
+ {
351
+ "name": "latent",
352
+ "type": "LATENT",
353
+ "link": 8
354
+ }
355
+ ],
356
+ "outputs": [
357
+ {
358
+ "name": "latent",
359
+ "type": "LATENT",
360
+ "links": [],
361
+ "slot_index": 0
362
+ },
363
+ {
364
+ "name": "status",
365
+ "type": "STRING",
366
+ "links": [],
367
+ "slot_index": 1
368
+ },
369
+ {
370
+ "name": "act_stats_path",
371
+ "type": "STRING",
372
+ "links": [
373
+ 9
374
+ ],
375
+ "slot_index": 2
376
+ }
377
+ ],
378
+ "properties": {
379
+ "Node name for S&R": "Krea2SVDQuantCaptureSave"
380
+ },
381
+ "widgets_values": [
382
+ "krea2_act_stats.safetensors",
383
+ false
384
+ ],
385
+ "title": "Capture Save - after the sampler",
386
+ "color": "#232",
387
+ "bgcolor": "#353"
388
+ },
389
+ {
390
+ "id": 10,
391
+ "type": "Krea2SVDQuantQuantize",
392
+ "pos": [
393
+ 2160,
394
+ 60
395
+ ],
396
+ "size": [
397
+ 420,
398
+ 440
399
+ ],
400
+ "flags": {},
401
+ "order": 9,
402
+ "mode": 0,
403
+ "inputs": [
404
+ {
405
+ "name": "act_stats",
406
+ "type": "STRING",
407
+ "link": 9,
408
+ "widget": {
409
+ "name": "act_stats"
410
+ }
411
+ }
412
+ ],
413
+ "outputs": [
414
+ {
415
+ "name": "summary",
416
+ "type": "STRING",
417
+ "links": [],
418
+ "slot_index": 0
419
+ }
420
+ ],
421
+ "properties": {
422
+ "Node name for S&R": "Krea2SVDQuantQuantize"
423
+ },
424
+ "widgets_values": [
425
+ "krea2_bf16.safetensors",
426
+ "svdq",
427
+ 64,
428
+ "uniform",
429
+ 100,
430
+ 256,
431
+ "turbo",
432
+ "",
433
+ false,
434
+ "",
435
+ 0
436
+ ],
437
+ "title": "Quantize - source_model must match the loader above",
438
+ "color": "#233",
439
+ "bgcolor": "#355"
440
+ }
441
+ ],
442
+ "links": [
443
+ [
444
+ 1,
445
+ 2,
446
+ 0,
447
+ 4,
448
+ 0,
449
+ "MODEL"
450
+ ],
451
+ [
452
+ 2,
453
+ 4,
454
+ 0,
455
+ 8,
456
+ 0,
457
+ "MODEL"
458
+ ],
459
+ [
460
+ 3,
461
+ 3,
462
+ 0,
463
+ 5,
464
+ 0,
465
+ "CLIP"
466
+ ],
467
+ [
468
+ 4,
469
+ 3,
470
+ 0,
471
+ 6,
472
+ 0,
473
+ "CLIP"
474
+ ],
475
+ [
476
+ 5,
477
+ 5,
478
+ 0,
479
+ 8,
480
+ 1,
481
+ "CONDITIONING"
482
+ ],
483
+ [
484
+ 6,
485
+ 6,
486
+ 0,
487
+ 8,
488
+ 2,
489
+ "CONDITIONING"
490
+ ],
491
+ [
492
+ 7,
493
+ 7,
494
+ 0,
495
+ 8,
496
+ 3,
497
+ "LATENT"
498
+ ],
499
+ [
500
+ 8,
501
+ 8,
502
+ 0,
503
+ 9,
504
+ 0,
505
+ "LATENT"
506
+ ],
507
+ [
508
+ 9,
509
+ 9,
510
+ 2,
511
+ 10,
512
+ 0,
513
+ "STRING"
514
+ ]
515
+ ],
516
+ "groups": [
517
+ {
518
+ "title": "Load BF16",
519
+ "bounding": [
520
+ 500,
521
+ 20,
522
+ 420,
523
+ 420
524
+ ],
525
+ "color": "#3f789e",
526
+ "font_size": 24,
527
+ "flags": {}
528
+ },
529
+ {
530
+ "title": "Calibration pass",
531
+ "bounding": [
532
+ 940,
533
+ 20,
534
+ 420,
535
+ 800
536
+ ],
537
+ "color": "#3f789e",
538
+ "font_size": 24,
539
+ "flags": {}
540
+ },
541
+ {
542
+ "title": "Quantize",
543
+ "bounding": [
544
+ 2200,
545
+ 20,
546
+ 460,
547
+ 520
548
+ ],
549
+ "color": "#8a4",
550
+ "font_size": 24,
551
+ "flags": {}
552
+ }
553
+ ],
554
+ "config": {},
555
+ "extra": {},
556
+ "version": 0.4
557
+ }
workflows/krea2_quantize_calibrated_api.json ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "1": {
3
+ "class_type": "UNETLoader",
4
+ "inputs": {
5
+ "unet_name": "krea2_bf16.safetensors",
6
+ "weight_dtype": "default"
7
+ },
8
+ "_meta": {
9
+ "title": "BF16 source (stock loader)"
10
+ }
11
+ },
12
+ "2": {
13
+ "class_type": "CLIPLoader",
14
+ "inputs": {
15
+ "clip_name": "qwen3vl_4b_fp8_scaled.safetensors",
16
+ "type": "krea2",
17
+ "device": "default"
18
+ },
19
+ "_meta": {
20
+ "title": "Text encoder (Qwen3-VL 4B)"
21
+ }
22
+ },
23
+ "3": {
24
+ "class_type": "Krea2SVDQuantCaptureStart",
25
+ "inputs": {
26
+ "reset": true,
27
+ "model": [
28
+ "1",
29
+ 0
30
+ ]
31
+ },
32
+ "_meta": {
33
+ "title": "Capture Start - before the sampler"
34
+ }
35
+ },
36
+ "4": {
37
+ "class_type": "CLIPTextEncode",
38
+ "inputs": {
39
+ "text": "A cluttered antique clockmaker's workshop seen through a cracked magnifying glass, brass gears laid out in a spiral on the workbench, a ginger cat asleep on a stack of leather-bound books, warm afternoon light with visible dust motes, photorealistic, 85mm lens, shallow depth of field",
40
+ "clip": [
41
+ "2",
42
+ 0
43
+ ]
44
+ },
45
+ "_meta": {
46
+ "title": "Calibration prompt"
47
+ }
48
+ },
49
+ "5": {
50
+ "class_type": "CLIPTextEncode",
51
+ "inputs": {
52
+ "text": "blurry, low resolution, jpeg artifacts, watermark, deformed, extra limbs",
53
+ "clip": [
54
+ "2",
55
+ 0
56
+ ]
57
+ },
58
+ "_meta": {
59
+ "title": "Negative (Turbo: replace with ConditioningZeroOut)"
60
+ }
61
+ },
62
+ "6": {
63
+ "class_type": "EmptySD3LatentImage",
64
+ "inputs": {
65
+ "width": 1024,
66
+ "height": 1024,
67
+ "batch_size": 1
68
+ },
69
+ "_meta": {
70
+ "title": "Latent 1024x1024"
71
+ }
72
+ },
73
+ "7": {
74
+ "class_type": "KSampler",
75
+ "inputs": {
76
+ "seed": 987654321,
77
+ "steps": 20,
78
+ "cfg": 3.5,
79
+ "sampler_name": "euler",
80
+ "scheduler": "simple",
81
+ "denoise": 1.0,
82
+ "model": [
83
+ "3",
84
+ 0
85
+ ],
86
+ "positive": [
87
+ "4",
88
+ 0
89
+ ],
90
+ "negative": [
91
+ "5",
92
+ 0
93
+ ],
94
+ "latent_image": [
95
+ "6",
96
+ 0
97
+ ]
98
+ },
99
+ "_meta": {
100
+ "title": "Calibration pass - base 20/3.5, Turbo 8/1.0"
101
+ }
102
+ },
103
+ "8": {
104
+ "class_type": "Krea2SVDQuantCaptureSave",
105
+ "inputs": {
106
+ "filename": "krea2_act_stats.safetensors",
107
+ "keep_capturing": false,
108
+ "latent": [
109
+ "7",
110
+ 0
111
+ ]
112
+ },
113
+ "_meta": {
114
+ "title": "Capture Save - after the sampler"
115
+ }
116
+ },
117
+ "9": {
118
+ "class_type": "Krea2SVDQuantQuantize",
119
+ "inputs": {
120
+ "source_model": "krea2_bf16.safetensors",
121
+ "format": "svdq",
122
+ "rank": 64,
123
+ "rank_alloc": "uniform",
124
+ "refine_iters": 100,
125
+ "groupsize": 256,
126
+ "variant": "turbo",
127
+ "output_name": "",
128
+ "overwrite": false,
129
+ "act_stats": [
130
+ "8",
131
+ 2
132
+ ],
133
+ "seed": 0
134
+ },
135
+ "_meta": {
136
+ "title": "Quantize - source_model must match the loader above"
137
+ }
138
+ }
139
+ }
workflows/krea2_svdquant_diagnostics.json ADDED
@@ -0,0 +1,323 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "00000000-0000-4000-8000-000000000000",
3
+ "revision": 0,
4
+ "last_node_id": 7,
5
+ "last_link_id": 4,
6
+ "nodes": [
7
+ {
8
+ "id": 1,
9
+ "type": "Note",
10
+ "pos": [
11
+ 60,
12
+ 60
13
+ ],
14
+ "size": [
15
+ 430,
16
+ 700
17
+ ],
18
+ "flags": {},
19
+ "order": 0,
20
+ "mode": 0,
21
+ "inputs": [],
22
+ "outputs": [],
23
+ "properties": {
24
+ "Node name for S&R": "Note"
25
+ },
26
+ "widgets_values": [
27
+ "KREA 2 SVDQUANT - DIAGNOSTICS\n\nRun this before reporting a bug, and before downloading 8 GB.\nNothing here generates an image.\n\nENV CHECK (left, no model needed)\n Answers the single most common report: \"the quantized checkpoint\n is slower than fp8\". Almost always the cause is that ComfyUI\n disabled comfy_kitchen's CUDA backend because torch was built\n against CUDA < 13, and the fallback dequantizes int4 in Python --\n slower than bf16, not faster. Needs no model, so run it first.\n\nDIAGNOSTICS (right, needs a checkpoint) - the mode widget:\n dispatch which kernel actually runs. START HERE.\n env versions + memory accounting, including where the\n low-rank factors currently live. Under lowvram they\n should read cpu between steps, not cuda.\n bench quantized vs bf16 timing per layer.\n profile torch.profiler table.\n compile graph breaks a TorchCompileModel run would pay.\n\n tokens = sequence length. 4096 is 1024x1024. Kernel selection is\n shape-dependent, so match your real resolution.\n\nWHAT TO PASTE INTO AN ISSUE\n Your GPU, the checkpoint filename, and the report output of both\n nodes. Both are OUTPUT_NODEs, so the text shows in the node itself\n and in the console. Neither is cached -- they re-measure the live\n process every run, which is the point."
28
+ ],
29
+ "title": "READ ME FIRST",
30
+ "color": "#432",
31
+ "bgcolor": "#653"
32
+ },
33
+ {
34
+ "id": 2,
35
+ "type": "Krea2SVDQuantEnvCheck",
36
+ "pos": [
37
+ 480,
38
+ 60
39
+ ],
40
+ "size": [
41
+ 400,
42
+ 80
43
+ ],
44
+ "flags": {},
45
+ "order": 1,
46
+ "mode": 0,
47
+ "inputs": [],
48
+ "outputs": [
49
+ {
50
+ "name": "report",
51
+ "type": "STRING",
52
+ "links": [
53
+ 1
54
+ ],
55
+ "slot_index": 0
56
+ }
57
+ ],
58
+ "properties": {
59
+ "Node name for S&R": "Krea2SVDQuantEnvCheck"
60
+ },
61
+ "widgets_values": [],
62
+ "title": "1. Env Check - no model needed, run this first",
63
+ "color": "#432",
64
+ "bgcolor": "#653"
65
+ },
66
+ {
67
+ "id": 3,
68
+ "type": "PreviewAny",
69
+ "pos": [
70
+ 480,
71
+ 180.0
72
+ ],
73
+ "size": [
74
+ 400,
75
+ 300
76
+ ],
77
+ "flags": {},
78
+ "order": 2,
79
+ "mode": 0,
80
+ "inputs": [
81
+ {
82
+ "name": "source",
83
+ "type": "*",
84
+ "link": 1
85
+ }
86
+ ],
87
+ "outputs": [],
88
+ "properties": {
89
+ "Node name for S&R": "PreviewAny"
90
+ },
91
+ "widgets_values": [],
92
+ "title": "Env Check report",
93
+ "color": "#432",
94
+ "bgcolor": "#653"
95
+ },
96
+ {
97
+ "id": 4,
98
+ "type": "Krea2SVDQuantW4A4Loader",
99
+ "pos": [
100
+ 900,
101
+ 60
102
+ ],
103
+ "size": [
104
+ 400,
105
+ 120
106
+ ],
107
+ "flags": {},
108
+ "order": 3,
109
+ "mode": 0,
110
+ "inputs": [],
111
+ "outputs": [
112
+ {
113
+ "name": "MODEL",
114
+ "type": "MODEL",
115
+ "links": [
116
+ 3
117
+ ],
118
+ "slot_index": 0
119
+ },
120
+ {
121
+ "name": "STRING",
122
+ "type": "STRING",
123
+ "links": [
124
+ 2
125
+ ],
126
+ "slot_index": 1
127
+ }
128
+ ],
129
+ "properties": {
130
+ "Node name for S&R": "Krea2SVDQuantW4A4Loader"
131
+ },
132
+ "widgets_values": [
133
+ "Krea2-Turbo-SVDQuant-W4A4-rank256-actaware.safetensors"
134
+ ],
135
+ "title": "2. Load the checkpoint you are diagnosing",
136
+ "color": "#233",
137
+ "bgcolor": "#355"
138
+ },
139
+ {
140
+ "id": 5,
141
+ "type": "PreviewAny",
142
+ "pos": [
143
+ 900,
144
+ 200.0
145
+ ],
146
+ "size": [
147
+ 400,
148
+ 200
149
+ ],
150
+ "flags": {},
151
+ "order": 4,
152
+ "mode": 0,
153
+ "inputs": [
154
+ {
155
+ "name": "source",
156
+ "type": "*",
157
+ "link": 2
158
+ }
159
+ ],
160
+ "outputs": [],
161
+ "properties": {
162
+ "Node name for S&R": "PreviewAny"
163
+ },
164
+ "widgets_values": [],
165
+ "title": "Loader status - names the kernel that will run",
166
+ "color": "#233",
167
+ "bgcolor": "#355"
168
+ },
169
+ {
170
+ "id": 6,
171
+ "type": "Krea2SVDQuantDiagnostics",
172
+ "pos": [
173
+ 1320,
174
+ 60
175
+ ],
176
+ "size": [
177
+ 400,
178
+ 130
179
+ ],
180
+ "flags": {},
181
+ "order": 5,
182
+ "mode": 0,
183
+ "inputs": [
184
+ {
185
+ "name": "model",
186
+ "type": "MODEL",
187
+ "link": 3
188
+ }
189
+ ],
190
+ "outputs": [
191
+ {
192
+ "name": "model",
193
+ "type": "MODEL",
194
+ "links": [],
195
+ "slot_index": 0
196
+ },
197
+ {
198
+ "name": "report",
199
+ "type": "STRING",
200
+ "links": [
201
+ 4
202
+ ],
203
+ "slot_index": 1
204
+ }
205
+ ],
206
+ "properties": {
207
+ "Node name for S&R": "Krea2SVDQuantDiagnostics"
208
+ },
209
+ "widgets_values": [
210
+ "dispatch",
211
+ 4096
212
+ ],
213
+ "title": "3. Diagnostics - start with mode=dispatch",
214
+ "color": "#432",
215
+ "bgcolor": "#653"
216
+ },
217
+ {
218
+ "id": 7,
219
+ "type": "PreviewAny",
220
+ "pos": [
221
+ 1320,
222
+ 220.0
223
+ ],
224
+ "size": [
225
+ 400,
226
+ 400
227
+ ],
228
+ "flags": {},
229
+ "order": 6,
230
+ "mode": 0,
231
+ "inputs": [
232
+ {
233
+ "name": "source",
234
+ "type": "*",
235
+ "link": 4
236
+ }
237
+ ],
238
+ "outputs": [],
239
+ "properties": {
240
+ "Node name for S&R": "PreviewAny"
241
+ },
242
+ "widgets_values": [],
243
+ "title": "Diagnostics report - paste this into an issue",
244
+ "color": "#432",
245
+ "bgcolor": "#653"
246
+ }
247
+ ],
248
+ "links": [
249
+ [
250
+ 1,
251
+ 2,
252
+ 0,
253
+ 3,
254
+ 0,
255
+ "STRING"
256
+ ],
257
+ [
258
+ 2,
259
+ 4,
260
+ 1,
261
+ 5,
262
+ 0,
263
+ "STRING"
264
+ ],
265
+ [
266
+ 3,
267
+ 4,
268
+ 0,
269
+ 6,
270
+ 0,
271
+ "MODEL"
272
+ ],
273
+ [
274
+ 4,
275
+ 6,
276
+ 1,
277
+ 7,
278
+ 0,
279
+ "STRING"
280
+ ]
281
+ ],
282
+ "groups": [
283
+ {
284
+ "title": "Step 1 - does the int4 kernel exist at all?",
285
+ "bounding": [
286
+ 500,
287
+ 20,
288
+ 420,
289
+ 480
290
+ ],
291
+ "color": "#a85",
292
+ "font_size": 24,
293
+ "flags": {}
294
+ },
295
+ {
296
+ "title": "Step 2 - load it",
297
+ "bounding": [
298
+ 940,
299
+ 20,
300
+ 420,
301
+ 480
302
+ ],
303
+ "color": "#3f789e",
304
+ "font_size": 24,
305
+ "flags": {}
306
+ },
307
+ {
308
+ "title": "Step 3 - what actually runs",
309
+ "bounding": [
310
+ 1360,
311
+ 20,
312
+ 420,
313
+ 620
314
+ ],
315
+ "color": "#8a4",
316
+ "font_size": 24,
317
+ "flags": {}
318
+ }
319
+ ],
320
+ "config": {},
321
+ "extra": {},
322
+ "version": 0.4
323
+ }
workflows/krea2_svdquant_diagnostics_api.json ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "1": {
3
+ "class_type": "Krea2SVDQuantEnvCheck",
4
+ "inputs": {},
5
+ "_meta": {
6
+ "title": "1. Env Check - no model needed, run this first"
7
+ }
8
+ },
9
+ "2": {
10
+ "class_type": "PreviewAny",
11
+ "inputs": {
12
+ "source": [
13
+ "1",
14
+ 0
15
+ ]
16
+ },
17
+ "_meta": {
18
+ "title": "Env Check report"
19
+ }
20
+ },
21
+ "3": {
22
+ "class_type": "Krea2SVDQuantW4A4Loader",
23
+ "inputs": {
24
+ "model_name": "Krea2-Turbo-SVDQuant-W4A4-rank256-actaware.safetensors"
25
+ },
26
+ "_meta": {
27
+ "title": "2. Load the checkpoint you are diagnosing"
28
+ }
29
+ },
30
+ "4": {
31
+ "class_type": "PreviewAny",
32
+ "inputs": {
33
+ "source": [
34
+ "3",
35
+ 1
36
+ ]
37
+ },
38
+ "_meta": {
39
+ "title": "Loader status - names the kernel that will run"
40
+ }
41
+ },
42
+ "5": {
43
+ "class_type": "Krea2SVDQuantDiagnostics",
44
+ "inputs": {
45
+ "mode": "dispatch",
46
+ "tokens": 4096,
47
+ "model": [
48
+ "3",
49
+ 0
50
+ ]
51
+ },
52
+ "_meta": {
53
+ "title": "3. Diagnostics - start with mode=dispatch"
54
+ }
55
+ },
56
+ "6": {
57
+ "class_type": "PreviewAny",
58
+ "inputs": {
59
+ "source": [
60
+ "5",
61
+ 1
62
+ ]
63
+ },
64
+ "_meta": {
65
+ "title": "Diagnostics report - paste this into an issue"
66
+ }
67
+ }
68
+ }
workflows/krea2_turbo_all_in_one_t2i.json ADDED
@@ -0,0 +1,517 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "00000000-0000-4000-8000-000000000000",
3
+ "revision": 0,
4
+ "last_node_id": 9,
5
+ "last_link_id": 10,
6
+ "nodes": [
7
+ {
8
+ "id": 1,
9
+ "type": "Note",
10
+ "pos": [
11
+ 60,
12
+ 60
13
+ ],
14
+ "size": [
15
+ 400,
16
+ 620
17
+ ],
18
+ "flags": {},
19
+ "order": 0,
20
+ "mode": 0,
21
+ "inputs": [],
22
+ "outputs": [],
23
+ "properties": {
24
+ "Node name for S&R": "Note"
25
+ },
26
+ "widgets_values": [
27
+ "KREA 2 TURBO - ALL-IN-ONE CHECKPOINT\n\n1. Checkpoint goes in ComfyUI/models/checkpoints/.\n This file is fully self-contained: it combines the diffusion model,\n the 4-bit quantized text encoder (Qwen3-VL 4B), and the VAE into a\n single safetensors file (~12 GB instead of 15.5 GB across 3 files).\n\n2. SVDQuant checkpoints (carrying low-rank factors) load with the\n \"Krea2 SVDQuant Checkpoint Loader\" node. Branchless checkpoints\n (--format w4a4 or int8) also load with stock CheckpointLoaderSimple.\n\n3. cfg MUST be 1.0. Krea 2 Turbo is cfg-distilled, so the negative\n prompt is zeroed out (ConditioningZeroOut) rather than encoded.\n 8 steps with euler / simple scheduler is what the model was distilled for.\n\n4. 1024x1024 native resolution. Latent size MUST be multiples of 16."
28
+ ],
29
+ "title": "READ ME FIRST",
30
+ "color": "#432",
31
+ "bgcolor": "#653"
32
+ },
33
+ {
34
+ "id": 2,
35
+ "type": "Krea2SVDQuantCheckpointLoader",
36
+ "pos": [
37
+ 480,
38
+ 60
39
+ ],
40
+ "size": [
41
+ 400,
42
+ 140
43
+ ],
44
+ "flags": {},
45
+ "order": 1,
46
+ "mode": 0,
47
+ "inputs": [],
48
+ "outputs": [
49
+ {
50
+ "name": "MODEL",
51
+ "type": "MODEL",
52
+ "links": [
53
+ 1,
54
+ 10
55
+ ],
56
+ "slot_index": 0
57
+ },
58
+ {
59
+ "name": "CLIP",
60
+ "type": "CLIP",
61
+ "links": [
62
+ 2
63
+ ],
64
+ "slot_index": 1
65
+ },
66
+ {
67
+ "name": "VAE",
68
+ "type": "VAE",
69
+ "links": [
70
+ 8
71
+ ],
72
+ "slot_index": 2
73
+ },
74
+ {
75
+ "name": "STRING",
76
+ "type": "STRING",
77
+ "links": [],
78
+ "slot_index": 3
79
+ }
80
+ ],
81
+ "properties": {
82
+ "Node name for S&R": "Krea2SVDQuantCheckpointLoader"
83
+ },
84
+ "widgets_values": [
85
+ "Krea2-Turbo-AllInOne-SVDQuant-W4A4-rank256-actaware-TEW4A4.safetensors"
86
+ ],
87
+ "title": "Krea2 SVDQuant Checkpoint Loader",
88
+ "color": "#233",
89
+ "bgcolor": "#355"
90
+ },
91
+ {
92
+ "id": 3,
93
+ "type": "CLIPTextEncode",
94
+ "pos": [
95
+ 900,
96
+ 60
97
+ ],
98
+ "size": [
99
+ 400,
100
+ 220
101
+ ],
102
+ "flags": {},
103
+ "order": 2,
104
+ "mode": 0,
105
+ "inputs": [
106
+ {
107
+ "name": "clip",
108
+ "type": "CLIP",
109
+ "link": 2
110
+ }
111
+ ],
112
+ "outputs": [
113
+ {
114
+ "name": "CONDITIONING",
115
+ "type": "CONDITIONING",
116
+ "links": [
117
+ 3,
118
+ 4
119
+ ],
120
+ "slot_index": 0
121
+ }
122
+ ],
123
+ "properties": {
124
+ "Node name for S&R": "CLIPTextEncode"
125
+ },
126
+ "widgets_values": [
127
+ "A cluttered antique clockmaker's workshop seen through a cracked magnifying glass, brass gears laid out in a spiral on the workbench, a ginger cat asleep on a stack of leather-bound books, warm afternoon light with visible dust motes, photorealistic, 85mm lens, shallow depth of field"
128
+ ],
129
+ "title": "Prompt",
130
+ "color": "#232",
131
+ "bgcolor": "#353"
132
+ },
133
+ {
134
+ "id": 4,
135
+ "type": "ConditioningZeroOut",
136
+ "pos": [
137
+ 900,
138
+ 340.0
139
+ ],
140
+ "size": [
141
+ 400,
142
+ 60
143
+ ],
144
+ "flags": {},
145
+ "order": 3,
146
+ "mode": 0,
147
+ "inputs": [
148
+ {
149
+ "name": "conditioning",
150
+ "type": "CONDITIONING",
151
+ "link": 3
152
+ }
153
+ ],
154
+ "outputs": [
155
+ {
156
+ "name": "CONDITIONING",
157
+ "type": "CONDITIONING",
158
+ "links": [
159
+ 5
160
+ ],
161
+ "slot_index": 0
162
+ }
163
+ ],
164
+ "properties": {
165
+ "Node name for S&R": "ConditioningZeroOut"
166
+ },
167
+ "widgets_values": [],
168
+ "title": "Negative (zeroed - required at cfg 1.0)",
169
+ "color": "#232",
170
+ "bgcolor": "#353"
171
+ },
172
+ {
173
+ "id": 5,
174
+ "type": "EmptySD3LatentImage",
175
+ "pos": [
176
+ 900,
177
+ 500.00000000000006
178
+ ],
179
+ "size": [
180
+ 400,
181
+ 120
182
+ ],
183
+ "flags": {},
184
+ "order": 4,
185
+ "mode": 0,
186
+ "inputs": [],
187
+ "outputs": [
188
+ {
189
+ "name": "LATENT",
190
+ "type": "LATENT",
191
+ "links": [
192
+ 6
193
+ ],
194
+ "slot_index": 0
195
+ }
196
+ ],
197
+ "properties": {
198
+ "Node name for S&R": "EmptySD3LatentImage"
199
+ },
200
+ "widgets_values": [
201
+ 1024,
202
+ 1024,
203
+ 1
204
+ ],
205
+ "title": "Latent 1024x1024",
206
+ "color": "#232",
207
+ "bgcolor": "#353"
208
+ },
209
+ {
210
+ "id": 6,
211
+ "type": "KSampler",
212
+ "pos": [
213
+ 1320,
214
+ 60
215
+ ],
216
+ "size": [
217
+ 400,
218
+ 280
219
+ ],
220
+ "flags": {},
221
+ "order": 5,
222
+ "mode": 0,
223
+ "inputs": [
224
+ {
225
+ "name": "model",
226
+ "type": "MODEL",
227
+ "link": 1
228
+ },
229
+ {
230
+ "name": "positive",
231
+ "type": "CONDITIONING",
232
+ "link": 4
233
+ },
234
+ {
235
+ "name": "negative",
236
+ "type": "CONDITIONING",
237
+ "link": 5
238
+ },
239
+ {
240
+ "name": "latent_image",
241
+ "type": "LATENT",
242
+ "link": 6
243
+ }
244
+ ],
245
+ "outputs": [
246
+ {
247
+ "name": "LATENT",
248
+ "type": "LATENT",
249
+ "links": [
250
+ 7
251
+ ],
252
+ "slot_index": 0
253
+ }
254
+ ],
255
+ "properties": {
256
+ "Node name for S&R": "KSampler"
257
+ },
258
+ "widgets_values": [
259
+ 987654321,
260
+ "randomize",
261
+ 8,
262
+ 1.0,
263
+ "euler",
264
+ "simple",
265
+ 1.0
266
+ ],
267
+ "title": "KSampler - 8 steps, cfg 1.0",
268
+ "color": "#323",
269
+ "bgcolor": "#535"
270
+ },
271
+ {
272
+ "id": 7,
273
+ "type": "VAEDecode",
274
+ "pos": [
275
+ 1740,
276
+ 60
277
+ ],
278
+ "size": [
279
+ 300,
280
+ 60
281
+ ],
282
+ "flags": {},
283
+ "order": 6,
284
+ "mode": 0,
285
+ "inputs": [
286
+ {
287
+ "name": "samples",
288
+ "type": "LATENT",
289
+ "link": 7
290
+ },
291
+ {
292
+ "name": "vae",
293
+ "type": "VAE",
294
+ "link": 8
295
+ }
296
+ ],
297
+ "outputs": [
298
+ {
299
+ "name": "IMAGE",
300
+ "type": "IMAGE",
301
+ "links": [
302
+ 9
303
+ ],
304
+ "slot_index": 0
305
+ }
306
+ ],
307
+ "properties": {
308
+ "Node name for S&R": "VAEDecode"
309
+ },
310
+ "widgets_values": [],
311
+ "title": "VAE Decode",
312
+ "color": "#323",
313
+ "bgcolor": "#535"
314
+ },
315
+ {
316
+ "id": 8,
317
+ "type": "SaveImage",
318
+ "pos": [
319
+ 1740,
320
+ 200.0
321
+ ],
322
+ "size": [
323
+ 400,
324
+ 300
325
+ ],
326
+ "flags": {},
327
+ "order": 7,
328
+ "mode": 0,
329
+ "inputs": [
330
+ {
331
+ "name": "images",
332
+ "type": "IMAGE",
333
+ "link": 9
334
+ }
335
+ ],
336
+ "outputs": [],
337
+ "properties": {
338
+ "Node name for S&R": "SaveImage"
339
+ },
340
+ "widgets_values": [
341
+ "krea2_turbo_aio_svdq"
342
+ ],
343
+ "title": "Save",
344
+ "color": "#323",
345
+ "bgcolor": "#535"
346
+ },
347
+ {
348
+ "id": 9,
349
+ "type": "Krea2SVDQuantDiagnostics",
350
+ "pos": [
351
+ 1320,
352
+ 540.0
353
+ ],
354
+ "size": [
355
+ 400,
356
+ 130
357
+ ],
358
+ "flags": {},
359
+ "order": 8,
360
+ "mode": 0,
361
+ "inputs": [
362
+ {
363
+ "name": "model",
364
+ "type": "MODEL",
365
+ "link": 10
366
+ }
367
+ ],
368
+ "outputs": [
369
+ {
370
+ "name": "MODEL",
371
+ "type": "MODEL",
372
+ "links": [],
373
+ "slot_index": 0
374
+ },
375
+ {
376
+ "name": "STRING",
377
+ "type": "STRING",
378
+ "links": [],
379
+ "slot_index": 1
380
+ }
381
+ ],
382
+ "properties": {
383
+ "Node name for S&R": "Krea2SVDQuantDiagnostics"
384
+ },
385
+ "widgets_values": [
386
+ "dispatch",
387
+ 4096
388
+ ],
389
+ "title": "Diagnostics (optional - run if slow)",
390
+ "color": "#432",
391
+ "bgcolor": "#653"
392
+ }
393
+ ],
394
+ "links": [
395
+ [
396
+ 1,
397
+ 2,
398
+ 0,
399
+ 6,
400
+ 0,
401
+ "MODEL"
402
+ ],
403
+ [
404
+ 2,
405
+ 2,
406
+ 1,
407
+ 3,
408
+ 0,
409
+ "CLIP"
410
+ ],
411
+ [
412
+ 3,
413
+ 3,
414
+ 0,
415
+ 4,
416
+ 0,
417
+ "CONDITIONING"
418
+ ],
419
+ [
420
+ 4,
421
+ 3,
422
+ 0,
423
+ 6,
424
+ 1,
425
+ "CONDITIONING"
426
+ ],
427
+ [
428
+ 5,
429
+ 4,
430
+ 0,
431
+ 6,
432
+ 2,
433
+ "CONDITIONING"
434
+ ],
435
+ [
436
+ 6,
437
+ 5,
438
+ 0,
439
+ 6,
440
+ 3,
441
+ "LATENT"
442
+ ],
443
+ [
444
+ 7,
445
+ 6,
446
+ 0,
447
+ 7,
448
+ 0,
449
+ "LATENT"
450
+ ],
451
+ [
452
+ 8,
453
+ 2,
454
+ 2,
455
+ 7,
456
+ 1,
457
+ "VAE"
458
+ ],
459
+ [
460
+ 9,
461
+ 7,
462
+ 0,
463
+ 8,
464
+ 0,
465
+ "IMAGE"
466
+ ],
467
+ [
468
+ 10,
469
+ 2,
470
+ 0,
471
+ 9,
472
+ 0,
473
+ "MODEL"
474
+ ]
475
+ ],
476
+ "groups": [
477
+ {
478
+ "title": "Load All-in-One",
479
+ "bounding": [
480
+ 500,
481
+ 20,
482
+ 420,
483
+ 280
484
+ ],
485
+ "color": "#3f789e",
486
+ "font_size": 24,
487
+ "flags": {}
488
+ },
489
+ {
490
+ "title": "Prompt",
491
+ "bounding": [
492
+ 940,
493
+ 20,
494
+ 420,
495
+ 620
496
+ ],
497
+ "color": "#3f789e",
498
+ "font_size": 24,
499
+ "flags": {}
500
+ },
501
+ {
502
+ "title": "Sample",
503
+ "bounding": [
504
+ 1360,
505
+ 20,
506
+ 420,
507
+ 640
508
+ ],
509
+ "color": "#8a4",
510
+ "font_size": 24,
511
+ "flags": {}
512
+ }
513
+ ],
514
+ "config": {},
515
+ "extra": {},
516
+ "version": 0.4
517
+ }
workflows/krea2_turbo_all_in_one_t2i_api.json ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "1": {
3
+ "class_type": "Krea2SVDQuantCheckpointLoader",
4
+ "inputs": {
5
+ "ckpt_name": "Krea2-Turbo-AllInOne-SVDQuant-W4A4-rank256-actaware-TEW4A4.safetensors"
6
+ },
7
+ "_meta": {
8
+ "title": "Krea2 SVDQuant Checkpoint Loader"
9
+ }
10
+ },
11
+ "2": {
12
+ "class_type": "CLIPTextEncode",
13
+ "inputs": {
14
+ "text": "A cluttered antique clockmaker's workshop seen through a cracked magnifying glass, brass gears laid out in a spiral on the workbench, a ginger cat asleep on a stack of leather-bound books, warm afternoon light with visible dust motes, photorealistic, 85mm lens, shallow depth of field",
15
+ "clip": [
16
+ "1",
17
+ 1
18
+ ]
19
+ },
20
+ "_meta": {
21
+ "title": "Prompt"
22
+ }
23
+ },
24
+ "3": {
25
+ "class_type": "ConditioningZeroOut",
26
+ "inputs": {
27
+ "conditioning": [
28
+ "2",
29
+ 0
30
+ ]
31
+ },
32
+ "_meta": {
33
+ "title": "Negative (zeroed - required at cfg 1.0)"
34
+ }
35
+ },
36
+ "4": {
37
+ "class_type": "EmptySD3LatentImage",
38
+ "inputs": {
39
+ "width": 1024,
40
+ "height": 1024,
41
+ "batch_size": 1
42
+ },
43
+ "_meta": {
44
+ "title": "Latent 1024x1024"
45
+ }
46
+ },
47
+ "5": {
48
+ "class_type": "KSampler",
49
+ "inputs": {
50
+ "seed": 987654321,
51
+ "steps": 8,
52
+ "cfg": 1.0,
53
+ "sampler_name": "euler",
54
+ "scheduler": "simple",
55
+ "denoise": 1.0,
56
+ "model": [
57
+ "1",
58
+ 0
59
+ ],
60
+ "positive": [
61
+ "2",
62
+ 0
63
+ ],
64
+ "negative": [
65
+ "3",
66
+ 0
67
+ ],
68
+ "latent_image": [
69
+ "4",
70
+ 0
71
+ ]
72
+ },
73
+ "_meta": {
74
+ "title": "KSampler - 8 steps, cfg 1.0"
75
+ }
76
+ },
77
+ "6": {
78
+ "class_type": "VAEDecode",
79
+ "inputs": {
80
+ "samples": [
81
+ "5",
82
+ 0
83
+ ],
84
+ "vae": [
85
+ "1",
86
+ 2
87
+ ]
88
+ },
89
+ "_meta": {
90
+ "title": "VAE Decode"
91
+ }
92
+ },
93
+ "7": {
94
+ "class_type": "SaveImage",
95
+ "inputs": {
96
+ "filename_prefix": "krea2_turbo_aio_svdq",
97
+ "images": [
98
+ "6",
99
+ 0
100
+ ]
101
+ },
102
+ "_meta": {
103
+ "title": "Save"
104
+ }
105
+ }
106
+ }
workflows/krea2_turbo_svdquant_w4a4_lora.json ADDED
@@ -0,0 +1,631 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "00000000-0000-4000-8000-000000000000",
3
+ "revision": 0,
4
+ "last_node_id": 12,
5
+ "last_link_id": 11,
6
+ "nodes": [
7
+ {
8
+ "id": 1,
9
+ "type": "Note",
10
+ "pos": [
11
+ 60,
12
+ 60
13
+ ],
14
+ "size": [
15
+ 400,
16
+ 720
17
+ ],
18
+ "flags": {},
19
+ "order": 0,
20
+ "mode": 0,
21
+ "inputs": [],
22
+ "outputs": [],
23
+ "properties": {
24
+ "Node name for S&R": "Note"
25
+ },
26
+ "widgets_values": [
27
+ "KREA 2 TURBO + LoRA - SVDQuant W4A4\n\nSame graph as the plain Turbo one, with LoRA loaders inserted between\nthe checkpoint loader and the sampler.\n\n1. USE THIS NODE, NOT THE STOCK ONE. On a quantized checkpoint the\n stock LoraLoaderModelOnly has to dequantize the 4-bit weight, add\n the LoRA, and requantize it -- which throws away the format and\n quantizes the LoRA delta to 4 bits along with it. This node\n attaches the LoRA as a parallel branch instead:\n (W + BA)x == Wx + B(Ax), so the quantized weight is never touched.\n\n2. CHECK THE CONSOLE. The node prints what it matched, e.g.\n 224 quantized layers, 32 normal layers\n If it says 0 quantized layers, the LoRA does not target this model\n and nothing will change. That line is the first thing to look at\n when a LoRA \"does nothing\".\n\n3. STACKING. Chain more of these nodes, one per LoRA. The whole stack\n is refolded each time, so strengths stay exact no matter how many\n you chain, and the cost stays one pair of GEMMs. The second loader\n here is bypassed (ctrl-B) -- unmute it to use it.\n\n4. RANK MATTERS HERE. Without a LoRA, branch ranks 64/128/256 measure\n the same. With one, rank 256 wins clearly and rank 64 loses most of\n its advantage. If you use LoRAs, download rank 256.\n\n5. adapters INPUT. Only affects LoKr / LoHa / OFT, which cannot fold\n into the branch. \"bypass\" is exact and costs ~1.8s per model call\n at 1440x1920; \"bake\" is free per-step but requantizes the delta.\n A plain up/down LoRA ignores this setting entirely."
28
+ ],
29
+ "title": "READ ME FIRST",
30
+ "color": "#432",
31
+ "bgcolor": "#653"
32
+ },
33
+ {
34
+ "id": 2,
35
+ "type": "Krea2SVDQuantW4A4Loader",
36
+ "pos": [
37
+ 480,
38
+ 60
39
+ ],
40
+ "size": [
41
+ 400,
42
+ 120
43
+ ],
44
+ "flags": {},
45
+ "order": 1,
46
+ "mode": 0,
47
+ "inputs": [],
48
+ "outputs": [
49
+ {
50
+ "name": "MODEL",
51
+ "type": "MODEL",
52
+ "links": [
53
+ 1
54
+ ],
55
+ "slot_index": 0
56
+ },
57
+ {
58
+ "name": "STRING",
59
+ "type": "STRING",
60
+ "links": [],
61
+ "slot_index": 1
62
+ }
63
+ ],
64
+ "properties": {
65
+ "Node name for S&R": "Krea2SVDQuantW4A4Loader"
66
+ },
67
+ "widgets_values": [
68
+ "Krea2-Turbo-SVDQuant-W4A4-rank256.safetensors"
69
+ ],
70
+ "title": "Krea2 SVDQuant W4A4 Loader (rank 256 for LoRAs)",
71
+ "color": "#233",
72
+ "bgcolor": "#355"
73
+ },
74
+ {
75
+ "id": 3,
76
+ "type": "CLIPLoader",
77
+ "pos": [
78
+ 480,
79
+ 260
80
+ ],
81
+ "size": [
82
+ 400,
83
+ 120
84
+ ],
85
+ "flags": {},
86
+ "order": 2,
87
+ "mode": 0,
88
+ "inputs": [],
89
+ "outputs": [
90
+ {
91
+ "name": "CLIP",
92
+ "type": "CLIP",
93
+ "links": [
94
+ 4
95
+ ],
96
+ "slot_index": 0
97
+ }
98
+ ],
99
+ "properties": {
100
+ "Node name for S&R": "CLIPLoader"
101
+ },
102
+ "widgets_values": [
103
+ "qwen3vl_4b_fp8_scaled.safetensors",
104
+ "krea2",
105
+ "default"
106
+ ],
107
+ "title": "Text encoder (Qwen3-VL 4B)",
108
+ "color": "#233",
109
+ "bgcolor": "#355"
110
+ },
111
+ {
112
+ "id": 4,
113
+ "type": "VAELoader",
114
+ "pos": [
115
+ 480,
116
+ 460
117
+ ],
118
+ "size": [
119
+ 400,
120
+ 80
121
+ ],
122
+ "flags": {},
123
+ "order": 3,
124
+ "mode": 0,
125
+ "inputs": [],
126
+ "outputs": [
127
+ {
128
+ "name": "VAE",
129
+ "type": "VAE",
130
+ "links": [
131
+ 10
132
+ ],
133
+ "slot_index": 0
134
+ }
135
+ ],
136
+ "properties": {
137
+ "Node name for S&R": "VAELoader"
138
+ },
139
+ "widgets_values": [
140
+ "qwen_image_vae.safetensors"
141
+ ],
142
+ "title": "VAE",
143
+ "color": "#233",
144
+ "bgcolor": "#355"
145
+ },
146
+ {
147
+ "id": 5,
148
+ "type": "Krea2SVDQuantLoraLoader",
149
+ "pos": [
150
+ 900,
151
+ 60
152
+ ],
153
+ "size": [
154
+ 400,
155
+ 140
156
+ ],
157
+ "flags": {},
158
+ "order": 4,
159
+ "mode": 0,
160
+ "inputs": [
161
+ {
162
+ "name": "model",
163
+ "type": "MODEL",
164
+ "link": 1
165
+ }
166
+ ],
167
+ "outputs": [
168
+ {
169
+ "name": "model",
170
+ "type": "MODEL",
171
+ "links": [
172
+ 2,
173
+ 3
174
+ ],
175
+ "slot_index": 0
176
+ }
177
+ ],
178
+ "properties": {
179
+ "Node name for S&R": "Krea2SVDQuantLoraLoader"
180
+ },
181
+ "widgets_values": [
182
+ "your_krea2_lora.safetensors",
183
+ 1.0,
184
+ "bypass (exact, slower)"
185
+ ],
186
+ "title": "Krea2 SVDQuant LoRA Loader",
187
+ "color": "#653",
188
+ "bgcolor": "#874"
189
+ },
190
+ {
191
+ "id": 6,
192
+ "type": "Krea2SVDQuantLoraLoader",
193
+ "pos": [
194
+ 900,
195
+ 260
196
+ ],
197
+ "size": [
198
+ 400,
199
+ 140
200
+ ],
201
+ "flags": {},
202
+ "order": 5,
203
+ "mode": 4,
204
+ "inputs": [
205
+ {
206
+ "name": "model",
207
+ "type": "MODEL",
208
+ "link": 2
209
+ }
210
+ ],
211
+ "outputs": [
212
+ {
213
+ "name": "model",
214
+ "type": "MODEL",
215
+ "links": [],
216
+ "slot_index": 0
217
+ }
218
+ ],
219
+ "properties": {
220
+ "Node name for S&R": "Krea2SVDQuantLoraLoader"
221
+ },
222
+ "widgets_values": [
223
+ "your_krea2_lora.safetensors",
224
+ 0.6,
225
+ "bypass (exact, slower)"
226
+ ],
227
+ "title": "Second LoRA (muted - ctrl-B to enable)",
228
+ "color": "#653",
229
+ "bgcolor": "#874"
230
+ },
231
+ {
232
+ "id": 7,
233
+ "type": "CLIPTextEncode",
234
+ "pos": [
235
+ 1320,
236
+ 60
237
+ ],
238
+ "size": [
239
+ 400,
240
+ 220
241
+ ],
242
+ "flags": {},
243
+ "order": 6,
244
+ "mode": 0,
245
+ "inputs": [
246
+ {
247
+ "name": "clip",
248
+ "type": "CLIP",
249
+ "link": 4
250
+ }
251
+ ],
252
+ "outputs": [
253
+ {
254
+ "name": "CONDITIONING",
255
+ "type": "CONDITIONING",
256
+ "links": [
257
+ 5,
258
+ 6
259
+ ],
260
+ "slot_index": 0
261
+ }
262
+ ],
263
+ "properties": {
264
+ "Node name for S&R": "CLIPTextEncode"
265
+ },
266
+ "widgets_values": [
267
+ "A cluttered antique clockmaker's workshop seen through a cracked magnifying glass, brass gears laid out in a spiral on the workbench, a ginger cat asleep on a stack of leather-bound books, warm afternoon light with visible dust motes, photorealistic, 85mm lens, shallow depth of field"
268
+ ],
269
+ "title": "Prompt (add your LoRA trigger words)",
270
+ "color": "#232",
271
+ "bgcolor": "#353"
272
+ },
273
+ {
274
+ "id": 8,
275
+ "type": "ConditioningZeroOut",
276
+ "pos": [
277
+ 1320,
278
+ 340.0
279
+ ],
280
+ "size": [
281
+ 400,
282
+ 60
283
+ ],
284
+ "flags": {},
285
+ "order": 7,
286
+ "mode": 0,
287
+ "inputs": [
288
+ {
289
+ "name": "conditioning",
290
+ "type": "CONDITIONING",
291
+ "link": 5
292
+ }
293
+ ],
294
+ "outputs": [
295
+ {
296
+ "name": "CONDITIONING",
297
+ "type": "CONDITIONING",
298
+ "links": [
299
+ 7
300
+ ],
301
+ "slot_index": 0
302
+ }
303
+ ],
304
+ "properties": {
305
+ "Node name for S&R": "ConditioningZeroOut"
306
+ },
307
+ "widgets_values": [],
308
+ "title": "Negative (zeroed - required at cfg 1.0)",
309
+ "color": "#232",
310
+ "bgcolor": "#353"
311
+ },
312
+ {
313
+ "id": 9,
314
+ "type": "EmptySD3LatentImage",
315
+ "pos": [
316
+ 1320,
317
+ 500.00000000000006
318
+ ],
319
+ "size": [
320
+ 400,
321
+ 120
322
+ ],
323
+ "flags": {},
324
+ "order": 8,
325
+ "mode": 0,
326
+ "inputs": [],
327
+ "outputs": [
328
+ {
329
+ "name": "LATENT",
330
+ "type": "LATENT",
331
+ "links": [
332
+ 8
333
+ ],
334
+ "slot_index": 0
335
+ }
336
+ ],
337
+ "properties": {
338
+ "Node name for S&R": "EmptySD3LatentImage"
339
+ },
340
+ "widgets_values": [
341
+ 1024,
342
+ 1024,
343
+ 1
344
+ ],
345
+ "title": "Latent 1024x1024",
346
+ "color": "#232",
347
+ "bgcolor": "#353"
348
+ },
349
+ {
350
+ "id": 10,
351
+ "type": "KSampler",
352
+ "pos": [
353
+ 1740,
354
+ 60
355
+ ],
356
+ "size": [
357
+ 400,
358
+ 280
359
+ ],
360
+ "flags": {},
361
+ "order": 9,
362
+ "mode": 0,
363
+ "inputs": [
364
+ {
365
+ "name": "model",
366
+ "type": "MODEL",
367
+ "link": 3
368
+ },
369
+ {
370
+ "name": "positive",
371
+ "type": "CONDITIONING",
372
+ "link": 6
373
+ },
374
+ {
375
+ "name": "negative",
376
+ "type": "CONDITIONING",
377
+ "link": 7
378
+ },
379
+ {
380
+ "name": "latent_image",
381
+ "type": "LATENT",
382
+ "link": 8
383
+ }
384
+ ],
385
+ "outputs": [
386
+ {
387
+ "name": "LATENT",
388
+ "type": "LATENT",
389
+ "links": [
390
+ 9
391
+ ],
392
+ "slot_index": 0
393
+ }
394
+ ],
395
+ "properties": {
396
+ "Node name for S&R": "KSampler"
397
+ },
398
+ "widgets_values": [
399
+ 987654321,
400
+ "randomize",
401
+ 8,
402
+ 1.0,
403
+ "euler",
404
+ "simple",
405
+ 1.0
406
+ ],
407
+ "title": "KSampler - 8 steps, cfg 1.0",
408
+ "color": "#323",
409
+ "bgcolor": "#535"
410
+ },
411
+ {
412
+ "id": 11,
413
+ "type": "VAEDecode",
414
+ "pos": [
415
+ 2160,
416
+ 60
417
+ ],
418
+ "size": [
419
+ 300,
420
+ 60
421
+ ],
422
+ "flags": {},
423
+ "order": 10,
424
+ "mode": 0,
425
+ "inputs": [
426
+ {
427
+ "name": "samples",
428
+ "type": "LATENT",
429
+ "link": 9
430
+ },
431
+ {
432
+ "name": "vae",
433
+ "type": "VAE",
434
+ "link": 10
435
+ }
436
+ ],
437
+ "outputs": [
438
+ {
439
+ "name": "IMAGE",
440
+ "type": "IMAGE",
441
+ "links": [
442
+ 11
443
+ ],
444
+ "slot_index": 0
445
+ }
446
+ ],
447
+ "properties": {
448
+ "Node name for S&R": "VAEDecode"
449
+ },
450
+ "widgets_values": [],
451
+ "title": "VAE Decode",
452
+ "color": "#323",
453
+ "bgcolor": "#535"
454
+ },
455
+ {
456
+ "id": 12,
457
+ "type": "SaveImage",
458
+ "pos": [
459
+ 2160,
460
+ 200.0
461
+ ],
462
+ "size": [
463
+ 400,
464
+ 300
465
+ ],
466
+ "flags": {},
467
+ "order": 11,
468
+ "mode": 0,
469
+ "inputs": [
470
+ {
471
+ "name": "images",
472
+ "type": "IMAGE",
473
+ "link": 11
474
+ }
475
+ ],
476
+ "outputs": [],
477
+ "properties": {
478
+ "Node name for S&R": "SaveImage"
479
+ },
480
+ "widgets_values": [
481
+ "krea2_turbo_lora"
482
+ ],
483
+ "title": "Save",
484
+ "color": "#323",
485
+ "bgcolor": "#535"
486
+ }
487
+ ],
488
+ "links": [
489
+ [
490
+ 1,
491
+ 2,
492
+ 0,
493
+ 5,
494
+ 0,
495
+ "MODEL"
496
+ ],
497
+ [
498
+ 2,
499
+ 5,
500
+ 0,
501
+ 6,
502
+ 0,
503
+ "MODEL"
504
+ ],
505
+ [
506
+ 3,
507
+ 5,
508
+ 0,
509
+ 10,
510
+ 0,
511
+ "MODEL"
512
+ ],
513
+ [
514
+ 4,
515
+ 3,
516
+ 0,
517
+ 7,
518
+ 0,
519
+ "CLIP"
520
+ ],
521
+ [
522
+ 5,
523
+ 7,
524
+ 0,
525
+ 8,
526
+ 0,
527
+ "CONDITIONING"
528
+ ],
529
+ [
530
+ 6,
531
+ 7,
532
+ 0,
533
+ 10,
534
+ 1,
535
+ "CONDITIONING"
536
+ ],
537
+ [
538
+ 7,
539
+ 8,
540
+ 0,
541
+ 10,
542
+ 2,
543
+ "CONDITIONING"
544
+ ],
545
+ [
546
+ 8,
547
+ 9,
548
+ 0,
549
+ 10,
550
+ 3,
551
+ "LATENT"
552
+ ],
553
+ [
554
+ 9,
555
+ 10,
556
+ 0,
557
+ 11,
558
+ 0,
559
+ "LATENT"
560
+ ],
561
+ [
562
+ 10,
563
+ 4,
564
+ 0,
565
+ 11,
566
+ 1,
567
+ "VAE"
568
+ ],
569
+ [
570
+ 11,
571
+ 11,
572
+ 0,
573
+ 12,
574
+ 0,
575
+ "IMAGE"
576
+ ]
577
+ ],
578
+ "groups": [
579
+ {
580
+ "title": "Load",
581
+ "bounding": [
582
+ 500,
583
+ 20,
584
+ 420,
585
+ 620
586
+ ],
587
+ "color": "#3f789e",
588
+ "font_size": 24,
589
+ "flags": {}
590
+ },
591
+ {
592
+ "title": "LoRA - chain one node per LoRA",
593
+ "bounding": [
594
+ 940,
595
+ 20,
596
+ 420,
597
+ 420
598
+ ],
599
+ "color": "#a85",
600
+ "font_size": 24,
601
+ "flags": {}
602
+ },
603
+ {
604
+ "title": "Prompt",
605
+ "bounding": [
606
+ 1360,
607
+ 20,
608
+ 420,
609
+ 620
610
+ ],
611
+ "color": "#3f789e",
612
+ "font_size": 24,
613
+ "flags": {}
614
+ },
615
+ {
616
+ "title": "Sample",
617
+ "bounding": [
618
+ 1780,
619
+ 20,
620
+ 420,
621
+ 640
622
+ ],
623
+ "color": "#8a4",
624
+ "font_size": 24,
625
+ "flags": {}
626
+ }
627
+ ],
628
+ "config": {},
629
+ "extra": {},
630
+ "version": 0.4
631
+ }
workflows/krea2_turbo_svdquant_w4a4_lora_api.json ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "1": {
3
+ "class_type": "Krea2SVDQuantW4A4Loader",
4
+ "inputs": {
5
+ "model_name": "Krea2-Turbo-SVDQuant-W4A4-rank256.safetensors"
6
+ },
7
+ "_meta": {
8
+ "title": "Krea2 SVDQuant W4A4 Loader (rank 256 for LoRAs)"
9
+ }
10
+ },
11
+ "2": {
12
+ "class_type": "CLIPLoader",
13
+ "inputs": {
14
+ "clip_name": "qwen3vl_4b_fp8_scaled.safetensors",
15
+ "type": "krea2",
16
+ "device": "default"
17
+ },
18
+ "_meta": {
19
+ "title": "Text encoder (Qwen3-VL 4B)"
20
+ }
21
+ },
22
+ "3": {
23
+ "class_type": "VAELoader",
24
+ "inputs": {
25
+ "vae_name": "qwen_image_vae.safetensors"
26
+ },
27
+ "_meta": {
28
+ "title": "VAE"
29
+ }
30
+ },
31
+ "4": {
32
+ "class_type": "Krea2SVDQuantLoraLoader",
33
+ "inputs": {
34
+ "lora_name": "your_krea2_lora.safetensors",
35
+ "strength": 1.0,
36
+ "adapters": "bypass (exact, slower)",
37
+ "model": [
38
+ "1",
39
+ 0
40
+ ]
41
+ },
42
+ "_meta": {
43
+ "title": "Krea2 SVDQuant LoRA Loader"
44
+ }
45
+ },
46
+ "5": {
47
+ "class_type": "CLIPTextEncode",
48
+ "inputs": {
49
+ "text": "A cluttered antique clockmaker's workshop seen through a cracked magnifying glass, brass gears laid out in a spiral on the workbench, a ginger cat asleep on a stack of leather-bound books, warm afternoon light with visible dust motes, photorealistic, 85mm lens, shallow depth of field",
50
+ "clip": [
51
+ "2",
52
+ 0
53
+ ]
54
+ },
55
+ "_meta": {
56
+ "title": "Prompt (add your LoRA trigger words)"
57
+ }
58
+ },
59
+ "6": {
60
+ "class_type": "ConditioningZeroOut",
61
+ "inputs": {
62
+ "conditioning": [
63
+ "5",
64
+ 0
65
+ ]
66
+ },
67
+ "_meta": {
68
+ "title": "Negative (zeroed - required at cfg 1.0)"
69
+ }
70
+ },
71
+ "7": {
72
+ "class_type": "EmptySD3LatentImage",
73
+ "inputs": {
74
+ "width": 1024,
75
+ "height": 1024,
76
+ "batch_size": 1
77
+ },
78
+ "_meta": {
79
+ "title": "Latent 1024x1024"
80
+ }
81
+ },
82
+ "8": {
83
+ "class_type": "KSampler",
84
+ "inputs": {
85
+ "seed": 987654321,
86
+ "steps": 8,
87
+ "cfg": 1.0,
88
+ "sampler_name": "euler",
89
+ "scheduler": "simple",
90
+ "denoise": 1.0,
91
+ "model": [
92
+ "4",
93
+ 0
94
+ ],
95
+ "positive": [
96
+ "5",
97
+ 0
98
+ ],
99
+ "negative": [
100
+ "6",
101
+ 0
102
+ ],
103
+ "latent_image": [
104
+ "7",
105
+ 0
106
+ ]
107
+ },
108
+ "_meta": {
109
+ "title": "KSampler - 8 steps, cfg 1.0"
110
+ }
111
+ },
112
+ "9": {
113
+ "class_type": "VAEDecode",
114
+ "inputs": {
115
+ "samples": [
116
+ "8",
117
+ 0
118
+ ],
119
+ "vae": [
120
+ "3",
121
+ 0
122
+ ]
123
+ },
124
+ "_meta": {
125
+ "title": "VAE Decode"
126
+ }
127
+ },
128
+ "10": {
129
+ "class_type": "SaveImage",
130
+ "inputs": {
131
+ "filename_prefix": "krea2_turbo_lora",
132
+ "images": [
133
+ "9",
134
+ 0
135
+ ]
136
+ },
137
+ "_meta": {
138
+ "title": "Save"
139
+ }
140
+ }
141
+ }