Repository navigation
Expand file tree
/
Copy pathlora_control_core.py
More file actions
342 lines (292 loc) · 12.5 KB
/
Copy pathlora_control_core.py
File metadata and controls
342 lines (292 loc) · 12.5 KB
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
"""Generic per-block LoRA analysis — no architecture table.
Blocks are derived from the tensor key itself (``<name>_<index>``), so this
works on FLUX, Wan, Qwen, Klein, Krea, MiniMax H3 and whatever ships next
without a line of per-model code.
Impact per block is the Frobenius norm of the weight delta the LoRA adds,
computed exactly and cheaply via the Gram trick (see ``delta_norm``).
Pure Python + torch; no ComfyUI imports, so ``tests/test_lora_control.py``
runs it standalone.
"""
from __future__ import annotations
import re
import torch
# Tokens that carry no block identity. Walking back from the index token, the
# first one of these ends the block name: `lora_unet_double_blocks_3` -> "double_blocks".
_NOISE = frozenset({
"lora", "lycoris", "unet", "diffusion", "model", "net", "base",
"weight", "default",
})
# suffix -> role. Order matters: longest first, so `.lora_down.weight` is not
# eaten by `.weight`.
_SUFFIXES = (
(".lora_up.weight", "up"),
(".lora_down.weight", "down"),
(".lora_B.weight", "up"),
(".lora_A.weight", "down"),
(".lora_b.weight", "up"),
(".lora_a.weight", "down"),
(".hada_w1_a", "hada"), (".hada_w1_b", "hada"),
(".hada_w2_a", "hada"), (".hada_w2_b", "hada"),
(".lokr_w1_a", "lokr"), (".lokr_w1_b", "lokr"),
(".lokr_w2_a", "lokr"), (".lokr_w2_b", "lokr"),
(".lokr_w1", "lokr"), (".lokr_w2", "lokr"),
(".dora_scale", "dora"),
(".diff_b", "diff"), (".diff", "diff"),
(".alpha", "alpha"),
# Bare A/B factors (some control-LoRAs ship these, e.g. Krea depth control).
# Last, so the longer `.lora_A.weight` forms above always win.
(".B", "up"), (".A", "down"),
)
_TYPE_OF_ROLE = {"hada": "LoHa", "lokr": "LoKR", "diff": "full-diff"}
def split_base(key: str):
"""Split a tensor key into (base, role). Returns None for unknown keys."""
for suffix, role in _SUFFIXES:
if key.endswith(suffix):
return key[: -len(suffix)], role
return None
def block_of(base: str):
"""Derive (block_name, index) from a tensor base name, or None.
Finds the first numeric path segment whose preceding words name a block or
a layer, and uses those words as the block group. That is what separates
`single_transformer_blocks.12` from `transformer_blocks.12` for free —
no per-architecture regex.
"""
toks = re.split(r"[._]", base)
for i, tok in enumerate(toks):
if not tok.isdigit():
continue
name_toks = []
for prev in reversed(toks[:i]):
if prev in _NOISE:
break
name_toks.append(prev)
name_toks.reverse()
# Drop a repeated word's earlier copies, keeping the last: the diffusers
# prefix collapses (`transformer.transformer_blocks` -> transformer_blocks)
# without eating the qualifier in `transformer.single_transformer_blocks`.
deduped = [t for j, t in enumerate(name_toks) if t not in name_toks[j + 1:]]
name = "_".join(deduped)
if "block" in name or "layer" in name:
return name, int(tok)
return None
def delta_norm(up: torch.Tensor, down: torch.Tensor) -> float:
"""Exact ||up @ down||_F without ever forming the product.
||BA||_F^2 = tr(B^T B . A A^T), and both Grams are only rank x rank, so this
costs O(r*d) instead of O(d^2). Beats ||B||.||A||, which is merely an upper
bound (submultiplicativity) and overstates blocks with misaligned factors.
"""
b = up.reshape(up.shape[0], -1).float() # (out, r)
a = down.reshape(down.shape[0], -1).float() # (r, in)
gb = b.T @ b # (r, r)
ga = a @ a.T # (r, r)
return float(torch.clamp((gb * ga.T).sum(), min=0.0).sqrt())
def analyze(state_dict: dict) -> dict:
"""Group a LoRA state dict into blocks and score each one by impact."""
parts: dict[str, dict] = {}
order: list[str] = []
for key, value in state_dict.items():
split = split_base(key)
if split is None:
continue
base, role = split
entry = parts.get(base)
if entry is None:
entry = parts[base] = {}
order.append(base)
entry[role] = value
types = set()
ranks: dict[int, int] = {}
energy: dict[str, float] = {} # block -> sum of squared norms
layers: dict[str, int] = {}
block_order: list[str] = []
sort_key: dict[str, tuple] = {} # block -> (group rank, index)
groups_seen: list[str] = []
unmatched: list[str] = []
for base in order:
entry = parts[base]
norm = 0.0
if "up" in entry and "down" in entry:
types.add("LoRA")
rank = entry["down"].shape[0]
ranks[rank] = ranks.get(rank, 0) + 1
norm = delta_norm(entry["up"], entry["down"])
alpha = entry.get("alpha")
if alpha is not None and rank:
norm *= float(alpha.reshape(-1)[0]) / rank
else:
# ponytail: LoHa/LoKR/diff get the product-of-norms proxy, not the
# exact delta. Ranking within the same type still holds, which is
# what the panel needs; exact needs per-format reconstruction.
for role, tensor in entry.items():
if role == "alpha":
continue
types.add(_TYPE_OF_ROLE.get(role, "LoRA"))
n = float(tensor.float().norm())
norm = n if norm == 0.0 else norm * n
found = block_of(base)
if found is None:
block = "other"
if len(unmatched) < 12:
unmatched.append(base)
else:
group, index = found
block = f"{group}_{index}"
if group not in groups_seen:
groups_seen.append(group)
sort_key[block] = (groups_seen.index(group), index)
if block not in energy:
energy[block] = 0.0
layers[block] = 0
block_order.append(block)
# Norms add in quadrature: the result is the true Frobenius norm of the
# whole block's delta, not the inflated linear sum.
energy[block] += norm * norm
layers[block] += 1
norms = {b: energy[b] ** 0.5 for b in energy}
# 'other' is a heterogeneous bucket — on models with many loose projections
# its pooled energy outweighs any single block and would flatten every real
# score. Reference the peak to real blocks; 'other' may read above 100.
peak = max((n for b, n in norms.items() if b != "other"), default=0.0) or 1.0
total = sum(energy.values()) or 1.0
blocks = {
b: {
"score": round(100.0 * norms[b] / peak, 1),
"share": round(100.0 * energy[b] / total, 2),
"norm": round(norms[b], 5),
"layers": layers[b],
}
for b in block_order
}
# Groups keep the order they first appear in the file (double before single
# in every trainer seen so far); inside a group, sort numerically — the file
# itself is usually alphabetical, which would read 0, 1, 10, 11, 2...
ordered = sorted((b for b in block_order if b != "other"), key=sort_key.get)
if "other" in blocks:
ordered.append("other")
lora_type = types.pop() if len(types) == 1 else ("mixed" if types else "unknown")
return {
"lora_type": lora_type,
"rank": max(ranks, key=ranks.get) if ranks else None,
"order": ordered,
"blocks": blocks,
"unmatched_sample": unmatched,
}
# ---------------------------------------------------------------------------
# Block selection
# ---------------------------------------------------------------------------
def split_block(name: str):
"""`double_blocks_7` -> ("double_blocks", 7); `other` -> ("other", None)."""
group, _, index = name.rpartition("_")
if group and index.isdigit():
return group, int(index)
return name, None
def select(selector: str, block_names) -> list[str]:
"""Resolve one selector against the known block names.
`*`/`all` · `double_blocks_7` · `double_blocks_0-3` · `single_blocks_*`
"""
selector = selector.strip()
if selector in ("*", "all"):
return list(block_names)
if selector in block_names:
return [selector]
if selector.endswith("_*"):
selector = selector[:-2]
group, _, span = selector.rpartition("_")
if group and "-" in span:
lo, _, hi = span.partition("-")
if lo.isdigit() and hi.isdigit():
lo, hi = int(lo), int(hi)
return [
n for n in block_names
if split_block(n)[0] == group
and split_block(n)[1] is not None
and lo <= split_block(n)[1] <= hi
]
return [n for n in block_names if split_block(n)[0] == selector]
def parse_blocks(text: str, block_names, default: float = 1.0) -> dict[str, float]:
"""Parse the block rule text into per-block multipliers (0.0 == off).
One `selector: value` per line, later lines override earlier ones. Blocks
no rule mentions keep `default`, so a rule file survives swapping the LoRA.
"""
weights = {b: default for b in block_names}
if not text or not text.strip():
return weights
for raw in text.splitlines():
line = raw.split("#", 1)[0].strip()
if not line or ":" not in line:
continue
selector, _, value = line.partition(":")
value = value.strip().lower()
if value in ("off", "no", "false"):
weight = 0.0
elif value in ("on", "yes", "true"):
weight = default
else:
try:
weight = float(value)
except ValueError:
continue
for block in select(selector, block_names):
weights[block] = weight
return weights
# Scaling a LoRA means scaling the product up@down exactly ONCE. These roles are
# the single factor to touch per format; scaling every tensor of a pair would
# square the multiplier (and cube it once alpha joins in).
_SCALED_ROLE = {"LoRA": "up", "LoHa": "hada", "LoKR": "lokr", "full-diff": "diff"}
def filter_state_dict(state_dict: dict, weights: dict[str, float],
default: float = 1.0) -> dict:
"""Drop blocks set to 0 and scale the rest by their multiplier."""
scaled_once: set[str] = set()
out = {}
for key, value in state_dict.items():
split = split_base(key)
base, role = split if split else (key, None)
found = block_of(base)
block = f"{found[0]}_{found[1]}" if found else "other"
weight = weights.get(block, default)
if weight == 0.0:
continue
if weight != 1.0 and base not in scaled_once and role in _SCALED_ROLE.values():
out[key] = value * weight
scaled_once.add(base)
else:
out[key] = value
return out
# ---------------------------------------------------------------------------
# Quantized-model detection
#
# Ported from shootthesound/comfyUI-Realtime-Lora (MIT) — ComfyUI's hook system
# walks weights assuming plain nn.Linear, so GGUF / fp8 layers raise or hang at
# sample time. Scheduling must fall back to a flat apply on those.
# ---------------------------------------------------------------------------
_FP8_DTYPES = tuple(
dt for dt in (
getattr(torch, "float8_e4m3fn", None),
getattr(torch, "float8_e5m2", None),
getattr(torch, "float8_e4m3fnuz", None),
getattr(torch, "float8_e5m2fnuz", None),
) if dt is not None
)
def is_quantized(model_patcher) -> tuple[bool, str]:
"""(is_quantized, label). Conservative: any failure reports not quantized."""
try:
inner = getattr(model_patcher, "model", None)
target = getattr(inner, "diffusion_model", None) or inner
if target is None:
return (False, "")
for module in target.modules():
cls = type(module).__name__.lower()
if "ggml" in cls or "gguf" in cls:
return (True, "GGUF")
if hasattr(module, "scale_weight") or hasattr(module, "weight_scale"):
return (True, "fp8 (scaled)")
for i, param in enumerate(target.parameters()):
if "ggml" in type(param).__name__.lower() or hasattr(param, "tensor_type"):
return (True, "GGUF")
if _FP8_DTYPES and param.dtype in _FP8_DTYPES:
return (True, "fp8")
if i >= 400:
break
return (False, "")
except Exception:
return (False, "")