Files
2026-07-05 10:37:43 +02:00

276 lines
11 KiB
Python

import os
import torch
import folder_paths
import comfy.utils
from safetensors.torch import save_file
LORA_A_SUFFIX = ".lora_A.weight"
LORA_B_SUFFIX = ".lora_B.weight"
def _lora_choices():
return folder_paths.get_filename_list("loras")
def _target_from_a_key(key):
if key.endswith(LORA_A_SUFFIX):
return key[:-len(LORA_A_SUFFIX)]
return None
def _alpha_for(sd, target, rank):
alpha = sd.get(f"{target}.alpha", None)
if alpha is None:
return float(rank)
return float(alpha.item())
def _is_gate_target(target):
return (
".attn.to_gate" in target or ".attn.gate" in target or
".ff.gate" in target or ".mlp.gate" in target
)
def _lora_factors(sd, target, strength):
a = sd[f"{target}{LORA_A_SUFFIX}"].float()
b = sd[f"{target}{LORA_B_SUFFIX}"].float()
rank = a.shape[0]
scale = _alpha_for(sd, target, rank) / rank
return a, b * (float(strength) * scale)
def _factor_dot(a_a, b_a, a_b, b_b):
return ((b_a.T @ b_b) * (a_a @ a_b.T)).sum()
def _factor_norm(a, b):
return torch.sqrt(torch.clamp(_factor_dot(a, b, a, b), min=0.0))
def _balanced_strengths(sd_a, sd_b, target, strength_a, strength_b, balance_mode):
if balance_mode != "per_layer_norm":
return strength_a, strength_b, False
a_a, b_a = _lora_factors(sd_a, target, strength_a)
a_b, b_b = _lora_factors(sd_b, target, strength_b)
norm_a = _factor_norm(a_a, b_a).item()
norm_b = _factor_norm(a_b, b_b).item()
if norm_a == 0.0 or norm_b == 0.0:
return strength_a, strength_b, False
target_norm = (norm_a + norm_b) * 0.5
return float(strength_a) * target_norm / norm_a, float(strength_b) * target_norm / norm_b, True
def _compress_factors(a, b, rank, device_mode):
current_rank = a.shape[0]
rank = max(1, min(int(rank), current_rank))
if rank >= current_rank:
return a.contiguous(), b.contiguous()
device = torch.device("cpu")
if device_mode == "cuda" and torch.cuda.is_available():
device = torch.device("cuda")
a_work = a.to(device)
b_work = b.to(device)
q, r = torch.linalg.qr(b_work, mode="reduced")
u, s, vh = torch.linalg.svd(r @ a_work, full_matrices=False)
u = u[:, :rank]
s = s[:rank]
vh = vh[:rank]
root = torch.sqrt(s)
b_out = q @ (u * root.unsqueeze(0))
a_out = root.unsqueeze(1) * vh
return a_out.cpu().contiguous(), b_out.cpu().contiguous()
def _concat_lora(sd_a, sd_b, target, strength_a, strength_b):
a_a = sd_a[f"{target}{LORA_A_SUFFIX}"].float()
b_a = sd_a[f"{target}{LORA_B_SUFFIX}"].float()
a_b = sd_b[f"{target}{LORA_A_SUFFIX}"].float()
b_b = sd_b[f"{target}{LORA_B_SUFFIX}"].float()
scale_a = float(strength_a) * _alpha_for(sd_a, target, a_a.shape[0]) / a_a.shape[0]
scale_b = float(strength_b) * _alpha_for(sd_b, target, a_b.shape[0]) / a_b.shape[0]
a = torch.cat([a_a, a_b], dim=0)
b = torch.cat([b_a * scale_a, b_b * scale_b], dim=1)
return a.contiguous(), b.contiguous()
def _max_input_rank(sd_a, sd_b, target):
return max(sd_a[f"{target}{LORA_A_SUFFIX}"].shape[0], sd_b[f"{target}{LORA_A_SUFFIX}"].shape[0])
def _single_lora(sd, target, strength):
a, b = _lora_factors(sd, target, strength)
return a.clone().contiguous(), b.clone().contiguous()
def _zero_lora(sd_a, sd_b, target, rank):
if rank <= 0:
rank = max(sd_a[f"{target}{LORA_A_SUFFIX}"].shape[0], sd_b[f"{target}{LORA_A_SUFFIX}"].shape[0])
in_dim = sd_a[f"{target}{LORA_A_SUFFIX}"].shape[1]
out_dim = sd_a[f"{target}{LORA_B_SUFFIX}"].shape[0]
a = torch.zeros((rank, in_dim), dtype=torch.float32)
b = torch.zeros((out_dim, rank), dtype=torch.float32)
return a, b
def _should_compress_gate(target, compression_mode):
if compression_mode == "all_gates":
return True
if compression_mode == "attn_to_gate_only":
return ".attn.to_gate" in target or ".attn.gate" in target
return False
def _gate_lora(sd_a, sd_b, target, strength_a, strength_b, opposite_strategy, opposite_threshold, gate_rank, compression_mode, compression_device):
a_a, b_a = _lora_factors(sd_a, target, strength_a)
a_b, b_b = _lora_factors(sd_b, target, strength_b)
norm_a = _factor_norm(a_a, b_a)
norm_b = _factor_norm(a_b, b_b)
if norm_a.item() == 0.0:
a, b = _single_lora(sd_b, target, strength_b)
conflict = False
elif norm_b.item() == 0.0:
a, b = _single_lora(sd_a, target, strength_a)
conflict = False
else:
cosine = _factor_dot(a_a, b_a, a_b, b_b) / (norm_a * norm_b)
conflict = bool(cosine.item() < -float(opposite_threshold))
if conflict:
if opposite_strategy == "zero_out":
a, b = _zero_lora(sd_a, sd_b, target, gate_rank)
else:
a, b = _single_lora(sd_a, target, strength_a) if norm_a >= norm_b else _single_lora(sd_b, target, strength_b)
else:
a, b = _concat_lora(sd_a, sd_b, target, strength_a, strength_b)
if _should_compress_gate(target, compression_mode):
a, b = _compress_factors(a, b, gate_rank, compression_device)
return a, b, conflict
def _copy_unpaired(out, sd, target, strength):
a, b = _single_lora(sd, target, strength)
out[f"{target}{LORA_A_SUFFIX}"] = a
out[f"{target}{LORA_B_SUFFIX}"] = b
def merge_krea2_loras(sd_a, sd_b, strength_a, strength_b, gate_strength, opposite_strategy, opposite_threshold, gate_rank, gate_compression="off", compression_device="cpu", normal_compression="off", balance_mode="off"):
targets_a = {_target_from_a_key(k) for k in sd_a if _target_from_a_key(k) is not None}
targets_b = {_target_from_a_key(k) for k in sd_b if _target_from_a_key(k) is not None}
out = {}
stats = {"normal": 0, "gate": 0, "gate_conflicts": 0, "balanced": 0, "only_a": 0, "only_b": 0}
for target in sorted(targets_a | targets_b):
has_a = target in targets_a and f"{target}{LORA_B_SUFFIX}" in sd_a
has_b = target in targets_b and f"{target}{LORA_B_SUFFIX}" in sd_b
if has_a and has_b:
target_strength_a, target_strength_b, balanced = _balanced_strengths(sd_a, sd_b, target, strength_a, strength_b, balance_mode)
stats["balanced"] += int(balanced)
if _is_gate_target(target):
a, b, conflict = _gate_lora(
sd_a, sd_b, target,
float(target_strength_a) * float(gate_strength),
float(target_strength_b) * float(gate_strength),
opposite_strategy,
opposite_threshold,
gate_rank,
gate_compression,
compression_device,
)
stats["gate"] += 1
stats["gate_conflicts"] += int(conflict)
else:
a, b = _concat_lora(sd_a, sd_b, target, target_strength_a, target_strength_b)
if normal_compression == "max_input_rank":
a, b = _compress_factors(a, b, _max_input_rank(sd_a, sd_b, target), compression_device)
stats["normal"] += 1
out[f"{target}{LORA_A_SUFFIX}"] = a
out[f"{target}{LORA_B_SUFFIX}"] = b
elif has_a:
_copy_unpaired(out, sd_a, target, strength_a)
stats["only_a"] += 1
elif has_b:
_copy_unpaired(out, sd_b, target, strength_b)
stats["only_b"] += 1
return out, stats
class Krea2GateAwareLoRAMerge:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"lora_a": (_lora_choices(),),
"lora_b": (_lora_choices(),),
"strength_a": ("FLOAT", {"default": 0.5, "min": -4.0, "max": 4.0, "step": 0.01}),
"strength_b": ("FLOAT", {"default": 0.5, "min": -4.0, "max": 4.0, "step": 0.01}),
"gate_strength": ("FLOAT", {"default": 0.5, "min": -4.0, "max": 4.0, "step": 0.01}),
"opposite_gate_deltas": (["pick_strongest", "zero_out"], {"default": "pick_strongest"}),
"opposite_threshold": ("FLOAT", {"default": 0.05, "min": 0.0, "max": 1.0, "step": 0.01}),
"gate_rank": ("INT", {"default": 32, "min": 0, "max": 256}),
"balance_mode": (["off", "per_layer_norm"], {"default": "off"}),
"gate_compression": (["off", "attn_to_gate_only", "all_gates"], {"default": "attn_to_gate_only"}),
"normal_compression": (["off", "max_input_rank"], {"default": "off"}),
"compression_device": (["cpu", "cuda"], {"default": "cpu"}),
"filename_prefix": ("STRING", {"default": "loras/krea2_merged"}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("info",)
FUNCTION = "merge"
OUTPUT_NODE = True
CATEGORY = "model/merging/krea2"
def merge(self, lora_a, lora_b, strength_a, strength_b, gate_strength, opposite_gate_deltas, opposite_threshold, gate_rank, balance_mode="off", gate_compression="attn_to_gate_only", normal_compression="off", compression_device="cpu", filename_prefix="loras/krea2_merged"):
path_a = folder_paths.get_full_path_or_raise("loras", lora_a)
path_b = folder_paths.get_full_path_or_raise("loras", lora_b)
sd_a = comfy.utils.load_torch_file(path_a, safe_load=True)
sd_b = comfy.utils.load_torch_file(path_b, safe_load=True)
merged, stats = merge_krea2_loras(
sd_a, sd_b, strength_a, strength_b, gate_strength,
opposite_gate_deltas, opposite_threshold, gate_rank, gate_compression, compression_device, normal_compression, balance_mode,
)
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(filename_prefix, folder_paths.get_output_directory())
output_checkpoint = f"{filename}_{counter:05}_.safetensors"
output_path = os.path.join(full_output_folder, output_checkpoint)
save_file(merged, output_path, metadata={
"merge_node": "Krea2GateAwareLoRAMerge",
"lora_a": lora_a,
"lora_b": lora_b,
"strength_a": str(strength_a),
"strength_b": str(strength_b),
"gate_strength": str(gate_strength),
"opposite_gate_deltas": opposite_gate_deltas,
"balance_mode": balance_mode,
"gate_compression": gate_compression,
"normal_compression": normal_compression,
"compression_device": compression_device,
})
rel_path = os.path.join(subfolder, output_checkpoint) if subfolder else output_checkpoint
info = (
f"Saved {rel_path}. "
f"normal={stats['normal']}, gate={stats['gate']}, "
f"gate_conflicts={stats['gate_conflicts']}, balanced={stats['balanced']}, "
f"only_a={stats['only_a']}, only_b={stats['only_b']}."
)
return {"ui": {"text": [info]}, "result": (info,)}
NODE_CLASS_MAPPINGS = {
"Krea2GateAwareLoRAMerge": Krea2GateAwareLoRAMerge,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"Krea2GateAwareLoRAMerge": "Krea2 Gate-Aware LoRA Merge",
}