Text-to-Image
Diffusers
image-generation
comfyui
quantization
int8
int4
svdquant
krea2
krea
diffusion
transformer
lowvram
Instructions to use AlperKTS/Krea-2-SVDQuant-ComfyUI with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Diffusers
How to use AlperKTS/Krea-2-SVDQuant-ComfyUI with Diffusers:
pip install -U diffusers transformers accelerate
import torch from diffusers import DiffusionPipeline # switch to "mps" for apple devices pipe = DiffusionPipeline.from_pretrained("AlperKTS/Krea-2-SVDQuant-ComfyUI", torch_dtype=torch.bfloat16, device_map="cuda") prompt = "Astronaut in a jungle, cold color palette, muted colors, detailed, 8k" image = pipe(prompt).images[0] - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- Draw Things
- DiffusionBee
| """LoRA support for Krea2 W4A4 quantized models (from ``quantize_krea2.py``). | |
| A normal `LoraLoaderModelOnly` cannot patch these quantized layers correctly: ComfyUI | |
| applies a LoRA by adding `down @ up` onto a module's `.weight`, but here `.weight` is a | |
| `QuantizedTensor` -- patching it that way means dequantize -> add -> requantize, which | |
| throws the 4-bit format away and re-quantizes the LoRA delta along with it. | |
| Krea2 LoRAs (ai-toolkit) target both kinds of layer: | |
| * `diffusion_model.blocks.N.{attn,mlp}.*` -> quantized, needs a parallel branch | |
| * `diffusion_model.txtfusion.*` -> ordinary Linear, ComfyUI patches it fine | |
| So this node splits the LoRA: quantized layers get the LoRA attached as an extra | |
| parallel branch (mathematically identical for a linear layer: `(W + BA)x == Wx + B(Ax)`, | |
| so the quantized weight itself is never touched), and everything else is handed to | |
| ComfyUI's normal patching path. | |
| Parsing is ComfyUI's job, not ours: `comfy.lora.load_lora` returns a weight adapter per | |
| layer and already knows every format and naming convention it supports. Plain up/down LoRAs | |
| are folded into the branch above, one pair of GEMMs for the whole stack. Everything else -- | |
| LoKr, LoHa, OFT -- runs through ComfyUI's bypass contract `g(f(x) + h(x))`, which is written | |
| for precisely this case: its docstring says "designed for quantized models where weights may | |
| not be accessible". Either way the 4-bit weight is read, never rewritten. | |
| The branch is installed through ``ModelPatcher.add_object_patch`` rather than by mutating | |
| the module. That is what makes the node behave like every other ComfyUI node: `clone()` | |
| shares the underlying `nn.Module`, so writing the LoRA onto the module directly would | |
| leak it into every other branch of the graph that came off the same loader, and would | |
| survive past the sampling run. Object patches are applied in `patch_model` and reverted | |
| afterwards, per patcher. | |
| Stacking works: chaining multiple of these nodes re-applies the whole LoRA stack from | |
| scratch each time, so strengths can change without leftover state from a previous value. | |
| """ | |
| from __future__ import annotations | |
| import copy | |
| import logging | |
| import os | |
| from collections import OrderedDict | |
| import torch | |
| import comfy.lora | |
| import comfy.model_management | |
| import comfy.utils | |
| import comfy.weight_adapter | |
| import folder_paths | |
| from .svdquant_diag import _CATEGORY, quantized_linears | |
| from .svdquant_w4a4 import add_low_rank, has_branch | |
| _PREFIX = "diffusion_model." | |
| _ALT_PREFIX = "transformer." | |
| # What to do with an adapter that cannot fold into the low-rank branch (LoKr, LoHa, OFT). | |
| # `bypass` computes it every forward and never touches the 4-bit weight; `bake` hands it to | |
| # ComfyUI, which rewrites the weight once and pays nothing per step. Measured on a 3090 at | |
| # 1440x1920 with a LoKr on all 224 blocks: bypass costs +1.8 s per model call, bake costs | |
| # nothing and quantizes the delta to 4 bits along with the weight. | |
| ADAPTER_BYPASS = "bypass (exact, slower)" | |
| ADAPTER_BAKE = "bake into the weight (fast, requantizes the delta)" | |
| # Reloading a 300 MB LoRA off disk on every graph execution is pure latency when the | |
| # usual edit is a strength slider. Keyed on mtime+size so editing the file invalidates. | |
| _LORA_CACHE: OrderedDict[str, tuple[tuple, dict]] = OrderedDict() | |
| _LORA_CACHE_MAX = 4 | |
| def _load_lora_cached(path: str) -> dict: | |
| stat = os.stat(path) | |
| stamp = (stat.st_mtime_ns, stat.st_size) | |
| hit = _LORA_CACHE.get(path) | |
| if hit is not None and hit[0] == stamp: | |
| # Without this the eviction below is insertion-ordered (FIFO), so cycling five LoRAs | |
| # can drop the one being used every run. | |
| _LORA_CACHE.move_to_end(path) | |
| return hit[1] | |
| sd = comfy.utils.load_torch_file(path, safe_load=True) | |
| if len(_LORA_CACHE) >= _LORA_CACHE_MAX: | |
| _LORA_CACHE.pop(next(iter(_LORA_CACHE))) | |
| _LORA_CACHE[path] = (stamp, sd) | |
| return sd | |
| def _key_map(model): | |
| """ComfyUI's LoRA-key to model-key map, plus the one naming it does not cover. | |
| `model_lora_keys_unet` already handles every format and every naming ComfyUI knows -- | |
| diffusers, lycoris, kohya -- so parsing is its job, not ours. The one gap is a diffusers | |
| *prefix* in front of *native* module names (`transformer.blocks.N.attn.wq`), which some | |
| PEFT-based extractions write: ComfyUI's `transformer.` entries are keyed on diffusers | |
| module names (`transformer.transformer_blocks.N.attn.to_q`), so the hybrid matches | |
| nothing and the LoRA silently does nothing. Aliasing the map rather than special-casing | |
| the parser means the fix applies to every format, not just plain LoRA. | |
| """ | |
| key_map = comfy.lora.model_lora_keys_unet(model, {}) | |
| for lora_key, model_key in list(key_map.items()): | |
| if lora_key.startswith(_PREFIX): | |
| key_map.setdefault(_ALT_PREFIX + lora_key[len(_PREFIX):], model_key) | |
| return key_map | |
| def _layer_of(model_key: str, quant_layers: set[str]) -> str | None: | |
| """`diffusion_model.blocks.0.attn.wq.weight` -> `blocks.0.attn.wq`, if it is quantized.""" | |
| if not model_key.startswith(_PREFIX) or not model_key.endswith(".weight"): | |
| return None | |
| layer = model_key[len(_PREFIX):-len(".weight")] | |
| return layer if layer in quant_layers else None | |
| def _plain_lora_factors(adapter, strength: float, device, dtype): | |
| """(l1, l2) for a plain up/down LoRA, or None if this adapter is anything else. | |
| Worth the special case: folded this way an N-LoRA stack costs one pair of GEMMs per step | |
| (see `_stack_factors`), where the generic adapter path costs a pair *per adapter*. Every | |
| ordinary Krea2 LoRA takes this route; the generic path is for the rest. | |
| """ | |
| if not isinstance(adapter, comfy.weight_adapter.LoRAAdapter): | |
| return None | |
| up, down, alpha, mid, dora_scale = adapter.weights[:5] | |
| reshape = adapter.weights[5] if len(adapter.weights) > 5 else None | |
| # mid is a Tucker/conv core, dora_scale rescales the *base* weight's norm, reshape means | |
| # the LoRA was trained against a differently-shaped weight. None of the three survive | |
| # being folded into a parallel branch, so they go to the generic path instead. | |
| if mid is not None or dora_scale is not None or reshape is not None or up.ndim != 2: | |
| return None | |
| # `if alpha` would treat a legitimate alpha of 0.0 as "no alpha" and silently run the | |
| # layer at full strength instead of disabling it. | |
| rank = int(down.shape[0]) | |
| mult = strength * (float(alpha) / rank if alpha is not None else 1.0) | |
| l1 = up.to(device=device, dtype=dtype) | |
| l2 = down.to(device=device, dtype=dtype) * mult | |
| return l1.contiguous(), l2.contiguous() | |
| def _supports_bypass(adapter) -> bool: | |
| """True when the adapter can run without touching the base weight. | |
| ComfyUI's bypass contract is `bypass(f)(x) = g(f(x) + h(x))`, written for exactly our | |
| situation -- its own docstring says "designed for quantized models where weights may not | |
| be accessible". An adapter that overrides neither is one ComfyUI can only apply by | |
| rewriting the weight, which for a QuantizedTensor means dequantize -> add -> requantize. | |
| """ | |
| base = comfy.weight_adapter.WeightAdapterBase | |
| return (type(adapter).h is not base.h) or (type(adapter).g is not base.g) | |
| def _bypass_runner(adapter, strength: float): | |
| """`y -> g(y + h(x, y))` for one adapter, staged the way the low-rank branch is staged. | |
| ComfyUI's own bypass hook parks adapter weights on the GPU at inject time "to avoid | |
| per-forward transfers", and a LoKr `w2` (1536x1536) is far bigger than a rank-64 LoRA | |
| factor, so caching the staged copy looked like the obvious win. Measured on a 3090 at | |
| 1024px it is worth nothing: 2.01 s/step cached against 2.02 s/step restaging every call. | |
| The cost is the Kronecker math itself -- ~38.7 GMAC per layer, ~0.5 s across 224 layers, | |
| which is the +0.66 s/step this path adds over a plain LoRA. So this keeps `cast_to` per | |
| call like `add_low_rank` does, and ComfyUI's VRAM accounting keeps seeing the truth | |
| instead of ~1 GB of adapter weights it does not know about. | |
| The shallow copy is because `h()` reads `self.weights`/`self.multiplier` and the adapter | |
| object is shared with every other patcher holding the same cached LoRA. | |
| """ | |
| def run(y, x): | |
| staged = copy.copy(adapter) | |
| staged.weights = [comfy.model_management.cast_to(w, x.dtype, x.device) | |
| if torch.is_tensor(w) else w for w in adapter.weights] | |
| staged.multiplier = strength | |
| return staged.g(y + staged.h(x, y)) | |
| return run | |
| def _make_lora_forward(module, factors, adapters): | |
| """A forward that is "quantized weight + svdq branch (if any) + this LoRA stack". | |
| Built on `module._krea2_forward` rather than `module.forward` so that it composes with | |
| the svdq branch but never with a previously installed LoRA patch -- each patcher owns | |
| the whole LoRA stack and rebuilds it from scratch. | |
| A no-low-rank checkpoint has quantized layers with no branch and therefore no | |
| `_krea2_forward`; there the module's own forward already *is* "quantized weight", so it | |
| is the right base. Object patches are applied in `patch_model`, after this runs, so what | |
| we capture is the unpatched forward either way. | |
| `factors` is the folded plain-LoRA branch (one pair of GEMMs for the whole stack); | |
| `adapters` are everything else -- LoKr, LoHa, OFT -- run through ComfyUI's bypass | |
| contract `g(f(x) + h(x))`, which never materialises a weight delta. | |
| """ | |
| base = getattr(module, "_krea2_forward", None) or module.forward | |
| l1, l2 = factors if factors else (None, None) | |
| runners = [_bypass_runner(adapter, strength) for adapter, strength in adapters] | |
| def forward(x, *args, **kwargs): | |
| y = base(x, *args, **kwargs) | |
| if l1 is not None: | |
| y = add_low_rank(y, x, l1, l2) | |
| for run in runners: | |
| y = run(y, x) | |
| return y | |
| return forward | |
| def _stack_factors(factors: list[tuple[torch.Tensor, torch.Tensor]]): | |
| """Collapse a LoRA stack for one layer into a single (l1, l2) pair. | |
| With the strength already folded into each l2, ``sum_i l1_i @ l2_i`` is exactly | |
| ``cat(l1, dim=1) @ cat(l2, dim=0)``, so an N-LoRA stack costs one pair of GEMMs per | |
| step instead of N. | |
| """ | |
| if len(factors) == 1: | |
| return factors[0] | |
| l1 = torch.cat([f[0] for f in factors], dim=1) | |
| l2 = torch.cat([f[1] for f in factors], dim=0) | |
| return l1.contiguous(), l2.contiguous() | |
| def collect_svdquant_lora(patcher, lora_sd, strength: float, quant_layers: set[str], | |
| device, dtype, into_branch: dict, into_adapters: dict, | |
| patch_leftover: bool = True, adapter_mode: str = ADAPTER_BYPASS): | |
| """Route one LoRA: quantized layers into `into_branch`/`into_adapters`, the rest normally. | |
| `patch_leftover=False` collects the quantized side only and leaves the non-quantized | |
| layers alone -- see `load_lora` for why the two sides of the stack are handled | |
| differently. | |
| Returns (quantized layers claimed, layers patched normally, how many of those normal | |
| patches landed on a *quantized* weight -- which is the case worth complaining about). | |
| """ | |
| loaded = comfy.lora.load_lora(lora_sd, _key_map(patcher.model), log_missing=False) | |
| applied = 0 | |
| leftover = {} | |
| dequantizing = 0 | |
| for model_key, patch in loaded.items(): | |
| layer = _layer_of(model_key, quant_layers) | |
| if layer is None: | |
| leftover[model_key] = patch | |
| continue | |
| factors = _plain_lora_factors(patch, strength, device, dtype) | |
| if factors is not None: | |
| # A plain LoRA folds into the branch for free in either mode -- one pair of | |
| # GEMMs for the whole stack -- so `bake` has nothing to win here. | |
| into_branch.setdefault(layer, []).append(factors) | |
| elif (adapter_mode == ADAPTER_BYPASS | |
| and isinstance(patch, comfy.weight_adapter.WeightAdapterBase) | |
| and _supports_bypass(patch)): | |
| into_adapters.setdefault(layer, []).append((patch, strength)) | |
| else: | |
| # `bake` mode, a `diff`/`set`/`bias` patch, or an adapter with no bypass form. | |
| # ComfyUI can still apply it, but only by rewriting the weight, so the LoRA | |
| # delta goes through 4-bit quantization along with it. | |
| leftover[model_key] = patch | |
| dequantizing += 1 | |
| continue | |
| applied += 1 | |
| if not patch_leftover: | |
| return applied, 0, 0 | |
| # Everything the quantized layers did not claim (txtfusion, etc.) goes through | |
| # ComfyUI's normal LoRA path. | |
| patched_normally = 0 | |
| if leftover: | |
| patcher.add_patches(leftover, strength) | |
| patched_normally = len(leftover) | |
| return applied, patched_normally, dequantizing | |
| class Krea2SVDQuantLoraLoader: | |
| def INPUT_TYPES(cls): | |
| return { | |
| "required": { | |
| "model": ("MODEL", { | |
| "tooltip": "Output of the Krea2 SVDQuant W4A4 Loader, or any Krea2 " | |
| "checkpoint with convrot_w4a4 quantized blocks.", | |
| }), | |
| "lora_name": (folder_paths.get_filename_list("loras"), { | |
| "tooltip": "A Krea2 LoRA. Targets 'blocks.N.{attn,mlp}.*' under either " | |
| "prefix ('diffusion_model.' or 'transformer.') for the " | |
| "quantized blocks; anything else it carries (txtfusion " | |
| "etc.) goes through ComfyUI's normal path.", | |
| }), | |
| "strength": ("FLOAT", { | |
| "default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01, | |
| "tooltip": "0 passes the model through untouched. Negative values invert " | |
| "the LoRA. Chain more of these nodes to stack LoRAs - the " | |
| "whole stack is rebuilt each time, so strengths stay exact.", | |
| }), | |
| }, | |
| "optional": { | |
| "adapters": ([ADAPTER_BYPASS, ADAPTER_BAKE], { | |
| "default": ADAPTER_BYPASS, | |
| "tooltip": "Only affects LoRAs that cannot fold into the low-rank branch " | |
| "(LoKr, LoHa, OFT); a plain LoRA is free either way. 'bypass' " | |
| "computes the adapter every forward and never touches the " | |
| "4-bit weight. 'bake' hands it to ComfyUI, which rewrites the " | |
| "weight once: no per-step cost, but the delta is quantized to " | |
| "4 bits with it. Measured with a LoKr at 1440x1920: bypass " | |
| "+1.8 s per model call, bake +0.", | |
| }), | |
| }, | |
| } | |
| RETURN_TYPES = ("MODEL",) | |
| RETURN_NAMES = ("model",) | |
| OUTPUT_TOOLTIPS = ("The model with the LoRA attached as a parallel branch.",) | |
| FUNCTION = "load_lora" | |
| CATEGORY = _CATEGORY | |
| TITLE = "Krea2 SVDQuant LoRA Loader" | |
| DESCRIPTION = ("Applies a LoRA to a Krea2 W4A4 quantized model as a parallel low-rank " | |
| "branch, leaving the 4-bit weight untouched. The stock loader has to " | |
| "dequantize, add and requantize, which puts the LoRA delta through 4-bit " | |
| "quantization along with the weight. LoKr/LoHa/OFT cannot fold into the " | |
| "branch and run per-forward instead; the 'adapters' input trades that " | |
| "exactness back for the stock loader's speed.") | |
| def load_lora(self, model, lora_name, strength, adapters=ADAPTER_BYPASS): | |
| if strength == 0: | |
| return (model,) | |
| patcher = model.clone() | |
| # Re-apply the whole stack from scratch rather than appending to whatever the | |
| # upstream node left behind. That keeps this node idempotent when a strength | |
| # changes, and still stacks correctly when nodes are chained. The mode rides along | |
| # per entry, so chaining a `bypass` LoRA and a `bake` one keeps each one's choice. | |
| stack = list(getattr(model, "krea2_lora_stack", [])) + [(lora_name, strength, adapters)] | |
| patcher.krea2_lora_stack = stack | |
| diffusion_model = patcher.model.diffusion_model | |
| # Every convrot_w4a4 layer needs the parallel-branch treatment, whether or not it | |
| # already carries an svdq branch. Keying this off `has_branch` instead would come up | |
| # empty on a no-low-rank checkpoint, silently route the whole LoRA to ComfyUI's | |
| # normal path, and there it cannot patch a QuantizedTensor weight at all -- the exact | |
| # failure this node exists to prevent, with no error to show for it. | |
| quant_modules = dict(quantized_linears(diffusion_model)) | |
| quant_layers = set(quant_modules) | |
| unbranched = sum(1 for m in quant_modules.values() if not has_branch(m)) | |
| # Attach on the offload device and let the forward stage them: matching whatever | |
| # device the module happens to be on right now would bake in a placement that | |
| # ComfyUI is free to change before the first step. | |
| device = patcher.offload_device | |
| dtype = patcher.model.get_dtype() | |
| # The two sides of the stack have to be replayed differently, because the two | |
| # mechanisms behind them compose differently: | |
| # | |
| # * `add_object_patch` is keyed, so writing the whole stack's folded factors for a | |
| # layer *replaces* whatever an upstream node put there. Replaying every entry is | |
| # what makes a strength change exact rather than cumulative. | |
| # * `add_patches` *appends*, and `clone()` copies the parent's `patches`. So | |
| # replaying an earlier entry's non-quantized layers adds a second copy on top of | |
| # the one just inherited: chaining two nodes used to apply the first LoRA's | |
| # txtfusion layers twice (measured: 3 patch entries per key instead of 2) while | |
| # its quantized layers stayed correct -- the two halves of one LoRA silently | |
| # running at different strengths. | |
| # | |
| # So only the newest entry patches normally; earlier ones are already inherited. | |
| branch: dict[str, list] = {} | |
| bypassed: dict[str, list] = {} | |
| quantized = normal = dequantizing = 0 | |
| newest = len(stack) - 1 | |
| for i, (name, amount, mode) in enumerate(stack): | |
| path = folder_paths.get_full_path_or_raise("loras", name) | |
| q, n, d = collect_svdquant_lora( | |
| patcher, _load_lora_cached(path), amount, quant_layers, device, dtype, | |
| branch, bypassed, patch_leftover=(i == newest), adapter_mode=mode) | |
| if i == newest: | |
| quantized, normal, dequantizing = q, n, d | |
| # This guard is about the LoRA this node just added, not the stack total: an upstream | |
| # LoRA matching plenty of layers must not excuse this one matching none. | |
| if quantized == 0 and normal == 0: | |
| raise ValueError( | |
| "no layer of {} matched this model ({} quantized layers were available); " | |
| "is it a Krea2 LoRA?".format(lora_name, len(quant_layers)) | |
| ) | |
| # Matching only non-quantized layers is *fine* -- a txtfusion-only adapter is a real | |
| # thing (`diffusion_model.txtfusion.projector.diff`), and refusing it would be a bug. | |
| # What is worth saying out loud is a patch that does target a quantized weight in a | |
| # form with no bypass path, because ComfyUI then rewrites the weight and the delta is | |
| # requantized to 4 bits along with it -- exactly what this node exists to avoid. | |
| if dequantizing and adapters == ADAPTER_BYPASS: | |
| logging.warning( | |
| "[krea2-svdquant] %s: %d quantized layer(s) carry a patch with no bypass " | |
| "form (a diff/set patch, or an adapter ComfyUI cannot apply without the " | |
| "weight). Those go through dequantize -> add -> requantize, so their delta " | |
| "is quantized to 4 bits too.", lora_name, dequantizing) | |
| elif dequantizing: | |
| logging.info( | |
| "[krea2-svdquant] %s: %d quantized layer(s) baked into the weight on " | |
| "request -- no per-step cost, delta quantized to 4 bits with the weight.", | |
| lora_name, dequantizing) | |
| for layer in set(branch) | set(bypassed): | |
| module = diffusion_model.get_submodule(layer) | |
| factors = branch.get(layer) | |
| patcher.add_object_patch( | |
| "diffusion_model.{}.forward".format(layer), | |
| _make_lora_forward(module, _stack_factors(factors) if factors else None, | |
| bypassed.get(layer, ())), | |
| ) | |
| # The dial is worthless if nobody finds it, and the shape that needs it is exactly | |
| # the one we can detect: an adapter running per forward on every block. | |
| if bypassed and adapters == ADAPTER_BYPASS: | |
| logging.info( | |
| "[krea2-svdquant] %d layer(s) run a per-forward adapter (LoKr/LoHa/OFT). " | |
| "That is exact but not free -- measured +1.8 s per model call at 1440x1920. " | |
| "Set this node's 'adapters' input to %r for the stock loader's speed, or see " | |
| "tools/bake_adapter.py to bake it in with no runtime cost at all.", | |
| len(bypassed), ADAPTER_BAKE) | |
| logging.info("[krea2-svdquant] LoRA stack %s -> %d quantized layers branched " | |
| "(%d via bypass adapters); %s added %d quantized, %d normal layers%s", | |
| [f"{n}@{a:.2f}" for n, a, _ in stack], len(set(branch) | set(bypassed)), | |
| len(bypassed), lora_name, quantized, normal, | |
| " (no-low-rank checkpoint: {} quantized layers carry no svdq branch)" | |
| .format(unbranched) if unbranched else "") | |
| return (patcher,) | |
| NODE_CLASS_MAPPINGS = {"Krea2SVDQuantLoraLoader": Krea2SVDQuantLoraLoader} | |
| NODE_DISPLAY_NAME_MAPPINGS = {"Krea2SVDQuantLoraLoader": "Krea2 SVDQuant LoRA Loader"} | |