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
File size: 11,876 Bytes
6f63bf4 d50078f 6f63bf4 d50078f 6f63bf4 d50078f 6f63bf4 d50078f 6f63bf4 d50078f 6f63bf4 d50078f 6f63bf4 d50078f 6f63bf4 b0956bc 6f63bf4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 | """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:
@classmethod
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.")
@classmethod
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:
@classmethod
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.")
@classmethod
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",
}
|