Krea-2-SVDQuant-ComfyUI / svdquant_capture.py
AlperKTS's picture
Sync the node pack: compile-friendly kernel op, faster conversion, honest int8 numbers
b0956bc verified
Raw
History Blame Contribute Delete
11.9 kB
"""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",
}