Krea-2-SVDQuant-ComfyUI / tools /bake_adapter.py
AlperKTS's picture
fix: bake_adapter offset, plus a preflight and a discoverability hint
a22837b verified
Raw
History Blame Contribute Delete
9.79 kB
"""Bake a LoRA into a Krea2 checkpoint *before* it is quantized.
python tools/bake_adapter.py raw.safetensors --lora Krea2/realism_engine.safetensors \
--format svdq --rank 256 --variant base --act-stats krea2_act_stats_base.safetensors
Why this exists. A LoKr, LoHa or OFT cannot fold into the low-rank branch, so the node has
two choices at runtime and neither is free: compute the adapter every forward (exact, and on
a 3090 at 1440x1920 that is +1.8 s per model call), or hand it to ComfyUI, which rewrites the
4-bit weight and requantizes the LoRA delta along with it (free, and lossy).
Baking before quantization avoids the choice. The delta is added to the **bf16** weight, and
then the SVDQuant split runs on the merged weight -- so the low-rank branch is fitted against
what you will actually sample with, and absorbs the error that the naive
dequantize-add-requantize path throws away. At runtime the LoRA is simply gone: no adapter,
no branch of its own, no per-step cost at all.
The delta is computed by ComfyUI's own weight adapters (`adapter.calculate_weight`), so every
format and naming convention ComfyUI supports works here, alpha and dora_scale included.
The merged weights are never written out whole -- the source is 24 GB -- they are streamed
into `convert()` through its `weight_patch` hook.
"""
from __future__ import annotations
import argparse
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(HERE)) # the node package
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(HERE)))) # ComfyUI root
import torch # noqa: E402
import comfy.lora # noqa: E402
import comfy.utils # noqa: E402
import comfy.weight_adapter # noqa: E402
from quantize_krea2 import ( # noqa: E402
LAYER_PREFIXES,
convert,
derive_out_path,
detect_prefix,
resolve_format,
)
_PREFIX = "diffusion_model."
_ALT_PREFIX = "transformer."
def build_key_map(keys, prefix: str) -> dict:
"""`{lora key: checkpoint key}` for a bare diffusion-model state dict.
ComfyUI's `model_lora_keys_unet` needs a live model; here there is only a file. The
mapping it would produce for the native names is mechanical, and the diffusers names come
from the same table ComfyUI uses (`krea2_to_diffusers`), so both stay in step with it.
"""
key_map = {}
for key in keys:
if not key.endswith(".weight"):
continue
name = key[: -len(".weight")]
bare = name[len(prefix):] if prefix and name.startswith(prefix) else name
for alias in (_PREFIX + bare, _ALT_PREFIX + bare, bare):
key_map.setdefault(alias, key)
to_diffusers = getattr(comfy.utils, "krea2_to_diffusers", None)
if to_diffusers is not None:
# `unet_config` only has to carry the layer counts the map is built from; the real
# config is not on disk next to the weights.
cfg = {"layers": sum(1 for k in keys if k.endswith("blocks.0.attn.wq.weight")) and 28}
try:
for lora_key, model_key in to_diffusers(cfg, output_prefix=prefix).items():
if model_key in keys and lora_key.endswith(".weight"):
stem = lora_key[: -len(".weight")]
key_map.setdefault(_PREFIX + stem, model_key)
key_map.setdefault(_ALT_PREFIX + stem, model_key)
key_map.setdefault(stem, model_key)
except Exception as exc: # a config mismatch is not fatal here
print("note: diffusers key map unavailable ({})".format(exc), flush=True)
return key_map
def load_patches(source_keys, prefix, loras):
"""`{checkpoint key: [(adapter, strength), ...]}` for every LoRA on the command line."""
key_map = build_key_map(source_keys, prefix)
patches: dict[str, list] = {}
for path, strength in loras:
sd = comfy.utils.load_torch_file(path, safe_load=True)
loaded = comfy.lora.load_lora(sd, key_map, log_missing=False)
if not loaded:
raise SystemExit("no layer of {} matched this checkpoint".format(path))
for key, patch in loaded.items():
patches.setdefault(key, []).append((patch, strength))
print(" {}: {} layers @ {:.2f}".format(os.path.basename(path), len(loaded), strength),
flush=True)
return patches
def make_weight_patch(patches: dict, applied: dict):
"""The `convert()` hook: weight -> weight + sum of the LoRA deltas for that key."""
def weight_patch(key: str, tensor: torch.Tensor) -> torch.Tensor:
entries = patches.get(key)
if not entries:
return tensor
# fp32 for the accumulation: the deltas are small next to the weight, and this is a
# one-off cost paid at build time rather than per step. `copy=True` because
# `calculate_weight` adds in place and `.to()` is a no-op on a tensor that is already
# fp32 -- without it this would mutate the caller's tensor.
merged = tensor.to(torch.float32, copy=True)
for adapter, strength in entries:
# `offset` is None when the patch covers the whole weight; passing 0 makes
# ComfyUI subscript it (`offset[0]`) and die -- and it dies on the *last* key,
# after the quantizer has already burned a quarter of an hour.
if isinstance(adapter, comfy.weight_adapter.WeightAdapterBase):
merged = adapter.calculate_weight(
merged, key, strength, strength, None, lambda x: x,
intermediate_dtype=torch.float32)
else:
merged = comfy.lora.calculate_weight(
[(strength, adapter, strength, None, None)], merged, key,
intermediate_dtype=torch.float32)
applied[key] = applied.get(key, 0) + len(entries)
return merged.to(tensor.dtype)
return weight_patch
def preflight(src: str, patches: dict) -> None:
"""Apply one patch of each kind for real, before the quantizer runs for a quarter hour.
The quantizer processes the block weights first and the plain ones last, so a mistake in
how a `diff` patch is applied surfaces only after every layer has been quantized. One
tensor per distinct patch type costs a couple of seconds and moves that to the start.
"""
from safetensors import safe_open
seen = set()
probes = []
for key, entries in patches.items():
kind = tuple(type(a).__name__ for a, _ in entries)
if kind not in seen:
seen.add(kind)
probes.append(key)
with safe_open(src, framework="pt", device="cpu") as handle:
for key in probes:
hook = make_weight_patch(patches, {})
hook(key, handle.get_tensor(key))
print(" preflight ok ({} patch shape(s))".format(len(probes)), flush=True)
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("src", help="the high-precision checkpoint (raw.safetensors, turbo...)")
ap.add_argument("--lora", action="append", required=True, metavar="PATH[:STRENGTH]",
help="repeat to bake several, in order; strength defaults to 1.0")
ap.add_argument("--format", choices=["int8", "w4a4", "svdq", "fp8"], default="svdq")
ap.add_argument("--groupsize", type=int, default=256)
ap.add_argument("--rank", type=int, default=256)
ap.add_argument("--rank-alloc", default="uniform")
ap.add_argument("--refine-iters", type=int, default=100)
ap.add_argument("--variant", choices=["turbo", "base", "unknown"], default="unknown")
ap.add_argument("--act-stats", default=None)
ap.add_argument("--device", default="cuda")
ap.add_argument("--out", default=None)
args = ap.parse_args()
loras = []
for spec in args.lora:
path, _, strength = spec.rpartition(":")
# A bare Windows path has a colon in it ("D:/loras/x.safetensors"), so only treat the
# tail as a strength when it actually parses as one.
try:
loras.append((path, float(strength)) if path else (spec, 1.0))
except ValueError:
loras.append((spec, 1.0))
fmt, rank = resolve_format(args.format, args.rank, rank_was_set=True)
from safetensors import safe_open
with safe_open(args.src, framework="pt", device="cpu") as handle:
keys = list(handle.keys())
prefix = detect_prefix(keys, default=LAYER_PREFIXES[0])
print("baking {} adapter(s) into {}".format(len(loras), os.path.basename(args.src)),
flush=True)
patches = load_patches(keys, prefix, loras)
applied: dict[str, int] = {}
preflight(args.src, patches)
out = args.out
if out is None:
out, _ = derive_out_path(args.src, args.format, rank, args.variant, args.rank_alloc,
args.act_stats)
stem, ext = os.path.splitext(out)
out = "{}-baked{}".format(stem, ext)
convert(args.src, out, fmt, args.groupsize, args.device, rank, args.refine_iters,
variant=args.variant, rank_alloc=args.rank_alloc, act_stats=args.act_stats,
weight_patch=make_weight_patch(patches, applied))
missing = set(patches) - set(applied)
if missing:
# Silence here would mean shipping a checkpoint that is quietly missing part of the
# LoRA, which is exactly the failure this tool exists to avoid.
raise SystemExit("{} matched layer(s) never reached the writer: {}".format(
len(missing), ", ".join(sorted(missing)[:5])))
print("baked {} layer(s) into {}".format(len(applied), out))
if __name__ == "__main__":
main()