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",
}