File size: 22,260 Bytes
91737e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6ab5b1d
 
 
 
 
 
 
91737e9
 
 
 
 
 
 
 
 
 
 
 
 
6ab5b1d
91737e9
 
 
 
 
 
 
6ab5b1d
91737e9
6ab5b1d
91737e9
 
 
 
 
6ab5b1d
 
91737e9
9e39d3d
 
 
 
 
 
 
 
91737e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6ab5b1d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91737e9
6ab5b1d
 
 
 
 
91737e9
6ab5b1d
 
 
 
 
 
 
 
 
 
 
 
 
 
91737e9
 
6ab5b1d
 
91737e9
6ab5b1d
 
 
 
 
 
 
91737e9
6ab5b1d
6bd6dcf
 
 
 
 
 
 
 
 
 
 
 
 
 
6ab5b1d
6bd6dcf
 
 
 
 
 
 
 
6ab5b1d
 
 
 
91737e9
 
 
 
 
 
 
 
 
6ab5b1d
 
 
 
91737e9
 
6ab5b1d
6bd6dcf
91737e9
 
6ab5b1d
 
 
6bd6dcf
 
6ab5b1d
91737e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6ab5b1d
9e39d3d
6ab5b1d
91737e9
 
 
 
 
6ab5b1d
 
91737e9
6ab5b1d
91737e9
 
6ab5b1d
 
 
 
 
 
 
 
 
9e39d3d
 
6ab5b1d
9e39d3d
 
 
6ab5b1d
 
9e39d3d
 
 
6ab5b1d
 
91737e9
 
 
 
6ab5b1d
91737e9
 
 
 
 
6ab5b1d
 
91737e9
6ab5b1d
91737e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9e39d3d
 
 
 
 
 
 
 
 
 
 
 
 
91737e9
 
 
 
 
 
 
 
 
 
 
9e39d3d
 
 
91737e9
9e39d3d
91737e9
 
 
 
 
 
9e39d3d
 
 
91737e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6ab5b1d
9e39d3d
6ab5b1d
91737e9
9e39d3d
91737e9
6ab5b1d
91737e9
9e39d3d
91737e9
6ab5b1d
91737e9
6ab5b1d
 
91737e9
 
 
 
 
6ab5b1d
 
 
 
 
9e39d3d
6ab5b1d
 
 
 
 
9e39d3d
 
 
 
 
6ab5b1d
9e39d3d
91737e9
6ab5b1d
91737e9
 
6ab5b1d
9e39d3d
91737e9
 
a22837b
 
 
 
 
 
 
 
 
 
6ab5b1d
 
9e39d3d
 
91737e9
 
 
 
 
 
 
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
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
"""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:
    @classmethod
    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"}