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
| """Capture per-input-channel activation statistics, for an activation-aware low-rank split. | |
| `quantize_krea2.py`'s refinement loop minimises **weight** reconstruction error, | |
| ``||W - (Q + L1 L2)||_F``. That is the true output error only if the input covariance is the | |
| identity -- the assumption its own docstring admits to. DeepCompressor instead minimises the | |
| error *through real activations*, which is most of why a calibrated W4A4 build lands closer to | |
| BF16 than a calibration-free one, and it is the last untried quality lever that costs nothing | |
| at runtime: same rank, same format, same file size, same kernel. | |
| Weighting the split by per-input-channel activation RMS approximates that objective. A channel | |
| the model drives hard deserves more of the branch's capacity than one that is usually near | |
| zero, and plain Frobenius spends them equally. | |
| Why this is a pair of ComfyUI nodes rather than a standalone script: the statistics have to | |
| come from real sampling, and real sampling needs the Qwen3-VL conditioning, the scheduler and | |
| the whole ComfyUI graph. Reimplementing that outside ComfyUI to run a calibration pass would | |
| be a second, subtly different sampler. Instead, put `Start` before the KSampler and `Save` | |
| after it, run whatever prompts you like, and the hooks ride along on the real thing. | |
| Accumulators persist across queued prompts on purpose -- calibration wants several prompts, | |
| and each ComfyUI run is a separate execution. `Start` with `reset=True` on the first one. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import os | |
| import torch | |
| import folder_paths | |
| from .quantize_krea2 import _QUANT_SUFFIXES | |
| from .svdquant_diag import _CATEGORY | |
| # layer name -> {"sumsq": float64 [in_features] on cpu, "count": int} | |
| _STATS: dict[str, dict] = {} | |
| _HANDLES: list = [] | |
| # Checked first inside every hook, so a hook that outlives its handle is inert rather than | |
| # quietly accumulating. A leaked hook is the worst failure this module has: it would keep | |
| # adding activations from later, unrelated generations into _STATS, and the next calibration | |
| # file would be contaminated with no sign of it. | |
| _ACTIVE = False | |
| FILE_VERSION = 1 | |
| def _hook(name: str): | |
| def pre_hook(module, args): | |
| if not _ACTIVE or not args: | |
| return | |
| x = args[0] | |
| if not isinstance(x, torch.Tensor) or x.ndim < 2: | |
| return | |
| # [..., in_features] -> per-channel sum of squares over every token seen. float64 on | |
| # the accumulator because this sums millions of terms across a whole calibration run | |
| # and float32 stops being able to represent the increments long before that. | |
| flat = x.detach().reshape(-1, x.shape[-1]) | |
| sq = flat.float().pow(2).sum(dim=0).double().cpu() | |
| entry = _STATS.get(name) | |
| if entry is None: | |
| _STATS[name] = {"sumsq": sq, "count": flat.shape[0]} | |
| else: | |
| entry["sumsq"] += sq | |
| entry["count"] += flat.shape[0] | |
| return pre_hook | |
| def is_target(name: str) -> bool: | |
| """The same layer set `quantize_krea2.is_target` picks, matched on a live module tree. | |
| It cannot be reused directly: that one takes checkpoint keys (`blocks.0.attn.wq.weight`, | |
| optionally prefixed) while `named_modules()` yields module paths. The rule is identical | |
| though -- inside `blocks.`, ending in one of the eight projection leaves -- and the | |
| suffix tuple is imported rather than retyped so the two cannot drift apart. | |
| Deliberately *not* keyed on the weight being quantized: the statistics are supposed to | |
| come from the BF16 model, where no weight is a QuantizedTensor yet. | |
| """ | |
| return name.startswith("blocks.") and name.endswith(_QUANT_SUFFIXES) | |
| def attach(diffusion_model) -> int: | |
| global _ACTIVE | |
| detach() | |
| count = 0 | |
| for name, module in diffusion_model.named_modules(): | |
| if is_target(name) and getattr(module, "weight", None) is not None: | |
| _HANDLES.append(module.register_forward_pre_hook(_hook(name))) | |
| count += 1 | |
| _ACTIVE = True | |
| return count | |
| def detach() -> None: | |
| """Stop capturing, then remove the hooks. | |
| `_ACTIVE` goes down first so capture stops even for a hook whose handle refuses to | |
| detach. A failed handle is kept in `_HANDLES` so the next `detach()` retries it, and the | |
| failure is logged at warning -- at DEBUG (ComfyUI's default is INFO) nobody would ever | |
| see the one condition that can silently corrupt a calibration file. | |
| """ | |
| global _ACTIVE | |
| _ACTIVE = False | |
| kept = [] | |
| for handle in _HANDLES: | |
| try: | |
| handle.remove() | |
| except Exception: | |
| logging.warning("[krea2-svdquant] could not remove a capture hook; capture is " | |
| "off but the hook is still installed", exc_info=True) | |
| kept.append(handle) | |
| _HANDLES[:] = kept | |
| def write(path: str) -> dict: | |
| """RMS per input channel, as a safetensors file keyed by layer name.""" | |
| from safetensors.torch import save_file | |
| if not _STATS: | |
| raise RuntimeError( | |
| "no activation statistics were collected. The Start node must run before the " | |
| "KSampler and the Save node after it -- if Save is not downstream of the " | |
| "sampler's output, ComfyUI is free to execute it first.") | |
| tensors = {} | |
| for name, entry in _STATS.items(): | |
| rms = (entry["sumsq"] / max(1, entry["count"])).sqrt().float() | |
| tensors[name] = rms.contiguous() | |
| counts = [e["count"] for e in _STATS.values()] | |
| # Every target layer is called exactly once per forward and sees the same token count, so | |
| # a clean capture ends with all 224 counts identical -- our own reference run was | |
| # 264200/264200. A spread means some layers accumulated activations the others did not, | |
| # and there is one way that happens: `detach()` only runs from the Save node, so a graph | |
| # that errors between Start and Save leaves the hooks live, and whatever is generated | |
| # before the next Start lands in here. `attach()` detaches first, which bounds the window | |
| # but does not clear what was already added. Loud, not fatal: the statistics are still | |
| # usable, just no longer only about what you meant to calibrate on. | |
| if min(counts) != max(counts): | |
| logging.warning( | |
| "[krea2-svdquant] uneven token counts across layers (%d to %d). Capture stayed " | |
| "live through something other than the calibration run -- most likely a graph " | |
| "that errored between the Start and Save nodes. Re-run the capture with " | |
| "reset=True on the first prompt if these statistics are meant to describe one " | |
| "model.", min(counts), max(counts)) | |
| metadata = { | |
| "krea2_actstats_version": str(FILE_VERSION), | |
| "krea2_actstats_layers": str(len(tensors)), | |
| "krea2_actstats_min_tokens": str(min(counts)), | |
| "krea2_actstats_max_tokens": str(max(counts)), | |
| } | |
| os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) | |
| save_file(tensors, path, metadata=metadata) | |
| return {"layers": len(tensors), "min_tokens": min(counts), "max_tokens": max(counts), | |
| "path": path} | |
| def summary() -> str: | |
| if not _STATS: | |
| return "no activation statistics collected yet" | |
| counts = [e["count"] for e in _STATS.values()] | |
| return "{} layers, {}-{} tokens each".format(len(_STATS), min(counts), max(counts)) | |
| class Krea2SVDQuantCaptureStart: | |
| def INPUT_TYPES(cls): | |
| return { | |
| "required": { | |
| "model": ("MODEL", {"tooltip": "The BF16 model, ideally. Statistics taken " | |
| "from a quantized model describe the " | |
| "quantized model's activations, which is the " | |
| "thing being corrected."}), | |
| "reset": ("BOOLEAN", {"default": True, | |
| "tooltip": "Clear anything gathered so far. Leave it " | |
| "off to accumulate across several prompts, " | |
| "which is the point of calibrating."}), | |
| } | |
| } | |
| RETURN_TYPES = ("MODEL", "STRING") | |
| RETURN_NAMES = ("model", "status") | |
| OUTPUT_TOOLTIPS = ("Wire this to the KSampler.", | |
| "How many layers are being watched.") | |
| OUTPUT_NODE = True | |
| FUNCTION = "start" | |
| CATEGORY = _CATEGORY | |
| TITLE = "Krea2 SVDQuant Capture Start" | |
| DESCRIPTION = ("Watches every quantized linear and records the RMS of its inputs during " | |
| "sampling. Feeds --act-stats in quantize_krea2.py.") | |
| def IS_CHANGED(cls, *args, **kwargs): | |
| # Attaching hooks is a side effect, so this must never be served from cache. | |
| return float("nan") | |
| def start(self, model, reset): | |
| if reset: | |
| _STATS.clear() | |
| attached = attach(model.model.diffusion_model) | |
| if attached == 0: | |
| raise RuntimeError( | |
| "found no layers to watch: nothing under 'blocks.' ends in one of {}. " | |
| "Capture targets exactly the layers quantize_krea2.py quantizes, so a model " | |
| "matching none of them is not one these statistics would apply to." | |
| .format(", ".join(_QUANT_SUFFIXES))) | |
| status = "capturing {} layers ({})".format(attached, summary()) | |
| logging.info("[krea2-svdquant] %s", status) | |
| return {"ui": {"text": [status]}, "result": (model, status)} | |
| class Krea2SVDQuantCaptureSave: | |
| def INPUT_TYPES(cls): | |
| return { | |
| "required": { | |
| "latent": ("LATENT", {"tooltip": "Wire the KSampler's output here. This is " | |
| "what forces the node to run *after* " | |
| "sampling rather than before it."}), | |
| "filename": ("STRING", {"default": "krea2_act_stats.safetensors", | |
| "tooltip": "Written under ComfyUI/output/."}), | |
| "keep_capturing": ("BOOLEAN", {"default": False, | |
| "tooltip": "Leave the hooks attached so the " | |
| "next queued prompt keeps adding " | |
| "to the same statistics."}), | |
| } | |
| } | |
| RETURN_TYPES = ("LATENT", "STRING") | |
| RETURN_NAMES = ("latent", "status") | |
| OUTPUT_TOOLTIPS = ("Passed through unchanged.", "Where the file went and what is in it.") | |
| OUTPUT_NODE = True | |
| FUNCTION = "save" | |
| CATEGORY = _CATEGORY | |
| TITLE = "Krea2 SVDQuant Capture Save" | |
| DESCRIPTION = ("Writes the captured activation RMS to ComfyUI/output/. Must be " | |
| "downstream of the KSampler.") | |
| def IS_CHANGED(cls, *args, **kwargs): | |
| return float("nan") | |
| def save(self, latent, filename, keep_capturing): | |
| path = os.path.join(folder_paths.get_output_directory(), filename) | |
| info = write(path) | |
| if not keep_capturing: | |
| detach() | |
| status = ("wrote {layers} layers to {path}\n" | |
| "tokens per layer: {min_tokens}-{max_tokens}{more}").format( | |
| more="" if keep_capturing else "\nhooks detached", **info) | |
| logging.info("[krea2-svdquant] %s", status.replace("\n", " ")) | |
| return {"ui": {"text": [status]}, "result": (latent, status)} | |
| NODE_CLASS_MAPPINGS = { | |
| "Krea2SVDQuantCaptureStart": Krea2SVDQuantCaptureStart, | |
| "Krea2SVDQuantCaptureSave": Krea2SVDQuantCaptureSave, | |
| } | |
| NODE_DISPLAY_NAME_MAPPINGS = { | |
| "Krea2SVDQuantCaptureStart": "Krea2 SVDQuant Capture Start", | |
| "Krea2SVDQuantCaptureSave": "Krea2 SVDQuant Capture Save", | |
| } | |