Files
ethanfel-ComfyUI-LoRA-Optim…/lora_optimizer.py
T
EthanfelandClaude Opus 4.6 689ec8cd20 feat: add LoKr and LoHa adapter support to merge pipeline
- Add LoKr/LoHa key suffixes to _collect_lora_prefixes for discovery
- New _get_lokr_diff/_get_loha_diff methods compute full diffs from
  Kronecker/Hadamard factors (including Tucker decomposition)
- Update _prepare_group_diffs, _process_prefix, _analyze_prefix to
  fall back to LoKr/LoHa when standard LoRA extraction returns None
- Fix _build_exact_linear_patch to detect LoKr/LoHa keys and fall
  through to dense path instead of silently dropping contributions
- Update analyze_and_enrich (Conflict Editor) with LoKr/LoHa support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 12:13:10 +01:00

9346 lines
439 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
ComfyUI-LoRA-Optimizer
Auto-optimizer node for combining multiple LoRAs via diff-based merging
with TIES conflict resolution and automatic parameter selection.
"""
import torch
import logging
import math
import os
import json
import hashlib
import time
import re
import gc
import importlib
import importlib.util
import concurrent.futures
import folder_paths
import comfy.utils
import comfy.sd
import comfy.lora
from comfy.weight_adapter.lora import LoRAAdapter
try:
from comfy.weight_adapter.lokr import LoKrAdapter
except Exception:
class LoKrAdapter:
def __init__(self, loaded_keys, weights):
self.loaded_keys = loaded_keys
self.weights = weights
try:
from comfy.weight_adapter.loha import LoHaAdapter
except Exception:
class LoHaAdapter:
def __init__(self, loaded_keys, weights):
self.loaded_keys = loaded_keys
self.weights = weights
from safetensors import safe_open
from safetensors.torch import save_file
TUNER_DATA_DIR = os.path.join(folder_paths.models_dir, "tuner_data")
os.makedirs(TUNER_DATA_DIR, exist_ok=True)
folder_paths.add_model_folder_path("tuner_data", TUNER_DATA_DIR)
def _read_safetensors_metadata(filepath):
"""Read metadata header from a safetensors file without loading tensors."""
try:
if not filepath.endswith(".safetensors"):
return {}
with safe_open(filepath, framework="pt") as f:
return dict(f.metadata()) if f.metadata() else {}
except Exception:
return {}
def _resolve_safe_output_path(base_dir, filename, suffix, label):
"""Resolve a save path under `base_dir`, allowing subdirectories but blocking traversal."""
if filename is None:
raise ValueError(f"[{label}] Filename is required.")
base_dir_real = os.path.realpath(base_dir)
base_name = filename if filename.endswith(suffix) else f"{filename}{suffix}"
candidate = os.path.realpath(os.path.join(base_dir_real, base_name))
try:
if os.path.commonpath([base_dir_real, candidate]) != base_dir_real:
raise ValueError
except ValueError as exc:
raise ValueError(f"[{label}] Path escapes base directory: {filename}") from exc
return candidate
# ---------------------------------------------------------------------------
# Architecture-aware threshold presets
# ---------------------------------------------------------------------------
# Each preset contains numeric thresholds used by density estimation, conflict
# detection, auto-strength, and scoring heuristics. The preset is selected
# based on detected model architecture (or manual override).
_ARCH_PRESETS = {
"sd_unet": {
"density_noise_floor_ratio": 0.1,
"density_clamp_min": 0.1,
"density_clamp_max": 0.9,
"dare_ideal_density": 0.7,
"consensus_cos_sim_min": 0.5,
"consensus_conflict_max": 0.15,
"orthogonal_cos_sim_max": 0.25,
"orthogonal_conflict_max": 0.60,
"ties_conflict_threshold": 0.25,
"magnitude_ratio_total_sign": 2.0,
"alignment_threshold": 0.1,
"suggested_max_strength_cap": 3.0,
"auto_strength_orthogonal_floor": 0.85,
"display_name": "SD/SDXL UNet",
"full_rank": {
"rank_threshold": 512,
"disable_slerp_upgrade": True,
"prefer_sum_orthogonal": True,
"auto_strength_floor": 1.0,
},
},
"dit": {
"density_noise_floor_ratio": 0.05,
"density_clamp_min": 0.4,
"density_clamp_max": 0.95,
"dare_ideal_density": 0.8,
"consensus_cos_sim_min": 0.5,
"consensus_conflict_max": 0.15,
"orthogonal_cos_sim_max": 0.25,
"orthogonal_conflict_max": 0.60,
"ties_conflict_threshold": 0.25,
"magnitude_ratio_total_sign": 2.0,
"alignment_threshold": 0.1,
"suggested_max_strength_cap": 5.0,
"auto_strength_orthogonal_floor": 0.85,
"display_name": "DiT (Flux/WAN/Z-Image/LTX/HunyuanVideo)",
"full_rank": {
"rank_threshold": 512,
"disable_slerp_upgrade": True,
"prefer_sum_orthogonal": True,
"auto_strength_floor": 1.0,
},
},
"llm": {
"density_noise_floor_ratio": 0.15,
"density_clamp_min": 0.1,
"density_clamp_max": 0.8,
"dare_ideal_density": 0.5,
"consensus_cos_sim_min": 0.5,
"consensus_conflict_max": 0.15,
"orthogonal_cos_sim_max": 0.25,
"orthogonal_conflict_max": 0.60,
"ties_conflict_threshold": 0.25,
"magnitude_ratio_total_sign": 2.0,
"alignment_threshold": 0.1,
"suggested_max_strength_cap": 3.0,
"auto_strength_orthogonal_floor": 0.9,
"display_name": "LLM (Qwen/LLaMA)",
"full_rank": {
"rank_threshold": 512,
"disable_slerp_upgrade": True,
"prefer_sum_orthogonal": True,
"auto_strength_floor": 1.0,
},
},
}
_VIDEO_ARCH_ORTHOGONAL_FLOOR = {"wan": 1.0, "ltx": 1.0}
_ARCH_TO_PRESET = {
"sdxl": "sd_unet", "unknown": "sd_unet",
"flux": "dit", "wan": "dit", "zimage": "dit", "ltx": "dit",
"acestep": "dit",
"qwen_image": "llm",
}
def _resolve_arch_preset(arch_override, detected_arch):
"""Resolve architecture preset from override or detected architecture."""
if arch_override and arch_override != "auto" and arch_override in _ARCH_PRESETS:
key = arch_override
else:
key = _ARCH_TO_PRESET.get(detected_arch, "sd_unet")
return key, _ARCH_PRESETS[key]
class LoRAStack:
"""
Node for creating a LoRA stack (input format for LoRAOptimizer).
Chain multiple Stack nodes to build a list of any length.
"""
def __init__(self):
self.loaded_loras = {}
@classmethod
def INPUT_TYPES(cls):
lora_list = folder_paths.get_filename_list("loras")
return {
"required": {
"lora_name": (lora_list, {"tooltip": "Pick a LoRA file to add to the stack. LoRAs are style/character/concept add-ons trained on top of a base model."}),
"strength": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.05}),
"conflict_mode": (["all", "low_conflict", "high_conflict"], {
"default": "all",
"tooltip": "Filter where this LoRA applies based on conflicts with other LoRAs. "
"'all': apply everywhere (default). "
"'low_conflict': only apply where this LoRA agrees with the majority — safe, avoids contested weights. "
"'high_conflict': only apply where this LoRA disagrees — forces this LoRA to dominate contested regions."
}),
"key_filter": (["all", "shared_only", "unique_only"], {
"default": "all",
"tooltip": "Filter which keys this LoRA contributes based on how many LoRAs share each key. "
"'all': contribute all keys (default). "
"'shared_only': only contribute to keys present in 2+ LoRAs. "
"'unique_only': only contribute to keys present in exactly 1 LoRA."
}),
},
"optional": {
"lora_stack": ("LORA_STACK", {"tooltip": "Connect another LoRA Stack node here to chain multiple LoRAs together."}),
}
}
RETURN_TYPES = ("LORA_STACK",)
RETURN_NAMES = ("lora_stack",)
FUNCTION = "add_to_stack"
CATEGORY = "LoRA Optimizer"
DESCRIPTION = "Adds a LoRA to the stack for use with LoRA Optimizer"
def add_to_stack(self, lora_name, strength, conflict_mode="all", key_filter="all", lora_stack=None):
lora_list = list(lora_stack) if lora_stack else []
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
lora = comfy.utils.load_torch_file(lora_path, safe_load=True)
lora_list.append({
"name": lora_name,
"lora": lora,
"strength": strength,
"conflict_mode": conflict_mode,
"key_filter": key_filter,
"metadata": _read_safetensors_metadata(lora_path),
})
return (lora_list,)
class LoRAStackDynamic:
"""
Dynamic LoRA stacker — single node with adjustable slot count.
Simple mode: one strength per LoRA (applies to both model and CLIP).
Advanced mode: separate model_strength and clip_strength per LoRA.
Outputs standard (name, model_str, clip_str) tuples.
"""
MAX_LORAS = 10
@classmethod
def INPUT_TYPES(cls):
loras = ["None"] + folder_paths.get_filename_list("loras")
inputs = {
"required": {
"settings_visibility": (["simple", "advanced"], {
"tooltip": "Simple: one strength slider per LoRA. "
"Advanced: separate model/clip strength, conflict mode, and key filter per LoRA."
}),
"input_mode": (["dropdown", "text"], {
"default": "dropdown",
"tooltip": "Dropdown: pick LoRAs from a list. "
"Text: type LoRA names or connect text nodes (short names are auto-resolved)."
}),
"lora_count": ("INT", {"default": 3, "min": 1, "max": cls.MAX_LORAS, "step": 1,
"tooltip": "How many LoRA slots to show. Increase to add more LoRAs."}),
}
}
for i in range(1, cls.MAX_LORAS + 1):
inputs["required"][f"lora_name_{i}"] = (loras, {
"tooltip": f"LoRA #{i} — pick a LoRA file or leave as 'None' to skip this slot."
})
inputs["required"][f"lora_name_text_{i}"] = ("STRING", {
"default": "None",
"tooltip": f"LoRA #{i} — type a LoRA filename (without extension or path), "
f"a full relative path, or 'None' to skip. Accepts text node connections."
})
inputs["required"][f"strength_{i}"] = ("FLOAT", {
"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.05,
"tooltip": f"How strongly LoRA #{i} affects the output. 1.0 = full effect."
})
inputs["required"][f"model_strength_{i}"] = ("FLOAT", {
"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.05,
"tooltip": f"How strongly LoRA #{i} affects image generation (visual style, composition)."
})
inputs["required"][f"clip_strength_{i}"] = ("FLOAT", {
"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.05,
"tooltip": f"How strongly LoRA #{i} affects text understanding (prompt interpretation)."
})
inputs["required"][f"conflict_mode_{i}"] = (["all", "low_conflict", "high_conflict"], {
"default": "all",
"tooltip": f"LoRA #{i} conflict filter. "
f"'all': apply everywhere (default). "
f"'low_conflict': only where this LoRA agrees with the majority. "
f"'high_conflict': only where this LoRA disagrees."
})
inputs["required"][f"key_filter_{i}"] = (["all", "shared_only", "unique_only"], {
"default": "all",
"tooltip": f"LoRA #{i} key filter. "
f"'all': contribute all keys (default). "
f"'shared_only': only contribute to keys present in 2+ LoRAs. "
f"'unique_only': only contribute to keys present in exactly 1 LoRA."
})
inputs["optional"] = {
"lora_stack": ("LORA_STACK", {"tooltip": "Connect another LoRA Stack node here to add even more LoRAs to the list."}),
"base_model_filter": (["All"], {
"default": "All",
"tooltip": "Filter LoRA dropdowns by base model type (dropdown mode only). Requires ComfyUI-Lora-Manager installed."
}),
}
return inputs
RETURN_TYPES = ("LORA_STACK",)
RETURN_NAMES = ("lora_stack",)
FUNCTION = "build_stack"
CATEGORY = "LoRA Optimizer"
DESCRIPTION = "Dynamic LoRA stacker with adjustable slot count and optional per-LoRA CLIP strength"
@classmethod
def VALIDATE_INPUTS(cls, **kwargs):
# base_model_filter options are populated dynamically by JS from Lora Manager
return True
@staticmethod
def _resolve_lora_name(name):
"""Resolve a short LoRA name to its full relative path.
Accepts full relative paths (returned as-is) or short names like
'Milena_20260216180133' which are matched against installed LoRAs
by filename (with or without extension).
"""
lora_list = folder_paths.get_filename_list("loras")
# Exact match (full relative path)
if name in lora_list:
return name
# Match by filename stem (without extension)
name_lower = name.lower()
for lora_path in lora_list:
filename = os.path.basename(lora_path)
stem = os.path.splitext(filename)[0]
if stem.lower() == name_lower:
return lora_path
# Match by filename with extension
for lora_path in lora_list:
filename = os.path.basename(lora_path)
if filename.lower() == name_lower:
return lora_path
# Substring match on filename stem (if unique)
matches = []
for lora_path in lora_list:
stem = os.path.splitext(os.path.basename(lora_path))[0]
if name_lower in stem.lower():
matches.append(lora_path)
if len(matches) == 1:
return matches[0]
logger = logging.getLogger("LoRAOptimizer")
if len(matches) > 1:
logger.warning(f"LoRA name '{name}' matches multiple files: {matches[:5]}... using first match")
return matches[0]
logger.warning(f"LoRA name '{name}' not found in installed LoRAs, skipping")
return None
def build_stack(self, settings_visibility, input_mode, lora_count, lora_stack=None, base_model_filter=None, **kwargs):
loras = []
use_text = input_mode == "text"
for i in range(1, lora_count + 1):
if use_text:
name = kwargs.get(f"lora_name_text_{i}", "None")
else:
name = kwargs.get(f"lora_name_{i}", "None")
if name == "None" or not name.strip():
continue
resolved = self._resolve_lora_name(name.strip())
if not resolved:
continue
conflict_mode = kwargs.get(f"conflict_mode_{i}", "all")
kf = kwargs.get(f"key_filter_{i}", "all")
if settings_visibility == "simple":
wt = kwargs.get(f"strength_{i}", 1.0)
loras.append((resolved, wt, wt, conflict_mode, kf))
else:
model_str = kwargs.get(f"model_strength_{i}", 1.0)
clip_str = kwargs.get(f"clip_strength_{i}", 1.0)
loras.append((resolved, model_str, clip_str, conflict_mode, kf))
# Chained lora_stack entries
if lora_stack is not None:
for l in lora_stack:
if isinstance(l, dict):
if l.get("name", "None") != "None":
s = l.get("strength", 1.0)
cm = l.get("conflict_mode", "all")
kf = l.get("key_filter", "all")
loras.append((l["name"], s, s, cm, kf))
elif isinstance(l, (tuple, list)):
if l[0] != "None":
loras.append(tuple(l))
else:
loras.append(l)
return (loras,)
class _DiffCache:
"""Cache for raw LoRA diffs across AutoTuner candidates.
Modes:
- "ram": All entries in RAM (fastest, most memory).
- "disk": All entries on disk via torch.save/mmap (slowest, least memory).
- "auto": RAM up to ram_pct of free system memory, then spills to disk.
"""
def __init__(self, mode="auto", ram_pct=0.5):
self.mode = mode
self._ram_store = {}
self._disk_store = {}
self._prefetch_buf = {}
self._prefetch_thread = None
self._ram_bytes = 0
self._ram_limit = None
self._cache_dir = None
self._disk_failed = False
if mode in ("disk", "auto"):
import tempfile, atexit, weakref
self._cache_dir = tempfile.mkdtemp(prefix="lora_diff_cache_",
dir=folder_paths.get_temp_directory())
# Clean up temp directory on process exit (guards against crash-orphaned files)
_dir = self._cache_dir
def _cleanup(d=_dir):
import shutil
shutil.rmtree(d, ignore_errors=True)
atexit.register(_cleanup)
weakref.finalize(self, _cleanup)
if mode == "auto":
try:
import psutil
self._ram_limit = int(psutil.virtual_memory().available * ram_pct)
except ImportError:
# psutil not available — use 4GB as fallback
self._ram_limit = 4 * 1024 * 1024 * 1024
def _use_disk(self, tensor_bytes):
if self.mode == "disk":
return True
if self.mode == "ram":
return False
# auto: spill to disk if RAM limit would be exceeded
return (self._ram_bytes + tensor_bytes) > self._ram_limit
def prefetch(self, keys):
"""Load disk entries into a prefetch buffer in a background thread."""
self._wait_prefetch()
self._prefetch_buf.clear()
disk_keys = [k for k in keys if k in self._disk_store]
if not disk_keys:
return
import threading
def _load():
for k in disk_keys:
try:
import numpy as np
arr = np.load(self._disk_store[k], mmap_mode='r')
self._prefetch_buf[k] = torch.from_numpy(arr.copy())
except Exception:
pass
self._prefetch_thread = threading.Thread(target=_load, daemon=True)
self._prefetch_thread.start()
def _wait_prefetch(self):
if self._prefetch_thread is not None:
self._prefetch_thread.join()
self._prefetch_thread = None
def get(self, key, device=None):
self._wait_prefetch()
# Check prefetch buffer first
if key in self._prefetch_buf:
val = self._prefetch_buf.pop(key)
return val.to(device) if device is not None else val
if key in self._ram_store:
val = self._ram_store[key]
return val.to(device) if device is not None else val
if key in self._disk_store:
import numpy as np
arr = np.load(self._disk_store[key], mmap_mode='r')
t = torch.from_numpy(arr.copy())
return t.to(device) if device is not None else t
return None
def put(self, key, tensor):
if key in self._ram_store or key in self._disk_store:
return
cached = tensor.detach().half().cpu()
tensor_bytes = cached.nelement() * cached.element_size()
if self._use_disk(tensor_bytes) and not self._disk_failed:
try:
import hashlib, numpy as np
name_hash = hashlib.sha256(f"{key[0]}_{key[1]}".encode()).hexdigest()[:16]
path = os.path.join(self._cache_dir, f"{name_hash}.npy")
np.save(path, cached.numpy())
self._disk_store[key] = path
return
except Exception as e:
self._disk_failed = True
logging.warning(f"[DiffCache] Disk cache failed, falling back to RAM — {e}")
# RAM storage (also used as fallback when disk fails)
self._ram_store[key] = cached
self._ram_bytes += tensor_bytes
def size_mb(self):
total = self._ram_bytes
for path in self._disk_store.values():
try:
total += os.path.getsize(path)
except OSError:
pass
return total / (1024 * 1024)
def clear(self):
self._wait_prefetch()
self._prefetch_buf.clear()
self._ram_store.clear()
self._disk_store.clear()
self._ram_bytes = 0
if self._cache_dir is not None:
import shutil, tempfile
shutil.rmtree(self._cache_dir, ignore_errors=True)
self._cache_dir = tempfile.mkdtemp(prefix="lora_diff_cache_",
dir=folder_paths.get_temp_directory())
def __contains__(self, key):
return key in self._ram_store or key in self._disk_store
class _LoRAMergeBase:
"""
Base class for diff-based LoRA merging.
Computes full weight diffs (Up @ Down x alpha) for LoRAs of any rank,
then merges the diffs. Supports TIES-Merging (NeurIPS 2023) for
resolving sign conflicts.
Not a registered ComfyUI node — subclassed by LoRAOptimizer.
"""
def __init__(self):
self.loaded_loras = {}
@staticmethod
def _get_compute_device():
if torch.cuda.is_available():
return torch.device("cuda")
return torch.device("cpu")
@staticmethod
def _detect_architecture(lora_sd):
"""
Detect model architecture from LoRA key patterns.
Returns: 'zimage', 'flux', 'wan', 'acestep', 'sdxl', 'ltx', 'qwen_image', or 'unknown'.
"""
keys = list(lora_sd.keys())
keys_str = ' '.join(k.lower() for k in keys)
# Z-Image Turbo (Lumina2): layers.N with attention patterns
# Handles: diffusion_model.layers.N, single_transformer_blocks.N (non-FLUX),
# lora_unet_layers_N (Musubi Tuner)
if any('diffusion_model.layers.' in k and ('attention' in k or 'adaln' in k.lower())
for k in keys):
return 'zimage'
if any('lora_unet_layers_' in k and 'attention' in k.lower() for k in keys):
return 'zimage'
# single_transformer_blocks WITHOUT transformer. or Kohya transformer_ prefix = Z-Image
if any('single_transformer_blocks' in k
and 'transformer.single_transformer_blocks' not in k
and 'transformer_single_transformer_blocks' not in k
for k in keys):
return 'zimage'
# Qwen-Image: transformer_blocks with img_mlp/txt_mlp/img_mod/txt_mod
# Must be checked BEFORE FLUX — both use transformer.transformer_blocks
# but Qwen has dual-stream markers (img_mlp, txt_mlp, img_mod, txt_mod, add_q_proj).
# Also detect Qwen LoRAs that only target attention (to_q/to_k/to_v) without
# dual-stream markers — these have transformer.transformer_blocks but lack
# FLUX-specific double_blocks/single_blocks patterns.
_has_qwen_markers = any('transformer_blocks' in k and
any(x in k for x in ['img_mlp', 'txt_mlp', 'img_mod', 'txt_mod', 'add_q_proj'])
for k in keys)
if _has_qwen_markers:
return 'qwen_image'
# Qwen attention-only LoRAs: transformer.transformer_blocks with to_q/to_k/to_v
# but NO double_blocks/single_blocks (which would indicate FLUX)
_has_transformer_blocks = any('transformer.transformer_blocks' in k for k in keys)
_has_flux_blocks = any('double_blocks' in k or 'single_blocks' in k for k in keys)
if _has_transformer_blocks and not _has_flux_blocks:
# transformer.transformer_blocks without FLUX block patterns = Qwen-Image
return 'qwen_image'
# FLUX: double/single blocks in various trainer formats
if any('transformer.single_transformer_blocks' in k or 'transformer.transformer_blocks' in k
for k in keys):
return 'flux'
if any('transformer_single_transformer_blocks' in k or 'transformer_double_blocks' in k
for k in keys):
return 'flux'
if any('double_blocks' in k or 'single_blocks' in k for k in keys):
return 'flux'
# Wan: blocks.N with self_attn/cross_attn/ffn
if any(('blocks.' in k or 'blocks_' in k) and
any(x in k for x in ['self_attn', 'cross_attn', 'ffn'])
for k in keys):
return 'wan'
# ACE-Step: layers.N with self_attn/cross_attn using q_proj/k_proj/v_proj
if any('layers.' in k and ('self_attn' in k or 'cross_attn' in k)
and any(x in k for x in ['q_proj', 'k_proj', 'v_proj', 'o_proj'])
for k in keys):
return 'acestep'
# LTX Video: transformer_blocks with attn1/attn2 and adaln_single
if any('adaln_single' in k for k in keys):
return 'ltx'
has_img_mlp = any('img_mlp' in k for k in keys)
if not has_img_mlp and any(
'transformer_blocks' in k and ('attn1' in k or 'attn2' in k)
and 'input_blocks' not in k and 'output_blocks' not in k
and 'down_blocks' not in k and 'up_blocks' not in k
and 'middle_block' not in k and 'mid_block' not in k
for k in keys):
return 'ltx'
# SDXL: text encoders or UNet block patterns
if 'lora_te1_' in keys_str or 'lora_te2_' in keys_str:
return 'sdxl'
if any('input_blocks' in k or 'output_blocks' in k for k in keys):
return 'sdxl'
if any('down_blocks' in k or 'up_blocks' in k for k in keys):
return 'sdxl'
return 'unknown'
@staticmethod
def _normalize_keys_zimage(lora_sd):
"""
Normalize Z-Image Turbo (Lumina2) LoRA keys to a canonical format.
Handles:
1. Split fused QKV (attention.qkv) into separate to_q/to_k/to_v
for per-component conflict analysis during merge.
2. Remap attention.out -> attention.to_out.0 (diffusers convention).
3. Standardize Musubi Tuner format (lora_unet_layers_N_...).
4. Ensure diffusion_model.layers.N prefix.
Returns new dict with normalized keys. Original dict is not modified.
"""
normalized = {}
processed = set()
# First pass: standardize prefixes (Musubi Tuner -> canonical)
prefix_fixed = {}
for k, v in lora_sd.items():
new_k = k
# Strip PEFT prefix first so subsequent checks work
if new_k.startswith('base_model.model.'):
new_k = new_k[len('base_model.model.'):]
# Musubi Tuner: lora_unet_layers_N_attention_... -> diffusion_model.layers.N.attention...
if new_k.startswith('lora_unet_'):
new_k = new_k.replace('lora_unet_', 'diffusion_model.')
# Convert underscore-separated to dot-separated for known patterns
new_k = re.sub(r'layers_(\d+)_', r'layers.\1.', new_k)
new_k = re.sub(r'attention_', 'attention.', new_k)
new_k = re.sub(r'feed_forward_', 'feed_forward.', new_k)
# Ensure diffusion_model. prefix
if new_k.startswith('layers.'):
new_k = 'diffusion_model.' + new_k
prefix_fixed[new_k] = v
# Second pass: split fused QKV and remap output projection
# Find all layer indices
layer_pattern = re.compile(r'((?:diffusion_model\.)?layers\.(\d+)\.attention)\.')
layers_seen = set()
for k in prefix_fixed:
m = layer_pattern.search(k)
if m:
layers_seen.add((m.group(1), int(m.group(2))))
for base, layer_idx in layers_seen:
# --- Split fused QKV ---
# In fused QKV LoRAs: lora_down/A is [rank, in_features] (shared),
# lora_up/B is [3*out_features, rank] (fused). Only the up/B
# matrix needs splitting; the down/A matrix is copied to all three.
for lora_fmt in [('.lora_A.weight', '.lora_B.weight'),
('.lora_down.weight', '.lora_up.weight'),
('.lora.down.weight', '.lora.up.weight')]:
down_suffix, up_suffix = lora_fmt
qkv_down_key = f"{base}.qkv{down_suffix}"
qkv_up_key = f"{base}.qkv{up_suffix}"
if qkv_down_key in prefix_fixed and qkv_up_key in prefix_fixed:
qkv_down = prefix_fixed[qkv_down_key]
qkv_up = prefix_fixed[qkv_up_key]
# Only the up/B matrix is fused [3*out, rank] — split it
out_dim = qkv_up.shape[0]
if out_dim % 3 != 0:
continue # Not valid fused QKV, try next format
q_up, k_up, v_up = torch.chunk(qkv_up, 3, dim=0)
# Down/A is shared [rank, in] — copy to all three
for comp, comp_up in [('to_q', q_up),
('to_k', k_up),
('to_v', v_up)]:
normalized[f"{base}.{comp}{down_suffix}"] = qkv_down
normalized[f"{base}.{comp}{up_suffix}"] = comp_up
# Copy alpha (same for all three components)
alpha_key = f"{base}.qkv.alpha"
if alpha_key in prefix_fixed:
for comp in ('to_q', 'to_k', 'to_v'):
normalized[f"{base}.{comp}.alpha"] = prefix_fixed[alpha_key]
processed.add(alpha_key)
processed.add(qkv_down_key)
processed.add(qkv_up_key)
break # Found QKV format, don't try others
# --- Remap output projection: attention.out -> attention.to_out.0 ---
for lora_fmt in [('.lora_A.weight', '.lora_B.weight'),
('.lora_up.weight', '.lora_down.weight'),
('.lora_B.weight', '.lora_A.weight'),
('.lora.up.weight', '.lora.down.weight')]:
sfx_a, sfx_b = lora_fmt
out_a = f"{base}.out{sfx_a}"
out_b = f"{base}.out{sfx_b}"
if out_a in prefix_fixed and out_b in prefix_fixed:
normalized[f"{base}.to_out.0{sfx_a}"] = prefix_fixed[out_a]
normalized[f"{base}.to_out.0{sfx_b}"] = prefix_fixed[out_b]
processed.add(out_a)
processed.add(out_b)
out_alpha = f"{base}.out.alpha"
if out_alpha in prefix_fixed:
normalized[f"{base}.to_out.0.alpha"] = prefix_fixed[out_alpha]
processed.add(out_alpha)
break
# Pass through all unprocessed keys
for k, v in prefix_fixed.items():
if k not in processed:
normalized[k] = v
return normalized
# Compound component names in FLUX models where underscores must be preserved
_FLUX_COMPOUND_NAMES = sorted([
'img_attn', 'txt_attn', 'img_mlp', 'txt_mlp',
'img_mod', 'txt_mod', 'img_norm1', 'img_norm2',
'txt_norm1', 'txt_norm2', 'query_norm', 'key_norm',
'lora_up', 'lora_down', 'lora_A', 'lora_B',
'lora_mid', 'redux_up', 'redux_down',
], key=len, reverse=True) # longest first to avoid partial matches
@classmethod
def _flux_kohya_underscore_to_dot(cls, rest):
"""Convert Kohya underscore-separated key to dot-separated,
preserving compound component names like img_attn, lora_up."""
protected = []
for i, name in enumerate(cls._FLUX_COMPOUND_NAMES):
placeholder = f'\x00{i}\x00'
if name in rest:
rest = rest.replace(name, placeholder)
protected.append((placeholder, name))
rest = rest.replace('_', '.')
for placeholder, name in protected:
rest = rest.replace(placeholder, name)
return rest
@classmethod
def _normalize_keys_flux(cls, lora_sd):
"""
Normalize FLUX LoRA keys from various trainer formats to canonical format.
Canonical format: diffusion_model.double_blocks.N.* / diffusion_model.single_blocks.N.*
Handles:
- AI-Toolkit: transformer.transformer_blocks.N -> double_blocks.N
transformer.single_transformer_blocks.N -> single_blocks.N
- Kohya: lora_transformer_double_blocks_N -> double_blocks.N
lora_transformer_single_transformer_blocks_N -> single_blocks.N
- Standard: double_blocks.N / single_blocks.N (ensure prefix)
"""
normalized = {}
for k, v in lora_sd.items():
new_k = k
# Strip PEFT prefix first so subsequent checks work
if new_k.startswith('base_model.model.'):
new_k = new_k[len('base_model.model.'):]
# AI-Toolkit format
# transformer.single_transformer_blocks.N -> diffusion_model.single_blocks.N
new_k = re.sub(
r'^transformer\.single_transformer_blocks\.(\d+)\.',
r'diffusion_model.single_blocks.\1.', new_k)
# transformer.transformer_blocks.N -> diffusion_model.double_blocks.N
new_k = re.sub(
r'^transformer\.transformer_blocks\.(\d+)\.',
r'diffusion_model.double_blocks.\1.', new_k)
# Kohya underscore format — smart replacement preserving compound names
m = re.match(r'^lora_transformer_single_transformer_blocks_(\d+)_(.*)', new_k)
if m:
block_num = m.group(1)
rest = cls._flux_kohya_underscore_to_dot(m.group(2))
new_k = f"diffusion_model.single_blocks.{block_num}.{rest}"
m = re.match(r'^lora_transformer_double_blocks_(\d+)_(.*)', new_k)
if m:
block_num = m.group(1)
rest = cls._flux_kohya_underscore_to_dot(m.group(2))
new_k = f"diffusion_model.double_blocks.{block_num}.{rest}"
# Ensure diffusion_model. prefix for standard format
if new_k.startswith('double_blocks.') or new_k.startswith('single_blocks.'):
new_k = 'diffusion_model.' + new_k
# Generic transformer. prefix -> diffusion_model.
if new_k.startswith('transformer.'):
new_k = new_k.replace('transformer.', 'diffusion_model.', 1)
normalized[new_k] = v
return normalized
@staticmethod
def _normalize_keys_wan(lora_sd):
"""
Normalize Wan LoRA keys from various trainer formats to canonical format.
Canonical format: diffusion_model.blocks.N.{self_attn,cross_attn,ffn}.*
Handles LyCORIS, diffusers, Fun LoRA, finetrainer formats.
"""
normalized = {}
for k, v in lora_sd.items():
new_k = k
# Strip PEFT prefix first so subsequent checks work
if new_k.startswith('base_model.model.'):
new_k = new_k[len('base_model.model.'):]
# LyCORIS/aitoolkit format
if new_k.startswith('lycoris_blocks_'):
new_k = new_k.replace('lycoris_blocks_', 'blocks.')
# Add dot separator after block number
new_k = re.sub(r'^blocks\.(\d+)_', r'blocks.\1.', new_k)
# Use regex to match both underscore and dot as leading separator
# (dot comes from the block number fix above)
new_k = re.sub(r'[._]cross_attn[._]', '.cross_attn.', new_k)
new_k = re.sub(r'[._]self_attn[._]', '.self_attn.', new_k)
new_k = re.sub(r'[._]ffn_net_0_proj', '.ffn.0', new_k)
new_k = re.sub(r'[._]ffn_net_2', '.ffn.2', new_k)
new_k = new_k.replace('to_out_0', 'o')
# Diffusers format prefixes
if new_k.startswith('transformer.'):
new_k = new_k.replace('transformer.', 'diffusion_model.', 1)
if new_k.startswith('pipe.dit.'):
new_k = new_k.replace('pipe.dit.', 'diffusion_model.', 1)
if new_k.startswith('blocks.'):
new_k = 'diffusion_model.' + new_k
if new_k.startswith('vace_blocks.'):
new_k = 'diffusion_model.' + new_k
# Common diffusers cleanup
new_k = new_k.replace('.default.', '.')
new_k = new_k.replace('.diff_m', '.modulation.diff')
# Fun LoRA format: lora_unet__blocks_N_...
if new_k.startswith('lora_unet__'):
parts = new_k.split('.')
main_part = parts[0]
weight_type = '.'.join(parts[1:]) if len(parts) > 1 else None
if 'blocks_' in main_part:
components = main_part[len('lora_unet__'):].split('_')
rebuilt = 'diffusion_model'
if components[0] == 'blocks':
rebuilt += f".blocks.{components[1]}"
idx = 2
if idx < len(components):
if (components[idx] == 'self' and idx + 1 < len(components)
and components[idx + 1] == 'attn'):
rebuilt += '.self_attn'
idx += 2
elif (components[idx] == 'cross' and idx + 1 < len(components)
and components[idx + 1] == 'attn'):
rebuilt += '.cross_attn'
idx += 2
elif components[idx] == 'ffn':
rebuilt += '.ffn'
idx += 1
if idx < len(components):
component = components[idx]
idx += 1
if idx < len(components) and components[idx] == 'img':
component += '_img'
idx += 1
rebuilt += f'.{component}'
# Append any remaining components with dot separators
while idx < len(components):
rebuilt += f'.{components[idx]}'
idx += 1
if weight_type:
if weight_type == 'alpha':
rebuilt += '.alpha'
elif weight_type in ('lora_down.weight', 'lora_down'):
rebuilt += '.lora_A.weight'
elif weight_type in ('lora_up.weight', 'lora_up'):
rebuilt += '.lora_B.weight'
else:
rebuilt += f'.{weight_type}'
if not rebuilt.endswith('.weight'):
rebuilt += '.weight'
new_k = rebuilt
else:
new_k = main_part.replace('lora_unet__', 'diffusion_model.')
new_k = new_k.replace('_', '.')
if weight_type:
if weight_type == 'alpha':
new_k += '.alpha'
elif weight_type in ('lora_down.weight', 'lora_down'):
new_k += '.lora_A.weight'
elif weight_type in ('lora_up.weight', 'lora_up'):
new_k += '.lora_B.weight'
else:
new_k += f'.{weight_type}'
if not new_k.endswith('.weight'):
new_k += '.weight'
# Finetrainer format
new_k = new_k.replace('.attn1.to_q.', '.self_attn.q.')
new_k = new_k.replace('.attn1.to_k.', '.self_attn.k.')
new_k = new_k.replace('.attn1.to_v.', '.self_attn.v.')
new_k = new_k.replace('.attn1.to_out.0.', '.self_attn.o.')
new_k = new_k.replace('.attn2.to_q.', '.cross_attn.q.')
new_k = new_k.replace('.attn2.to_k.', '.cross_attn.k.')
new_k = new_k.replace('.attn2.to_v.', '.cross_attn.v.')
new_k = new_k.replace('.attn2.to_out.0.', '.cross_attn.o.')
normalized[new_k] = v
# Note: RS-LoRA compensation removed. RS-LoRA files omit alpha and
# rely on sqrt(rank) scaling, but we can't distinguish them from
# standard PEFT LoRAs that also omit alpha. False positives cause
# ~4x weight amplification (rank 16) to ~5.66x (rank 32). When alpha
# is missing, _get_lora_key_info defaults alpha=rank (scale=1.0),
# which is correct for standard LoRAs and only slightly weak for
# RS-LoRA.
return normalized
@staticmethod
def _normalize_keys_sdxl(lora_sd):
"""
Normalize SDXL LoRA keys to canonical format.
Canonical format: lora_unet_* / lora_te1_* / lora_te2_* (Kohya convention).
Handles diffusers-format keys (down_blocks.N, up_blocks.N, mid_block).
"""
normalized = {}
for k, v in lora_sd.items():
new_k = k
# Strip PEFT prefix first so subsequent checks work
if new_k.startswith('base_model.model.'):
new_k = new_k[len('base_model.model.'):]
# Diffusers format: text_encoder.* -> lora_te1_*, text_encoder_2.* -> lora_te2_*
if new_k.startswith('text_encoder_2.'):
new_k = 'lora_te2_' + new_k[len('text_encoder_2.'):].replace('.', '_')
elif new_k.startswith('text_encoder.'):
new_k = 'lora_te1_' + new_k[len('text_encoder.'):].replace('.', '_')
# Diffusers UNet: unet.* -> lora_unet_*
if new_k.startswith('unet.'):
new_k = 'lora_unet_' + new_k[len('unet.'):].replace('.', '_')
normalized[new_k] = v
return normalized
@staticmethod
def _normalize_keys_ltx(lora_sd):
"""
Normalize LTX Video LoRA keys to canonical format.
Canonical format: diffusion_model.transformer_blocks.N.attn1/attn2.to_q/to_k/to_v.*
LTX uses standard separate Q/K/V — only prefix standardization needed.
"""
normalized = {}
for k, v in lora_sd.items():
new_k = k
# Strip PEFT prefix first so subsequent checks work
if new_k.startswith('base_model.model.'):
new_k = new_k[len('base_model.model.'):]
# Kohya format: lora_unet_transformer_blocks_N_... -> diffusion_model.transformer_blocks.N...
if new_k.startswith('lora_unet_'):
new_k = new_k.replace('lora_unet_', 'diffusion_model.')
# Convert underscores back to dots for known structural segments
new_k = re.sub(r'transformer_blocks_(\d+)_', r'transformer_blocks.\1.', new_k)
new_k = re.sub(r'attn(\d)_', r'attn\1.', new_k)
new_k = re.sub(r'to_(q|k|v|out)_', r'to_\1.', new_k)
new_k = re.sub(r'ff_net_(\d+)_', r'ff.net.\1.', new_k)
# Diffusers format: unet.* -> diffusion_model.*
if new_k.startswith('unet.'):
new_k = new_k.replace('unet.', 'diffusion_model.', 1)
# Ensure diffusion_model. prefix
if new_k.startswith('transformer_blocks.'):
new_k = 'diffusion_model.' + new_k
normalized[new_k] = v
return normalized
@staticmethod
def _normalize_keys_qwen_image(lora_sd):
"""
Normalize Qwen-Image LoRA keys to canonical format.
Canonical format: diffusion_model.transformer_blocks.N.*
Qwen-Image uses separate Q/K/V with dual-stream (image+text) attention.
Supports transformer.*, lycoris_*, and direct key formats.
"""
normalized = {}
for k, v in lora_sd.items():
new_k = k
# Strip PEFT prefix first so subsequent checks work
if new_k.startswith('base_model.model.'):
new_k = new_k[len('base_model.model.'):]
# LyCORIS format: lycoris_transformer_blocks_N_... -> diffusion_model.transformer_blocks.N...
if new_k.startswith('lycoris_'):
new_k = new_k.replace('lycoris_', 'diffusion_model.')
new_k = re.sub(r'transformer_blocks_(\d+)_', r'transformer_blocks.\1.', new_k)
# Restore dots for known component names
for comp in ['to_q', 'to_k', 'to_v', 'add_q_proj', 'add_k_proj', 'add_v_proj',
'img_mlp', 'txt_mlp', 'img_mod', 'txt_mod', 'img_norm1', 'img_norm2',
'txt_norm1', 'txt_norm2']:
new_k = new_k.replace(f'_{comp}_', f'.{comp}.')
if new_k.endswith(f'_{comp}'):
new_k = new_k[:-len(f'_{comp}')] + f'.{comp}'
# transformer.* -> diffusion_model.*
if new_k.startswith('transformer.'):
new_k = new_k.replace('transformer.', 'diffusion_model.', 1)
# Ensure diffusion_model. prefix
if new_k.startswith('transformer_blocks.'):
new_k = 'diffusion_model.' + new_k
normalized[new_k] = v
return normalized
_ACESTEP_COMPOUND_NAMES = sorted([
"self_attn", "cross_attn",
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj",
"lora_up", "lora_down", "lora_A", "lora_B", "lora_mid",
], key=len, reverse=True)
@classmethod
def _acestep_underscore_to_dot(cls, rest):
"""Convert Kohya-style ACE-Step keys to dotted form while preserving
compound component names like self_attn and q_proj."""
protected = []
for i, name in enumerate(cls._ACESTEP_COMPOUND_NAMES):
placeholder = f"\x01{i}\x01"
if name in rest:
rest = rest.replace(name, placeholder)
protected.append((placeholder, name))
rest = rest.replace("_", ".")
for placeholder, name in protected:
rest = rest.replace(placeholder, name)
return rest
@classmethod
def _normalize_keys_acestep(cls, lora_sd):
"""
Normalize ACE-Step LoRA keys to canonical
diffusion_model.layers.N.{self,cross}_attn.* form.
"""
normalized = {}
for k, v in lora_sd.items():
new_k = k
if new_k.startswith("base_model.model."):
new_k = new_k[len("base_model.model."):]
if new_k.startswith("lora_unet_"):
rest = new_k[len("lora_unet_"):]
rest = cls._acestep_underscore_to_dot(rest)
new_k = f"diffusion_model.{rest}"
new_k = re.sub(r"^transformer\.", "diffusion_model.", new_k)
new_k = re.sub(r"^model\.", "diffusion_model.", new_k)
if new_k.startswith("layers."):
new_k = "diffusion_model." + new_k
if new_k.startswith("diffusion_model.layers."):
new_k = re.sub(r"\.to_(q|k|v|out)\.", lambda m: f".{m.group(1)}_proj.", new_k)
normalized[new_k] = v
return normalized
@classmethod
def _normalize_keys(cls, lora_sd, architecture):
"""
Dispatch to architecture-specific key normalizer.
Returns a new dict with normalized keys.
"""
if architecture == 'zimage':
return cls._normalize_keys_zimage(lora_sd)
elif architecture == 'flux':
return cls._normalize_keys_flux(lora_sd)
elif architecture == 'wan':
return cls._normalize_keys_wan(lora_sd)
elif architecture == 'acestep':
return cls._normalize_keys_acestep(lora_sd)
elif architecture == 'sdxl':
return cls._normalize_keys_sdxl(lora_sd)
elif architecture == 'ltx':
return cls._normalize_keys_ltx(lora_sd)
elif architecture == 'qwen_image':
return cls._normalize_keys_qwen_image(lora_sd)
return lora_sd # unknown — pass through unchanged
@staticmethod
def _refuse_zimage_patches(patches):
"""
Re-fuse split to_q/to_k/to_v patches back into fused QKV patches
for Z-Image Turbo models. Also remaps to_out.0 -> out.
Called after merging, before applying patches to the model.
Returns a new dict with fused patches.
"""
fused = {}
qkv_groups = {} # base -> {comp: (key, patch)}
for key, patch in patches.items():
# Handle both string keys and tuple keys
if isinstance(key, tuple):
key_str = key[0]
else:
key_str = key
# Detect to_q/to_k/to_v patterns in the key
m = re.search(r'(layers\.\d+\.attention)\.to_(q|k|v)(?:\.|$)', key_str)
if m:
base = m.group(1)
comp = m.group(2)
if base not in qkv_groups:
qkv_groups[base] = {}
qkv_groups[base][comp] = (key, patch)
continue
# Detect to_out.0 -> out remap
m_out = re.search(r'(layers\.\d+\.attention)\.to_out\.0(?:\.|$)', key_str)
if m_out:
new_key_str = key_str.replace('.to_out.0', '.out')
if isinstance(key, tuple):
new_key = (new_key_str,) + key[1:]
else:
new_key = new_key_str
fused[new_key] = patch
continue
# Not a QKV or out key — pass through
fused[key] = patch
# Fuse QKV groups
for base, comps in qkv_groups.items():
if len(comps) == 3 and 'q' in comps and 'k' in comps and 'v' in comps:
# All three components present — fuse
q_key, q_patch = comps['q']
k_key, k_patch = comps['k']
v_key, v_patch = comps['v']
# Build the fused key name
if isinstance(q_key, tuple):
fused_key_str = re.sub(r'\.to_q(?=\.|$)', '.qkv', q_key[0])
fused_key = (fused_key_str,) + q_key[1:]
else:
fused_key_str = re.sub(r'\.to_q(?=\.|$)', '.qkv', q_key)
fused_key = fused_key_str
# Handle different patch formats
if all(isinstance(p, tuple) and p[0] == "diff" for p in [q_patch, k_patch, v_patch]):
# Full-rank diff patch: ("diff", (tensor,))
q_diff = q_patch[1][0]
k_diff = k_patch[1][0]
v_diff = v_patch[1][0]
store_dtype = q_diff.dtype
fused_diff = torch.cat([q_diff, k_diff, v_diff], dim=0)
if store_dtype not in (torch.float32, torch.float64):
fused_diff = fused_diff.to(store_dtype)
fused[fused_key] = ("diff", (fused_diff,))
elif all(isinstance(p, LoRAAdapter) for p in [q_patch, k_patch, v_patch]):
q_data = q_patch.weights
k_data = k_patch.weights
v_data = v_patch.weights
# weights = (mat_up, mat_down, alpha, mid, dora_scale, reshape)
# Check if down matrices are shared (true for original LoRA,
# false for independently SVD-compressed patches)
if q_data[1] is k_data[1] and k_data[1] is v_data[1]:
# Shared down: concatenate ups, keep one down copy
fused_up = torch.cat([q_data[0], k_data[0], v_data[0]], dim=0)
fused_down = q_data[1]
fused_alpha = q_data[2]
fused_patch = LoRAAdapter(set(), (fused_up, fused_down, fused_alpha, None, None, None))
fused[fused_key] = fused_patch
else:
# Independent decompositions (e.g., SVD-compressed) —
# expand to full-rank diffs, then fuse as a single diff patch
parts = []
store_dtype = q_data[0].dtype
for comp_data in [q_data, k_data, v_data]:
alpha = comp_data[2] if comp_data[2] is not None else float(comp_data[1].shape[0])
rank = comp_data[1].shape[0]
diff = torch.mm(comp_data[0].float(), comp_data[1].float()) * (alpha / rank)
parts.append(diff)
fused_diff = torch.cat(parts, dim=0)
if store_dtype not in (torch.float32, torch.float64):
fused_diff = fused_diff.to(store_dtype)
fused[fused_key] = ("diff", (fused_diff,))
elif any(hasattr(p, "weights") for p in [q_patch, k_patch, v_patch]):
store_dtype = torch.float16
for candidate in [q_patch, k_patch, v_patch]:
if hasattr(candidate, "weights") and candidate.weights[0] is not None:
dtype = candidate.weights[0].dtype
if dtype not in (torch.float32, torch.float64):
store_dtype = dtype
break
parts = [_LoRAMergeBase._expand_patch_to_diff(comp_patch) for comp_patch in [q_patch, k_patch, v_patch]]
fused_diff = torch.cat(parts, dim=0)
if store_dtype not in (torch.float32, torch.float64):
fused_diff = fused_diff.to(store_dtype)
fused[fused_key] = ("diff", (fused_diff,))
else:
# Unknown patch format — pass through unfused
for comp_key, comp_patch in [(q_key, q_patch), (k_key, k_patch), (v_key, v_patch)]:
fused[comp_key] = comp_patch
else:
# Incomplete QKV group — pass through individual components
for comp, (comp_key, comp_patch) in comps.items():
fused[comp_key] = comp_patch
return fused
def _load_lora(self, lora_name):
"""Loads LoRA file with caching"""
if lora_name == "None" or lora_name is None:
return None
if lora_name in self.loaded_loras:
return self.loaded_loras[lora_name]
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
lora = comfy.utils.load_torch_file(lora_path, safe_load=True)
self.loaded_loras[lora_name] = lora
return lora
def _get_model_keys(self, model):
"""Get LoRA prefix → target key mapping for the model."""
if model is None:
return {}
return comfy.lora.model_lora_keys_unet(model.model, {})
@staticmethod
def _collect_lora_prefixes(active_loras):
"""Collect all LoRA key prefixes from a stack in deterministic order."""
all_lora_prefixes = set()
suffixes = [
".lora_up.weight", ".lora_down.weight",
"_lora.up.weight", "_lora.down.weight",
".lora_B.weight", ".lora_A.weight",
".lora.up.weight", ".lora.down.weight",
# LoKr (Kronecker)
".lokr_w1", ".lokr_w2",
".lokr_w1_a", ".lokr_w1_b",
".lokr_w2_a", ".lokr_w2_b",
".lokr_t2",
# LoHa (Hadamard)
".hada_w1_a", ".hada_w1_b",
".hada_w2_a", ".hada_w2_b",
".hada_t1", ".hada_t2",
".alpha",
]
for item in active_loras:
for key in item["lora"].keys():
for suffix in suffixes:
if key.endswith(suffix):
all_lora_prefixes.add(key[:-len(suffix)])
break
return sorted(all_lora_prefixes)
@staticmethod
def _resolve_target_key(lora_prefix, model_keys, clip_keys):
"""Resolve a LoRA prefix to a model/CLIP target key."""
if lora_prefix in model_keys:
return (model_keys[lora_prefix], False)
if lora_prefix in clip_keys:
return (clip_keys[lora_prefix], True)
return (None, False)
@staticmethod
def _make_target_group_id(target_key, is_clip):
return (bool(is_clip), target_key)
@staticmethod
def _choose_canonical_prefix(aliases):
"""Pick a stable human-readable label for an alias group."""
if not aliases:
return ""
def _sort_key(alias):
return (
alias.startswith("lora_"),
alias.count("_"),
len(alias),
alias,
)
return min(sorted(set(aliases)), key=_sort_key)
def _build_target_groups(self, all_lora_prefixes, model_keys, clip_keys):
"""
Group aliases by resolved (is_clip, target_key) so analysis/merge operates
on actual model weights rather than raw trainer-specific prefixes.
"""
grouped = {}
for prefix in all_lora_prefixes:
target_key, is_clip = self._resolve_target_key(prefix, model_keys, clip_keys)
if target_key is None:
continue
group_id = self._make_target_group_id(target_key, is_clip)
entry = grouped.setdefault(group_id, {
"target_key": target_key,
"is_clip": is_clip,
"aliases": [],
})
entry["aliases"].append(prefix)
ordered = {}
prepared = []
for entry in grouped.values():
aliases = sorted(set(entry["aliases"]))
canonical = self._choose_canonical_prefix(aliases)
prepared.append((entry["is_clip"], canonical, {
"target_key": entry["target_key"],
"is_clip": entry["is_clip"],
"aliases": aliases,
"label_prefix": canonical,
}))
for _is_clip, canonical, entry in sorted(prepared, key=lambda item: (item[0], item[1])):
ordered[canonical] = entry
return ordered
@staticmethod
def _group_target_key(target_group):
return target_group["target_key"]
def _resolve_target_shape(self, target_key, is_clip, model, clip):
"""Resolve the actual target tensor shape for a target key."""
offset = None
if isinstance(target_key, tuple):
actual_key = target_key[0]
if len(target_key) > 1:
offset = target_key[1]
else:
actual_key = target_key
if is_clip:
target_weight = comfy.utils.get_attr(clip.cond_stage_model, actual_key)
else:
target_weight = comfy.utils.get_attr(model.model, actual_key)
target_shape = list(target_weight.shape)
if offset is not None:
target_shape[offset[0]] = offset[2]
return torch.Size(target_shape)
@staticmethod
def _resolve_branch_strength(item, is_clip, clip_strength_multiplier):
"""Base per-LoRA strength for the target branch before auto-scaling."""
if is_clip:
if item["clip_strength"] is not None:
return item["clip_strength"]
return item["strength"]
return item["strength"]
def _apply_conflict_modes(self, diffs, eff_strengths, active_loras, merge_refinement="none"):
"""Apply per-LoRA conflict_mode masking to already-aggregated diffs."""
if len(diffs) <= 1:
return diffs
if not any(active_loras[idx].get("conflict_mode", "all") != "all" for idx in diffs):
return diffs
indices = sorted(diffs.keys())
ref = diffs[indices[0]]
if merge_refinement != "none" and ref.dim() >= 2:
out_dim = ref.shape[0]
sign_sum = torch.zeros(out_dim, device=ref.device, dtype=torch.float32)
for idx in indices:
effective = diffs[idx] if eff_strengths[idx] >= 0 else -diffs[idx]
sign_sum += effective.reshape(out_dim, -1).to(dtype=torch.float32).sum(dim=1).sign()
majority_sign = torch.where(sign_sum >= 0, 1.0, -1.0)
majority_sign = majority_sign.reshape(-1, *([1] * (ref.dim() - 1))).expand_as(ref)
else:
sign_sum = torch.zeros_like(ref, dtype=torch.float32)
for idx in indices:
effective = diffs[idx] if eff_strengths[idx] >= 0 else -diffs[idx]
sign_sum += effective.sign()
majority_sign = torch.where(sign_sum >= 0, 1.0, -1.0)
masked = {}
for idx in indices:
diff = diffs[idx]
effective = diff if eff_strengths[idx] >= 0 else -diff
cm = active_loras[idx].get("conflict_mode", "all")
if cm == "low_conflict":
diff = diff * ((effective * majority_sign) > 0).float()
elif cm == "high_conflict":
diff = diff * ((effective * majority_sign) < 0).float()
masked[idx] = diff
return masked
def _prepare_group_diffs(self, target_group, active_loras, model, clip, device,
clip_strength_multiplier=1.0, merge_refinement="none",
diff_cache=None, auto_scale=1.0):
"""
Aggregate all alias contributions that resolve to the same target weight.
Returns metadata plus one diff per contributing LoRA after key_filter and
conflict_mode are applied.
"""
target_key = self._group_target_key(target_group)
is_clip = target_group["is_clip"]
try:
target_shape = self._resolve_target_shape(target_key, is_clip, model, clip)
except (AttributeError, RuntimeError, IndexError):
return None
use_gpu = device is not None and device.type != "cpu"
aggregated = {}
ranks = {}
raw_contributors = set()
storage_dtype = None
skip_count = 0
for i, item in enumerate(active_loras):
diff_accum = None
rank_sum = 0
for alias in target_group["aliases"]:
cache_key = (alias, i)
if diff_cache is not None and cache_key in diff_cache:
diff = diff_cache.get(cache_key, device=device if use_gpu else None)
if diff is not None:
diff = diff.float()
raw_contributors.add(i)
rank_sum += 1 # rank unknown from cache
diff_accum = diff if diff_accum is None else diff_accum + diff
continue
lora_info = self._get_lora_key_info(item["lora"], alias)
if lora_info is not None:
mat_up, mat_down, alpha, mid = lora_info
rank_sum += mat_down.shape[0]
raw_contributors.add(i)
if storage_dtype is None:
storage_dtype = mat_up.dtype
diff = self._compute_lora_diff(
mat_up, mat_down, alpha, mid, target_shape,
device=device if use_gpu else None,
to_cpu=not use_gpu,
)
else:
# Try LoKr / LoHa formats
alt = self._get_lokr_diff(
item["lora"], alias,
device=device if use_gpu else None, to_cpu=not use_gpu,
)
if alt is None:
alt = self._get_loha_diff(
item["lora"], alias,
device=device if use_gpu else None, to_cpu=not use_gpu,
)
if alt is not None:
diff, alt_rank, alt_dtype = alt
try:
diff = diff.reshape(target_shape)
except RuntimeError:
diff = None
if diff is not None:
rank_sum += alt_rank
raw_contributors.add(i)
if storage_dtype is None:
storage_dtype = alt_dtype
else:
diff = None
if diff is not None:
if diff_cache is not None:
diff_cache.put(cache_key, diff)
diff = diff.float()
else:
skip_count += 1
continue
diff_accum = diff if diff_accum is None else diff_accum + diff
if diff_accum is not None:
aggregated[i] = diff_accum
ranks[i] = rank_sum
raw_n = len(aggregated)
if raw_n == 0:
if skip_count > 0:
return {
"label_prefix": target_group["label_prefix"],
"target_key": target_key,
"is_clip": is_clip,
"raw_n_loras": 0,
"diffs": {},
"eff_strengths": {},
"rank_sums": {},
"target_shape": target_shape,
"storage_dtype": storage_dtype,
"skip_count": skip_count,
}
return None
filtered = {}
eff_strengths = {}
rank_sums = {}
for i, diff in aggregated.items():
kf = active_loras[i].get("key_filter", "all")
if kf == "shared_only" and raw_n < 2:
continue
if kf == "unique_only" and raw_n != 1:
continue
filtered[i] = diff
eff_strengths[i] = self._resolve_branch_strength(
active_loras[i], is_clip, clip_strength_multiplier
) * auto_scale
rank_sums[i] = ranks.get(i, 0)
if not filtered:
return {
"label_prefix": target_group["label_prefix"],
"target_key": target_key,
"is_clip": is_clip,
"raw_n_loras": raw_n,
"diffs": {},
"eff_strengths": {},
"rank_sums": {},
"target_shape": target_shape,
"storage_dtype": storage_dtype,
"skip_count": skip_count,
}
filtered = self._apply_conflict_modes(
filtered, eff_strengths, active_loras, merge_refinement=merge_refinement
)
return {
"label_prefix": target_group["label_prefix"],
"target_key": target_key,
"is_clip": is_clip,
"raw_n_loras": raw_n,
"diffs": filtered,
"eff_strengths": eff_strengths,
"rank_sums": rank_sums,
"target_shape": target_shape,
"storage_dtype": storage_dtype,
"skip_count": skip_count,
}
def _get_lora_key_info(self, lora_dict, key_prefix):
"""
Extracts LoRA information for the given key.
Returns (mat_up, mat_down, alpha, mid) or None for standard LoRA.
"""
# LoRA key formats
formats = [
("{}.lora_up.weight", "{}.lora_down.weight"), # regular
("{}_lora.up.weight", "{}_lora.down.weight"), # diffusers
("{}.lora_B.weight", "{}.lora_A.weight"), # diffusers2
("{}.lora.up.weight", "{}.lora.down.weight"), # diffusers3
]
for up_fmt, down_fmt in formats:
up_key = up_fmt.format(key_prefix)
down_key = down_fmt.format(key_prefix)
if up_key in lora_dict and down_key in lora_dict:
mat_up = lora_dict[up_key]
mat_down = lora_dict[down_key]
# Alpha
alpha_key = "{}.alpha".format(key_prefix)
alpha = lora_dict.get(alpha_key, None)
if alpha is not None:
alpha = alpha.item()
else:
alpha = mat_down.shape[0] # rank as default
# Mid (for LoCon)
mid_key = "{}.lora_mid.weight".format(key_prefix)
mid = lora_dict.get(mid_key, None)
return (mat_up, mat_down, alpha, mid)
return None
@staticmethod
def _has_lokr_keys(lora_dict, key_prefix):
"""Check if lora_dict has LoKr keys for the given prefix (no tensor loading)."""
p = key_prefix
has_w1 = f"{p}.lokr_w1" in lora_dict or (f"{p}.lokr_w1_a" in lora_dict and f"{p}.lokr_w1_b" in lora_dict)
has_w2 = f"{p}.lokr_w2" in lora_dict or (f"{p}.lokr_w2_a" in lora_dict and f"{p}.lokr_w2_b" in lora_dict)
return has_w1 and has_w2
@staticmethod
def _has_loha_keys(lora_dict, key_prefix):
"""Check if lora_dict has LoHa keys for the given prefix (no tensor loading)."""
p = key_prefix
return (f"{p}.hada_w1_a" in lora_dict and f"{p}.hada_w1_b" in lora_dict
and f"{p}.hada_w2_a" in lora_dict and f"{p}.hada_w2_b" in lora_dict)
def _get_lokr_diff(self, lora_dict, key_prefix, device=None, to_cpu=True):
"""
Extract LoKr (Kronecker) factors and compute full diff.
Returns (diff, rank, dtype) or None.
"""
p = key_prefix
w1 = lora_dict.get(f"{p}.lokr_w1")
w2 = lora_dict.get(f"{p}.lokr_w2")
w1_a = lora_dict.get(f"{p}.lokr_w1_a")
w1_b = lora_dict.get(f"{p}.lokr_w1_b")
w2_a = lora_dict.get(f"{p}.lokr_w2_a")
w2_b = lora_dict.get(f"{p}.lokr_w2_b")
t2 = lora_dict.get(f"{p}.lokr_t2")
has_w1 = w1 is not None or (w1_a is not None and w1_b is not None)
has_w2 = w2 is not None or (w2_a is not None and w2_b is not None)
if not (has_w1 and has_w2):
return None
alpha = lora_dict.get(f"{p}.alpha")
if alpha is not None:
alpha = alpha.item()
dim = None
ref_tensor = w1 if w1 is not None else (w1_a if w1_a is not None else w2_a)
dtype = ref_tensor.dtype
if device is not None:
w1 = w1.to(device) if w1 is not None else None
w2 = w2.to(device) if w2 is not None else None
w1_a = w1_a.to(device) if w1_a is not None else None
w1_b = w1_b.to(device) if w1_b is not None else None
w2_a = w2_a.to(device) if w2_a is not None else None
w2_b = w2_b.to(device) if w2_b is not None else None
t2 = t2.to(device) if t2 is not None else None
if w1 is None:
dim = w1_b.shape[0]
w1 = torch.mm(w1_a.float(), w1_b.float())
else:
w1 = w1.float()
if w2 is None:
dim = w2_b.shape[0]
if t2 is None:
w2 = torch.mm(w2_a.float(), w2_b.float())
else:
w2 = torch.einsum(
"i j k l, j r, i p -> p r k l",
t2.float(), w2_b.float(), w2_a.float(),
)
else:
w2 = w2.float()
if len(w2.shape) == 4:
w1 = w1.unsqueeze(2).unsqueeze(2)
scale = alpha / dim if (alpha is not None and dim is not None) else 1.0
diff = torch.kron(w1, w2) * scale
rank = dim if dim is not None else min(w1.shape)
if to_cpu and diff.device.type != "cpu":
diff = diff.cpu()
return (diff, rank, dtype)
def _get_loha_diff(self, lora_dict, key_prefix, device=None, to_cpu=True):
"""
Extract LoHa (Hadamard) factors and compute full diff.
Returns (diff, rank, dtype) or None.
"""
p = key_prefix
w1a = lora_dict.get(f"{p}.hada_w1_a")
w1b = lora_dict.get(f"{p}.hada_w1_b")
w2a = lora_dict.get(f"{p}.hada_w2_a")
w2b = lora_dict.get(f"{p}.hada_w2_b")
if w1a is None or w1b is None or w2a is None or w2b is None:
return None
t1 = lora_dict.get(f"{p}.hada_t1")
t2 = lora_dict.get(f"{p}.hada_t2")
alpha = lora_dict.get(f"{p}.alpha")
if alpha is not None:
alpha = alpha.item()
dtype = w1a.dtype
rank = w1b.shape[0]
if device is not None:
w1a = w1a.to(device)
w1b = w1b.to(device)
w2a = w2a.to(device)
w2b = w2b.to(device)
t1 = t1.to(device) if t1 is not None else None
t2 = t2.to(device) if t2 is not None else None
if t1 is not None:
m1 = torch.einsum(
"i j k l, j r, i p -> p r k l",
t1.float(), w1b.float(), w1a.float(),
)
m2 = torch.einsum(
"i j k l, j r, i p -> p r k l",
t2.float(), w2b.float(), w2a.float(),
)
else:
m1 = torch.mm(w1a.float(), w1b.float())
m2 = torch.mm(w2a.float(), w2b.float())
scale = alpha / rank if alpha is not None else 1.0
diff = (m1 * m2) * scale
if to_cpu and diff.device.type != "cpu":
diff = diff.cpu()
return (diff, rank, dtype)
def _compute_lora_diff(self, mat_up, mat_down, alpha, mid, target_shape, device=None, to_cpu=True):
"""
Computes full diff for a single LoRA.
diff = mat_up @ mat_down × (alpha / rank)
When device is given, matrices are moved there for faster matmul,
then the result is returned on CPU to avoid VRAM accumulation.
"""
rank = mat_down.shape[0]
scale = alpha / rank
if device is not None:
mat_up = mat_up.to(device)
mat_down = mat_down.to(device)
if mid is not None:
mid = mid.to(device)
if mid is not None:
# LoCon with mid matrix (rare)
final_shape = [mat_down.shape[1], mat_down.shape[0], mid.shape[2], mid.shape[3]]
mat_down = (
torch.mm(
mat_down.transpose(0, 1).flatten(start_dim=1).float(),
mid.transpose(0, 1).flatten(start_dim=1).float(),
)
.reshape(final_shape)
.transpose(0, 1)
)
# Compute diff
diff = torch.mm(
mat_up.flatten(start_dim=1).float(),
mat_down.flatten(start_dim=1).float()
)
# Try to reshape to target shape
try:
diff = diff.reshape(target_shape)
except RuntimeError:
# If shape doesn't match, skip
return None
diff = diff * scale
if to_cpu and device is not None and device.type != "cpu":
return diff.cpu()
return diff
@staticmethod
def _stable_data_hash(value):
"""Create a compact stable hash for nested JSON-like data and tensors."""
def _normalize(obj):
if isinstance(obj, torch.Tensor):
t = obj.detach().float().cpu()
sample = t.flatten()[:16].tolist()
return {
"__tensor__": True,
"shape": list(t.shape),
"dtype": str(t.dtype),
"sample": sample,
}
if isinstance(obj, dict):
return {str(k): _normalize(obj[k]) for k in sorted(obj.keys(), key=str)}
if isinstance(obj, (list, tuple)):
return [_normalize(v) for v in obj]
if isinstance(obj, (str, int, float, bool)) or obj is None:
return obj
return repr(obj)
payload = json.dumps(_normalize(value), sort_keys=True, separators=(",", ":"))
return hashlib.sha256(payload.encode()).hexdigest()[:16]
@staticmethod
def _safe_unit_clamp(value):
return max(-1.0, min(1.0, float(value)))
@staticmethod
def _compute_subspace_basis(diff, rank_hint=None, max_rank=8):
if diff is None:
return None
if diff.dim() >= 2:
mat = diff.reshape(diff.shape[0], -1).float()
else:
mat = diff.float().reshape(1, -1)
if min(mat.shape) <= 0:
return None
rank_cap = min(max_rank, min(mat.shape))
if rank_hint is not None:
rank_cap = min(rank_cap, max(1, int(rank_hint)))
rank_cap = max(rank_cap, 1)
try:
q = min(rank_cap, min(mat.shape))
U, _S, V = torch.svd_lowrank(mat, q=q)
return {"left": U[:, :q], "right": V[:, :q]}
except Exception:
try:
left, _ = torch.linalg.qr(mat, mode="reduced")
right, _ = torch.linalg.qr(mat.T, mode="reduced")
return {
"left": left[:, :rank_cap],
"right": right[:, :rank_cap],
}
except Exception:
return None
@classmethod
def _compute_subspace_overlap(cls, basis_a, basis_b):
if not basis_a or not basis_b:
return 0.0
def _project_overlap(q_a, q_b):
if q_a is None or q_b is None or q_a.numel() == 0 or q_b.numel() == 0:
return None
cross = torch.mm(q_a.transpose(0, 1), q_b)
denom = max(1, min(q_a.shape[1], q_b.shape[1]))
value = (cross.square().sum() / denom).item()
return min(max(value, 0.0), 1.0)
left = _project_overlap(basis_a.get("left"), basis_b.get("left"))
right = _project_overlap(basis_a.get("right"), basis_b.get("right"))
values = [v for v in (left, right) if v is not None]
return sum(values) / len(values) if values else 0.0
def _sample_pair_metrics(self, diff_a, diff_b, basis_a=None, basis_b=None, device=None):
"""
Pairwise overlap/conflict metrics with a magnitude-aware noise floor,
excess-conflict baseline, and optional subspace overlap.
"""
flat_a = diff_a.flatten()
flat_b = diff_b.flatten()
if device is not None:
flat_a = flat_a.to(device=device, dtype=torch.float32)
flat_b = flat_b.to(device=device, dtype=torch.float32)
elif flat_a.dtype != torch.float32:
flat_a = flat_a.float()
flat_b = flat_b.float()
if flat_a.numel() != flat_b.numel():
return {
"overlap": 0,
"conflict": 0,
"dot": 0.0,
"norm_a_sq": 0.0,
"norm_b_sq": 0.0,
"weighted_total": 0.0,
"weighted_conflict": 0.0,
"expected_conflict": 0.0,
"excess_conflict": 0.0,
"subspace_overlap": 0.0,
"subspace_weight": 0.0,
}
n = flat_a.numel()
if n > 100000:
target_device = flat_a.device
g = torch.Generator(device=target_device).manual_seed(42)
indices = torch.randperm(n, device=target_device, generator=g)[:100000]
flat_a = flat_a[indices]
flat_b = flat_b[indices]
mask = (flat_a != 0) & (flat_b != 0)
n_overlap = mask.sum().item()
if n_overlap == 0:
return {
"overlap": 0,
"conflict": 0,
"dot": 0.0,
"norm_a_sq": 0.0,
"norm_b_sq": 0.0,
"weighted_total": 0.0,
"weighted_conflict": 0.0,
"expected_conflict": 0.0,
"excess_conflict": 0.0,
"subspace_overlap": 0.0,
"subspace_weight": 0.0,
}
a_overlap = flat_a[mask]
b_overlap = flat_b[mask]
dot = (a_overlap * b_overlap).sum().item()
norm_a_sq = (a_overlap * a_overlap).sum().item()
norm_b_sq = (b_overlap * b_overlap).sum().item()
n_conflict = (a_overlap.sign() != b_overlap.sign()).sum().item()
a_rms = a_overlap.square().mean().sqrt()
b_rms = b_overlap.square().mean().sqrt()
noise_floor = max(a_rms.item(), b_rms.item()) * 0.05
strong_mask = (a_overlap.abs() > noise_floor) & (b_overlap.abs() > noise_floor)
if strong_mask.any():
a_strong = a_overlap[strong_mask]
b_strong = b_overlap[strong_mask]
else:
a_strong = a_overlap
b_strong = b_overlap
weights = torch.minimum(a_strong.abs(), b_strong.abs())
weighted_total = weights.sum().item()
mismatch = a_strong.sign() != b_strong.sign()
weighted_conflict = weights[mismatch].sum().item() if weighted_total > 0 else 0.0
denom = math.sqrt(norm_a_sq * norm_b_sq) if norm_a_sq > 0 and norm_b_sq > 0 else 0.0
cos_sim = dot / denom if denom > 0 else 0.0
cos_sim = self._safe_unit_clamp(cos_sim)
expected_conflict = math.acos(cos_sim) / math.pi if denom > 0 else 0.0
weighted_ratio = (weighted_conflict / weighted_total) if weighted_total > 0 else 0.0
excess_conflict = max(weighted_ratio - expected_conflict, 0.0)
subspace_overlap = self._compute_subspace_overlap(basis_a, basis_b)
subspace_weight = math.sqrt(norm_a_sq * norm_b_sq) if norm_a_sq > 0 and norm_b_sq > 0 else 0.0
return {
"overlap": n_overlap,
"conflict": n_conflict,
"dot": dot,
"norm_a_sq": norm_a_sq,
"norm_b_sq": norm_b_sq,
"weighted_total": weighted_total,
"weighted_conflict": weighted_conflict,
"expected_conflict": expected_conflict,
"excess_conflict": excess_conflict,
"subspace_overlap": subspace_overlap,
"subspace_weight": subspace_weight,
}
@staticmethod
def _ties_trim(tensor, density):
"""
TIES Step 1: Trim — keep only the top-k% values by absolute magnitude.
Everything else is zeroed out (noise removal).
"""
flat = tensor.flatten()
n = flat.numel()
k = max(1, int(n * density))
if k >= n:
return tensor
_, indices = torch.topk(flat.abs(), k)
mask = torch.zeros_like(flat, dtype=torch.bool)
mask[indices] = True
return (flat * mask).reshape(tensor.shape)
@staticmethod
def _dare_sparsify(tensor, density, generator=None, dampening=0.0):
"""
DARE sparsification: randomly drop parameters and rescale survivors.
Each element is kept with probability `density`, then rescaled by 1/q.
With dampening=0: q=density (standard DARE). With dampening>0: q is
interpolated toward 1.0, reducing noise amplification (DAREx, ICLR 2025).
"""
if density >= 1.0:
return tensor
mask = torch.bernoulli(
torch.full(tensor.shape, density, dtype=tensor.dtype, device=tensor.device),
generator=generator
)
q = density + dampening * (1.0 - density)
return tensor * mask * (1.0 / q)
@staticmethod
def _della_sparsify(tensor, density, epsilon=0.3, generator=None):
"""
DELLA sparsification: magnitude-aware dropout.
Low-magnitude elements are dropped with higher probability.
Survivors are rescaled by 1/(1-p_i) to preserve expected value.
"""
if density >= 1.0:
return tensor
original_shape = tensor.shape
mat = tensor.unsqueeze(0) if tensor.dim() < 2 else tensor.reshape(tensor.shape[0], -1)
nrows, ncols = mat.shape
p_min = max((1.0 - density) - epsilon / 2.0, 0.0)
# Double-argsort gives ascending magnitude ranks; invert so low-magnitude
# elements get HIGH drop probability (rank 0 = highest magnitude → p_min)
asc_ranks = mat.abs().argsort(dim=1).argsort(dim=1).float()
ranks = (ncols - 1) - asc_ranks
drop_probs = (p_min + (epsilon / ncols) * ranks).clamp(0.0, 1.0)
keep_probs = 1.0 - drop_probs
mask = torch.bernoulli(keep_probs, generator=generator)
rescale = torch.where(mask > 0, 1.0 / keep_probs.clamp(min=1e-6), torch.zeros_like(keep_probs))
return (mat * mask * rescale).reshape(original_shape)
@staticmethod
def _compute_conflict_mask(diffs_with_weights):
"""
Boolean mask: True where 2+ diffs have opposing signs (actual interference).
Uses sign-corrected diffs (weight sign applied).
"""
has_positive = torch.zeros_like(diffs_with_weights[0][0], dtype=torch.bool)
has_negative = torch.zeros_like(has_positive)
for diff, weight in diffs_with_weights:
effective = diff if weight >= 0 else -diff
nonzero = effective != 0
has_positive |= (nonzero & (effective > 0))
has_negative |= (nonzero & (effective < 0))
return has_positive & has_negative
@staticmethod
def _dare_sparsify_conflict(tensor, conflict_mask, density, generator=None, dampening=0.0):
if density >= 1.0:
return tensor
rand_mask = torch.bernoulli(
torch.full(tensor.shape, density, dtype=tensor.dtype, device=tensor.device),
generator=generator
)
q = density + dampening * (1.0 - density)
return torch.where(conflict_mask, tensor * rand_mask * (1.0 / q), tensor)
@staticmethod
def _della_sparsify_conflict(tensor, conflict_mask, density, epsilon=0.3, generator=None):
if density >= 1.0:
return tensor
della_result = _LoRAMergeBase._della_sparsify(tensor, density, epsilon, generator)
return torch.where(conflict_mask, della_result, tensor)
@staticmethod
def _estimate_patch_memory(patches_dict):
"""Estimate total bytes used by patch tensors in an add_patches-style dict."""
total = 0
for v in patches_dict.values():
# LoRAAdapter: .weights = (mat_up, mat_down, alpha, ...)
# diff patch: ("diff", (tensor,))
data = v.weights if hasattr(v, 'weights') else v
if isinstance(data, (tuple, list)):
for item in data:
if isinstance(item, torch.Tensor):
total += item.nelement() * item.element_size()
elif isinstance(item, (tuple, list)):
for sub in item:
if isinstance(sub, torch.Tensor):
total += sub.nelement() * sub.element_size()
return total
@staticmethod
def _estimate_single_patch_bytes(patch):
"""Estimate byte size of a single patch entry (diff tuple or LoRAAdapter)."""
total = 0
data = patch.weights if hasattr(patch, 'weights') else patch
if isinstance(data, (tuple, list)):
for item in data:
if isinstance(item, torch.Tensor):
total += item.nelement() * item.element_size()
elif isinstance(item, (tuple, list)):
for sub in item:
if isinstance(sub, torch.Tensor):
total += sub.nelement() * sub.element_size()
return total
@staticmethod
def _expand_patch_to_diff(patch):
"""Expand a patch (diff tuple, LoRAAdapter, LoKrAdapter, LoHaAdapter) to a float32 diff tensor."""
if isinstance(patch, tuple) and patch[0] == "diff":
return patch[1][0].float()
elif isinstance(patch, LoKrAdapter):
weights = patch.weights
w1, w2, alpha = weights[0], weights[1], weights[2]
w1_a, w1_b = weights[3], weights[4]
w2_a, w2_b, t2 = weights[5], weights[6], weights[7]
if isinstance(alpha, torch.Tensor):
alpha = alpha.item()
dim = None
if w1 is None:
dim = w1_b.shape[0]
w1 = torch.mm(w1_a.float(), w1_b.float())
else:
w1 = w1.float()
if w2 is None:
dim = w2_b.shape[0]
if t2 is None:
w2 = torch.mm(w2_a.float(), w2_b.float())
else:
w2 = torch.einsum(
"i j k l, j r, i p -> p r k l",
t2.float(), w2_b.float(), w2_a.float(),
)
else:
w2 = w2.float()
if len(w2.shape) == 4:
w1 = w1.unsqueeze(2).unsqueeze(2)
scale = alpha / dim if (alpha is not None and dim is not None) else 1.0
return torch.kron(w1, w2) * scale
elif isinstance(patch, LoHaAdapter):
weights = patch.weights
w1a, w1b, alpha = weights[0], weights[1], weights[2]
w2a, w2b = weights[3], weights[4]
t1, t2 = weights[5], weights[6]
if isinstance(alpha, torch.Tensor):
alpha = alpha.item()
rank = w1b.shape[0]
if t1 is not None:
m1 = torch.einsum(
"i j k l, j r, i p -> p r k l",
t1.float(), w1b.float(), w1a.float(),
)
m2 = torch.einsum(
"i j k l, j r, i p -> p r k l",
t2.float(), w2b.float(), w2a.float(),
)
else:
m1 = torch.mm(w1a.float(), w1b.float())
m2 = torch.mm(w2a.float(), w2b.float())
scale = alpha / rank if alpha is not None else 1.0
return (m1 * m2) * scale
elif hasattr(patch, 'weights'):
w = patch.weights
mat_up, mat_down, alpha = w[0], w[1], w[2]
mid = w[3]
rank = mat_down.shape[0]
if isinstance(alpha, torch.Tensor):
alpha = alpha.item()
if mid is not None:
final_shape = [mat_down.shape[1], mat_down.shape[0], mid.shape[2], mid.shape[3]]
mat_down = torch.mm(
mat_down.transpose(0, 1).flatten(start_dim=1).float(),
mid.transpose(0, 1).flatten(start_dim=1).float(),
).reshape(final_shape).transpose(0, 1)
diff = torch.mm(
mat_up.flatten(start_dim=1).float(),
mat_down.flatten(start_dim=1).float(),
)
return diff * (alpha / rank)
raise ValueError(f"Unknown patch format: {type(patch)}")
@staticmethod
def _move_patch_to_device(patch, device):
"""Move all tensors in a patch to the given device. Returns new patch."""
if hasattr(patch, 'weights'):
inner = patch.weights
moved = tuple(
t.to(device) if isinstance(t, torch.Tensor) else t
for t in inner
)
if isinstance(patch, LoKrAdapter):
return LoKrAdapter(patch.loaded_keys, moved)
if isinstance(patch, LoHaAdapter):
return LoHaAdapter(patch.loaded_keys, moved)
return LoRAAdapter(patch.loaded_keys, moved)
elif isinstance(patch, tuple) and len(patch) == 2 and isinstance(patch[0], str):
# ("diff", (tensor,))
moved_inner = tuple(
t.to(device) if isinstance(t, torch.Tensor) else t
for t in patch[1]
)
return (patch[0], moved_inner)
return patch
@staticmethod
def _update_model_size(patcher, patches_dict):
"""Update a ModelPatcher's reported size to include patch memory.
ComfyUI's model_size() only counts base model weights, making patches
invisible to the memory manager. This causes large patched models to
never be evicted when other models need RAM. By updating the cached
size, ComfyUI's eviction logic sees the true memory footprint.
"""
patch_bytes = _LoRAMergeBase._estimate_patch_memory(patches_dict)
if patch_bytes > 0 and hasattr(patcher, 'model_size'):
patcher.size = patcher.model_size() + patch_bytes
logging.debug(f"[LoRA Optimizer] Updated model size: +{patch_bytes / (1024**2):.0f}MB patches")
@staticmethod
def _ties_elect_sign(trimmed_diffs, method="frequency"):
"""
TIES Step 2: Elect Sign — determine majority sign direction per weight position.
Args:
trimmed_diffs: list of trimmed diff tensors (same shape)
method: "frequency" (count votes) or "total" (sum magnitudes)
Returns:
majority_sign: tensor of +1/-1 per position
"""
# Iterate instead of torch.stack to avoid allocating [N, *shape] tensor
ref = trimmed_diffs[0]
total = torch.zeros_like(ref, dtype=torch.float32)
if method == "total":
for d in trimmed_diffs:
total.add_(d.to(dtype=torch.float32))
else:
# sign() returns -1/0/+1: zeros don't vote, matching original behavior
for d in trimmed_diffs:
total.add_(d.sign())
# +1 where majority is positive or tied, -1 where majority is negative
majority_sign = torch.where(total >= 0,
torch.tensor(1.0, device=total.device, dtype=total.dtype),
torch.tensor(-1.0, device=total.device, dtype=total.dtype))
return majority_sign
@staticmethod
def _columnwise_elect_sign(trimmed_diffs, method="frequency"):
"""
Column-wise sign election: each output neuron (row) votes as a unit
instead of each element voting independently. For Conv2d tensors,
[c_out, c_in, kH, kW] is reshaped to [c_out, -1] so each output
channel votes together.
Falls back to element-wise for 1D tensors (biases, norms).
"""
ref = trimmed_diffs[0]
if ref.dim() < 2:
return _LoRAMergeBase._ties_elect_sign(trimmed_diffs, method)
out_dim = ref.shape[0]
total = torch.zeros(out_dim, device=ref.device, dtype=torch.float32)
for d in trimmed_diffs:
row_vals = d.reshape(out_dim, -1).to(dtype=torch.float32)
if method == "total":
total.add_(row_vals.sum(dim=1))
else:
total.add_(row_vals.sum(dim=1).sign())
majority = torch.where(total >= 0, 1.0, -1.0)
return majority.reshape(-1, *([1] * (ref.dim() - 1))).expand_as(ref)
@staticmethod
def _ties_disjoint_merge(trimmed_diffs, weights, majority_sign):
"""
TIES Step 3: Disjoint Merge — average only contributors that agree
with the elected majority sign at each position.
Args:
trimmed_diffs: list of trimmed diff tensors
weights: list of scalar weights corresponding to each diff
majority_sign: tensor of +1/-1 per position
Returns:
merged tensor
"""
result = torch.zeros_like(trimmed_diffs[0], dtype=torch.float32)
contributor_count = torch.zeros_like(result)
for diff, weight in zip(trimmed_diffs, weights):
diff_f = diff.to(dtype=torch.float32)
# Positions where diff agrees with elected majority sign (and is non-zero)
sign_match = (diff_f * majority_sign) > 0
result.add_(torch.where(sign_match, diff_f * weight,
torch.tensor(0.0, device=result.device, dtype=result.dtype)))
contributor_count.add_(sign_match.float())
# Average by number of contributors (avoid div-by-zero)
contributor_count.clamp_(min=1.0)
return result.div_(contributor_count)
@staticmethod
def _columnwise_disjoint_merge(trimmed_diffs, weights, majority_sign):
"""
Column-wise disjoint merge: a row contributes entirely if its dominant
sign matches the majority. Falls back to element-wise for 1D tensors.
"""
ref = trimmed_diffs[0]
if ref.dim() < 2:
return _LoRAMergeBase._ties_disjoint_merge(trimmed_diffs, weights, majority_sign)
out_dim = ref.shape[0]
row_majority = majority_sign.reshape(out_dim, -1)[:, 0]
result = torch.zeros_like(ref, dtype=torch.float32)
contributor_count = torch.zeros(out_dim, device=ref.device, dtype=torch.float32)
for diff, weight in zip(trimmed_diffs, weights):
diff_f = diff.to(dtype=torch.float32)
row_sign = diff_f.reshape(out_dim, -1).sum(dim=1).sign()
sign_match = (row_sign * row_majority) > 0
mask = sign_match.reshape(-1, *([1] * (ref.dim() - 1))).expand_as(ref)
result.add_(torch.where(mask, diff_f * weight, torch.zeros_like(diff_f)))
contributor_count.add_(sign_match.float())
contributor_count.clamp_(min=1.0)
return result.div_(contributor_count.reshape(-1, *([1] * (ref.dim() - 1))))
@staticmethod
@torch.no_grad()
def _tall_masks(diffs_with_weights, lambda_threshold=1.0):
"""
TALL-masks: identify per-parameter importance. "Selfish" weights
(important to only one LoRA) are protected from merge averaging.
Returns (consensus_diffs, selfish_additions) where selfish_additions
is a tensor to add back after merging, or None if no selfish weights.
"""
if len(diffs_with_weights) < 2:
return diffs_with_weights, None
ref = diffs_with_weights[0][0]
# Tentative weighted sum
d_merged = torch.zeros_like(ref, dtype=torch.float32)
for d, w in diffs_with_weights:
d_merged.add_(d.to(dtype=torch.float32) * w)
# Per-LoRA importance masks
masks = []
for d, w in diffs_with_weights:
contribution = d.to(dtype=torch.float32) * w
others = d_merged - contribution
mask = contribution.abs() >= others.abs() * lambda_threshold
masks.append(mask)
# Agreement count per position
agreement = torch.zeros_like(ref, dtype=torch.float32)
for m in masks:
agreement.add_(m.float())
# Separate selfish (agreement==1) from consensus
selfish_additions = torch.zeros_like(ref, dtype=torch.float32)
consensus_diffs = []
has_selfish = False
for i, (d, w) in enumerate(diffs_with_weights):
selfish_mask = masks[i] & (agreement == 1)
if selfish_mask.any():
selfish_additions.add_(d.to(dtype=torch.float32) * w * selfish_mask.float())
has_selfish = True
consensus_d = torch.where(selfish_mask, torch.zeros(1, device=d.device, dtype=d.dtype), d)
consensus_diffs.append((consensus_d, w))
return consensus_diffs, selfish_additions if has_selfish else None
@staticmethod
@torch.no_grad()
def _do_orthogonalize(diffs_with_weights):
"""
DO-Merging: Decouple & Orthogonalize direction vectors while preserving magnitudes.
Reduces directional interference between LoRA diffs by applying Modified Gram-Schmidt
orthogonalization on unit direction vectors, then recombining with original magnitudes.
Paper: arxiv 2505.15875 (May 2025)
"""
if len(diffs_with_weights) < 2:
return diffs_with_weights
first = diffs_with_weights[0][0]
if first.dim() < 2:
return diffs_with_weights
# Flatten, decompose into magnitude and unit direction
dtype = first.dtype
shapes = [d.shape for d, _ in diffs_with_weights]
flat = [d.to(dtype=torch.float32).flatten() for d, _ in diffs_with_weights]
weights = [w for _, w in diffs_with_weights]
magnitudes = []
directions = []
for v in flat:
mag = v.norm()
magnitudes.append(mag)
if mag > 1e-8:
directions.append(v / mag)
else:
directions.append(torch.zeros_like(v))
del flat
# Modified Gram-Schmidt orthogonalization (numerically stable)
# Operates in-place on directions (not used after this loop)
ortho = []
for i in range(len(directions)):
q = directions[i]
for j in range(len(ortho)):
proj = torch.dot(q, ortho[j])
q = q - proj * ortho[j]
q_norm = q.norm()
if q_norm > 1e-8:
q = q / q_norm
else:
q = torch.zeros_like(q)
ortho.append(q)
del directions
# Recombine: orthogonalized direction * original magnitude
result = []
for i in range(len(diffs_with_weights)):
recombined = (ortho[i] * magnitudes[i]).to(dtype=dtype).reshape(shapes[i])
result.append((recombined, weights[i]))
return result
@staticmethod
@torch.no_grad()
def _knots_align(diffs_with_weights, compute_device=None, svd_device=None):
"""
KnOTS SVD alignment: project LoRA diffs into a shared SVD basis
for better comparability before merging. Concatenates all diffs
column-wise, computes truncated SVD, then reconstructs each diff
in the shared basis.
For [4096, 4096] with 5 LoRAs → M is [4096, 20480] ≈ 320MB.
SVD rank capped at 256. Falls back to CPU on OOM.
"""
if len(diffs_with_weights) < 2:
return diffs_with_weights
ref = diffs_with_weights[0][0]
if ref.dim() < 2 or min(ref.shape) < 2:
return diffs_with_weights
n = len(diffs_with_weights)
out_dim = ref.shape[0]
in_dim = ref.reshape(out_dim, -1).shape[1]
original_shape = ref.shape
dev = svd_device if svd_device is not None else (compute_device or ref.device)
# Concatenate column-wise: [out, N*in]
cols = [d.reshape(out_dim, -1).to(device=dev, dtype=torch.float32)
for d, _ in diffs_with_weights]
M = torch.cat(cols, dim=1)
del cols
rank = min(out_dim, n * in_dim, 256)
try:
U, S, V = torch.svd_lowrank(M, q=rank)
except (torch.cuda.OutOfMemoryError, RuntimeError):
if M.is_cuda:
logging.warning("[LoRA Optimizer] KnOTS SVD OOM on GPU, falling back to CPU")
torch.cuda.empty_cache()
M = M.cpu()
try:
U, S, V = torch.svd_lowrank(M, q=rank)
except RuntimeError:
logging.warning("[LoRA Optimizer] KnOTS SVD also failed on CPU, skipping alignment")
del M
return diffs_with_weights
else:
return diffs_with_weights
del M
# After CPU fallback, SVD results are on CPU — ensure aligned diffs
# return on the same device as the input diffs
output_device = compute_device if compute_device is not None else ref.device
# Reconstruct each diff in shared basis
aligned = []
US = U * S.unsqueeze(0) # [out, rank]
for i, (_, w) in enumerate(diffs_with_weights):
Vi = V[i * in_dim:(i + 1) * in_dim, :] # [in, rank]
aligned_diff = (US @ Vi.T).reshape(original_shape)
if aligned_diff.device != output_device:
aligned_diff = aligned_diff.to(output_device)
aligned.append((aligned_diff, w))
del U, S, V, US
return aligned
@staticmethod
@torch.no_grad()
def _compress_to_lowrank(diff, rank, svd_device=None, output_dtype=None):
"""
Re-compress a full-rank diff tensor to low-rank via truncated SVD.
Returns ("lora", (mat_up, mat_down, alpha=rank, None)) so ComfyUI
computes up @ down * (rank/rank) = up @ down (no extra scaling).
svd_device: where to run SVD. GPU is ~10-50x faster. CPU if None.
output_dtype: cast output to this dtype. None = same as input.
For a [4096, 4096] diff at rank 128: 64MB → 2MB (~32x reduction).
"""
original_shape = diff.shape
if output_dtype is None:
output_dtype = diff.dtype
# Reshape to 2D for SVD: [out_features, in_features]
mat = diff.reshape(original_shape[0], -1).float()
rank = min(rank, min(mat.shape))
# Move to requested device for SVD (GPU is much faster for matmul-heavy randomized SVD)
if svd_device is not None and mat.device != svd_device:
mat = mat.to(svd_device)
if rank > min(mat.shape) // 2:
U, S, Vh = torch.linalg.svd(mat, full_matrices=False)
del mat
U = U[:, :rank]
S = S[:rank]
V = Vh[:rank, :].T
del Vh
else:
q_oversample = min(rank + max(20, rank // 5), min(mat.shape))
U, S, V = torch.svd_lowrank(mat, q=q_oversample, niter=4)
del mat
U = U[:, :rank]
S = S[:rank]
V = V[:, :rank]
# U: [out, rank], S: [rank], V: [in, rank]
# Reconstruct as: mat_up = U * sqrt(S), mat_down = sqrt(S) * V^T
# Return on CPU for storage (ComfyUI moves to device when applying)
sqrt_S = S.sqrt()
mat_up = (U * sqrt_S.unsqueeze(0)).to(output_dtype).cpu()
mat_down = ((V * sqrt_S.unsqueeze(0)).T).to(output_dtype).cpu()
del U, S, V, sqrt_S
# alpha=rank so ComfyUI computes: up @ down * (rank/rank) = up @ down
return LoRAAdapter(set(), (mat_up, mat_down, float(rank), None, None, None))
@staticmethod
@torch.no_grad()
def _estimate_save_rank(initial_rank, model_patches, clip_patches,
max_error=0.05, n_samples=3):
"""
Estimate the minimum SVD rank needed to reconstruct sample diff patches
within `max_error` relative Frobenius error.
"""
samples = []
for patch in list(model_patches.values()) + list(clip_patches.values()):
if isinstance(patch, tuple) and patch[0] == "diff":
samples.append(patch[1][0])
if len(samples) >= n_samples:
break
if not samples:
return max(initial_rank, 64)
rank = max(initial_rank, 64)
for sample in samples:
mat = sample.reshape(sample.shape[0], -1).float()
singular_values = torch.linalg.svdvals(mat)
total_sq = (singular_values ** 2).sum().item()
if total_sq == 0:
continue
threshold_sq = (max_error * max_error) * total_sq
cumulative_sq = 0.0
needed = len(singular_values)
for idx in range(len(singular_values)):
cumulative_sq += singular_values[idx].item() ** 2
if total_sq - cumulative_sq <= threshold_sq:
needed = idx + 1
break
if needed > rank:
rank = needed
del singular_values, mat
return rank
@torch.no_grad()
def _merge_diffs(self, diffs_with_weights, mode, density=0.5, majority_sign_method="frequency",
compute_device=None, sparsification="disabled",
sparsification_density=0.7, sparsification_generator=None,
merge_refinement="none", dare_dampening=0.0,
keep_on_gpu=False):
"""
Merges a list of diffs with their weights.
When compute_device is given, tensors are moved there for faster ops,
then the result is returned on CPU (unless keep_on_gpu=True).
"""
if len(diffs_with_weights) == 0:
return None
if len(diffs_with_weights) == 1:
diff, weight = diffs_with_weights[0]
result = diff * weight
if compute_device is not None and compute_device.type != "cpu" and result.is_cuda and not keep_on_gpu:
return result.cpu()
return result
# All diffs should have the same shape (verified during computation)
ref_diff = diffs_with_weights[0][0]
dtype = ref_diff.dtype
dev = compute_device if compute_device is not None else ref_diff.device
to_cpu = (compute_device is not None and compute_device.type != "cpu"
and not keep_on_gpu)
# DARE/DELLA preprocessing for non-TIES modes
# (TIES replaces its trim step instead — handled in the ties branch)
if sparsification != "disabled" and mode != "ties":
is_conflict = sparsification in ("dare_conflict", "della_conflict")
if is_conflict:
for idx in range(len(diffs_with_weights)):
diff, weight = diffs_with_weights[idx]
diffs_with_weights[idx] = (diff.to(device=dev, dtype=torch.float32), weight)
conflict_mask = self._compute_conflict_mask(diffs_with_weights)
# Guard: if conflict mask covers >40% of positions, the "conflicts"
# are likely base-rate noise from orthogonal LoRAs (expected ~50%
# sign disagreement for uncorrelated vectors). Skip sparsification
# entirely — there are no real conflicts to resolve.
conflict_frac = conflict_mask.float().mean().item()
if conflict_frac > 0.40:
del conflict_mask
is_conflict = False
self._sparsification_skipped = getattr(self, '_sparsification_skipped', 0) + 1
if is_conflict:
is_dare = sparsification == "dare_conflict"
sparsify_fn = (self._dare_sparsify_conflict if is_dare
else self._della_sparsify_conflict)
for idx in range(len(diffs_with_weights)):
diff, weight = diffs_with_weights[idx]
kwargs = dict(generator=sparsification_generator)
if is_dare:
kwargs["dampening"] = dare_dampening
diff = sparsify_fn(diff, conflict_mask, sparsification_density,
**kwargs)
diffs_with_weights[idx] = (diff.to(dtype), weight)
del conflict_mask
else:
is_dare = sparsification == "dare"
sparsify_fn = (self._dare_sparsify if is_dare
else self._della_sparsify)
for idx in range(len(diffs_with_weights)):
diff, weight = diffs_with_weights[idx]
diff = diff.to(device=dev, dtype=torch.float32)
kwargs = dict(generator=sparsification_generator)
if is_dare:
kwargs["dampening"] = dare_dampening
diff = sparsify_fn(diff, sparsification_density,
**kwargs)
diffs_with_weights[idx] = (diff.to(dtype), weight)
# Refine/full merge refinement pipeline (non-TIES modes)
# TIES has its own enhancement path below (after trim)
# Order matters: TALL-masks must run BEFORE orthogonalization.
# Orthogonalized diffs have uncorrelated element-wise distributions,
# which causes TALL-masks to classify every position as "selfish"
# (agreement=1 everywhere), zeroing out all consensus diffs.
selfish_additions = None
if merge_refinement != "none" and len(diffs_with_weights) >= 2 and mode != "ties":
diffs_with_weights, selfish_additions = self._tall_masks(diffs_with_weights)
first = diffs_with_weights[0][0]
if first.dim() >= 2:
diffs_with_weights = self._do_orthogonalize(diffs_with_weights)
if merge_refinement == "full":
first = diffs_with_weights[0][0]
if first.dim() >= 2 and min(first.shape) >= 2:
diffs_with_weights = self._knots_align(
diffs_with_weights, compute_device=dev, svd_device=dev)
if mode == "weighted_average":
result = torch.zeros(ref_diff.shape, dtype=torch.float32, device=dev)
total_weight = sum(abs(w) for _, w in diffs_with_weights)
if total_weight == 0:
return result.to(dtype).cpu() if to_cpu else result.to(dtype)
for idx in range(len(diffs_with_weights)):
diff, weight = diffs_with_weights[idx]
diffs_with_weights[idx] = None # Free input diff early
result.add_(diff.to(device=dev, dtype=torch.float32) * (weight / total_weight))
if selfish_additions is not None:
result = result + selfish_additions.to(device=result.device, dtype=torch.float32)
result = result.to(dtype)
return result.cpu() if to_cpu else result
elif mode == "weighted_sum":
result = torch.zeros(ref_diff.shape, dtype=torch.float32, device=dev)
for idx in range(len(diffs_with_weights)):
diff, weight = diffs_with_weights[idx]
diffs_with_weights[idx] = None # Free input diff early
result.add_(diff.to(device=dev, dtype=torch.float32) * weight)
if selfish_additions is not None:
result = result + selfish_additions.to(device=result.device, dtype=torch.float32)
result = result.to(dtype)
return result.cpu() if to_cpu else result
elif mode == "normalize":
# Normalization by "energy" (sum of squared weights)
weights = [w for _, w in diffs_with_weights]
sum_sq = sum(w*w for w in weights)
if sum_sq == 0:
z = torch.zeros(ref_diff.shape, device=dev)
return z.cpu() if to_cpu else z
scale = 1.0 / math.sqrt(sum_sq)
result = torch.zeros(ref_diff.shape, dtype=torch.float32, device=dev)
for idx in range(len(diffs_with_weights)):
diff, weight = diffs_with_weights[idx]
diffs_with_weights[idx] = None # Free input diff early
result.add_(diff.to(device=dev, dtype=torch.float32) * weight * scale)
if selfish_additions is not None:
result = result + selfish_additions.to(device=result.device, dtype=torch.float32)
result = result.to(dtype)
return result.cpu() if to_cpu else result
elif mode == "slerp":
# Iterative pairwise SLERP — magnitude-preserving blend for N diffs.
# For 2 diffs: equivalent to standard SLERP.
# For 3+ diffs: iteratively SLERPs accumulated result with next diff,
# sorted by descending |weight| so strongest LoRA anchors direction.
# Final norm corrected to match weighted average of input norms.
n_diffs = len(diffs_with_weights)
# Handle negative weights by negating diff direction
items = []
for idx in range(n_diffs):
diff, weight = diffs_with_weights[idx]
diffs_with_weights[idx] = None # Free input diff early
v = diff.to(device=dev, dtype=torch.float32).flatten()
del diff
if weight < 0:
v = -v
items.append((v, abs(weight)))
# Sort by descending weight (strongest LoRA anchors direction)
items.sort(key=lambda x: x[1], reverse=True)
# All-zero weights: return zero (consistent with other modes)
total_w = sum(w for _, w in items)
if total_w == 0:
z = torch.zeros(ref_diff.shape, device=dev, dtype=dtype)
return z.cpu() if to_cpu else z
# Pre-compute norms for later correction (before vectors are consumed)
input_norms = [(v.norm().item(), w) for v, w in items]
# Iterative pairwise SLERP
acc_v, acc_w = items[0]
items[0] = None # Free tensor reference
for k in range(1, n_diffs):
next_v, next_w = items[k]
items[k] = None # Free tensor reference
frac = next_w / (acc_w + next_w) if (acc_w + next_w) > 0 else 0.5
# Compute angle between accumulated and next vector
norm_acc = acc_v.norm()
norm_next = next_v.norm()
denom = norm_acc * norm_next
if denom > 0:
cos_theta = (torch.dot(acc_v, next_v) / denom).clamp(-1.0, 1.0)
else:
cos_theta = torch.tensor(1.0, device=dev)
theta = torch.acos(cos_theta)
# Nearly-parallel fallback to linear interpolation
if theta.item() < 1e-6:
acc_v = (1.0 - frac) * acc_v + frac * next_v
else:
sin_theta = torch.sin(theta)
a = torch.sin((1.0 - frac) * theta) / sin_theta
b = torch.sin(frac * theta) / sin_theta
acc_v = a * acc_v + b * next_v
acc_w = acc_w + next_w
del next_v
del items
# Norm correction: rescale to match weighted average of input norms
target_norm = sum(n * w for n, w in input_norms) / total_w
current_norm = acc_v.norm().item()
if current_norm > 1e-8:
acc_v = acc_v * (target_norm / current_norm)
result = acc_v.reshape(ref_diff.shape)
del acc_v
if selfish_additions is not None:
result = result + selfish_additions.to(device=result.device, dtype=torch.float32)
result = result.to(dtype)
return result.cpu() if to_cpu else result
elif mode == "consensus":
# Consensus merge: Fisher-proxy + MAGIC calibration + MonoSoup spectral cleanup
# Optimized for similar LoRAs (high cosine similarity, low conflict)
# Step 1: Fisher-Proxy weighted merge
# Weight each parameter by |diff|^2 as importance proxy
numerator = torch.zeros(ref_diff.shape, dtype=torch.float32, device=dev)
denominator = torch.zeros(ref_diff.shape, dtype=torch.float32, device=dev)
input_norms = []
abs_weight_list = []
for idx in range(len(diffs_with_weights)):
d, w = diffs_with_weights[idx]
diffs_with_weights[idx] = None # Free early
d_f = d.to(device=dev, dtype=torch.float32)
del d
importance = d_f.square()
aw = abs(w)
numerator.add_(d_f * w * importance)
denominator.add_(aw * importance)
input_norms.append(d_f.norm().item() * aw)
abs_weight_list.append(aw)
del d_f, importance
# Safe division (zero importance → zero result)
result = torch.where(denominator > 0, numerator / denominator, torch.zeros_like(numerator))
del numerator, denominator
# Step 2: MAGIC magnitude calibration
# Rescale merged result so L2 norm matches weighted average of input norms
merged_norm = result.norm().item()
total_aw = sum(abs_weight_list)
if total_aw > 0 and merged_norm > 1e-8:
target_norm = sum(input_norms) / total_aw
result.mul_(target_norm / merged_norm)
del input_norms, abs_weight_list
# Step 3: MonoSoup spectral cleanup (2D+ weights only)
if result.dim() >= 2 and min(result.shape) >= 4:
mat = result.reshape(result.shape[0], -1)
try:
rank_budget = min(min(mat.shape), 128)
U, S, V = torch.svd_lowrank(mat, q=rank_budget)
# Entropy-based effective rank
s_sum = S.sum()
if s_sum < 1e-10:
del U, S, V, mat
raise RuntimeError("zero singular values")
p = S / s_sum
p = p.clamp(min=1e-10) # avoid log(0)
entropy = -(p * p.log()).sum().item()
eff_rank = min(int(math.exp(entropy) + 0.5), rank_budget)
eff_rank = max(eff_rank, 1)
# Soft spectral gate: smooth transition instead of hard cutoff
gate = torch.sigmoid(4.0 * (torch.arange(rank_budget, device=dev, dtype=torch.float32) - eff_rank) * (-1.0 / max(eff_rank, 1)))
S_gated = S * gate
# Preserve original norm (spectral cleanup shouldn't change magnitude)
pre_norm = result.norm()
result = (U * S_gated.unsqueeze(0)) @ V.T
result = result.reshape(ref_diff.shape)
post_norm = result.norm()
if post_norm > 1e-8:
result.mul_(pre_norm / post_norm)
del U, S, V, S_gated, gate, mat
except (RuntimeError, torch.cuda.OutOfMemoryError):
pass # SVD failed, skip spectral cleanup
if selfish_additions is not None:
result = result + selfish_additions.to(device=result.device, dtype=torch.float32)
result = result.to(dtype)
return result.cpu() if to_cpu else result
elif mode == "ties":
# TIES-Merging: Trim, Elect Sign, Disjoint Merge
# Pre-multiply diffs by sign(weight) so negative strengths vote correctly,
# then use abs(weight) for magnitude in disjoint merge.
# Memory-optimized: free input diffs after trimming to reduce peak VRAM.
trimmed = []
abs_weights = []
is_conflict = sparsification in ("dare_conflict", "della_conflict")
if is_conflict:
signed_diffs = []
for idx in range(len(diffs_with_weights)):
d, w = diffs_with_weights[idx]
diffs_with_weights[idx] = None
d_f = d.to(device=dev, dtype=torch.float32)
del d
if w < 0:
d_f = -d_f
signed_diffs.append(d_f)
abs_weights.append(abs(w))
conflict_mask = self._compute_conflict_mask(
[(d, 1.0) for d in signed_diffs])
is_dare = sparsification == "dare_conflict"
sparsify_fn = (self._dare_sparsify_conflict if is_dare
else self._della_sparsify_conflict)
for d_f in signed_diffs:
kwargs = dict(generator=sparsification_generator)
if is_dare:
kwargs["dampening"] = dare_dampening
trimmed.append(sparsify_fn(d_f, conflict_mask, sparsification_density,
**kwargs))
del signed_diffs, conflict_mask
else:
for idx in range(len(diffs_with_weights)):
d, w = diffs_with_weights[idx]
diffs_with_weights[idx] = None # Free input diff early
d_f = d.to(device=dev, dtype=torch.float32)
del d
if w < 0:
d_f = -d_f
# DARE/DELLA replaces TIES trim step when enabled
if sparsification == "dare":
trimmed.append(self._dare_sparsify(d_f, sparsification_density, generator=sparsification_generator, dampening=dare_dampening))
elif sparsification == "della":
trimmed.append(self._della_sparsify(d_f, sparsification_density, generator=sparsification_generator))
else:
trimmed.append(self._ties_trim(d_f, density))
abs_weights.append(abs(w))
# Refine/full merge refinement pipeline for TIES
# TALL-masks before orthogonalization (see non-TIES comment above)
ties_selfish = None
if merge_refinement != "none" and len(trimmed) >= 2:
# Re-pair trimmed diffs with abs_weights for enhancement pipeline
trimmed_pairs = list(zip(trimmed, abs_weights))
trimmed_pairs, ties_selfish = self._tall_masks(trimmed_pairs)
first = trimmed_pairs[0][0]
if first.dim() >= 2:
trimmed_pairs = self._do_orthogonalize(trimmed_pairs)
if merge_refinement == "full":
first = trimmed_pairs[0][0]
if first.dim() >= 2 and min(first.shape) >= 2:
trimmed_pairs = self._knots_align(
trimmed_pairs, compute_device=dev, svd_device=dev)
trimmed = [d for d, _ in trimmed_pairs]
abs_weights = [w for _, w in trimmed_pairs]
del trimmed_pairs
# Step 2: Elect majority sign
if merge_refinement != "none":
majority_sign = self._columnwise_elect_sign(trimmed, majority_sign_method)
else:
majority_sign = self._ties_elect_sign(trimmed, majority_sign_method)
# Step 3: Disjoint merge
if merge_refinement != "none":
result = self._columnwise_disjoint_merge(trimmed, abs_weights, majority_sign)
else:
result = self._ties_disjoint_merge(trimmed, abs_weights, majority_sign)
del trimmed, majority_sign
if ties_selfish is not None:
result = result.to(dtype=torch.float32) + ties_selfish.to(device=result.device, dtype=torch.float32)
result = result.to(dtype)
return result.cpu() if to_cpu else result
return None
def _normalize_stack(self, lora_stack, normalize_keys="disabled"):
"""
Normalize a LoRA stack into a consistent list of dicts.
Accepts two formats:
- Standard tuples: [(lora_name, model_strength, clip_strength), ...]
Used by Efficiency Nodes, Comfyroll, and other popular node packs.
LoRAs are loaded from disk (cached in self.loaded_loras).
- LoRAStack dicts: [{"name": str, "lora": dict, "strength": float}, ...]
Already loaded, clip_strength defaults to None (use global multiplier).
Returns list of dicts with keys: name, lora, strength, clip_strength.
clip_strength is None when the global multiplier should be used.
"""
if not lora_stack:
return []
first = lora_stack[0]
normalized = []
if isinstance(first, (tuple, list)):
# Standard format: (lora_name, model_strength, clip_strength[, conflict_mode[, key_filter]])
for entry in lora_stack:
if not isinstance(entry, (tuple, list)) or len(entry) < 3:
logging.warning("[LoRA Optimizer] Skipping malformed tuple entry (expected 3 elements)")
continue
lora_name, model_str, clip_str = entry[0], entry[1], entry[2]
conflict_mode = entry[3] if len(entry) > 3 else "all"
key_filter = entry[4] if len(entry) > 4 else "all"
# Load LoRA with caching
if lora_name in self.loaded_loras:
lora_dict = self.loaded_loras[lora_name]
lora_path = None
else:
try:
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
lora_dict = comfy.utils.load_torch_file(lora_path, safe_load=True)
self.loaded_loras[lora_name] = lora_dict
except Exception as e:
logging.warning(f"[LoRA Optimizer] Failed to load LoRA '{lora_name}': {e}")
continue
metadata = {}
if lora_path is not None:
metadata = _read_safetensors_metadata(lora_path)
normalized.append({
"name": lora_name,
"lora": lora_dict,
"strength": model_str,
"clip_strength": clip_str,
"conflict_mode": conflict_mode,
"key_filter": key_filter,
"metadata": metadata,
})
elif isinstance(first, dict):
# LoRAStack format: already loaded dicts
for item in lora_stack:
if not isinstance(item, dict) or "lora" not in item or "strength" not in item or "name" not in item:
logging.warning("[LoRA Optimizer] Skipping malformed dict entry (expected keys: name, lora, strength)")
continue
normalized.append({
"name": item["name"],
"lora": item["lora"],
"strength": item["strength"],
"clip_strength": None, # use global multiplier
"conflict_mode": item.get("conflict_mode", "all"),
"key_filter": item.get("key_filter", "all"),
"metadata": item.get("metadata", {}),
})
else:
logging.warning("[LoRA Optimizer] Unrecognized stack format")
return []
# Always detect architecture (used for preset selection even without key normalization)
if len(normalized) > 0:
arch = "unknown"
for item in normalized:
detected = self._detect_architecture(item["lora"])
if detected != "unknown":
arch = detected
break
self._detected_arch = arch if arch != "unknown" else None
# Architecture-aware key normalization (only when enabled)
if normalize_keys == "enabled":
if arch != "unknown":
logging.info(f"[LoRA Optimizer] Architecture detected: {arch}")
logging.info(f"[LoRA Optimizer] Normalizing keys for {len(normalized)} LoRAs...")
for item in normalized:
item["lora"] = self._normalize_keys(item["lora"], arch)
else:
logging.info("[LoRA Optimizer] Architecture: unknown (no key normalization applied)")
# Update loaded_loras cache to point at normalized dicts so the
# pre-normalization copies can be garbage-collected. This avoids
# keeping both raw and normalized state dicts in memory (saves
# 500MB-3GB for large models like Qwen).
for item in normalized:
name = item["name"]
if name in self.loaded_loras:
self.loaded_loras[name] = item["lora"]
else:
self._detected_arch = None
return normalized
def _sample_conflict(self, diff_a, diff_b, device=None):
"""
Compute sign conflict ratio and cosine similarity components between
two diff tensors. Samples up to 100k positions for large tensors.
Returns (n_overlap, n_conflict, dot, norm_a_sq, norm_b_sq).
"""
# Avoid unnecessary .float() copies — diffs from _analyze_prefix are already float32
flat_a = diff_a.flatten()
flat_b = diff_b.flatten()
if device is not None:
flat_a = flat_a.to(device=device, dtype=torch.float32)
flat_b = flat_b.to(device=device, dtype=torch.float32)
elif flat_a.dtype != torch.float32:
flat_a = flat_a.float()
flat_b = flat_b.float()
if flat_a.numel() != flat_b.numel():
return (0, 0, 0.0, 0.0, 0.0)
n = flat_a.numel()
if n > 100000:
target_device = flat_a.device
g = torch.Generator(device=target_device).manual_seed(42)
indices = torch.randperm(n, device=target_device, generator=g)[:100000]
flat_a = flat_a[indices]
flat_b = flat_b[indices]
# Only consider positions where both are non-zero
mask = (flat_a != 0) & (flat_b != 0)
n_overlap = mask.sum().item()
if n_overlap == 0:
return (0, 0, 0.0, 0.0, 0.0)
a_overlap = flat_a[mask]
b_overlap = flat_b[mask]
n_conflict = (a_overlap.sign() != b_overlap.sign()).sum().item()
# Cosine similarity components (on overlap region)
dot = (a_overlap * b_overlap).sum().item()
norm_a_sq = (a_overlap * a_overlap).sum().item()
norm_b_sq = (b_overlap * b_overlap).sum().item()
return (n_overlap, n_conflict, dot, norm_a_sq, norm_b_sq)
def _generate_param_grid():
"""Generate all valid merge parameter combinations for AutoTuner sweep."""
grid = []
merge_modes = ["weighted_average", "slerp", "consensus", "ties"]
sparsifications = ["disabled", "dare", "della", "dare_conflict", "della_conflict"]
densities = [0.5, 0.7, 0.9]
dampenings = [0.0, 0.3, 0.6]
qualities = ["none", "refine", "full"]
auto_strengths = ["enabled", "disabled"]
strategy_sets = ["full", "no_slerp", "basic"]
for spars in sparsifications:
density_vals = densities if spars != "disabled" else [0.7]
for density in density_vals:
damp_vals = dampenings if spars in ("dare", "dare_conflict") else [0.0]
for dampening in damp_vals:
for quality in qualities:
for auto_str in auto_strengths:
# per_prefix: optimizer auto-selects mode per prefix,
# merge_mode is irrelevant — emit one per strategy_set
for strat_set in strategy_sets:
grid.append({
"merge_mode": "per_prefix_auto",
"sparsification": spars,
"sparsification_density": density,
"dare_dampening": dampening,
"merge_refinement": quality,
"auto_strength": auto_str,
"optimization_mode": "per_prefix",
"strategy_set": strat_set,
})
# global: merge_mode matters — emit one per mode
# strategy_set is irrelevant for global (mode is explicit)
for mode in merge_modes:
grid.append({
"merge_mode": mode,
"sparsification": spars,
"sparsification_density": density,
"dare_dampening": dampening,
"merge_refinement": quality,
"auto_strength": auto_str,
"optimization_mode": "global",
"strategy_set": "full",
})
return grid
def _score_config_heuristic(config, avg_conflict_ratio, avg_cos_sim,
magnitude_ratio, prefix_stats, arch_preset=None,
avg_excess_conflict=None, avg_subspace_overlap=0.0):
"""
Score a merge config against analysis metrics (no merge needed).
Returns float score in [0, 1] where higher = better predicted quality.
Thresholds come from arch_preset.
"""
if arch_preset is None:
arch_preset = _ARCH_PRESETS["sd_unet"]
mode = config["merge_mode"]
spars = config["sparsification"]
density = config["sparsification_density"]
quality = config["merge_refinement"]
auto_str = config["auto_strength"]
opt_mode = config["optimization_mode"]
score = 0.0
consensus_cos = arch_preset["consensus_cos_sim_min"]
consensus_conf = arch_preset["consensus_conflict_max"]
ortho_cos = arch_preset["orthogonal_cos_sim_max"]
ortho_conf = arch_preset["orthogonal_conflict_max"]
ties_thresh = arch_preset["ties_conflict_threshold"]
mag_thresh = arch_preset["magnitude_ratio_total_sign"]
ideal_density = arch_preset["dare_ideal_density"]
is_orthogonal = abs(avg_cos_sim) < ortho_cos and avg_subspace_overlap < 0.35
effective_conflict = avg_conflict_ratio
if avg_excess_conflict is not None:
effective_conflict = max(avg_excess_conflict, 0.0)
if avg_subspace_overlap > 0:
effective_conflict *= (0.5 + 0.5 * avg_subspace_overlap)
decision_conflicts = [
ps.get("decision_conflict", ps.get("excess_conflict", ps.get("conflict_ratio", 0.0)))
for ps in prefix_stats.values()
if ps.get("n_loras", 0) > 1
] if prefix_stats else []
# --- Mode fit score (0-0.4) ---
if opt_mode == "per_prefix":
if abs(avg_cos_sim) < ortho_cos and avg_subspace_overlap < 0.35 and effective_conflict < ortho_conf:
score += 0.40
elif decision_conflicts:
if max(decision_conflicts) - min(decision_conflicts) > 0.10:
score += 0.35
else:
score += 0.30
else:
score += 0.30
elif mode == "consensus":
if avg_cos_sim > consensus_cos and effective_conflict < consensus_conf and avg_subspace_overlap >= 0.35:
score += 0.4
elif avg_cos_sim > consensus_cos * 0.6 and effective_conflict < ties_thresh:
score += 0.25
else:
score += 0.05
elif mode == "slerp":
if effective_conflict < ties_thresh * 1.2:
score += 0.35
elif effective_conflict < ortho_conf * 0.83:
score += 0.20
else:
score += 0.10
elif mode == "weighted_average":
if abs(avg_cos_sim) < ortho_cos and avg_subspace_overlap < 0.35 and effective_conflict < ortho_conf:
score += 0.30
elif effective_conflict < ortho_conf * 0.67:
score += 0.20
else:
score += 0.10
elif mode == "ties":
if effective_conflict > ties_thresh:
if abs(avg_cos_sim) < ortho_cos and avg_subspace_overlap < 0.35:
score += 0.15
else:
score += 0.35
elif effective_conflict > ties_thresh * 0.6:
score += 0.20
else:
score += 0.10
# --- Sparsification fit (0-0.15) ---
if spars != "disabled":
conflict_benefit = min(effective_conflict / 0.5, 1.0) * 0.10
score += conflict_benefit
density_penalty = abs(density - ideal_density) * 0.05
score += 0.05 - density_penalty
if "_conflict" in spars and decision_conflicts and len(decision_conflicts) > 1:
variance = max(decision_conflicts) - min(decision_conflicts)
score += variance * 0.05
# DELLA uses magnitude-aware masking — benefits when LoRAs have uneven norms
if spars in ("della", "della_conflict"):
if magnitude_ratio > mag_thresh:
score += 0.03
else:
if effective_conflict < consensus_conf:
score += 0.10
# --- Quality fit (0-0.15) ---
# Enhanced/maximum resolve conflicts via orthogonalization, column-wise
# resolution, TALL masks, and SVD alignment. When LoRAs are orthogonal
# there are no real conflicts — the extra processing treats noise as
# signal and can smooth away valid independent contributions.
if quality == "full":
if is_orthogonal:
score += min(effective_conflict / 0.3, 1.0) * 0.10
else:
score += 0.05 + min(effective_conflict / 0.3, 1.0) * 0.10
elif quality == "refine":
if is_orthogonal:
score += min(effective_conflict / 0.3, 1.0) * 0.10
else:
score += 0.08 + min(effective_conflict / 0.3, 1.0) * 0.07
else:
if effective_conflict < 0.10:
score += 0.10
else:
score += 0.05
# --- Auto-strength fit (0-0.15) ---
if auto_str == "enabled":
if magnitude_ratio > mag_thresh:
score += 0.15
elif magnitude_ratio > mag_thresh * 0.75:
score += 0.10
else:
score += 0.05
else:
if magnitude_ratio < mag_thresh * 0.75:
score += 0.10
else:
score += 0.03
# --- Optimization mode fit (0-0.10) ---
# Per-prefix benefit already scored in mode fit section above.
if opt_mode == "per_prefix":
score += 0.10
else:
score += 0.07
# --- Strategy set fit (0-0.05, per_prefix only) ---
strat_set = config.get("strategy_set", "full")
if opt_mode == "per_prefix":
if is_orthogonal:
# Orthogonal LoRAs: strategy_set impact is architecture-dependent
# (SLERP helps WAN but hurts Z-Image) — stay neutral, let Phase 2 decide
score += 0.04
elif effective_conflict < ties_thresh:
# Low conflict, non-orthogonal: SLERP upgrade can help
if strat_set == "full":
score += 0.05
elif strat_set == "no_slerp":
score += 0.03
else:
score += 0.02
else:
# High conflict: strategy_set has less impact (TIES dominates)
score += 0.03
return score
def _score_merge_result(model_patches, clip_patches, compute_svd=True,
score_device=None, arch_preset=None):
"""
Score an actual merge result by measuring output quality metrics.
Returns dict with individual metrics and composite score in [0, 1].
When compute_svd=False, skips the expensive SVD-based effective rank
computation and scores using norm consistency + sparsity only.
When score_device is set (e.g. "cuda"), tensors are moved there for
faster norm/SVD/sparsity computation.
When arch_preset is provided, uses arch-aware ideal sparsity from
dare_ideal_density instead of hardcoded 40%.
"""
norms = []
importance_values = []
effective_ranks = []
sparsities = []
all_patches = (
[(False, key, patch) for key, patch in model_patches.items()] +
[(True, key, patch) for key, patch in clip_patches.items()]
)
total = len(all_patches)
device_label = f", device={score_device}" if score_device else ""
logging.info(f"[LoRA AutoTuner] Scoring merge quality ({total} patches"
f"{', +SVD' if compute_svd else ''}{device_label})")
log_interval = max(1, total // 4) # Log at ~25%, 50%, 75%, 100%
for patch_idx, (_is_clip, patch_key, patch) in enumerate(all_patches):
if (patch_idx + 1) % log_interval == 0 or patch_idx + 1 == total:
logging.info(f"[LoRA AutoTuner] Scored {patch_idx + 1}/{total} patches")
if patch is None:
continue
target_key = patch_key[0] if isinstance(patch_key, tuple) else patch_key
if isinstance(patch, tuple) and len(patch) >= 2:
tensor = patch[1][0] if isinstance(patch[1], tuple) else patch[1]
elif isinstance(patch, LoKrAdapter):
weights = patch.weights
w1, w2, alpha = weights[0], weights[1], weights[2]
w1_a, w1_b = weights[3], weights[4]
w2_a, w2_b, t2 = weights[5], weights[6], weights[7]
dim = None
if w1 is None:
dim = w1_b.shape[0]
w1 = torch.mm(w1_a.float(), w1_b.float())
if w2 is None:
dim = w2_b.shape[0]
if t2 is None:
w2 = torch.mm(w2_a.float(), w2_b.float())
else:
w2 = torch.einsum("i j k l, j r, i p -> p r k l", t2.float(), w2_b.float(), w2_a.float())
if isinstance(alpha, torch.Tensor):
alpha = alpha.item()
scale = alpha / dim if (alpha is not None and dim is not None) else 1.0
fro_norm = (w1.float().norm().item() * w2.float().norm().item() * abs(scale))
norms.append(fro_norm)
importance_values.append(fro_norm)
del w1, w2
continue
elif isinstance(patch, LoHaAdapter):
diff = _LoRAMergeBase._expand_patch_to_diff(patch)
if score_device is not None:
diff = diff.to(score_device)
fro_norm = diff.norm().item()
norms.append(fro_norm)
importance_values.append(fro_norm)
max_val = diff.abs().max().item()
threshold = max_val * 0.01
if threshold > 0:
sparsity = (diff.abs() < threshold).float().mean().item()
sparsities.append(sparsity)
del diff
continue
elif isinstance(patch, LoRAAdapter):
# Low-rank patch: compute norm from factors without materializing full diff
mat_up, mat_down, alpha, mid, _, _ = patch.weights
if mat_up is not None and mat_down is not None:
rank = mat_down.shape[0] if mat_down.dim() >= 1 else 1
scale = alpha / rank if rank > 0 else 1.0
up_flat = mat_up.flatten(start_dim=1).float()
down_flat = mat_down.flatten(start_dim=1).float()
if score_device is not None:
up_flat = up_flat.to(score_device)
down_flat = down_flat.to(score_device)
# ||AB||_F^2 = tr(A^T A B B^T) — avoids materializing full diff
gram_up = torch.mm(up_flat.T, up_flat)
gram_down = torch.mm(down_flat, down_flat.T)
fro_norm = (torch.trace(gram_up @ gram_down).clamp(min=0) ** 0.5 * abs(scale)).item()
norms.append(fro_norm)
importance_values.append(fro_norm)
# Estimate element-wise sparsity by sampling columns of the product
n_cols = down_flat.shape[1]
sample_k = min(64, n_cols)
col_idx = torch.randperm(n_cols, device=down_flat.device)[:sample_k]
sampled = torch.mm(up_flat, down_flat[:, col_idx]) * scale
max_val = sampled.abs().max().item()
threshold = max_val * 0.01
if threshold > 0:
sparsity = (sampled.abs() < threshold).float().mean().item()
sparsities.append(sparsity)
del sampled
continue
else:
continue
if tensor is None:
continue
t = tensor.float()
if score_device is not None:
t = t.to(score_device)
# Frobenius norm
fro_norm = t.norm().item()
norms.append(fro_norm)
importance_values.append(fro_norm)
# Effective rank via spectral analysis (optional, expensive)
if compute_svd and t.dim() == 2 and min(t.shape) > 1:
try:
s = torch.linalg.svdvals(t)[:min(min(t.shape), 64)]
s_norm = s / (s.sum() + 1e-10)
entropy = -(s_norm * (s_norm + 1e-10).log()).sum().item()
eff_rank = min(math.exp(entropy), min(t.shape))
effective_ranks.append(eff_rank)
except Exception:
pass
# Sparsity
threshold = t.abs().max().item() * 0.01
if threshold > 0:
sparsity = (t.abs() < threshold).float().mean().item()
sparsities.append(sparsity)
metrics = {}
if norms:
metrics["norm_mean"] = sum(norms) / len(norms)
metrics["norm_std"] = (sum((n - metrics["norm_mean"])**2 for n in norms)
/ len(norms)) ** 0.5
metrics["norm_cv"] = metrics["norm_std"] / (metrics["norm_mean"] + 1e-10)
else:
metrics["norm_mean"] = 0.0
metrics["norm_cv"] = 1.0
if importance_values:
metrics["importance_mean"] = sum(importance_values) / len(importance_values)
metrics["importance_std"] = (sum((n - metrics["importance_mean"])**2 for n in importance_values)
/ len(importance_values)) ** 0.5
metrics["importance_cv"] = metrics["importance_std"] / (metrics["importance_mean"] + 1e-10)
else:
metrics["importance_mean"] = 0.0
metrics["importance_cv"] = metrics["norm_cv"]
if effective_ranks:
metrics["effective_rank_mean"] = sum(effective_ranks) / len(effective_ranks)
else:
metrics["effective_rank_mean"] = 0.0
if sparsities:
avg_sparsity = sum(sparsities) / len(sparsities)
metrics["sparsity_mean"] = avg_sparsity
# Arch-aware ideal sparsity: derive from dare_ideal_density
if arch_preset is not None:
ideal_sparsity = 1.0 - arch_preset.get("dare_ideal_density", 0.7)
else:
ideal_sparsity = 0.4 # legacy default
metrics["sparsity_fit"] = max(0.0, 1.0 - abs(avg_sparsity - ideal_sparsity) * 2.0)
else:
metrics["sparsity_mean"] = 0.0
metrics["sparsity_fit"] = 0.5
# Total squared energy of merged output (sum of squared norms)
metrics["norm_energy_sq"] = sum(n ** 2 for n in norms)
# Composite score — rebalance weights when SVD is skipped
score = 0.0
if effective_ranks:
rank_score = min(metrics["effective_rank_mean"] / 40.0, 1.0)
score += rank_score * 0.4
cv_score = max(0.0, 1.0 - metrics["norm_cv"])
score += cv_score * 0.3
score += metrics["sparsity_fit"] * 0.3
else:
# No SVD data: norm consistency 50%, sparsity 50%
cv_score = max(0.0, 1.0 - metrics["norm_cv"])
score += cv_score * 0.5
score += metrics["sparsity_fit"] * 0.5
metrics["composite_score"] = score
return metrics
def _load_python_callable(module_path, callable_name):
"""Load a callable from either a Python file path or importable module name."""
if not module_path or not callable_name:
raise ValueError("module_path and callable_name are required")
if os.path.isfile(module_path):
module_name = f"lora_optimizer_eval_{hashlib.sha256(module_path.encode()).hexdigest()[:12]}"
spec = importlib.util.spec_from_file_location(module_name, module_path)
if spec is None or spec.loader is None:
raise ImportError(f"Unable to load evaluator module from {module_path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
else:
module = importlib.import_module(module_path)
fn = getattr(module, callable_name, None)
if fn is None or not callable(fn):
raise AttributeError(f"Callable '{callable_name}' not found in {module_path}")
return fn
def _run_autotuner_evaluator(evaluator, model, clip, lora_data, config, analysis_summary):
"""
Run an optional external evaluator. The callable receives keyword args:
model, clip, lora_data, config, context. It may return a float score or
a dict with {'score': float, 'details': ...}.
"""
if not evaluator:
return None
module_path = evaluator.get("module_path")
callable_name = evaluator.get("callable_name")
if not module_path or not callable_name:
return None
try:
fn = _load_python_callable(module_path, callable_name)
result = fn(
model=model,
clip=clip,
lora_data=lora_data,
config=config,
context=evaluator.get("context", {}),
analysis_summary=analysis_summary,
)
except Exception as exc:
logging.warning(f"[LoRA AutoTuner] External evaluator failed: {exc}")
return {"score": None, "details": {"error": str(exc)}}
if isinstance(result, dict):
score = result.get("score")
details = result.get("details")
else:
score = result
details = None
try:
if score is None:
return {"score": None, "details": details}
score = float(score)
except (TypeError, ValueError):
logging.warning(f"[LoRA AutoTuner] External evaluator returned invalid score: {score!r}")
return {"score": None, "details": details}
return {
"score": max(0.0, min(1.0, score)),
"details": details,
}
class LoRAOptimizer(_LoRAMergeBase):
"""
Auto-optimizer that analyzes a LoRA stack (sign conflicts, magnitude
distributions, overlap) and automatically selects merge modes
and parameters, then performs the merge.
Outputs the merged model/clip plus an analysis report explaining
what was chosen and why.
Two-pass streaming architecture:
Pass 1 — Analysis: resolves aliases to target groups, computes diffs
per group, samples conflict and magnitude statistics, then discards
diffs immediately. Only lightweight scalars and small sample tensors
are kept in memory.
Pass 2 — Merge: recomputes diffs per group and merges with the
auto-selected strategy. Each group's diffs are freed after merging.
Peak memory is roughly one target group at a time, but the exact peak
still depends on layer size, overlap, and enabled quality/compression
options.
Limitation: the optimizer only analyzes LoRAs in its own stack. It has
no visibility into LoRA patches already applied to the model by upstream
nodes (via Load LoRA, etc.). Those patches stack additively on top of
the optimizer's output, which could cause overexposure. Fully baked
merges (safetensors checkpoints) are indistinguishable from base weights
and cannot be detected at all.
"""
def __init__(self):
super().__init__()
self._merge_cache = {} # single-entry: {cache_key: (model_patches, clip_patches, report, clip_strength_out, lora_data)}
self._detected_arch = None
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL", {"tooltip": "Your base model (e.g. SDXL, Flux). The merged LoRAs will be applied to it."}),
"lora_stack": ("LORA_STACK", {"tooltip": "Connect a LoRA Stack node here. This is the list of LoRAs you want to merge together."}),
"output_strength": ("FLOAT", {"default": 1.0, "min": -1.0, "max": 10.0, "step": 0.05,
"tooltip": "Master volume for the merged result. 1.0 = full effect, 0.5 = half, 0 = disabled. Set to -1 for auto: uses the suggested max strength (compensates for energy lost during merge)."}),
},
"optional": {
"clip": ("CLIP", {"tooltip": "The text encoder. Connect this so LoRAs can also affect how your prompts are understood. Leave empty for video models that don't use CLIP."}),
"clip_strength_multiplier": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 10.0, "step": 0.05,
"tooltip": "How strongly LoRAs affect text understanding, relative to output_strength. At 1.0, CLIP uses the same strength as the model. Lower values reduce LoRA influence on prompt interpretation while keeping the visual effect."}),
"auto_strength": (["enabled", "disabled"], {
"default": "enabled",
"tooltip": "Automatically turns down individual LoRA strengths when combining many LoRAs to avoid oversaturated or distorted results. Useful when stacking 3+ LoRAs."
}),
"auto_strength_floor": ("FLOAT", {
"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.05,
"tooltip": "Minimum auto-strength scale factor for orthogonal LoRAs. -1 = architecture-aware default (1.0 for motion-heavy video architectures, lower for image models). Set manually to preserve more or less independent LoRA energy."
}),
"free_vram_between_passes": (["disabled", "enabled"], {
"default": "disabled",
"tooltip": "Frees GPU memory between processing steps. Enable if you're running out of VRAM. Barely affects speed."
}),
"vram_budget": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.05,
"tooltip": "Fraction of free VRAM to use for storing merged patches. 0 = all CPU (default), 1.0 = use all free VRAM. Reduces RAM usage on GPU systems."
}),
"optimization_mode": (["per_prefix", "global", "additive"], {
"default": "per_prefix",
"tooltip": "How the optimizer decides to combine LoRAs. 'per_prefix' (recommended): automatically picks the best method for each layer. 'global': uses one method everywhere. 'additive': simple weighted addition with no conflict resolution — preserves all weights exactly. Use for edit, distillation, or DPO LoRAs. (Previously: 'additive' was called 'weighted_sum_only'.)"
}),
"cache_patches": (["enabled", "disabled"], {
"default": "enabled",
"tooltip": "Keep the merge result in memory so re-running the workflow is instant (no re-merge needed). Disable to save RAM — recommended for large video models like Wan or LTX."
}),
"patch_compression": (["smart", "aggressive", "disabled"], {
"default": "smart",
"tooltip": "Shrink the merged result to use less memory. "
"'smart' (recommended): compresses layers where it's lossless, skips layers that already went through rank reduction. "
"'aggressive': compresses everything including rank-reduced layers — saves the most memory but slightly lossy. "
"'disabled': no compression, uses more RAM. "
"(Previously: this setting was called 'compress_patches' with values non_ties/all/disabled.)"
}),
"svd_device": (["gpu", "cpu"], {
"default": "gpu",
"tooltip": "Where to run compression math. GPU is much faster (10-50x). Switch to CPU only if you get out-of-memory errors during the merge."
}),
"normalize_keys": (["disabled", "enabled"], {
"default": "enabled",
"tooltip": "Remaps LoRA keys to a canonical format so LoRAs from different training tools (Kohya, AI-Toolkit, PEFT, Musubi Tuner) can be merged correctly. Also splits fused QKV into separate Q/K/V for per-component conflict analysis. Recommended to keep enabled — disable only if it causes issues with unusual LoRA formats."
}),
"sparsification": (["disabled", "dare", "della", "dare_conflict", "della_conflict"], {
"default": "disabled",
"tooltip": "Reduces interference between LoRAs by sparsifying weights before merging. "
"DARE: random dropout everywhere. DELLA: magnitude-aware dropout everywhere. "
"Conflict variants (recommended): same algorithms but ONLY applied where LoRAs "
"push in opposite directions — unique contributions are preserved untouched."
}),
"sparsification_density": ("FLOAT", {
"default": 0.7, "min": 0.01, "max": 1.0, "step": 0.05,
"tooltip": "What percentage of weights to keep (0.7 = keep 70%, drop 30%). Lower values drop more weights — reduces interference but may lose detail. "
"At 1.0, no weights are dropped (equivalent to disabled). "
"Note: in TIES mode, sparsification replaces the trim step — setting density to 1.0 disables both sparsification AND trimming."
}),
"dare_dampening": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.05,
"tooltip": "DAREx dampening: reduces the aggressiveness of DARE's rescaling factor. "
"At 0.0 (default): standard DARE rescaling (1/density). "
"At higher values: dampened rescaling that reduces noise amplification at low density values. "
"Only affects DARE/DARE-conflict modes. Based on DAREx (ICLR 2025)."
}),
"merge_refinement": (["none", "refine", "full"], {
"default": "none",
"tooltip": "Optional preprocessing steps applied to weight diffs before merging. "
"none: merge as-is, no extra processing. "
"refine: adds direction orthogonalization + selfish weight protection "
"(TALL-masks) to reduce interference between LoRAs (minimal extra compute). "
"full: adds SVD alignment (KnOTS) on top of refine for maximum "
"interference reduction (uses more VRAM for SVD decomposition). "
"Higher levels help most when LoRAs have high conflict; "
"for low-conflict or orthogonal LoRAs, 'none' is usually fine. "
"(Previously: this setting was called 'merge_quality' with values standard/enhanced/maximum.)"
}),
"strategy_set": (["full", "no_slerp", "basic"], {
"default": "full",
"tooltip": "Which merge strategies the auto-selector can choose from. "
"'full': all strategies available (consensus, SLERP, orthogonal detection). "
"'no_slerp': same detection logic but SLERP is excluded (weighted_average stays as-is). "
"'basic': only TIES vs weighted_average, no advanced strategy selection. "
"(Previously: this setting was called 'behavior_profile' with values v1.2/no_slerp/classic.)"
}),
"architecture_preset": (["auto", "sd_unet", "dit", "llm"], {
"default": "auto",
"tooltip": "Architecture-aware threshold tuning. 'auto' detects from LoRA keys. "
"'sd_unet': SD/SDXL UNet defaults. 'dit': DiT models (Flux, WAN, Z-Image, LTX, HunyuanVideo) "
"with higher density floors and wider strength range. 'llm': LLM-based models (Qwen, LLaMA)."
}),
"merge_strategy_override": ("STRING", {
"default": "",
"forceInput": True,
"tooltip": "Connect the merge_strategy output from a LoRA Conflict Editor to override the optimizer's auto-detected strategy."
}),
"decision_smoothing": ("FLOAT", {
"default": 0.25, "min": 0.0, "max": 1.0, "step": 0.05,
"tooltip": "Smooth per-group strategy metrics toward each block's average before Pass 2 decisions. 0 disables smoothing; 0.2-0.4 usually removes noisy mode flips without washing out real differences."
}),
"smooth_slerp_gate": ("BOOLEAN", {
"default": False,
"tooltip": "When enabled, uses smoothed cosine (decision_cosine) for SLERP gate instead of raw avg_cos_sim. Can affect SLERP/weighted_average ratio."
}),
"tuner_data": ("TUNER_DATA", {
"tooltip": "Connect from the LoRA AutoTuner's tuner_data output. Used when settings_source is 'from_autotuner'."
}),
"settings_source": (["manual", "from_autotuner", "from_tuner_data"], {
"default": "manual",
"tooltip": "manual: use widget settings. from_autotuner: passthrough when chained with a live AutoTuner. from_tuner_data: apply the top-ranked config from loaded tuner data (use with Load Tuner Data)."
}),
}
}
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "TUNER_DATA", "LORA_DATA")
RETURN_NAMES = ("model", "clip", "analysis_report", "tuner_data", "lora_data")
FUNCTION = "execute_node"
CATEGORY = "LoRA Optimizer"
DESCRIPTION = "Auto-analyzes a LoRA stack and selects heuristic merge strategies per weight group. Outputs merged model + analysis report. Best for style/character LoRAs — apply edit, distillation (LCM/Turbo/Hyper), or DPO LoRAs via a standard Load LoRA node instead."
@staticmethod
def _compute_cache_key(lora_stack, output_strength, clip_strength_multiplier, auto_strength, optimization_mode="per_prefix", patch_compression="smart", svd_device="gpu", normalize_keys="disabled", sparsification="disabled", sparsification_density=0.7, dare_dampening=0.0, merge_strategy_override="", merge_refinement="none", strategy_set="full", architecture_preset="auto", auto_strength_floor=-1.0, decision_smoothing=0.25, smooth_slerp_gate=False):
"""
Build a deterministic SHA-256 hash (16 hex chars) from the stack
configuration. Used by IS_CHANGED to let ComfyUI skip re-execution
when nothing changed.
"""
h = hashlib.sha256()
if lora_stack:
first = lora_stack[0] if len(lora_stack) > 0 else None
entries = []
if isinstance(first, (tuple, list)):
for entry in lora_stack:
cm = entry[3] if len(entry) > 3 else "all"
kf = entry[4] if len(entry) > 4 else "all"
cs = float(entry[2]) if entry[2] is not None else -1.0
entries.append((str(entry[0]), float(entry[1]), cs, cm, kf))
elif isinstance(first, dict):
for item in lora_stack:
cm = item.get("conflict_mode", "all")
kf = item.get("key_filter", "all")
entries.append((str(item.get("name", "")), float(item.get("strength", 0)), cm, kf))
entries.sort()
h.update(json.dumps(entries).encode())
h.update(f"|os={output_strength}|csm={clip_strength_multiplier}|as={auto_strength}|om={optimization_mode}|cp={patch_compression}|sd={svd_device}|nk={normalize_keys}|sp={sparsification}|spd={sparsification_density}|dd={dare_dampening}|mso={merge_strategy_override}|mq={merge_refinement}|bp={strategy_set}|ap={architecture_preset}|asf={auto_strength_floor}|ds={decision_smoothing}|ssg={smooth_slerp_gate}".encode())
return h.hexdigest()[:16]
@classmethod
def IS_CHANGED(cls, model, lora_stack, output_strength, clip=None,
clip_strength_multiplier=1.0, auto_strength="disabled",
auto_strength_floor=-1.0,
free_vram_between_passes="disabled", vram_budget=0.0,
optimization_mode="per_prefix",
cache_patches="enabled", patch_compression="smart",
svd_device="gpu", normalize_keys="disabled",
sparsification="disabled", sparsification_density=0.7,
dare_dampening=0.0,
merge_strategy_override="", merge_refinement="none",
strategy_set="full", architecture_preset="auto",
decision_smoothing=0.25, smooth_slerp_gate=False,
tuner_data=None, settings_source="manual"):
base_key = cls._compute_cache_key(lora_stack, output_strength,
clip_strength_multiplier, auto_strength,
optimization_mode, patch_compression,
svd_device, normalize_keys,
sparsification, sparsification_density,
dare_dampening,
merge_strategy_override, merge_refinement,
strategy_set, architecture_preset,
auto_strength_floor,
decision_smoothing, smooth_slerp_gate)
cache_key = f"{base_key}|mid={id(model)}|ss={settings_source}"
if settings_source in ("from_autotuner", "from_tuner_data") and tuner_data is not None:
return f"{cache_key}|at={id(tuner_data)}"
return cache_key
def _save_report_to_disk(self, cache_key, lora_combo, auto_strength, report, selected_params):
"""
Persist the analysis report as JSON for later reference.
Saved to {user_dir}/lora_optimizer_reports/{cache_key}.json.
Failures are silently logged — never blocks the merge.
"""
try:
user_dir = folder_paths.get_user_directory()
report_dir = os.path.join(user_dir, "lora_optimizer_reports")
os.makedirs(report_dir, exist_ok=True)
report_path = os.path.join(report_dir, f"{cache_key}.json")
data = {
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
"lora_combo": lora_combo,
"auto_strength": auto_strength,
"report": report,
"selected_params": selected_params,
}
with open(report_path, "w") as f:
json.dump(data, f, indent=2)
return report_path
except Exception as e:
logging.warning(f"[LoRA Optimizer] Failed to save report: {e}")
return None
def _process_prefix(self, lora_prefix, active_loras, model_keys, clip_keys,
model, clip, device):
"""
Process a single LoRA key prefix: resolve target key, compute diffs for
each active LoRA against the target shape. Thread-safe — all writes go
to local variables, reads from shared immutable data.
Returns (lora_prefix, diffs_for_key, lora_diffs_for_key, partial_stats,
target_info, skip_count) or None if prefix should be skipped.
partial_stats is a list of (lora_index, rank, l2_norm) tuples.
"""
target_key = None
is_clip = False
if lora_prefix in model_keys:
target_key = model_keys[lora_prefix]
elif lora_prefix in clip_keys:
target_key = clip_keys[lora_prefix]
is_clip = True
if target_key is None:
return None
# Handle tuple keys (for sliced weights, e.g. Flux linear1_qkv)
offset = None
if isinstance(target_key, tuple):
actual_key = target_key[0]
if len(target_key) > 1:
offset = target_key[1]
else:
actual_key = target_key
# Get target weight shape (use sliced shape when offset present)
try:
if is_clip:
target_weight = comfy.utils.get_attr(clip.cond_stage_model, actual_key)
else:
target_weight = comfy.utils.get_attr(model.model, actual_key)
target_shape = list(target_weight.shape)
if offset is not None:
target_shape[offset[0]] = offset[2]
target_shape = torch.Size(target_shape)
except (AttributeError, RuntimeError, IndexError):
return None
diffs_for_key = []
lora_diffs_for_key = {}
partial_stats = []
skip_count = 0
for i, item in enumerate(active_loras):
lora_info = self._get_lora_key_info(item["lora"], lora_prefix)
if lora_info is not None:
mat_up, mat_down, alpha, mid = lora_info
rank = mat_down.shape[0]
diff = self._compute_lora_diff(mat_up, mat_down, alpha, mid, target_shape, device=device)
else:
# Try LoKr / LoHa formats
alt = self._get_lokr_diff(item["lora"], lora_prefix, device=device)
if alt is None:
alt = self._get_loha_diff(item["lora"], lora_prefix, device=device)
if alt is not None:
diff, rank, _ = alt
try:
diff = diff.reshape(target_shape)
except RuntimeError:
diff = None
rank = 0
else:
continue
if diff is not None:
# For CLIP keys, use per-LoRA clip_strength when available
if is_clip and item["clip_strength"] is not None:
eff_strength = item["clip_strength"]
else:
eff_strength = item["strength"]
diffs_for_key.append((diff, eff_strength, i))
# Store sign-corrected diff for conflict analysis;
# negative strength inverts the LoRA's direction
lora_diffs_for_key[i] = diff if eff_strength >= 0 else -diff
# Always use model strength for l2_norms so _compute_auto_strengths
# can correctly undo the weighting (it divides by model strength)
l2 = diff.float().norm().item() * abs(item["strength"])
partial_stats.append((i, rank, l2))
else:
skip_count += 1
if len(diffs_for_key) == 0:
if skip_count > 0:
return (lora_prefix, [], {}, [], (target_key, is_clip), skip_count)
return None
return (lora_prefix, diffs_for_key, lora_diffs_for_key, partial_stats,
(target_key, is_clip), skip_count)
@torch.no_grad()
def _analyze_prefix(self, lora_prefix, active_loras, model_keys, clip_keys,
model, clip, device, n_magnitude_samples=1000):
"""
Pass 1 per-prefix analysis: compute diffs, sample conflicts and
magnitudes, then discard diffs. Returns lightweight scalars/samples
so the caller never accumulates full-rank diff tensors.
All GPU work (diff computation, conflict sampling, magnitude sampling)
stays on GPU until the final small results are copied to CPU, avoiding
the GPU→CPU→GPU bounce that kills pipelining.
Returns (lora_prefix, partial_stats, pair_conflicts, magnitude_samples,
target_info, skip_count) or None if prefix should be skipped.
partial_stats: list of (lora_index, rank, l2_norm)
pair_conflicts: dict mapping (i, j) -> (overlap, conflict, dot, norm_a_sq, norm_b_sq)
magnitude_samples: list of small 1D CPU float tensors
"""
# --- Resolve target key and shape (same as _process_prefix) ---
target_key = None
is_clip = False
if lora_prefix in model_keys:
target_key = model_keys[lora_prefix]
elif lora_prefix in clip_keys:
target_key = clip_keys[lora_prefix]
is_clip = True
if target_key is None:
return None
offset = None
if isinstance(target_key, tuple):
actual_key = target_key[0]
if len(target_key) > 1:
offset = target_key[1]
else:
actual_key = target_key
try:
if is_clip:
target_weight = comfy.utils.get_attr(clip.cond_stage_model, actual_key)
else:
target_weight = comfy.utils.get_attr(model.model, actual_key)
target_shape = list(target_weight.shape)
if offset is not None:
target_shape[offset[0]] = offset[2]
target_shape = torch.Size(target_shape)
except (AttributeError, RuntimeError, IndexError):
return None
# --- Compute diffs for all active LoRAs ---
# Keep diffs on GPU (device) for conflict + magnitude analysis.
# _compute_lora_diff normally returns CPU when device=cuda, so we
# call it with device=None and manually move matrices to GPU.
use_gpu = device is not None and device.type != "cpu"
diffs = {} # lora_index -> diff tensor (on device)
eff_strengths = {} # lora_index -> effective strength
partial_stats = []
skip_count = 0
for i, item in enumerate(active_loras):
lora_info = self._get_lora_key_info(item["lora"], lora_prefix)
if lora_info is not None:
mat_up, mat_down, alpha, mid = lora_info
rank = mat_down.shape[0]
# Compute diff on GPU but DON'T copy back to CPU yet
if use_gpu:
mat_up = mat_up.to(device)
mat_down = mat_down.to(device)
if mid is not None:
mid = mid.to(device)
scale = alpha / rank
if mid is not None:
final_shape = [mat_down.shape[1], mat_down.shape[0], mid.shape[2], mid.shape[3]]
mat_down = (
torch.mm(
mat_down.transpose(0, 1).flatten(start_dim=1).float(),
mid.transpose(0, 1).flatten(start_dim=1).float(),
)
.reshape(final_shape)
.transpose(0, 1)
)
diff = torch.mm(
mat_up.flatten(start_dim=1).float(),
mat_down.flatten(start_dim=1).float()
)
del mat_up, mat_down # Free LoRA matrices from GPU
try:
diff = diff.reshape(target_shape)
except RuntimeError:
skip_count += 1
continue
diff = diff * scale
else:
# Try LoKr / LoHa formats
alt = self._get_lokr_diff(
item["lora"], lora_prefix,
device=device if use_gpu else None, to_cpu=False,
)
if alt is None:
alt = self._get_loha_diff(
item["lora"], lora_prefix,
device=device if use_gpu else None, to_cpu=False,
)
if alt is None:
continue
diff, rank, _ = alt
try:
diff = diff.reshape(target_shape)
except RuntimeError:
skip_count += 1
continue
if is_clip and item["clip_strength"] is not None:
eff_strength = item["clip_strength"]
else:
eff_strength = item["strength"]
diffs[i] = diff
eff_strengths[i] = eff_strength
l2 = diff.float().norm().item() * abs(item["strength"])
partial_stats.append((i, rank, l2))
if len(diffs) == 0:
if skip_count > 0:
return (lora_prefix, [], {}, [], (target_key, is_clip), skip_count)
return None
# --- Pairwise conflict analysis (sign-corrected, stays on device) ---
pair_conflicts = {}
lora_indices = sorted(diffs.keys())
for ai in range(len(lora_indices)):
for bi in range(ai + 1, len(lora_indices)):
i, j = lora_indices[ai], lora_indices[bi]
diff_i = diffs[i] if eff_strengths[i] >= 0 else -diffs[i]
diff_j = diffs[j] if eff_strengths[j] >= 0 else -diffs[j]
ov, conf, dot, na_sq, nb_sq = self._sample_conflict(diff_i, diff_j, device=device)
pair_conflicts[(i, j)] = (ov, conf, dot, na_sq, nb_sq)
# --- Magnitude sampling (sample on device, free each diff eagerly) ---
magnitude_samples = []
seed = hash(lora_prefix) & 0xFFFFFFFF
sample_dev = diffs[lora_indices[0]].device
mag_g = torch.Generator(device=sample_dev).manual_seed(seed)
for i in lora_indices:
flat = diffs[i].flatten().abs().float() * abs(eff_strengths[i])
del diffs[i] # Free this diff's GPU memory immediately
n = flat.numel()
if n > n_magnitude_samples:
indices = torch.randint(0, n, (n_magnitude_samples,),
device=sample_dev, generator=mag_g)
flat = flat[indices]
magnitude_samples.append(flat.cpu())
return (lora_prefix, partial_stats, pair_conflicts, magnitude_samples,
(target_key, is_clip), skip_count)
@torch.no_grad()
def _analyze_target_group(self, target_group, active_loras, model, clip, device,
clip_strength_multiplier=1.0, merge_refinement="none", n_magnitude_samples=1000):
"""
Pass 1 analysis for one resolved target group. All aliases that hit the
same underlying weight are aggregated per LoRA before statistics are
computed, so mixed-trainer overlaps are analyzed as one merge unit.
"""
prepared = self._prepare_group_diffs(
target_group, active_loras, model, clip, device,
clip_strength_multiplier=clip_strength_multiplier,
merge_refinement=merge_refinement,
)
if prepared is None:
return None
diffs = prepared["diffs"]
eff_strengths = prepared["eff_strengths"]
rank_sums = prepared["rank_sums"]
skip_count = prepared["skip_count"]
target_key = prepared["target_key"]
is_clip = prepared["is_clip"]
raw_n = prepared["raw_n_loras"]
if len(diffs) == 0:
if skip_count > 0 or raw_n > 0:
return (
target_group["label_prefix"], [], {}, [], (target_key, is_clip),
skip_count, raw_n, {}, {}
)
return None
partial_stats = []
per_lora_norm_sq = {}
bases = {}
for i, diff in diffs.items():
norm_sq = diff.float().square().sum().item()
per_lora_norm_sq[i] = norm_sq
display_l2 = math.sqrt(norm_sq) * abs(active_loras[i]["strength"])
partial_stats.append((i, rank_sums.get(i, 0), display_l2, norm_sq))
bases[i] = self._compute_subspace_basis(diff, rank_hint=rank_sums.get(i, 1))
pair_conflicts = {}
lora_indices = sorted(diffs.keys())
for ai in range(len(lora_indices)):
for bi in range(ai + 1, len(lora_indices)):
i, j = lora_indices[ai], lora_indices[bi]
diff_i = diffs[i] if eff_strengths[i] >= 0 else -diffs[i]
diff_j = diffs[j] if eff_strengths[j] >= 0 else -diffs[j]
pair_conflicts[(i, j)] = self._sample_pair_metrics(
diff_i, diff_j, basis_a=bases.get(i), basis_b=bases.get(j),
device=device
)
magnitude_samples = []
seed = hash(target_group["label_prefix"]) & 0xFFFFFFFF
sample_dev = diffs[lora_indices[0]].device
mag_g = torch.Generator(device=sample_dev).manual_seed(seed)
for i in lora_indices:
flat = diffs[i].flatten().abs().float() * abs(eff_strengths[i])
del diffs[i]
n = flat.numel()
if n > n_magnitude_samples:
indices = torch.randint(0, n, (n_magnitude_samples,),
device=sample_dev, generator=mag_g)
flat = flat[indices]
magnitude_samples.append(flat.cpu())
return (
target_group["label_prefix"],
partial_stats,
pair_conflicts,
magnitude_samples,
(target_key, is_clip),
skip_count,
raw_n,
per_lora_norm_sq,
)
def _run_group_analysis(self, target_groups, active_loras, model, clip,
compute_device, clip_strength_multiplier=1.0,
merge_refinement="none",
decision_smoothing=0.0, progress_cb=None):
"""
Shared Pass 1 runner used by both the optimizer and AutoTuner.
Returns the same lightweight accumulators both call sites need.
"""
use_gpu = compute_device.type != "cpu"
per_lora_stats = [{
"name": item["name"],
"strength": item["strength"],
"ranks": [],
"key_count": 0,
"l2_norms": [],
} for item in active_loras]
pairs = [(i, j) for i in range(len(active_loras))
for j in range(i + 1, len(active_loras))]
branch_energy = {
"model": {
"norm_sq": [0.0] * len(active_loras),
"dot": {(i, j): 0.0 for i, j in pairs},
"importance": [0.0] * len(active_loras),
},
"clip": {
"norm_sq": [0.0] * len(active_loras),
"dot": {(i, j): 0.0 for i, j in pairs},
"importance": [0.0] * len(active_loras),
},
}
pair_accum = {
(i, j): {
"overlap": 0,
"conflict": 0,
"dot": 0.0,
"norm_a_sq": 0.0,
"norm_b_sq": 0.0,
"weighted_total": 0.0,
"weighted_conflict": 0.0,
"expected_conflict_weighted": 0.0,
"excess_conflict_weighted": 0.0,
"subspace_num": 0.0,
"subspace_den": 0.0,
} for i, j in pairs
}
all_magnitude_samples = []
all_key_targets = {}
prefix_stats = {}
skipped_keys = 0
prefix_count = 0
def _collect_analysis_result(result):
nonlocal skipped_keys, prefix_count
if result is None:
return
(prefix, partial_stats, pair_conflicts, mag_samples, target_info, skips,
raw_n, per_lora_norm_sq) = result
is_clip = target_info[1]
branch_name = "clip" if is_clip else "model"
skipped_keys += skips
if len(partial_stats) > 0:
all_key_targets[prefix] = target_info
prefix_count += 1
for (idx, rank, l2, norm_sq) in partial_stats:
per_lora_stats[idx]["ranks"].append(rank)
per_lora_stats[idx]["key_count"] += 1
per_lora_stats[idx]["l2_norms"].append(l2)
branch_energy[branch_name]["norm_sq"][idx] += norm_sq
for (i, j), metrics in pair_conflicts.items():
acc = pair_accum[(i, j)]
acc["overlap"] += metrics["overlap"]
acc["conflict"] += metrics["conflict"]
acc["dot"] += metrics["dot"]
acc["norm_a_sq"] += metrics["norm_a_sq"]
acc["norm_b_sq"] += metrics["norm_b_sq"]
acc["weighted_total"] += metrics["weighted_total"]
acc["weighted_conflict"] += metrics["weighted_conflict"]
acc["expected_conflict_weighted"] += metrics["expected_conflict"] * metrics["weighted_total"]
acc["excess_conflict_weighted"] += metrics["excess_conflict"] * metrics["weighted_total"]
acc["subspace_num"] += metrics["subspace_overlap"] * metrics["subspace_weight"]
acc["subspace_den"] += metrics["subspace_weight"]
branch_energy[branch_name]["dot"][(i, j)] += metrics["dot"]
all_magnitude_samples.extend(mag_samples)
if len(partial_stats) > 0:
pf_overlap = sum(m["overlap"] for m in pair_conflicts.values())
pf_conflict = sum(m["conflict"] for m in pair_conflicts.values())
pf_conflict_ratio = pf_conflict / pf_overlap if pf_overlap > 0 else 0.0
pf_weighted_total = sum(m["weighted_total"] for m in pair_conflicts.values())
pf_weighted_conflict = sum(m["weighted_conflict"] for m in pair_conflicts.values())
pf_weighted_ratio = (pf_weighted_conflict / pf_weighted_total) if pf_weighted_total > 0 else pf_conflict_ratio
pf_expected_conflict = (
sum(m["expected_conflict"] * m["weighted_total"] for m in pair_conflicts.values()) / pf_weighted_total
) if pf_weighted_total > 0 else 0.0
pf_excess_conflict = (
sum(m["excess_conflict"] * m["weighted_total"] for m in pair_conflicts.values()) / pf_weighted_total
) if pf_weighted_total > 0 else 0.0
pf_l2s = [math.sqrt(v) for v in per_lora_norm_sq.values() if v > 0]
pf_mag_ratio = (max(pf_l2s) / min(pf_l2s)) if len(pf_l2s) >= 2 else 1.0
pf_activation_ratio = pf_mag_ratio
pf_cos_sims = []
for metrics in pair_conflicts.values():
denom = (metrics["norm_a_sq"] ** 0.5) * (metrics["norm_b_sq"] ** 0.5)
if denom > 0:
pf_cos_sims.append(metrics["dot"] / denom)
avg_cos_sim = sum(pf_cos_sims) / len(pf_cos_sims) if pf_cos_sims else 0.0
pf_subspace_den = sum(m["subspace_weight"] for m in pair_conflicts.values())
avg_subspace_overlap = (
sum(m["subspace_overlap"] * m["subspace_weight"] for m in pair_conflicts.values()) / pf_subspace_den
) if pf_subspace_den > 0 else 0.0
prefix_stats[prefix] = {
"n_loras": len(partial_stats),
"raw_n_loras": raw_n,
"conflict_ratio": pf_conflict_ratio,
"weighted_conflict_ratio": pf_weighted_ratio,
"expected_conflict": pf_expected_conflict,
"excess_conflict": pf_excess_conflict,
"magnitude_ratio": pf_mag_ratio,
"activation_ratio": pf_activation_ratio,
"magnitude_samples": list(mag_samples),
"avg_cos_sim": avg_cos_sim,
"avg_subspace_overlap": avg_subspace_overlap,
"per_lora_norm_sq": dict(per_lora_norm_sq),
"pairwise_dots": {
pair: vals["dot"] for pair, vals in pair_conflicts.items()
},
}
group_items = list(target_groups.values())
if use_gpu:
for target_group in group_items:
result = self._analyze_target_group(
target_group, active_loras, model, clip, compute_device,
clip_strength_multiplier=clip_strength_multiplier,
merge_refinement=merge_refinement,
)
_collect_analysis_result(result)
if progress_cb is not None:
progress_cb()
else:
max_workers = min(4, max(1, len(group_items)))
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(
self._analyze_target_group, target_group, active_loras,
model, clip, compute_device, clip_strength_multiplier,
merge_refinement
): target_group["label_prefix"]
for target_group in group_items
}
for future in concurrent.futures.as_completed(futures):
_collect_analysis_result(future.result())
if progress_cb is not None:
progress_cb()
prefix_stats = self._apply_block_smoothing(prefix_stats, strength=decision_smoothing)
return {
"all_key_targets": all_key_targets,
"target_groups": dict(target_groups),
"prefix_stats": prefix_stats,
"per_lora_stats": per_lora_stats,
"pair_accum": pair_accum,
"branch_energy": branch_energy,
"all_magnitude_samples": all_magnitude_samples,
"prefix_count": prefix_count,
"skipped_keys": skipped_keys,
"pairs": pairs,
}
def _estimate_density(self, all_key_diffs, arch_preset=None):
"""
Estimate TIES density parameter from magnitude distribution.
Uses fraction of values above noise floor of the max magnitude as a
sparsity proxy. Thresholds come from arch_preset.
"""
if arch_preset is None:
arch_preset = _ARCH_PRESETS["sd_unet"]
samples = []
max_samples_per_key = 1000
g = torch.Generator().manual_seed(42)
for key, diffs_list in all_key_diffs.items():
for entry in diffs_list:
diff, strength = entry[0], entry[1]
flat = diff.flatten().abs().float().cpu() * abs(strength)
n = flat.numel()
if n > max_samples_per_key:
indices = torch.randperm(n, generator=g)[:max_samples_per_key]
flat = flat[indices]
samples.append(flat)
if len(samples) == 0:
return 0.5
all_samples = torch.cat(samples)
if all_samples.numel() == 0:
return 0.5
max_val = all_samples.max().item()
if max_val <= 0:
return 0.5
noise_floor = max_val * arch_preset["density_noise_floor_ratio"]
above_noise = (all_samples > noise_floor).float().mean().item()
return max(arch_preset["density_clamp_min"], min(arch_preset["density_clamp_max"], above_noise))
def _estimate_density_from_samples(self, magnitude_samples, arch_preset=None):
"""
Estimate TIES density from pre-sampled magnitude tensors.
Takes a list of 1D CPU float tensors (from _analyze_prefix).
Thresholds come from arch_preset.
"""
if arch_preset is None:
arch_preset = _ARCH_PRESETS["sd_unet"]
if len(magnitude_samples) == 0:
return 0.5
all_samples = torch.cat(magnitude_samples)
if all_samples.numel() == 0:
return 0.5
max_val = all_samples.max().item()
if max_val <= 0:
return 0.5
noise_floor = max_val * arch_preset["density_noise_floor_ratio"]
above_noise = (all_samples > noise_floor).float().mean().item()
return max(arch_preset["density_clamp_min"], min(arch_preset["density_clamp_max"], above_noise))
def _compute_branch_auto_scale(self, branch_name, strengths, norm_sq, dot_accum,
arch_preset=None, detected_arch=None,
auto_strength_floor=-1.0, is_full_rank=False):
"""Exact streamed auto-strength scale for one branch."""
if arch_preset is None:
arch_preset = _ARCH_PRESETS["sd_unet"]
n = len(strengths)
effective = [abs(strengths[i]) * math.sqrt(max(norm_sq[i], 0.0)) for i in range(n)]
nonzero = [e for e in effective if e > 0]
reasoning = []
if len(nonzero) <= 1:
reasoning.append(f"{branch_name}: single contributing branch or none — no adjustment needed")
return {
"scale": 1.0,
"new_strengths": list(strengths),
"reasoning": reasoning,
}
energy_sq = 0.0
orthogonal_energy_sq = 0.0
pairwise_cos = []
for i in range(n):
energy_sq += (strengths[i] ** 2) * norm_sq[i]
orthogonal_energy_sq += (strengths[i] ** 2) * norm_sq[i]
for (i, j), dot in dot_accum.items():
if norm_sq[i] <= 0 or norm_sq[j] <= 0:
continue
energy_sq += 2.0 * strengths[i] * strengths[j] * dot
denom = math.sqrt(norm_sq[i]) * math.sqrt(norm_sq[j])
if denom > 0:
pairwise_cos.append(dot / denom)
energy_sq = max(energy_sq, 0.0)
current_energy = math.sqrt(energy_sq)
orthogonal_energy = math.sqrt(max(orthogonal_energy_sq, 0.0))
reference_energy = max(effective)
scale = min(reference_energy / current_energy, 1.0) if current_energy > 0 else 1.0
floor_applied = False
floor = None
if pairwise_cos:
avg_cos = sum(pairwise_cos) / len(pairwise_cos)
alignment_thresh = arch_preset["alignment_threshold"]
if abs(avg_cos) <= alignment_thresh:
if auto_strength_floor >= 0:
floor = auto_strength_floor
elif is_full_rank:
floor = arch_preset.get("full_rank", {}).get("auto_strength_floor", 1.0)
else:
floor = _VIDEO_ARCH_ORTHOGONAL_FLOOR.get(
detected_arch,
arch_preset.get("auto_strength_orthogonal_floor", 0.85),
)
if scale < floor:
scale = floor
floor_applied = True
new_strengths = [s * scale if effective[i] > 0 else s for i, s in enumerate(strengths)]
reasoning.append(f"{branch_name}: scale factor {scale:.4f}")
if pairwise_cos:
avg_cos = sum(pairwise_cos) / len(pairwise_cos)
alignment_thresh = arch_preset["alignment_threshold"]
if avg_cos > alignment_thresh:
alignment_desc = "mostly aligned (reinforcing)"
elif avg_cos < -alignment_thresh:
alignment_desc = "mostly opposing (cancelling)"
else:
alignment_desc = "mostly orthogonal (independent)"
if floor_applied:
arch_label = detected_arch or "unknown"
if is_full_rank:
reasoning.append(
f"{branch_name}: full-rank orthogonal floor {floor:.2f} applied "
f"to preserve complete weight deltas"
)
else:
reasoning.append(
f"{branch_name}: orthogonal floor {floor:.2f} applied for {arch_label} "
f"to preserve independent contributions"
)
reasoning.append(
f"{branch_name}: exact streamed energy {current_energy:.4f} "
f"(orthogonal baseline {orthogonal_energy:.4f}, avg cos {avg_cos:.3f} — {alignment_desc})"
)
return {
"scale": scale,
"new_strengths": new_strengths,
"reasoning": reasoning,
}
def _compute_auto_strengths(self, active_loras, branch_energy,
clip_strength_multiplier=1.0, arch_preset=None,
detected_arch=None, auto_strength_floor=-1.0,
is_full_rank=False):
"""
Compute exact streamed auto-strength scaling separately for model and
CLIP branches, using accumulated Frobenius norms and pairwise dots.
"""
if arch_preset is None:
arch_preset = _ARCH_PRESETS["sd_unet"]
model_strengths = [item["strength"] for item in active_loras]
clip_strengths = [
item["clip_strength"] if item["clip_strength"] is not None else item["strength"]
for item in active_loras
]
model_info = self._compute_branch_auto_scale(
"Model",
model_strengths,
branch_energy["model"]["norm_sq"],
branch_energy["model"]["dot"],
arch_preset=arch_preset,
detected_arch=detected_arch,
auto_strength_floor=auto_strength_floor,
is_full_rank=is_full_rank,
)
clip_info = self._compute_branch_auto_scale(
"CLIP",
clip_strengths,
branch_energy["clip"]["norm_sq"],
branch_energy["clip"]["dot"],
arch_preset=arch_preset,
detected_arch=detected_arch,
auto_strength_floor=auto_strength_floor,
is_full_rank=is_full_rank,
)
reasoning = []
reasoning.extend(model_info["reasoning"])
if any(v > 0 for v in branch_energy["clip"]["norm_sq"]):
reasoning.extend(clip_info["reasoning"])
return {
"model_scale": model_info["scale"],
"clip_scale": clip_info["scale"],
"model_strengths": model_info["new_strengths"],
"clip_strengths": clip_info["new_strengths"],
"original_model_strengths": model_strengths,
"original_clip_strengths": clip_strengths,
"names": [item["name"] for item in active_loras],
"clip_uses_global_multiplier": [
item["clip_strength"] is None for item in active_loras
],
"clip_strength_multiplier": clip_strength_multiplier,
"reasoning": reasoning,
}
def _build_exact_linear_patch(self, target_group, active_loras, raw_n_loras,
mode, is_clip_key=False, model_scale=1.0):
"""
Build an exact low-rank patch for linear merges by concatenating factors
instead of materializing a dense diff. Falls back to None when the group
contains unsupported parameterizations (for example LoCon mid matrices).
"""
if mode not in ("weighted_sum", "weighted_average", "normalize"):
return None
pieces = []
lora_weights = {}
has_conflict_modes = False
for i, item in enumerate(active_loras):
kf = item.get("key_filter", "all")
if kf == "shared_only" and raw_n_loras < 2:
continue
if kf == "unique_only" and raw_n_loras != 1:
continue
if item.get("conflict_mode", "all") != "all":
has_conflict_modes = True
break
if is_clip_key:
base_weight = item["clip_strength"] if item["clip_strength"] is not None else item["strength"]
else:
base_weight = item["strength"] * model_scale
contributed = False
for alias in target_group["aliases"]:
lora_info = self._get_lora_key_info(item["lora"], alias)
if lora_info is None:
# Check if this alias has LoKr/LoHa keys — can't represent
# as low-rank factors, fall through to dense diff path
if self._has_lokr_keys(item["lora"], alias) or self._has_loha_keys(item["lora"], alias):
return None
continue
mat_up, mat_down, alpha, mid = lora_info
if mid is not None:
return None
pieces.append((i, mat_up, mat_down, alpha))
contributed = True
if contributed:
lora_weights[i] = base_weight
if has_conflict_modes or not pieces:
return None
if mode == "weighted_average":
total_weight = sum(abs(w) for w in lora_weights.values())
if total_weight == 0:
return None
per_lora_scales = {idx: w / total_weight for idx, w in lora_weights.items()}
elif mode == "normalize":
denom = math.sqrt(sum(w * w for w in lora_weights.values()))
if denom == 0:
return None
per_lora_scales = {idx: w / denom for idx, w in lora_weights.items()}
else:
per_lora_scales = dict(lora_weights)
up_parts = []
down_parts = []
total_rank = 0
for lora_idx, mat_up, mat_down, alpha in pieces:
weight = per_lora_scales[lora_idx]
rank = mat_down.shape[0]
total_rank += rank
piece_scale = weight * (alpha / rank)
up_parts.append(mat_up * piece_scale)
down_parts.append(mat_down)
if total_rank <= 0:
return None
fused_up = torch.cat(up_parts, dim=1)
fused_down = torch.cat(down_parts, dim=0)
return {
"patch": LoRAAdapter(set(), (fused_up, fused_down, float(total_rank), None, None, None)),
"weights": per_lora_scales,
}
@staticmethod
def _extract_block_name(prefix):
"""
Extract a human-readable block name from a LoRA key prefix.
Handles common architectures: SD1.5, SDXL, Flux, Wan, etc.
Examples:
lora_unet_input_blocks_4_1_transformer_blocks_0_attn2_to_q -> input_blocks.4
lora_unet_double_blocks_12_img_attn_proj -> double_blocks.12
lora_unet_down_blocks_2_attentions_1_transformer_blocks_0_attn1_to_k -> down_blocks.2
diffusion_model.joint_blocks.5.x_block.attn.qkv -> joint_blocks.5
transformer.blocks.8.attn1.to_q -> blocks.8
Falls back to the first two meaningful segments if no pattern matches.
"""
import re
# Normalize separators: lora_unet_input_blocks_4 -> input_blocks.4
# Strip common prefixes
p = prefix
for strip in ["lora_unet_", "lora_te_", "lora_te1_", "lora_te2_",
"diffusion_model.", "transformer.", "model."]:
if p.startswith(strip):
p = p[len(strip):]
break
# Replace underscores with dots for pattern matching
p_dots = re.sub(r'_', '.', p)
# Match: word.number (e.g., input.blocks.4, double.blocks.12, down.blocks.2)
m = re.match(r'([a-z]+(?:\.[a-z]+)*?)\.(\d+)', p_dots)
if m:
block_type = m.group(1).replace('.', '_')
block_num = m.group(2)
return f"{block_type}.{block_num}"
# Fallback: first segment
parts = re.split(r'[._]', prefix)
meaningful = [p for p in parts if p not in ("lora", "unet", "te", "te1", "te2",
"diffusion", "model", "transformer")]
if len(meaningful) >= 2:
return f"{meaningful[0]}.{meaningful[1]}"
elif meaningful:
return meaningful[0]
return prefix[:30]
def _apply_block_smoothing(self, prefix_stats, strength=0.0):
"""
Smooth noisy per-group decision metrics toward the average of their
surrounding logical block. Raw metrics are preserved; decision_* fields
are what Pass 2 should consume.
"""
if not prefix_stats:
return prefix_stats
strength = max(0.0, min(1.0, float(strength or 0.0)))
block_groups = {}
for prefix, stat in prefix_stats.items():
block_name = self._extract_block_name(prefix)
stat["block_name"] = block_name
block_groups.setdefault(block_name, []).append((prefix, stat))
metric_keys = (
"conflict_ratio",
"weighted_conflict_ratio",
"expected_conflict",
"excess_conflict",
"avg_cos_sim",
"avg_subspace_overlap",
"magnitude_ratio",
"activation_ratio",
)
for entries in block_groups.values():
if not entries:
continue
weights = []
for _prefix, stat in entries:
weight = sum(stat.get("per_lora_norm_sq", {}).values())
weights.append(weight if weight > 0 else 1.0)
total_weight = sum(weights) if weights else 0.0
block_means = {}
for key in metric_keys:
values = [stat.get(key) for _prefix, stat in entries if stat.get(key) is not None]
if not values:
continue
weighted_sum = 0.0
for weight, (_prefix, stat) in zip(weights, entries):
weighted_sum += stat.get(key, 0.0) * weight
block_means[key] = weighted_sum / total_weight if total_weight > 0 else sum(values) / len(values)
for prefix, stat in entries:
stat["block_size"] = len(entries)
stat["block_smoothing_strength"] = strength
for key, mean_value in block_means.items():
raw_value = stat.get(key, mean_value)
smoothed = raw_value if strength <= 0 or len(entries) == 1 else ((1.0 - strength) * raw_value + strength * mean_value)
stat[f"smoothed_{key}"] = smoothed
stat["decision_conflict"] = stat.get("smoothed_excess_conflict", stat.get("excess_conflict", stat.get("conflict_ratio", 0.0)))
stat["decision_cosine"] = stat.get("smoothed_avg_cos_sim", stat.get("avg_cos_sim", 0.0))
stat["decision_subspace_overlap"] = stat.get("smoothed_avg_subspace_overlap", stat.get("avg_subspace_overlap", 0.0))
stat["decision_magnitude_ratio"] = stat.get("smoothed_activation_ratio", stat.get("activation_ratio", stat.get("magnitude_ratio", 1.0)))
return prefix_stats
def _auto_select_params(self, avg_conflict_ratio, magnitude_ratio, all_key_diffs=None,
magnitude_samples=None, avg_cos_sim=0.0,
avg_excess_conflict=None, avg_subspace_overlap=0.0,
strategy_set="full", arch_preset=None,
precomputed_density=None):
"""
Decision logic for auto-selecting merge parameters.
Returns (mode, density, sign_method, reasoning_lines).
Density can be estimated from either all_key_diffs (legacy bulk path)
or magnitude_samples (streaming path). Thresholds come from arch_preset.
"""
if arch_preset is None:
arch_preset = _ARCH_PRESETS["sd_unet"]
reasoning = []
effective_conflict = avg_conflict_ratio
if avg_excess_conflict is not None:
effective_conflict = max(avg_excess_conflict, 0.0)
if avg_subspace_overlap > 0:
effective_conflict *= (0.5 + 0.5 * avg_subspace_overlap)
# High similarity + low conflict → consensus mode (Fisher-proxy + magnitude calibration)
if (strategy_set == "full"
and avg_cos_sim > arch_preset["consensus_cos_sim_min"]
and effective_conflict < arch_preset["consensus_conflict_max"]
and avg_subspace_overlap >= 0.35):
mode = "consensus"
reasoning.append(f"Cosine similarity {avg_cos_sim:.2f} > {arch_preset['consensus_cos_sim_min']} "
f"and excess conflict {effective_conflict:.1%} < {arch_preset['consensus_conflict_max']:.0%} -> consensus mode")
reasoning.append(" Fisher-proxy importance weighting + magnitude calibration + spectral cleanup")
density = 0.5 # unused
sign_method = "frequency" # unused
return (mode, density, sign_method, reasoning)
# Near-orthogonal LoRAs: ~50% sign conflict is the base rate for
# independent vectors, not actual semantic conflict. TIES trimming
# destroys both signals. Use weighted_average as the global mode,
# upgraded to SLERP per-prefix to preserve magnitude (important for
# video LoRAs where motion energy matters).
if (strategy_set in ("full", "no_slerp")
and abs(avg_cos_sim) < arch_preset["orthogonal_cos_sim_max"]
and effective_conflict < arch_preset["orthogonal_conflict_max"]
and avg_subspace_overlap < 0.35):
mode = "weighted_average"
reasoning.append(f"Cosine similarity {avg_cos_sim:.2f} near zero (orthogonal LoRAs) — "
f"sign conflict {avg_conflict_ratio:.1%} is base-rate noise, not real conflict")
if strategy_set == "full":
reasoning.append(" Using weighted_average (upgraded to SLERP per-prefix to preserve magnitude)")
else:
reasoning.append(" Using weighted_average to preserve both signals (SLERP upgrade disabled by profile)")
density = 0.5
sign_method = "frequency"
return (mode, density, sign_method, reasoning)
# Select mode based on sign conflict
if effective_conflict > arch_preset["ties_conflict_threshold"]:
mode = "ties"
reasoning.append(f"Excess conflict {effective_conflict:.1%} > {arch_preset['ties_conflict_threshold']:.0%} threshold -> TIES mode selected")
if avg_subspace_overlap > 0:
reasoning.append(f" Subspace overlap {avg_subspace_overlap:.2f} suggests the conflicting LoRAs target similar directions")
reasoning.append(" TIES resolves sign conflicts via trim + elect sign + disjoint merge")
else:
mode = "weighted_average"
reasoning.append(f"Excess conflict {effective_conflict:.1%} <= {arch_preset['ties_conflict_threshold']:.0%} threshold -> weighted_average mode selected")
reasoning.append(" Low conflict means LoRAs are mostly compatible, simple averaging works well")
# Auto-density (TIES only)
if mode == "ties":
if magnitude_samples is not None:
density = self._estimate_density_from_samples(magnitude_samples, arch_preset=arch_preset)
elif all_key_diffs is not None:
density = self._estimate_density(all_key_diffs, arch_preset=arch_preset)
elif precomputed_density is not None:
density = precomputed_density
else:
density = 0.5
reasoning.append(f"Auto-density estimated at {density:.2f} from magnitude distribution")
else:
density = 0.5 # unused but set for completeness
# Sign method (only relevant for TIES mode)
if mode == "ties":
if magnitude_ratio > arch_preset["magnitude_ratio_total_sign"]:
sign_method = "total"
reasoning.append(f"Magnitude ratio {magnitude_ratio:.2f}x > {arch_preset['magnitude_ratio_total_sign']:.0f}x -> 'total' sign method (magnitude-weighted voting)")
reasoning.append(" Stronger LoRA gets more influence in sign election")
else:
sign_method = "frequency"
reasoning.append(f"Magnitude ratio {magnitude_ratio:.2f}x <= {arch_preset['magnitude_ratio_total_sign']:.0f}x -> 'frequency' sign method (equal voting)")
reasoning.append(" Similar-strength LoRAs get equal votes")
else:
sign_method = "frequency" # unused, default for completeness
return (mode, density, sign_method, reasoning)
def _build_report(self, lora_stats, pairwise_conflicts, collection_stats,
mode, density, sign_method, reasoning, merge_summary,
auto_strength_info=None, strategy_counts=None, optimization_mode="global",
prefix_decisions=None, detected_arch=None, normalize_keys="disabled",
sparsification="disabled", sparsification_density=0.7,
dare_dampening=0.0,
merge_refinement="none",
compatibility_warnings=None,
strategy_set="full",
architecture_preset=None,
is_full_rank=False):
"""Format analysis as a multi-line report string."""
lines = []
lines.append("=" * 50)
lines.append("LORA OPTIMIZER - ANALYSIS REPORT")
lines.append("=" * 50)
# Architecture preset info
if architecture_preset and architecture_preset in _ARCH_PRESETS:
preset_info = _ARCH_PRESETS[architecture_preset]
lines.append(f"Architecture preset: {architecture_preset} ({preset_info['display_name']})")
if detected_arch:
arch_names = {
'zimage': 'Z-Image Turbo (Lumina2)',
'flux': 'FLUX',
'wan': 'Wan 2.1/2.2',
'acestep': 'ACE-Step',
'sdxl': 'SDXL',
'ltx': 'LTX Video',
'qwen_image': 'Qwen-Image',
}
lines.append(f"Detected architecture: {arch_names.get(detected_arch, detected_arch)}")
if normalize_keys == "enabled":
lines.append(f"Key normalization: enabled")
lines.append("")
elif normalize_keys == "enabled" and detected_arch:
arch_names = {
'zimage': 'Z-Image Turbo (Lumina2)',
'flux': 'FLUX',
'wan': 'Wan 2.1/2.2',
'acestep': 'ACE-Step',
'sdxl': 'SDXL',
'ltx': 'LTX Video',
'qwen_image': 'Qwen-Image',
}
lines.append(f"Architecture: {arch_names.get(detected_arch, detected_arch)} (auto-detected)")
lines.append(f"Key normalization: enabled")
lines.append("")
if compatibility_warnings:
lines.append("")
lines.append("!!! COMPATIBILITY WARNING !!!")
for warn in compatibility_warnings:
lines.append(f" {warn['name_i']} vs {warn['name_j']}: cosine similarity = {warn['cosine_sim']:.3f}")
lines.append(f" These LoRAs work against each other and may cancel out.")
lines.append(f" Consider removing one or using conflict_mode='high_conflict'")
lines.append("")
# Per-LoRA Analysis
lines.append("")
lines.append("--- Per-LoRA Analysis ---")
for stat in lora_stats:
lines.append(f" {stat['name']}:")
lines.append(f" Strength: {stat.get('original_strength', stat['strength'])}")
lines.append(f" Keys: {stat['key_count']}")
if stat['key_count'] > 0:
lines.append(f" Avg rank: {stat['avg_rank']:.0f}")
lines.append(f" L2 norm (mean): {stat['l2_mean']:.4f}")
else:
lines.append(f" Avg rank: N/A (no compatible keys)")
lines.append(f" L2 norm (mean): N/A")
if stat.get("conflict_mode", "all") != "all":
lines.append(f" Conflict mode: {stat['conflict_mode']}")
if stat.get("key_filter", "all") != "all":
lines.append(f" Key filter: {stat['key_filter']}")
# Auto-Strength Adjustment (between Per-LoRA and Pairwise)
if auto_strength_info is not None:
lines.append("")
lines.append("--- Auto-Strength Adjustment ---")
for i, name in enumerate(auto_strength_info["names"]):
orig = auto_strength_info["original_model_strengths"][i]
new = auto_strength_info["model_strengths"][i]
line = f" {name}: model {orig} -> {new:.4f}"
if i < len(auto_strength_info.get("original_clip_strengths", [])):
clip_orig = auto_strength_info["original_clip_strengths"][i]
clip_new = auto_strength_info["clip_strengths"][i]
line += f", clip {clip_orig} -> {clip_new:.4f}"
if auto_strength_info.get("clip_uses_global_multiplier", [False] * len(auto_strength_info["names"]))[i]:
line += " (pre-global multiplier)"
lines.append(line)
for r in auto_strength_info["reasoning"]:
lines.append(f" {r}")
# Pairwise Analysis
if pairwise_conflicts:
lines.append("")
lines.append("--- Pairwise Analysis ---")
for pc in pairwise_conflicts:
lines.append(f" {pc['pair']}:")
lines.append(f" Overlapping positions: {pc['overlap']}")
lines.append(f" Sign conflicts: {pc['conflicts']} ({pc['ratio']:.1%})")
if 'weighted_ratio' in pc:
lines.append(f" Magnitude-weighted conflict: {pc['weighted_ratio']:.1%}")
if 'expected_conflict' in pc:
lines.append(f" Excess conflict over cosine baseline: {pc.get('excess_conflict', 0.0):.1%} (expected {pc['expected_conflict']:.1%})")
if 'cosine_sim' in pc:
lines.append(f" Cosine similarity: {pc['cosine_sim']:.3f}")
if 'subspace_overlap' in pc:
lines.append(f" Subspace overlap: {pc['subspace_overlap']:.2f}")
# Collection Statistics
lines.append("")
lines.append("--- Collection Statistics ---")
lines.append(f" Total LoRAs: {collection_stats['n_loras']}")
lines.append(f" Total target groups: {collection_stats['total_keys']}")
lines.append(f" Avg sign conflict ratio: {collection_stats['avg_conflict']:.1%}")
if "avg_weighted_conflict" in collection_stats:
lines.append(f" Avg weighted conflict ratio: {collection_stats['avg_weighted_conflict']:.1%}")
if "avg_excess_conflict" in collection_stats:
lines.append(f" Avg excess conflict: {collection_stats['avg_excess_conflict']:.1%}")
if "avg_subspace_overlap" in collection_stats:
lines.append(f" Avg subspace overlap: {collection_stats['avg_subspace_overlap']:.2f}")
lines.append(f" Importance ratio (max/min frobenius): {collection_stats['magnitude_ratio']:.2f}x")
if collection_stats.get("decision_smoothing", 0.0) > 0:
lines.append(f" Decision smoothing: {collection_stats['decision_smoothing']:.2f}")
# Auto-Selected Parameters
lines.append("")
lines.append("--- Auto-Selected Parameters ---")
if optimization_mode == "additive":
lines.append(f" Merge mode: weighted_sum (forced by additive)")
lines.append(f" Auto-detected mode: {mode} (overridden)")
else:
lines.append(f" Merge mode: {mode}")
if mode == "ties":
lines.append(f" Density: {density:.2f}")
lines.append(f" Sign method: {sign_method}")
if optimization_mode == "per_prefix":
lines.append(" (global fallback — each target group uses its own parameters)")
if sparsification != "disabled":
display_name = {
"dare": "DARE", "della": "DELLA",
"dare_conflict": "DARE (conflict-aware)",
"della_conflict": "DELLA (conflict-aware)",
}.get(sparsification, sparsification.upper())
lines.append(f" Sparsification: {display_name}")
lines.append(f" Sparsification density: {sparsification_density:.2f} (keep rate)")
if dare_dampening > 0 and sparsification in ("dare", "dare_conflict"):
q = sparsification_density + dare_dampening * (1.0 - sparsification_density)
lines.append(f" DAREx dampening: {dare_dampening:.2f} (rescale factor: 1/{q:.2f} = {1.0/q:.2f}x vs standard 1/{sparsification_density:.2f} = {1.0/sparsification_density:.2f}x)")
if optimization_mode == "per_prefix":
lines.append(f" For TIES prefixes: replaces trim step; others: preprocessing")
elif optimization_mode == "additive":
lines.append(f" Applied as preprocessing before weighted_sum")
elif mode == "ties":
lines.append(f" Note: {display_name} replaces TIES trim step")
else:
lines.append(f" Applied as preprocessing before {mode}")
if merge_refinement != "none":
quality_desc = {
"refine": "Refine (orthogonalize + TALL-masks)",
"full": "Full (orthogonalize + KnOTS SVD alignment + TALL-masks)",
}
lines.append(f" Merge refinement: {quality_desc.get(merge_refinement, merge_refinement)}")
if strategy_set != "full":
profile_desc = {
"no_slerp": "no_slerp (full detection, no SLERP upgrade)",
"basic": "basic (TIES vs weighted_average only)",
}
lines.append(f" Strategy set: {profile_desc.get(strategy_set, strategy_set)}")
# Per-Prefix Strategy breakdown (only in per_prefix mode)
if optimization_mode == "per_prefix" and strategy_counts:
lines.append("")
lines.append("--- Per-Group Strategy ---")
total_pf = sum(strategy_counts.values())
if total_pf > 0:
if strategy_counts.get("weighted_sum", 0) > 0:
n = strategy_counts["weighted_sum"]
lines.append(f" weighted_sum (single LoRA): {n:>4} groups ({n/total_pf:.0%})")
if strategy_counts.get("slerp", 0) > 0:
n = strategy_counts["slerp"]
lines.append(f" slerp (low conflict): {n:>4} groups ({n/total_pf:.0%})")
if strategy_counts.get("weighted_average", 0) > 0:
n = strategy_counts["weighted_average"]
lines.append(f" weighted_average (orthogonal): {n:>4} groups ({n/total_pf:.0%})")
if strategy_counts.get("consensus", 0) > 0:
n = strategy_counts["consensus"]
lines.append(f" consensus (high similarity): {n:>4} groups ({n/total_pf:.0%})")
if strategy_counts.get("ties", 0) > 0:
n = strategy_counts["ties"]
lines.append(f" ties (high conflict): {n:>4} groups ({n/total_pf:.0%})")
lines.append(f" Total: {total_pf:>4} groups")
# Block Strategy Map (per_prefix mode only)
if optimization_mode == "per_prefix" and prefix_decisions:
# Group prefixes by block name
block_data = {} # block_name -> list of (mode, conflict, n_loras)
for prefix, pf_mode, conflict, n_loras in prefix_decisions:
block_name = self._extract_block_name(prefix)
if block_name not in block_data:
block_data[block_name] = []
block_data[block_name].append((pf_mode, conflict, n_loras))
# Aggregate per block: dominant strategy, avg conflict, max n_loras
# Priority-based dominant: ties > slerp > avg > sum (show most interesting)
mode_priority = {"ties": 4, "consensus": 3, "slerp": 2, "weighted_average": 1, "weighted_sum": 0}
block_summary = []
for block_name, entries in block_data.items():
modes = [e[0] for e in entries]
conflicts = [e[1] for e in entries]
n_loras_max = max(e[2] for e in entries)
mode_counts = {}
for m in modes:
mode_counts[m] = mode_counts.get(m, 0) + 1
# Pick highest-priority mode present (not most frequent)
dominant = max(mode_counts, key=lambda m: mode_priority.get(m, -1))
# Avg conflict only over multi-LoRA prefixes (sum prefixes have 0 conflict)
multi_conflicts = [c for m, c in zip(modes, conflicts) if m != "weighted_sum"]
avg_conflict = sum(multi_conflicts) / len(multi_conflicts) if multi_conflicts else 0
n_prefixes = len(entries)
block_summary.append((block_name, dominant, avg_conflict, n_loras_max, n_prefixes, mode_counts))
# Sort by block name for consistent ordering
block_summary.sort(key=lambda x: x[0])
lines.append("")
lines.append("--- Block Strategy Map ---")
symbols = {"weighted_sum": "====", "slerp": "~~~~", "weighted_average": "----", "ties": "####", "consensus": "++++"}
labels = {"weighted_sum": "sum", "slerp": "slrp", "weighted_average": "avg", "ties": "TIES", "consensus": "cons"}
# Find max block name length for alignment
max_name = max(len(b[0]) for b in block_summary) if block_summary else 10
for block_name, dominant, avg_conflict, n_loras_max, n_prefixes, mode_counts in block_summary:
sym = symbols.get(dominant, "????")
lbl = labels.get(dominant, dominant)
if len(mode_counts) == 1 and dominant == "weighted_sum":
detail = "1 LoRA"
else:
# Show breakdown when block has mixed strategies
parts = []
for m in ("weighted_sum", "weighted_average", "slerp", "consensus", "ties"):
if mode_counts.get(m, 0) > 0:
parts.append(f"{mode_counts[m]} {labels.get(m, m)}")
detail = f"{avg_conflict:.0%} conflict ({', '.join(parts)})"
count_str = f"({n_prefixes}x)" if n_prefixes > 1 else ""
lines.append(f" {block_name:<{max_name}} {sym} {lbl:<5} {detail} {count_str}")
lines.append(f" Legend: ==== sum ~~~~ slerp ---- avg ++++ cons #### TIES")
# Reasoning
lines.append("")
lines.append("--- Reasoning ---")
for r in reasoning:
lines.append(f" {r}")
# Merge Summary
lines.append("")
lines.append("--- Merge Summary ---")
lines.append(f" Keys processed: {merge_summary['keys_processed']}")
lines.append(f" Model patches: {merge_summary['model_patches']}")
lines.append(f" CLIP patches: {merge_summary['clip_patches']}")
if merge_summary.get('skipped_keys', 0) > 0:
lines.append(f" Skipped keys: {merge_summary['skipped_keys']} (shape mismatch, e.g. sliced weights)")
os_val = merge_summary['output_strength']
auto_os = merge_summary.get('auto_output_strength', False)
if auto_os:
lines.append(f" Output strength: {os_val:.2f} (auto — suggested max)")
else:
lines.append(f" Output strength: {os_val}")
lines.append(f" CLIP strength: {merge_summary['clip_strength']}")
if not auto_os and merge_summary.get('suggested_max_strength') is not None:
sms = merge_summary['suggested_max_strength']
lines.append(f" Suggested max output_strength: {sms:.2f}")
if sms >= 3.0:
lines.append(f" (capped at 3.0 — actual headroom may be higher)")
elif sms == 1.0:
lines.append(f" (energy preserved — no compensation needed)")
lines.append("")
lines.append("=" * 50)
return "\n".join(lines)
def execute_node(self, model, lora_stack, output_strength, clip=None,
clip_strength_multiplier=1.0, auto_strength="enabled",
auto_strength_floor=-1.0,
free_vram_between_passes="disabled", vram_budget=0.0,
optimization_mode="per_prefix", cache_patches="enabled",
patch_compression="smart", svd_device="gpu",
normalize_keys="enabled", sparsification="disabled",
sparsification_density=0.7, dare_dampening=0.0,
merge_strategy_override="", merge_refinement="none",
strategy_set="full", architecture_preset="auto",
decision_smoothing=0.25,
tuner_data=None, settings_source="manual"):
"""
ComfyUI entry point. Supports an AutoTuner bridge mode:
- from_autotuner: passthrough, using the upstream AutoTuner merge and
syncing the optimizer widgets from tuner_data for manual refinement.
- manual: run the optimizer normally using the current widget values.
"""
if settings_source == "from_autotuner":
if tuner_data is None or "top_n" not in tuner_data or len(tuner_data["top_n"]) == 0:
logging.warning("[AutoTuner Bridge] No valid tuner_data — falling back to manual merge")
else:
entry = tuner_data["top_n"][0]
config = entry["config"]
applied_config = dict(config)
if "auto_strength_floor" in tuner_data:
applied_config["auto_strength_floor"] = tuner_data["auto_strength_floor"]
score = entry.get("score_final", entry.get("score_measured", entry.get("score_heuristic", 0.0)))
mode_display = ("per-prefix (auto)" if config["optimization_mode"] == "per_prefix"
else config.get("merge_mode", "unknown"))
report_lines = [
f"[AutoTuner Bridge] Passthrough — model merged by AutoTuner (rank #1, score: {score:.3f}):",
f" optimization_mode: {config['optimization_mode']}",
f" merge mode: {mode_display}",
f" merge_refinement: {config['merge_refinement']}",
]
if config["sparsification"] != "disabled":
report_lines.append(
f" sparsification: {config['sparsification']} "
f"(density: {config['sparsification_density']}, "
f"dampening: {config['dare_dampening']})"
)
report_lines.append(f" auto_strength: {config['auto_strength']}")
if "auto_strength_floor" in tuner_data:
report_lines.append(f" auto_strength_floor: {tuner_data['auto_strength_floor']}")
if "decision_smoothing" in tuner_data:
report_lines.append(f" decision_smoothing: {tuner_data['decision_smoothing']}")
report_lines.append("")
report_lines.append("Switch settings_source to 'manual' to tweak from these settings.")
report = "\n".join(report_lines)
logging.info("[AutoTuner Bridge] Passthrough mode — model already merged by AutoTuner")
return {"result": (model, clip, report, tuner_data, None),
"ui": {"applied_settings": [json.dumps(applied_config)]}}
if settings_source == "from_tuner_data":
if tuner_data is None or "top_n" not in tuner_data or len(tuner_data["top_n"]) == 0:
logging.warning("[Tuner Data] No valid tuner_data — falling back to manual merge")
else:
entry = tuner_data["top_n"][0]
config = entry["config"]
strategy_override = config["merge_mode"] if config["optimization_mode"] == "global" else ""
resolved_smoothing = tuner_data.get("decision_smoothing", decision_smoothing)
result = self.optimize_merge(
model, lora_stack, output_strength,
clip=clip,
clip_strength_multiplier=clip_strength_multiplier,
auto_strength=config["auto_strength"],
auto_strength_floor=tuner_data.get("auto_strength_floor", auto_strength_floor),
free_vram_between_passes=free_vram_between_passes,
vram_budget=vram_budget,
optimization_mode=config["optimization_mode"],
cache_patches=cache_patches,
patch_compression=patch_compression,
svd_device=svd_device,
normalize_keys=tuner_data.get("normalize_keys", normalize_keys),
sparsification=config["sparsification"],
sparsification_density=config["sparsification_density"],
dare_dampening=config["dare_dampening"],
merge_strategy_override=strategy_override,
merge_refinement=config["merge_refinement"],
strategy_set=tuner_data.get("strategy_set", strategy_set),
architecture_preset=tuner_data.get("architecture_preset", architecture_preset),
decision_smoothing=resolved_smoothing,
)
applied_config = dict(config)
return {"result": result, "ui": {"applied_settings": [json.dumps(applied_config)]}}
return self.optimize_merge(
model, lora_stack, output_strength,
clip=clip,
clip_strength_multiplier=clip_strength_multiplier,
auto_strength=auto_strength,
auto_strength_floor=auto_strength_floor,
free_vram_between_passes=free_vram_between_passes,
vram_budget=vram_budget,
optimization_mode=optimization_mode,
cache_patches=cache_patches,
patch_compression=patch_compression,
svd_device=svd_device,
normalize_keys=normalize_keys,
sparsification=sparsification,
sparsification_density=sparsification_density,
dare_dampening=dare_dampening,
merge_strategy_override=merge_strategy_override,
merge_refinement=merge_refinement,
strategy_set=strategy_set,
architecture_preset=architecture_preset,
decision_smoothing=decision_smoothing,
)
def optimize_merge(self, model, lora_stack, output_strength, clip=None, clip_strength_multiplier=1.0, auto_strength="disabled", auto_strength_floor=-1.0, free_vram_between_passes="disabled", vram_budget=0.0, optimization_mode="per_prefix", cache_patches="enabled", patch_compression="smart", svd_device="gpu", normalize_keys="disabled", sparsification="disabled", sparsification_density=0.7, dare_dampening=0.0, merge_strategy_override="", merge_refinement="none", strategy_set="full", architecture_preset="auto", decision_smoothing=0.25, smooth_slerp_gate=False, _analysis_cache=None, _diff_cache=None, _skip_report=False):
"""
Main entry point. Two-pass streaming architecture:
Pass 1: Resolve aliases to target groups, compute diffs, sample metrics, discard diffs
Decision: Finalize stats, auto-select params from lightweight accumulators
Pass 2: Recompute diffs per target group, merge immediately, discard
Peak memory tracks the largest active target group, not the whole stack.
"""
# Free stale cached models when the input model changes — prevents
# the old patched model from staying in RAM after switching models.
current_mid = id(model) if model is not None else None
prev_mid = getattr(self, '_cached_model_id', None)
if prev_mid is not None and current_mid != prev_mid:
if hasattr(self, '_merge_cache') and self._merge_cache:
self._merge_cache.clear()
if hasattr(self, '_autotuner_cache') and getattr(self, '_autotuner_cache', None):
self._autotuner_cache.clear()
delegate = getattr(self, '_autotuner_delegate', None)
if delegate is not None:
if hasattr(delegate, '_merge_cache') and delegate._merge_cache:
delegate._merge_cache.clear()
if hasattr(delegate, '_autotuner_cache') and getattr(delegate, '_autotuner_cache', None):
delegate._autotuner_cache.clear()
delegate._cached_model_id = None
gc.collect()
self._cached_model_id = current_mid
# Normalize stack format (standard tuples or LoRAStack dicts)
if not lora_stack or len(lora_stack) == 0:
return (model, clip, "No LoRAs in stack.", None, None)
normalized_stack = self._normalize_stack(lora_stack, normalize_keys=normalize_keys)
active_loras = [item for item in normalized_stack if item["strength"] != 0]
if len(active_loras) == 0:
return (model, clip, "No LoRAs in stack (all zero strength or malformed).", None, None)
# Resolve architecture preset from override or auto-detection
preset_key, arch_preset = _resolve_arch_preset(
architecture_preset, getattr(self, '_detected_arch', None) or 'unknown')
logging.info(f"[LoRA Optimizer] Architecture preset: {preset_key} ({arch_preset['display_name']})")
# Single LoRA: skip analysis, apply directly via ComfyUI's standard
# additive LoRA application (faster than diff-based pipeline).
# auto_strength is a no-op with a single LoRA (scale would be 1.0).
# Skip fast path for Z-Image: normalized keys (to_q/to_k/to_v) won't
# match the model's fused qkv keys — need full pipeline + re-fusion.
if len(active_loras) == 1 and getattr(self, '_detected_arch', None) != 'zimage':
item = active_loras[0]
lora_dict = item["lora"]
strength = item["strength"]
resolved_output_strength = 1.0 if output_strength < 0 else output_strength
if item["clip_strength"] is not None:
clip_str = item["clip_strength"]
else:
clip_str = strength * clip_strength_multiplier
new_model, new_clip = comfy.sd.load_lora_for_models(
model, clip, lora_dict, resolved_output_strength * strength, resolved_output_strength * clip_str
)
report = (
"=" * 50 + "\n"
"LORA OPTIMIZER - ANALYSIS REPORT\n"
"=" * 50 + "\n\n"
"Single LoRA detected — bypassing analysis.\n"
f" Name: {item['name']}\n"
f" Strength: {strength}\n"
f" Applied directly with output_strength={resolved_output_strength}\n"
"\n" + "=" * 50
)
return (new_model, new_clip, report, None, None)
# Check instance-level patch cache (survives ComfyUI re-execution
# triggered by downstream seed changes or similar non-LoRA changes)
cache_key = self._compute_cache_key(lora_stack, output_strength,
clip_strength_multiplier, auto_strength,
optimization_mode, patch_compression,
svd_device, normalize_keys,
sparsification, sparsification_density,
dare_dampening,
merge_strategy_override, merge_refinement,
strategy_set, architecture_preset,
auto_strength_floor,
decision_smoothing)
cache_key = f"{cache_key}|mid={id(model)}"
if cache_patches == "enabled" and cache_key in self._merge_cache:
model_patches, clip_patches, report, clip_strength_out, lora_data = self._merge_cache[cache_key]
cached_output_strength = output_strength
if cached_output_strength < 0 and lora_data and lora_data.get("suggested_max_strength") is not None:
cached_output_strength = lora_data["suggested_max_strength"]
clip_strength_out = cached_output_strength * clip_strength_multiplier
elif cached_output_strength < 0:
cached_output_strength = 1.0
new_model = model
new_clip = clip
if model is not None and len(model_patches) > 0:
new_model = model.clone()
new_model.add_patches(model_patches, cached_output_strength)
self._update_model_size(new_model, model_patches)
if clip is not None and len(clip_patches) > 0:
new_clip = clip.clone()
new_clip.add_patches(clip_patches, clip_strength_out)
self._update_model_size(new_clip, clip_patches)
logging.info(f"[LoRA Optimizer] Using cached merge result ({len(model_patches)} model + {len(clip_patches)} CLIP patches)")
return (new_model, new_clip, report, None, lora_data)
logging.info(f"[LoRA Optimizer] Starting analysis of {len(active_loras)} LoRAs")
t_start = time.time()
# Get key maps
model_keys = self._get_model_keys(model)
clip_keys = {}
if clip is not None:
clip_keys = comfy.lora.model_lora_keys_clip(clip.cond_stage_model, {})
# Build target groups so all aliases for the same underlying weight are
# analyzed and merged together.
all_lora_prefixes = self._collect_lora_prefixes(active_loras)
target_groups = self._build_target_groups(all_lora_prefixes, model_keys, clip_keys)
if not target_groups:
return (model, clip, "No compatible LoRA keys found. "
"LoRAs may be incompatible with this model architecture.", None, None)
compute_device = self._get_compute_device()
use_gpu = compute_device.type != "cpu"
if _analysis_cache is not None:
# Use pre-computed Pass 1 data (from AutoTuner)
all_key_targets = _analysis_cache["all_key_targets"]
target_groups = _analysis_cache.get("target_groups") or {
prefix: {
"target_key": target_info[0],
"is_clip": target_info[1],
"aliases": [prefix],
"label_prefix": prefix,
}
for prefix, target_info in all_key_targets.items()
}
prefix_stats = _analysis_cache["prefix_stats"]
per_lora_stats = _analysis_cache["per_lora_stats"]
pair_accum = _analysis_cache["pair_accum"]
branch_energy = _analysis_cache.get("branch_energy", {
"model": {"norm_sq": [0.0] * len(active_loras), "dot": {pair: 0.0 for pair in _analysis_cache.get("pair_accum", {}).keys()}},
"clip": {"norm_sq": [0.0] * len(active_loras), "dot": {pair: 0.0 for pair in _analysis_cache.get("pair_accum", {}).keys()}},
})
all_magnitude_samples = _analysis_cache["all_magnitude_samples"]
prefix_count = _analysis_cache["prefix_count"]
skipped_keys = _analysis_cache["skipped_keys"]
pairs = _analysis_cache.get("pairs") or [
(i, j) for i in range(len(active_loras))
for j in range(i + 1, len(active_loras))
]
logging.info(f"[LoRA Optimizer] Using cached analysis ({prefix_count} target groups, skipping Pass 1)")
else:
# =====================================================================
# Pass 1 — Analysis (streaming: diffs computed, sampled, and discarded)
# =====================================================================
logging.info("[LoRA Optimizer] Pass 1: Analyzing weight diffs (streaming)...")
logging.info(f"[LoRA Optimizer] {len(target_groups)} target groups from {len(all_lora_prefixes)} aliases across {len(active_loras)} LoRAs")
logging.info(f"[LoRA Optimizer] Compute device: {compute_device}"
f" ({'sequential' if use_gpu else 'threaded'})")
t_pass1 = time.time()
analysis_data = self._run_group_analysis(
target_groups, active_loras, model, clip, compute_device,
clip_strength_multiplier=clip_strength_multiplier,
merge_refinement=merge_refinement,
decision_smoothing=decision_smoothing,
)
all_key_targets = analysis_data["all_key_targets"]
target_groups = analysis_data["target_groups"]
prefix_stats = analysis_data["prefix_stats"]
per_lora_stats = analysis_data["per_lora_stats"]
pair_accum = analysis_data["pair_accum"]
branch_energy = analysis_data["branch_energy"]
all_magnitude_samples = analysis_data["all_magnitude_samples"]
prefix_count = analysis_data["prefix_count"]
skipped_keys = analysis_data["skipped_keys"]
pairs = analysis_data["pairs"]
if prefix_count == 0:
return (model, clip, "No compatible LoRA keys found. "
"LoRAs may be incompatible with this model architecture.", None, None)
# Log per-LoRA summaries
for i, stat in enumerate(per_lora_stats):
avg_r = sum(stat["ranks"]) / len(stat["ranks"]) if stat["ranks"] else 0
logging.info(f"[LoRA Optimizer] {stat['name']} ({i+1}/{len(active_loras)}): "
f"{stat['key_count']} groups, avg rank {avg_r:.0f}")
logging.info(f"[LoRA Optimizer] Total: {prefix_count} target groups ({time.time() - t_pass1:.1f}s)")
# =====================================================================
# Decision — finalize stats, auto-select params (scalars only)
# =====================================================================
# Finalize per-LoRA stats
lora_stats = []
l2_means = []
for i, stat in enumerate(per_lora_stats):
avg_rank = sum(stat["ranks"]) / len(stat["ranks"]) if stat["ranks"] else 0
l2_mean = sum(stat["l2_norms"]) / len(stat["l2_norms"]) if stat["l2_norms"] else 0
l2_means.append(l2_mean)
lora_stats.append({
"name": stat["name"],
"strength": stat["strength"],
"key_count": stat["key_count"],
"avg_rank": avg_rank,
"l2_mean": l2_mean,
"conflict_mode": active_loras[i].get("conflict_mode", "all"),
"key_filter": active_loras[i].get("key_filter", "all"),
})
fr_preset = arch_preset.get("full_rank", {})
fr_rank_threshold = fr_preset.get("rank_threshold", 512)
global_avg_rank = (sum(s["avg_rank"] for s in lora_stats) / len(lora_stats)) if lora_stats else 0
is_full_rank = global_avg_rank >= fr_rank_threshold
logging.info(f"[LoRA Optimizer] Global avg rank: {global_avg_rank:.0f} (full-rank threshold: {fr_rank_threshold})")
if is_full_rank:
logging.info(
f"[LoRA Optimizer] Full-rank LoRAs detected "
f"(avg rank {global_avg_rank:.0f} >= {fr_rank_threshold})"
)
if fr_preset.get("disable_slerp_upgrade", False):
logging.info("[LoRA Optimizer] SLERP upgrade disabled for full-rank patches")
if fr_preset.get("prefer_sum_orthogonal", False):
logging.info("[LoRA Optimizer] Orthogonal full-rank patches will use weighted_sum (additive)")
# Pairwise conflict stats and cosine similarity from accumulated counts
total_overlap = 0
total_conflict = 0
total_weighted_total = 0.0
total_weighted_conflict = 0.0
total_expected_conflict_weighted = 0.0
total_excess_conflict_weighted = 0.0
total_subspace_num = 0.0
total_subspace_den = 0.0
pairwise_conflicts = []
pairwise_similarities = {}
for i, j in pairs:
pair_metrics = pair_accum[(i, j)]
pair_overlap = pair_metrics["overlap"]
pair_conflict = pair_metrics["conflict"]
pair_dot = pair_metrics["dot"]
pair_na_sq = pair_metrics["norm_a_sq"]
pair_nb_sq = pair_metrics["norm_b_sq"]
total_overlap += pair_overlap
total_conflict += pair_conflict
total_weighted_total += pair_metrics["weighted_total"]
total_weighted_conflict += pair_metrics["weighted_conflict"]
total_expected_conflict_weighted += pair_metrics["expected_conflict_weighted"]
total_excess_conflict_weighted += pair_metrics["excess_conflict_weighted"]
total_subspace_num += pair_metrics["subspace_num"]
total_subspace_den += pair_metrics["subspace_den"]
ratio = pair_conflict / pair_overlap if pair_overlap > 0 else 0
weighted_ratio = (pair_metrics["weighted_conflict"] / pair_metrics["weighted_total"]) if pair_metrics["weighted_total"] > 0 else ratio
expected_conflict = (pair_metrics["expected_conflict_weighted"] / pair_metrics["weighted_total"]) if pair_metrics["weighted_total"] > 0 else 0.0
excess_conflict = (pair_metrics["excess_conflict_weighted"] / pair_metrics["weighted_total"]) if pair_metrics["weighted_total"] > 0 else 0.0
subspace_overlap = (pair_metrics["subspace_num"] / pair_metrics["subspace_den"]) if pair_metrics["subspace_den"] > 0 else 0.0
denom = math.sqrt(pair_na_sq) * math.sqrt(pair_nb_sq)
cos_sim = pair_dot / denom if denom > 0 else 0.0
pairwise_similarities[(i, j)] = cos_sim
name_i = active_loras[i]['name']
name_j = active_loras[j]['name']
if name_i == name_j:
pair_label = f"{name_i} [#{i+1}, str={active_loras[i]['strength']}] vs {name_j} [#{j+1}, str={active_loras[j]['strength']}]"
else:
pair_label = f"{name_i} vs {name_j}"
pairwise_conflicts.append({
"pair": pair_label,
"overlap": pair_overlap,
"conflicts": pair_conflict,
"ratio": ratio,
"weighted_ratio": weighted_ratio,
"expected_conflict": expected_conflict,
"excess_conflict": excess_conflict,
"cosine_sim": cos_sim,
"subspace_overlap": subspace_overlap,
})
logging.info(f"[LoRA Optimizer] {pair_label} -> raw={ratio:.1%}, excess={excess_conflict:.1%}, cos_sim={cos_sim:.3f}, subspace={subspace_overlap:.2f}")
# Compatibility warnings for opposing LoRAs
compatibility_warnings = []
for (i, j), cos_sim in pairwise_similarities.items():
if cos_sim < -0.1:
compatibility_warnings.append({
"name_i": active_loras[i]['name'],
"name_j": active_loras[j]['name'],
"cosine_sim": cos_sim,
})
logging.warning(
f"[LoRA Optimizer] WARNING: {active_loras[i]['name']} vs {active_loras[j]['name']} "
f"have negative cosine similarity ({cos_sim:.3f}) — they work against each other"
)
avg_conflict_ratio = total_conflict / total_overlap if total_overlap > 0 else 0
avg_weighted_conflict_ratio = total_weighted_conflict / total_weighted_total if total_weighted_total > 0 else avg_conflict_ratio
avg_expected_conflict = total_expected_conflict_weighted / total_weighted_total if total_weighted_total > 0 else 0.0
avg_excess_conflict = total_excess_conflict_weighted / total_weighted_total if total_weighted_total > 0 else 0.0
avg_subspace_overlap = total_subspace_num / total_subspace_den if total_subspace_den > 0 else 0.0
logging.info(f"[LoRA Optimizer] Average conflict ratio: {avg_conflict_ratio:.1%}")
logging.info(f"[LoRA Optimizer] Excess conflict: {avg_excess_conflict:.1%} | subspace overlap: {avg_subspace_overlap:.2f}")
# Magnitude ratio
branch_measure = branch_energy["model"]["norm_sq"]
model_effective = [
abs(active_loras[i]["strength"]) * math.sqrt(max(branch_measure[i], 0.0))
for i in range(len(active_loras))
]
valid_l2 = [m for m in model_effective if m > 0]
if len(valid_l2) >= 2:
magnitude_ratio = max(valid_l2) / min(valid_l2)
else:
magnitude_ratio = 1.0
collection_stats = {
"n_loras": len(active_loras),
"total_keys": prefix_count,
"avg_conflict": avg_conflict_ratio,
"avg_weighted_conflict": avg_weighted_conflict_ratio,
"avg_expected_conflict": avg_expected_conflict,
"avg_excess_conflict": avg_excess_conflict,
"avg_subspace_overlap": avg_subspace_overlap,
"magnitude_ratio": magnitude_ratio,
"decision_smoothing": decision_smoothing,
}
# Auto-select parameters (density estimated from pre-sampled magnitudes)
global_avg_cos_sim = (sum(ps.get("cosine_sim", 0.0) for ps in pairwise_conflicts)
/ len(pairwise_conflicts)) if pairwise_conflicts else 0.0
mode, density, sign_method, reasoning = self._auto_select_params(
avg_conflict_ratio, magnitude_ratio, magnitude_samples=all_magnitude_samples,
avg_cos_sim=global_avg_cos_sim, strategy_set=strategy_set,
avg_excess_conflict=avg_excess_conflict,
avg_subspace_overlap=avg_subspace_overlap,
arch_preset=arch_preset
)
del all_magnitude_samples
# Apply merge strategy override from Conflict Editor
# Skip when user explicitly chose additive (protects DPO/edit LoRAs)
if merge_strategy_override and optimization_mode != "additive":
if merge_strategy_override in ("ties", "weighted_average", "weighted_sum", "consensus", "slerp"):
mode = merge_strategy_override
reasoning.append(f"Merge mode overridden to '{mode}' by Conflict Editor")
else:
logging.warning(f"[LoRA Optimizer] Invalid merge_strategy_override '{merge_strategy_override}' — ignoring")
logging.info(f"[LoRA Optimizer] Decision: {mode} ({reasoning[0] if reasoning else 'no reasoning'})")
if mode == "ties":
logging.info(f"[LoRA Optimizer] density={density:.2f}, sign_method={sign_method}")
# Auto-strength adjustment
auto_strength_info = None
model_auto_scale = 1.0
clip_auto_scale = 1.0
if auto_strength == "enabled":
auto_strength_info = self._compute_auto_strengths(
active_loras, branch_energy,
clip_strength_multiplier=clip_strength_multiplier,
arch_preset=arch_preset,
detected_arch=getattr(self, '_detected_arch', None),
auto_strength_floor=auto_strength_floor,
is_full_rank=is_full_rank,
)
model_auto_scale = auto_strength_info["model_scale"]
clip_auto_scale = auto_strength_info["clip_scale"]
for i, stat in enumerate(lora_stats):
stat["original_strength"] = stat["strength"]
stat["strength"] = auto_strength_info["model_strengths"][i]
if auto_strength_info["reasoning"]:
logging.info(f"[LoRA Optimizer] Auto-strength: {auto_strength_info['reasoning'][0]}")
for i in range(len(active_loras)):
logging.info(
f"[LoRA Optimizer] {active_loras[i]['name']}: "
f"model {active_loras[i]['strength']} -> {auto_strength_info['model_strengths'][i]:.4f}"
)
# Free GPU cache between passes if requested
if free_vram_between_passes == "enabled" and use_gpu:
torch.cuda.empty_cache()
# Resolve patch_compression rank (sum of input LoRA ranks)
sum_rank = sum(int(stat["avg_rank"]) for stat in lora_stats if stat["avg_rank"] > 0)
compress_rank = 0 # 0 = disabled
if patch_compression in ("smart", "aggressive"):
compress_rank = max(sum_rank, 64) # floor at 64
logging.info(f"[LoRA Optimizer] Patch compression: {patch_compression} (rank {compress_rank} from sum of input LoRA ranks)")
# Resolve SVD device for compression
resolved_svd_device = None
if compress_rank > 0 and svd_device == "gpu" and torch.cuda.is_available():
resolved_svd_device = torch.device("cuda")
elif compress_rank > 0 and svd_device == "cpu":
resolved_svd_device = None # CPU is the default in _compress_to_lowrank
# =====================================================================
# Pass 2 — Merge (recompute diffs per target group, merge, discard)
# =====================================================================
logging.info(f"[LoRA Optimizer] Pass 2: Merging {len(target_groups)} target groups "
f"({optimization_mode} strategy, "
f"{'sequential' if use_gpu else 'threaded'})...")
t_pass2 = time.time()
model_patches = {}
clip_patches = {}
processed_keys = 0
compressed_count = 0
strategy_counts = {"weighted_sum": 0, "weighted_average": 0, "slerp": 0, "ties": 0, "consensus": 0}
prefix_decisions = [] # list of (prefix, mode, conflict_ratio, n_loras) for block map
# VRAM budget for patch storage
vram_budget_bytes = 0
gpu_patch_bytes = 0
if vram_budget > 0 and use_gpu:
free_vram = comfy.model_management.get_free_memory(compute_device)
safety_margin = 256 * 1024 * 1024 # 256MB headroom
usable = max(0, free_vram - safety_margin)
vram_budget_bytes = int(usable * vram_budget)
logging.info(f"[LoRA Optimizer] VRAM patch budget: {vram_budget_bytes // (1024**2)}MB "
f"({vram_budget*100:.0f}% of {usable // (1024**2)}MB free)")
def _merge_one_group(label_prefix, target_group):
"""Recompute diffs for one target group, merge, return patch or None."""
nonlocal gpu_patch_bytes
should_keep = vram_budget_bytes > 0 and gpu_patch_bytes < vram_budget_bytes
target_key = target_group["target_key"]
is_clip_key = target_group["is_clip"]
# Determine strategy BEFORE computing diffs (use Pass 1 stats)
pf_conflict = 0.0
pf_n_loras = 0
pf_mode = mode
pf_density = density
pf_sign = sign_method
pf_orthogonal = False
pf_opposing = False
if optimization_mode == "additive":
pf_mode = "weighted_sum"
pf_n_loras = prefix_stats.get(label_prefix, {}).get("n_loras", 0)
pf_conflict = prefix_stats.get(label_prefix, {}).get("decision_conflict",
prefix_stats.get(label_prefix, {}).get("conflict_ratio", 0.0))
elif optimization_mode == "per_prefix" and label_prefix in prefix_stats:
pf = prefix_stats[label_prefix]
pf_conflict = pf.get("decision_conflict", pf.get("conflict_ratio", 0.0))
pf_n_loras = pf["n_loras"]
if pf["n_loras"] <= 1:
pf_mode = "weighted_sum"
pf_density = 0.5
pf_sign = "frequency"
else:
pf_mode, pf_density, pf_sign, _ = self._auto_select_params(
pf["conflict_ratio"], pf.get("decision_magnitude_ratio", pf["magnitude_ratio"]),
magnitude_samples=pf.get("magnitude_samples"),
avg_cos_sim=pf.get("decision_cosine", pf.get("avg_cos_sim", 0.0)),
avg_excess_conflict=pf.get("decision_conflict", pf.get("excess_conflict", pf.get("conflict_ratio", 0.0))),
avg_subspace_overlap=pf.get("decision_subspace_overlap", pf.get("avg_subspace_overlap", 0.0)),
strategy_set=strategy_set,
arch_preset=arch_preset,
precomputed_density=pf.get("precomputed_density"),
)
# Upgrade weighted_average → slerp for 2+ non-opposing LoRAs.
# SLERP preserves magnitude better than weighted_average's /N reduction,
# which is critical for video LoRAs where motion energy matters.
# Skip for opposing LoRAs (cos < 0): SLERP interpolates between opposing
# directions while preserving magnitude, amplifying artifacts.
pf_raw_cos = pf.get("decision_cosine", pf.get("avg_cos_sim", 0.0)) if smooth_slerp_gate else pf.get("avg_cos_sim", 0.0)
pf_orthogonal = abs(pf_raw_cos) < arch_preset["orthogonal_cos_sim_max"]
pf_opposing = pf_raw_cos < 0
# Full-rank gate: skip SLERP upgrade — for full-rank patches the
# information is spread across all dimensions, and SLERP's
# hypersphere interpolation loses signal from both LoRAs.
if (pf_mode == "weighted_average" and pf["n_loras"] >= 2
and strategy_set == "full"
and not pf_opposing
and not (is_full_rank and fr_preset.get("disable_slerp_upgrade", False))):
pf_mode = "slerp"
if (is_full_rank and fr_preset.get("prefer_sum_orthogonal", False)
and pf_mode == "weighted_average" and pf_orthogonal):
pf_mode = "weighted_sum"
# Apply merge strategy override from Conflict Editor (takes priority over auto-selection)
# Skip when user explicitly chose additive (protects DPO/edit LoRAs)
if (merge_strategy_override and optimization_mode != "additive"
and merge_strategy_override in ("ties", "weighted_average", "weighted_sum", "consensus", "slerp")):
pf_mode = merge_strategy_override
pf = prefix_stats.get(label_prefix, {})
raw_n = pf.get("raw_n_loras", pf_n_loras)
if pf_n_loras <= 1 and pf_mode != "weighted_sum":
pf_mode = "weighted_sum"
linear_stats = None
if (pf_mode in ("weighted_sum", "weighted_average", "normalize")
and sparsification == "disabled"
and merge_refinement == "none"):
linear_patch_info = self._build_exact_linear_patch(
target_group, active_loras, raw_n, pf_mode,
is_clip_key=is_clip_key, model_scale=model_auto_scale,
)
if linear_patch_info is not None:
patch = linear_patch_info["patch"]
weights = linear_patch_info["weights"]
input_norms_mean = (
sum(math.sqrt(max(pf.get("per_lora_norm_sq", {}).get(i, 0.0), 0.0)) * abs(w)
for i, w in weights.items()) / len(weights)
) if weights else 0.0
energy_sq = 0.0
for i, weight in weights.items():
energy_sq += (weight ** 2) * pf.get("per_lora_norm_sq", {}).get(i, 0.0)
for (i, j), dot in pf.get("pairwise_dots", {}).items():
if i in weights and j in weights:
energy_sq += 2.0 * weights[i] * weights[j] * dot
merged_norm = math.sqrt(max(energy_sq, 0.0))
linear_stats = (input_norms_mean, merged_norm)
if should_keep:
p_bytes = self._estimate_single_patch_bytes(patch)
if gpu_patch_bytes + p_bytes <= vram_budget_bytes:
patch = self._move_patch_to_device(patch, compute_device)
gpu_patch_bytes += p_bytes
return (
target_key, is_clip_key, patch, pf_mode, label_prefix,
pf_conflict, max(pf_n_loras, 1), False,
linear_stats[0], linear_stats[1]
)
prepared = self._prepare_group_diffs(
target_group, active_loras, model, clip, compute_device,
clip_strength_multiplier=clip_strength_multiplier,
merge_refinement=merge_refinement,
diff_cache=_diff_cache,
auto_scale=model_auto_scale if not is_clip_key else 1.0,
)
if prepared is None or len(prepared["diffs"]) == 0:
return None
diffs_list = []
storage_dtype = prepared["storage_dtype"]
for i in sorted(prepared["diffs"].keys()):
diffs_list.append((prepared["diffs"][i], prepared["eff_strengths"][i]))
if len(diffs_list) <= 1 and pf_mode != "weighted_sum":
pf_mode = "weighted_sum"
# Create deterministic per-group RNG for reproducible sparsification
sp_gen = None
if sparsification != "disabled":
seed = int(hashlib.sha256(label_prefix.encode()).hexdigest(), 16) % (2**63)
gen_device = compute_device if compute_device is not None else torch.device('cpu')
sp_gen = torch.Generator(device=gen_device)
sp_gen.manual_seed(seed)
input_norms_mean = (sum(d.float().norm().item() * abs(w) for d, w in diffs_list)
/ len(diffs_list)) if diffs_list else 0.0
# For orthogonal/opposing weighted_average, force standard quality.
# TALL-masks classifies most positions as "selfish" for orthogonal
# LoRAs (each dominates independent positions) and adds them back at
# full strength AFTER the merge, bypassing weighted_average's /N
# normalization. This converts weighted_average → weighted_sum (~Nx energy).
# Opposing LoRAs have a similar problem: the magnitude calibration
# in enhanced quality doesn't account for the directional cancellation
# that weighted_average provides.
pf_quality = merge_refinement
if (pf_mode == "weighted_average" and pf_opposing):
pf_quality = "none"
merged_diff = self._merge_diffs(
diffs_list, pf_mode,
density=pf_density, majority_sign_method=pf_sign,
compute_device=compute_device,
sparsification=sparsification,
sparsification_density=sparsification_density,
sparsification_generator=sp_gen,
merge_refinement=pf_quality,
dare_dampening=dare_dampening,
keep_on_gpu=should_keep,
)
merged_norm = merged_diff.float().norm().item() if merged_diff is not None else 0.0
diffs_list.clear() # Free input diffs from GPU
if merged_diff is None:
return None
# Compress full-rank diff to low-rank via SVD if requested
# non_ties: skip compression on TIES prefixes (lossy); all: compress everything
should_compress = (compress_rank > 0 and
(patch_compression == "aggressive" or pf_mode != "ties"))
# Downcast from float32 to native weight dtype (e.g. fp16/bf16)
# to halve memory — ComfyUI handles dtype conversion when applying
if storage_dtype is not None and merged_diff.dtype != storage_dtype:
merged_diff = merged_diff.to(storage_dtype)
if should_compress:
patch = self._compress_to_lowrank(merged_diff, compress_rank, svd_device=resolved_svd_device)
del merged_diff
is_compressed = True
else:
patch = ("diff", (merged_diff,))
is_compressed = False
if should_keep:
p_bytes = self._estimate_single_patch_bytes(patch)
if gpu_patch_bytes + p_bytes <= vram_budget_bytes:
patch = self._move_patch_to_device(patch, compute_device)
gpu_patch_bytes += p_bytes
else:
patch = self._move_patch_to_device(patch, torch.device("cpu"))
return (target_key, is_clip_key, patch, pf_mode, label_prefix, pf_conflict, max(pf_n_loras, 1), is_compressed, input_norms_mean, merged_norm)
lowrank_count = 0
total_input_energy = 0.0
total_merged_energy = 0.0
_overwrite_count = 0
_overwrite_examples = []
def _collect_merge_result(result):
nonlocal processed_keys, lowrank_count, compressed_count
nonlocal total_input_energy, total_merged_energy
nonlocal _overwrite_count
if result is None:
return
target_key, is_clip_key, patch, used_mode, prefix, conflict, n_loras, is_compressed, inp_norm, mrg_norm = result
total_input_energy += inp_norm
total_merged_energy += mrg_norm
target_dict = clip_patches if is_clip_key else model_patches
if target_key in target_dict:
# Target-key collision: accumulate diffs instead of overwriting
_overwrite_count += 1
existing = target_dict[target_key]
existing_diff = self._expand_patch_to_diff(existing)
new_diff = self._expand_patch_to_diff(patch)
accumulated = existing_diff + new_diff
# Use the smaller dtype to save memory
store_dt = existing_diff.dtype if existing_diff.dtype != torch.float32 else new_diff.dtype
if store_dt != torch.float32:
accumulated = accumulated.to(store_dt)
target_dict[target_key] = ("diff", (accumulated,))
# Fix lowrank_count if existing was a low-rank adapter (now replaced by diff)
if isinstance(existing, (LoRAAdapter, LoKrAdapter, LoHaAdapter)):
lowrank_count -= 1
if len(_overwrite_examples) < 3:
_overwrite_examples.append(f"{'CLIP' if is_clip_key else 'MODEL'} {prefix} -> {target_key}")
else:
target_dict[target_key] = patch
if isinstance(patch, (LoRAAdapter, LoKrAdapter, LoHaAdapter)):
lowrank_count += 1
processed_keys += 1
if is_compressed:
compressed_count += 1
strategy_counts[used_mode] = strategy_counts.get(used_mode, 0) + 1
prefix_decisions.append((prefix, used_mode, conflict, n_loras))
if use_gpu:
group_items = list(target_groups.items())
n_loras = len(active_loras)
for idx, (label_prefix, target_group) in enumerate(group_items):
if _diff_cache is not None and idx + 1 < len(group_items):
next_group = group_items[idx + 1][1]
prefetch_keys = [
(alias, i)
for alias in next_group["aliases"]
for i in range(n_loras)
]
_diff_cache.prefetch(prefetch_keys)
_collect_merge_result(_merge_one_group(label_prefix, target_group))
else:
max_workers = min(4, max(1, len(target_groups)))
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(_merge_one_group, label_prefix, target_group): label_prefix
for label_prefix, target_group in target_groups.items()
}
for future in concurrent.futures.as_completed(futures):
_collect_merge_result(future.result())
fullrank_count = processed_keys - lowrank_count
if _overwrite_count > 0:
logging.info(f"[LoRA Optimizer] {_overwrite_count} target-key collisions resolved "
f"(different LoRA key formats targeting the same model weight — diffs accumulated)")
logging.info(f"[LoRA Optimizer] Model patches: {len(model_patches)}, "
f"CLIP patches: {len(clip_patches)} ({time.time() - t_pass2:.1f}s)")
if lowrank_count > 0:
logging.info(f"[LoRA Optimizer] Low-rank patches: {lowrank_count} "
f"(full-rank: {fullrank_count}) — "
f"~{lowrank_count}/{processed_keys} keys use minimal RAM")
if optimization_mode == "per_prefix":
logging.info(f"[LoRA Optimizer] Per-prefix strategies: "
f"{strategy_counts.get('weighted_sum', 0)} sum, "
f"{strategy_counts.get('slerp', 0)} slerp, "
f"{strategy_counts.get('weighted_average', 0)} avg, "
f"{strategy_counts.get('consensus', 0)} cons, "
f"{strategy_counts.get('ties', 0)} ties")
spars_skipped = getattr(self, '_sparsification_skipped', 0)
if spars_skipped > 0:
logging.info(f"[LoRA Optimizer] Conflict-aware sparsification skipped for "
f"{spars_skipped} groups (base-rate noise from orthogonal LoRAs)")
self._sparsification_skipped = 0
if compressed_count > 0:
passthrough_count = lowrank_count - compressed_count
logging.info(f"[LoRA Optimizer] SVD-compressed: {compressed_count} patches "
f"(rank {compress_rank}), passthrough: {passthrough_count}, "
f"full-rank: {fullrank_count}")
if vram_budget_bytes > 0:
gpu_count = 0
cpu_count = 0
for p in list(model_patches.values()) + list(clip_patches.values()):
data = p.weights if hasattr(p, 'weights') else p
if isinstance(data, (tuple, list)):
for item in data:
if isinstance(item, torch.Tensor):
gpu_count += 1 if item.is_cuda else 0
cpu_count += 1 if not item.is_cuda else 0
break
elif isinstance(item, (tuple, list)):
for sub in item:
if isinstance(sub, torch.Tensor):
gpu_count += 1 if sub.is_cuda else 0
cpu_count += 1 if not sub.is_cuda else 0
break
break
logging.info(f"[LoRA Optimizer] VRAM budget: {gpu_count} patches on GPU "
f"({gpu_patch_bytes // (1024**2)}MB), {cpu_count} on CPU")
suggested_max_strength = None
if total_merged_energy > 0 and total_input_energy > 0:
norm_ratio = total_merged_energy / total_input_energy
suggested_max_strength = max(1.0, min(1.0 / norm_ratio, arch_preset["suggested_max_strength_cap"]))
# Free analysis data no longer needed (skip when AutoTuner owns the data)
if _analysis_cache is None:
prefix_stats.clear()
self.loaded_loras.clear()
if use_gpu:
torch.cuda.empty_cache()
# Re-fuse Z-Image QKV patches if architecture normalization was used
if getattr(self, '_detected_arch', None) == 'zimage':
if len(model_patches) > 0:
model_patches = self._refuse_zimage_patches(model_patches)
logging.info(f"[LoRA Optimizer] Re-fused Z-Image QKV patches ({len(model_patches)} model patches)")
# Build reverse key map: target_key → canonical prefix metadata
# (used by SaveMergedLoRA to reconstruct standard LoRA key names)
reverse_key_map = {}
for label_prefix, target_group in target_groups.items():
target_key = target_group["target_key"]
tkey = target_key[0] if isinstance(target_key, tuple) else target_key
entry = {
"canonical_prefix": label_prefix,
"aliases": list(target_group["aliases"]),
}
reverse_key_map[target_key] = entry
reverse_key_map[tkey] = entry
# Apply patches
new_model = model
new_clip = clip
auto_output_strength = False
if output_strength < 0 and suggested_max_strength is not None:
output_strength = suggested_max_strength
auto_output_strength = True
logging.info(f"[LoRA Optimizer] Auto output_strength: {output_strength:.2f} (suggested max)")
elif output_strength < 0:
output_strength = 1.0
logging.info("[LoRA Optimizer] Auto output_strength: no suggestion available, using 1.0")
all_explicit_clip = all(item["clip_strength"] is not None for item in active_loras)
if all_explicit_clip:
clip_strength_out = output_strength * clip_auto_scale
else:
clip_strength_out = output_strength * clip_strength_multiplier * clip_auto_scale
if model is not None and len(model_patches) > 0:
new_model = model.clone()
new_model.add_patches(model_patches, output_strength)
self._update_model_size(new_model, model_patches)
if clip is not None and len(clip_patches) > 0:
new_clip = clip.clone()
new_clip.add_patches(clip_patches, clip_strength_out)
self._update_model_size(new_clip, clip_patches)
merge_summary = {
"keys_processed": processed_keys,
"model_patches": len(model_patches),
"clip_patches": len(clip_patches),
"skipped_keys": skipped_keys,
"output_strength": output_strength,
"clip_strength": clip_strength_out,
"suggested_max_strength": suggested_max_strength,
"auto_output_strength": auto_output_strength,
}
report = self._build_report(
lora_stats, pairwise_conflicts, collection_stats,
mode, density, sign_method, reasoning, merge_summary,
auto_strength_info=auto_strength_info,
strategy_counts=strategy_counts if optimization_mode == "per_prefix" else None,
optimization_mode=optimization_mode,
prefix_decisions=prefix_decisions if optimization_mode == "per_prefix" else None,
detected_arch=getattr(self, '_detected_arch', None),
normalize_keys=normalize_keys,
sparsification=sparsification,
sparsification_density=sparsification_density,
dare_dampening=dare_dampening,
merge_refinement=merge_refinement,
compatibility_warnings=compatibility_warnings,
strategy_set=strategy_set,
architecture_preset=preset_key,
)
# Bundle LORA_DATA for optional downstream saving
lora_data = {
"model_patches": model_patches,
"clip_patches": clip_patches,
"key_map": reverse_key_map,
"output_strength": output_strength,
"clip_strength": clip_strength_out,
"suggested_max_strength": suggested_max_strength,
"sum_rank": compress_rank if compress_rank > 0 else 128,
"merge_metadata": {
"source_loras": [{"name": item["name"], "strength": item["strength"]} for item in active_loras],
"mode": mode,
"optimization_mode": optimization_mode,
"architecture": getattr(self, '_detected_arch', None) or 'unknown',
"architecture_preset": preset_key,
"auto_strength": auto_strength,
"sparsification": sparsification,
"sparsification_density": sparsification_density,
"merge_refinement": merge_refinement,
"strategy_set": strategy_set,
"bake_strength_output": output_strength,
"bake_strength_clip": clip_strength_out,
},
}
# Cache patches for re-use (single entry to limit memory)
if cache_patches == "enabled":
self._merge_cache = {cache_key: (model_patches, clip_patches, report, clip_strength_out, lora_data)}
else:
self._merge_cache = {}
logging.info("[LoRA Optimizer] Patch cache disabled — RAM freed after merge")
# Save report to disk for later reference
lora_combo = [[item["name"], item["strength"]] for item in active_loras]
selected_params = {"mode": mode, "density": density, "sign_method": sign_method, "optimization_mode": optimization_mode}
if optimization_mode == "per_prefix":
selected_params["strategy_counts"] = dict(strategy_counts)
# Report is returned in the UI output — no need to also save to disk
logging.info(f"[LoRA Optimizer] Done! {processed_keys} keys processed ({time.time() - t_start:.1f}s total)")
return (new_model, new_clip, report, None, lora_data)
class LoRAMergeSettings:
"""
Common merge settings shared between Optimizer and AutoTuner modes.
Connect to the 'merge_settings' input of either settings node.
"""
_DEFAULTS = {
"normalize_keys": "enabled",
"architecture_preset": "auto",
"auto_strength_floor": -1.0,
"decision_smoothing": 0.25,
"smooth_slerp_gate": False,
"vram_budget": 0.0,
"cache_patches": "enabled",
}
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"normalize_keys": (["disabled", "enabled"], {
"default": "enabled",
"tooltip": "Ensures LoRAs from different training tools (Kohya, PEFT, etc.) work together. Keep enabled unless you have a specific reason to disable."
}),
"architecture_preset": (["auto", "sd_unet", "dit", "llm"], {
"default": "auto",
"tooltip": "Tells the optimizer what type of model you're using so it can pick the best merge settings. 'auto' detects it for you. Only change if auto-detection gets it wrong."
}),
"auto_strength_floor": ("FLOAT", {
"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.05,
"tooltip": "How much the weakest LoRA is allowed to be scaled down during auto-strength. Higher values keep all LoRAs more visible. -1 picks a good default based on model type."
}),
"decision_smoothing": ("FLOAT", {
"default": 0.25, "min": 0.0, "max": 1.0, "step": 0.05,
"tooltip": "Prevents the optimizer from picking wildly different merge methods for similar layers. Higher values = more consistent choices across the model. 0.2-0.4 is usually a good range."
}),
"smooth_slerp_gate": ("BOOLEAN", {
"default": False,
"tooltip": "Changes how the optimizer decides when to use SLERP blending. When enabled, the decision is smoother and more stable. Try enabling if you notice inconsistent results between runs."
}),
"vram_budget": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.05,
"tooltip": "How much GPU memory to use for storing merge results. 0 = keep everything in system RAM (safest). Increase to use GPU memory and reduce RAM usage."
}),
"cache_patches": (["enabled", "disabled"], {
"default": "enabled",
"tooltip": "Keeps the merge result in memory so re-running the workflow is instant. Disable to free RAM — recommended for large video models."
}),
},
}
RETURN_TYPES = ("MERGE_SETTINGS",)
RETURN_NAMES = ("merge_settings",)
FUNCTION = "build_settings"
CATEGORY = "LoRA Optimizer"
DESCRIPTION = (
"Shared settings that apply to both Optimizer and AutoTuner modes. "
"Connect to the 'merge_settings' input on an Optimizer Settings or AutoTuner Settings node."
)
def build_settings(self, normalize_keys, architecture_preset,
auto_strength_floor, decision_smoothing,
smooth_slerp_gate, vram_budget, cache_patches):
return ({
"normalize_keys": normalize_keys,
"architecture_preset": architecture_preset,
"auto_strength_floor": auto_strength_floor,
"decision_smoothing": decision_smoothing,
"smooth_slerp_gate": smooth_slerp_gate,
"vram_budget": vram_budget,
"cache_patches": cache_patches,
},)
class LoRAOptimizerSettings:
"""
Pure data node that outputs Advanced-mode settings for the Simple optimizer.
Connect to the 'settings' input of LoRA Optimizer to unlock all Advanced
knobs without switching node types.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"auto_strength": (["enabled", "disabled"], {
"default": "enabled",
"tooltip": "Automatically turns down individual LoRA strengths when combining many LoRAs, preventing oversaturation. Recommended to keep enabled."
}),
"optimization_mode": (["per_prefix", "global", "additive"], {
"default": "per_prefix",
"tooltip": "How the optimizer picks merge methods. 'per_prefix' (recommended): picks the best method for each part of the model. 'global': uses one method everywhere. 'additive': simple stacking with no conflict handling — use for edit/DPO LoRAs."
}),
"merge_refinement": (["none", "refine", "full"], {
"default": "none",
"tooltip": "Extra processing to reduce interference between LoRAs. 'none': fastest, usually fine. 'refine': light cleanup for better quality. 'full': most thorough but slower. Try 'refine' if you see artifacts or color shifts."
}),
"sparsification": (["disabled", "dare", "della", "dare_conflict", "della_conflict"], {
"default": "disabled",
"tooltip": "Removes low-impact weights before merging to reduce interference between LoRAs. The 'conflict' variants (recommended if enabling) only trim where LoRAs disagree, leaving unique contributions intact."
}),
"sparsification_density": ("FLOAT", {
"default": 0.7, "min": 0.01, "max": 1.0, "step": 0.05,
"tooltip": "How much of each LoRA to keep when sparsification is enabled. 0.7 = keep 70%. Lower values are more aggressive — reduces interference but may lose detail."
}),
"dare_dampening": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.05,
"tooltip": "Controls how strongly surviving weights are boosted after sparsification trims some away. 0 = standard boost, higher = gentler. Only matters when sparsification is enabled."
}),
"patch_compression": (["smart", "aggressive", "disabled"], {
"default": "smart",
"tooltip": "Shrinks the merged result to use less memory. 'smart' (recommended): only compresses where it's lossless. 'aggressive': compresses everything, saves more memory but slightly lossy. 'disabled': no compression."
}),
"svd_device": (["gpu", "cpu"], {
"default": "gpu",
"tooltip": "Where to run compression math. GPU is much faster. Switch to CPU only if you get out-of-memory errors."
}),
"free_vram_between_passes": (["disabled", "enabled"], {
"default": "disabled",
"tooltip": "Frees GPU memory between merge steps. Enable if you're running out of VRAM on large models. Barely affects speed."
}),
"strategy_set": (["full", "no_slerp", "basic"], {
"default": "full",
"tooltip": "Which merge methods the optimizer can choose from. 'full' (recommended): all methods available including SLERP blending. 'no_slerp': excludes SLERP. 'basic': only simple averaging."
}),
},
"optional": {
"merge_settings": ("MERGE_SETTINGS", {
"tooltip": "Connect a LoRA Merge Settings node here to share common settings. Uses good defaults if not connected."
}),
"merge_strategy_override": ("STRING", {
"forceInput": True,
"tooltip": "Forces one specific merge method everywhere instead of letting the optimizer choose. Connect from a LoRA Conflict Editor node."
}),
},
}
RETURN_TYPES = ("OPTIMIZER_SETTINGS",)
RETURN_NAMES = ("settings",)
FUNCTION = "build_settings"
CATEGORY = "LoRA Optimizer"
DESCRIPTION = (
"Advanced settings for fine-tuning how LoRAs are merged. "
"Connect to the 'settings' input on the LoRA Optimizer node."
)
def build_settings(self, auto_strength, optimization_mode, merge_refinement,
sparsification, sparsification_density, dare_dampening,
patch_compression, svd_device,
free_vram_between_passes, strategy_set,
merge_settings=None, merge_strategy_override=""):
ms = merge_settings if merge_settings is not None else LoRAMergeSettings._DEFAULTS
return ({
"mode": "advanced",
"auto_strength": auto_strength,
"optimization_mode": optimization_mode,
"merge_refinement": merge_refinement,
"sparsification": sparsification,
"sparsification_density": sparsification_density,
"dare_dampening": dare_dampening,
"patch_compression": patch_compression,
"svd_device": svd_device,
"cache_patches": ms["cache_patches"],
"free_vram_between_passes": free_vram_between_passes,
"normalize_keys": ms["normalize_keys"],
"strategy_set": strategy_set,
"architecture_preset": ms["architecture_preset"],
"auto_strength_floor": ms["auto_strength_floor"],
"decision_smoothing": ms["decision_smoothing"],
"smooth_slerp_gate": ms["smooth_slerp_gate"],
"vram_budget": ms["vram_budget"],
"merge_strategy_override": merge_strategy_override,
},)
class LoRAAutoTunerSettings:
"""
Pure data node that outputs AutoTuner-mode settings for the Simple optimizer.
Connect to the 'settings' input of LoRA Optimizer to run a full AutoTuner
sweep without switching node types.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"top_n": ("INT", {
"default": 3, "min": 1, "max": 10, "step": 1,
"tooltip": "How many of the best configurations to try with a real merge. Higher = explores more options but takes longer."
}),
"scoring_svd": (["disabled", "enabled"], {
"default": "disabled",
"tooltip": "Uses a more thorough (but slower) scoring method to rank configurations. Usually not needed — enable if the default ranking seems off."
}),
"scoring_device": (["cpu", "gpu"], {
"default": "gpu",
"tooltip": "Where to run scoring math. GPU is much faster, especially with scoring_svd enabled."
}),
"scoring_speed": (["full", "fast", "turbo", "turbo+"], {
"default": "turbo",
"tooltip": "How thoroughly to score each configuration. 'full': most accurate, slowest. 'turbo' (recommended): good balance of speed and accuracy. 'turbo+': fastest, may miss subtle differences."
}),
"scoring_formula": (["v2", "v1"], {
"default": "v2",
"tooltip": "Which scoring formula to use. v2 (recommended): smarter scoring that adapts to your model type. v1: older formula, kept for comparison."
}),
"diff_cache_mode": (["disabled", "auto", "ram", "disk"], {
"default": "auto",
"tooltip": "Caches intermediate data to speed up the sweep. 'auto' (recommended): uses RAM first, spills to disk if needed. 'disabled': slower but uses no extra memory."
}),
"diff_cache_ram_pct": ("FLOAT", {
"default": 0.5, "min": 0.1, "max": 0.9, "step": 0.05,
"tooltip": "How much of your free RAM the diff cache can use (in 'auto' mode). 0.5 = up to half your available RAM."
}),
"record_dataset": (["disabled", "enabled"], {
"default": "disabled",
"tooltip": "Saves detailed scoring data to a file for analysis. Only useful for developers tuning the scoring system."
}),
},
"optional": {
"merge_settings": ("MERGE_SETTINGS", {
"tooltip": "Connect a LoRA Merge Settings node here to share common settings. Uses good defaults if not connected."
}),
"evaluator": ("AUTOTUNER_EVALUATOR", {
"tooltip": "Connect an external evaluator to influence how configurations are ranked. Optional — the built-in scoring works well on its own."
}),
},
}
RETURN_TYPES = ("OPTIMIZER_SETTINGS",)
RETURN_NAMES = ("settings",)
FUNCTION = "build_settings"
CATEGORY = "LoRA Optimizer"
DESCRIPTION = (
"Runs an automatic parameter sweep to find the best merge settings for your LoRA stack. "
"Connect to the 'settings' input on the LoRA Optimizer node."
)
def build_settings(self, top_n, scoring_svd, scoring_device, scoring_speed,
scoring_formula,
diff_cache_mode, diff_cache_ram_pct, record_dataset,
merge_settings=None, evaluator=None):
ms = merge_settings if merge_settings is not None else LoRAMergeSettings._DEFAULTS
return ({
"mode": "autotuner",
"top_n": top_n,
"scoring_svd": scoring_svd,
"scoring_device": scoring_device,
"scoring_speed": scoring_speed,
"scoring_formula": scoring_formula,
"output_mode": "merge",
"smooth_slerp_gate": ms["smooth_slerp_gate"],
"normalize_keys": ms["normalize_keys"],
"architecture_preset": ms["architecture_preset"],
"auto_strength_floor": ms["auto_strength_floor"],
"decision_smoothing": ms["decision_smoothing"],
"vram_budget": ms["vram_budget"],
"cache_patches": ms["cache_patches"],
"diff_cache_mode": diff_cache_mode,
"diff_cache_ram_pct": diff_cache_ram_pct,
"record_dataset": record_dataset,
"evaluator": evaluator,
},)
class LoRAOptimizerSimple(LoRAOptimizer):
"""
Simplified LoRA Optimizer with sensible defaults. Exposes only model,
LoRA stack, output strength, and optional CLIP inputs. For sparsification,
merge-quality, SVD-device, and other knobs use the Advanced variant.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL", {
"tooltip": "Your base model (e.g. SDXL, Flux). The merged LoRAs will be applied to it."
}),
"lora_stack": ("LORA_STACK", {
"tooltip": "Connect your LoRA Stack node here — this is the list of LoRAs you want to merge together."
}),
"output_strength": ("FLOAT", {
"default": 1.0, "min": -1.0, "max": 10.0, "step": 0.05,
"tooltip": "Master volume for the merged result. 1.0 = full effect, 0.5 = half. Set to -1 for auto: the optimizer picks a good strength for you."
}),
},
"optional": {
"clip": ("CLIP", {
"tooltip": "Connect your text encoder so LoRAs can also affect how prompts are understood. Leave empty for video models."
}),
"clip_strength_multiplier": ("FLOAT", {
"default": 1.0, "min": 0.0, "max": 10.0, "step": 0.05,
"tooltip": "How strongly LoRAs affect text understanding. At 1.0, same strength as the model. Lower values reduce LoRA influence on prompts while keeping the visual effect."
}),
"tuner_data": ("TUNER_DATA", {
"tooltip": "Connect results from a LoRA AutoTuner or Load Tuner Data node. The optimizer will use the best settings found by the tuner instead of defaults."
}),
"settings": ("OPTIMIZER_SETTINGS", {
"tooltip": "Connect a Settings node (Optimizer Settings or AutoTuner Settings) for full control. Takes priority over tuner_data and defaults."
}),
},
}
FUNCTION = "execute_simple"
DESCRIPTION = (
"Simplified LoRA Optimizer — merges a LoRA stack with good defaults. "
"Connect tuner_data to apply AutoTuner results, or connect settings "
"from an Optimizer/AutoTuner Settings node for full control."
)
_SIMPLE_DEFAULTS = dict(
auto_strength="enabled",
auto_strength_floor=-1.0,
free_vram_between_passes="disabled",
vram_budget=0.0,
optimization_mode="per_prefix",
cache_patches="enabled",
patch_compression="smart",
svd_device="gpu",
normalize_keys="enabled",
sparsification="disabled",
sparsification_density=0.7,
dare_dampening=0.0,
merge_strategy_override="",
merge_refinement="none",
strategy_set="full",
architecture_preset="auto",
decision_smoothing=0.25,
smooth_slerp_gate=False,
)
def execute_simple(self, model, lora_stack, output_strength,
clip=None, clip_strength_multiplier=1.0, tuner_data=None,
settings=None):
# Priority 1: settings node connected
if settings is not None:
mode = settings.get("mode")
if mode == "advanced":
# Free delegate caches when switching away from autotuner mode
delegate = getattr(self, '_autotuner_delegate', None)
if delegate is not None:
if getattr(delegate, '_autotuner_cache', None):
delegate._autotuner_cache.clear()
if getattr(delegate, '_merge_cache', None):
delegate._merge_cache.clear()
return super().optimize_merge(
model, lora_stack, output_strength,
clip=clip, clip_strength_multiplier=clip_strength_multiplier,
auto_strength=settings["auto_strength"],
auto_strength_floor=settings["auto_strength_floor"],
optimization_mode=settings["optimization_mode"],
sparsification=settings["sparsification"],
sparsification_density=settings["sparsification_density"],
dare_dampening=settings["dare_dampening"],
merge_strategy_override=settings.get("merge_strategy_override", ""),
merge_refinement=settings["merge_refinement"],
strategy_set=settings["strategy_set"],
normalize_keys=settings["normalize_keys"],
architecture_preset=settings["architecture_preset"],
decision_smoothing=settings["decision_smoothing"],
smooth_slerp_gate=settings["smooth_slerp_gate"],
cache_patches=settings["cache_patches"],
patch_compression=settings["patch_compression"],
svd_device=settings["svd_device"],
free_vram_between_passes=settings["free_vram_between_passes"],
vram_budget=settings["vram_budget"],
)
elif mode == "autotuner":
# Free optimizer cache when switching away from advanced mode
if self._merge_cache:
self._merge_cache.clear()
if not hasattr(self, '_autotuner_delegate'):
self._autotuner_delegate = LoRAAutoTuner()
result = self._autotuner_delegate.auto_tune(
model, lora_stack, output_strength,
clip=clip, clip_strength_multiplier=clip_strength_multiplier,
top_n=settings["top_n"],
scoring_svd=settings["scoring_svd"],
scoring_device=settings["scoring_device"],
scoring_speed=settings["scoring_speed"],
scoring_formula=settings.get("scoring_formula", "v2"),
output_mode=settings["output_mode"],
smooth_slerp_gate=settings["smooth_slerp_gate"],
normalize_keys=settings["normalize_keys"],
architecture_preset=settings["architecture_preset"],
auto_strength_floor=settings["auto_strength_floor"],
decision_smoothing=settings["decision_smoothing"],
vram_budget=settings["vram_budget"],
cache_patches=settings["cache_patches"],
diff_cache_mode=settings["diff_cache_mode"],
diff_cache_ram_pct=settings["diff_cache_ram_pct"],
record_dataset=settings["record_dataset"],
evaluator=settings.get("evaluator"),
)
# Map 6-value AutoTuner return to 5-value Simple return
# (model, clip, report, analysis_report, tuner_data, lora_data)
# → (model, clip, combined_report, tuner_data, lora_data)
at_model, at_clip, report, analysis_report, at_tuner_data, at_lora_data = result
combined_report = report
if analysis_report:
combined_report = f"{report}\n\n{'=' * 50}\nANALYSIS REPORT\n{'=' * 50}\n{analysis_report}"
return (at_model, at_clip, combined_report, at_tuner_data, at_lora_data)
else:
raise ValueError(
f"[LoRA Optimizer] Unknown settings mode: {mode!r}. "
f"Expected 'advanced' or 'autotuner'."
)
# Priority 2: tuner_data connected (no settings)
if tuner_data is not None and "top_n" in tuner_data and len(tuner_data["top_n"]) > 0:
entry = tuner_data["top_n"][0]
config = entry["config"]
strategy_override = config["merge_mode"] if config["optimization_mode"] == "global" else ""
return super().optimize_merge(
model, lora_stack, output_strength,
clip=clip, clip_strength_multiplier=clip_strength_multiplier,
auto_strength=config["auto_strength"],
auto_strength_floor=tuner_data.get("auto_strength_floor", -1.0),
optimization_mode=config["optimization_mode"],
sparsification=config["sparsification"],
sparsification_density=config["sparsification_density"],
dare_dampening=config["dare_dampening"],
merge_strategy_override=strategy_override,
merge_refinement=config["merge_refinement"],
strategy_set=config.get("strategy_set", "full"),
normalize_keys=tuner_data.get("normalize_keys", "enabled"),
architecture_preset=tuner_data.get("architecture_preset", "auto"),
decision_smoothing=tuner_data.get("decision_smoothing", 0.25),
cache_patches="enabled",
patch_compression="smart",
svd_device="gpu",
vram_budget=0.0,
)
# Priority 3: simple defaults
return super().optimize_merge(
model, lora_stack, output_strength,
clip=clip, clip_strength_multiplier=clip_strength_multiplier,
**self._SIMPLE_DEFAULTS,
)
@classmethod
def IS_CHANGED(cls, model, lora_stack, output_strength,
clip=None, clip_strength_multiplier=1.0, tuner_data=None,
settings=None):
base = LoRAOptimizer.IS_CHANGED(
model, lora_stack, output_strength,
clip=clip, clip_strength_multiplier=clip_strength_multiplier,
**cls._SIMPLE_DEFAULTS,
)
if settings is not None:
import hashlib, json
settings_hash = hashlib.md5(
json.dumps(settings, sort_keys=True, default=str).encode()
).hexdigest()[:12]
return f"{base}|settings={settings_hash}"
if tuner_data is not None:
return f"{base}|td={id(tuner_data)}"
return base
class LoRAAutoTuner(LoRAOptimizer):
"""
Automatic parameter sweep that ranks merge configurations for a given
LoRA stack. Runs Pass 1 analysis once, scores all parameter combinations
via heuristic, then merges top-N candidates and measures output quality.
Outputs the top-ranked result as MODEL/CLIP, plus a ranked report and
TUNER_DATA for optional override via a Merge Selector node.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL", {
"tooltip": "Base model to merge LoRAs into."
}),
"lora_stack": ("LORA_STACK", {
"tooltip": "LoRA stack from a LoRA Stack node."
}),
"output_strength": ("FLOAT", {
"default": 1.0, "min": -1.0, "max": 10.0, "step": 0.05,
"tooltip": "Master volume for the merged result. Set to -1 for auto (uses suggested max strength)."
}),
},
"optional": {
"clip": ("CLIP", {
"tooltip": "Optional CLIP model for text-encoder LoRA keys."
}),
"clip_strength_multiplier": ("FLOAT", {
"default": 1.0, "min": 0.0, "max": 10.0, "step": 0.05,
"tooltip": "Multiplier for CLIP LoRA strengths."
}),
"top_n": ("INT", {
"default": 3, "min": 1, "max": 10, "step": 1,
"tooltip": "Number of top configurations to evaluate via actual merge. Higher = slower but explores more options."
}),
"normalize_keys": (["disabled", "enabled"], {
"default": "enabled",
"tooltip": "Makes LoRAs from different training tools compatible."
}),
"scoring_svd": (["disabled", "enabled"], {
"default": "disabled",
"tooltip": "Enable SVD-based effective rank scoring for candidates. More thorough but much slower on large models. Skipped for orthogonal LoRAs (near-zero cosine similarity) where it does not improve ranking accuracy."
}),
"scoring_device": (["cpu", "gpu"], {
"default": "gpu",
"tooltip": "Device for scoring computations. GPU is much faster when SVD scoring is enabled."
}),
"architecture_preset": (["auto", "sd_unet", "dit", "llm"], {
"default": "auto",
"tooltip": "Architecture-aware threshold tuning. 'auto' detects from LoRA keys."
}),
"auto_strength_floor": ("FLOAT", {
"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.05,
"tooltip": "Minimum auto-strength scale factor for orthogonal LoRAs. -1 = architecture-aware default. Increase toward 1.0 to preserve more independent LoRA energy."
}),
"evaluator": ("AUTOTUNER_EVALUATOR", {
"tooltip": "Optional external evaluator spec. Use this to blend prompt/reference scoring from your own generation code with the built-in merge metrics."
}),
"record_dataset": (["disabled", "enabled"], {
"default": "disabled",
"tooltip": "Record analysis metrics and scored configs to a JSONL dataset file for threshold tuning research. Saved to lora_optimizer_reports/autotuner_dataset.jsonl."
}),
"cache_patches": (["enabled", "disabled"], {
"default": "enabled",
"tooltip": "Cache the AutoTuner result in RAM so re-execution with the same inputs skips the full sweep. Disable to free RAM after merge (recommended for video models)."
}),
"diff_cache_mode": (["disabled", "auto", "ram", "disk"], {
"default": "auto",
"tooltip": "Cache LoRA diffs across candidates to skip redundant computation. 'disabled' recomputes each time (slowest, no extra memory). 'auto' uses RAM up to diff_cache_ram_pct of free memory then spills to disk (recommended). 'ram' caches entirely in memory (~1.5GB SDXL, ~6GB Flux — fastest). 'disk' caches entirely to temp files (~1-10ms per diff vs 5-50ms to recompute). WARNING: ram/disk can use significant memory/storage on large models."
}),
"diff_cache_ram_pct": ("FLOAT", {
"default": 0.5, "min": 0.1, "max": 0.9, "step": 0.05,
"tooltip": "Fraction of free system RAM to use for diff cache in 'auto' mode. 0.5 = use up to 50% of available RAM before spilling to disk."
}),
"vram_budget": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.05,
"tooltip": "Fraction of free VRAM to use for storing merged patches. 0 = all CPU (default), 1.0 = use all free VRAM. Reduces RAM usage on GPU systems."
}),
"scoring_speed": (["full", "fast", "turbo", "turbo+"], {
"default": "turbo",
"tooltip": "Controls how many prefixes Phase 2 scores per candidate. "
"All candidates are scored on the same subset so ranking stays fair.\n\n"
"• full — Score every prefix. Best accuracy, slowest. Use when merging very different LoRAs (e.g. style + character + concept) where block behavior varies a lot.\n"
"• fast — Every 2nd prefix (~50%% faster). Good default for most merges.\n"
"• turbo — Every 3rd prefix (~67%% faster). Works well when your LoRAs have similar conflict across blocks (e.g. multiple characters from the same trainer).\n"
"• turbo+ — Every 4th prefix (~75%% faster). Best for large models (DiT/Flux/WAN) or when iterating quickly. May miss subtle block-level differences on SD/SDXL."
}),
"scoring_formula": (["v2", "v1"], {
"default": "v2",
"tooltip": "Phase 2 scoring formula. v2: arch-aware sparsity + energy metrics (recommended). v1: legacy scoring with fixed 40% sparsity target."
}),
"output_mode": (["merge", "tuning_only"], {
"default": "merge",
"tooltip": "merge: output the top-ranked merged model. tuning_only: skip the final merge and pass the base model through so a downstream optimizer can apply the selected AutoTuner settings."
}),
"decision_smoothing": ("FLOAT", {
"default": 0.25, "min": 0.0, "max": 1.0, "step": 0.05,
"tooltip": "Smooth per-group decision metrics toward each block average before candidate ranking and final merge. 0 disables smoothing."
}),
"smooth_slerp_gate": ("BOOLEAN", {
"default": False,
"tooltip": "When enabled, uses smoothed cosine (decision_cosine) for SLERP gate instead of raw avg_cos_sim. Can affect SLERP/weighted_average ratio."
}),
},
}
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING", "TUNER_DATA", "LORA_DATA")
RETURN_NAMES = ("model", "clip", "report", "analysis_report", "tuner_data", "lora_data")
FUNCTION = "auto_tune"
CATEGORY = "LoRA Optimizer"
DESCRIPTION = (
"Automatically sweeps all merge parameters and ranks "
"configurations for your LoRA stack. Outputs the top-ranked merge "
"directly. Connect TUNER_DATA to a Merge Selector node to try alternatives."
)
def auto_tune(self, model, lora_stack, output_strength, clip=None,
clip_strength_multiplier=1.0, top_n=3, normalize_keys="disabled",
scoring_svd="disabled", scoring_device="gpu",
architecture_preset="auto", auto_strength_floor=-1.0, evaluator=None,
record_dataset="disabled",
cache_patches="enabled",
diff_cache_mode="disabled", diff_cache_ram_pct=0.5, vram_budget=0.0,
scoring_speed="full", scoring_formula="v2", output_mode="merge",
decision_smoothing=0.25, smooth_slerp_gate=False):
import hashlib, json
# Free stale cached models when the input model changes
current_mid = id(model) if model is not None else None
prev_mid = getattr(self, '_cached_model_id', None)
if prev_mid is not None and current_mid != prev_mid:
if hasattr(self, '_merge_cache') and self._merge_cache:
self._merge_cache.clear()
if hasattr(self, '_autotuner_cache') and getattr(self, '_autotuner_cache', None):
self._autotuner_cache.clear()
gc.collect()
self._cached_model_id = current_mid
# --- Normalize & validate stack ---
normalized_stack = self._normalize_stack(lora_stack, normalize_keys=normalize_keys)
active_loras = [item for item in normalized_stack if item["strength"] != 0]
if not active_loras:
return (model, clip, "No active LoRAs in stack.", "", None, None)
if len(active_loras) == 1:
# Single LoRA: nothing to tune, delegate directly
if output_mode == "tuning_only":
return (model, clip, "Single LoRA detected -- tuning_only passthrough.", "", None, None)
merged_model, merged_clip, report, _, lora_data = super().optimize_merge(
model, lora_stack, output_strength,
clip=clip, clip_strength_multiplier=clip_strength_multiplier,
normalize_keys=normalize_keys, strategy_set="full",
architecture_preset=architecture_preset, vram_budget=vram_budget,
auto_strength_floor=auto_strength_floor,
decision_smoothing=decision_smoothing,
smooth_slerp_gate=smooth_slerp_gate,
)
return (merged_model, merged_clip,
"Single LoRA detected -- no parameters to tune.\n\n" + report, report, None, lora_data)
# Compute lora_hash for cache validation
hash_input = json.dumps([(l["name"], l["strength"]) for l in active_loras],
sort_keys=True)
lora_hash = hashlib.sha256(hash_input.encode()).hexdigest()[:16]
evaluator_hash = self._stable_data_hash(evaluator) if evaluator is not None else ""
# Check AutoTuner cache
at_cache_key = hashlib.sha256(
f"{lora_hash}|os={output_strength}|csm={clip_strength_multiplier}"
f"|top_n={top_n}|nk={normalize_keys}|ss={scoring_svd}"
f"|ap={architecture_preset}|vb={vram_budget}"
f"|spd={scoring_speed}|mid={id(model)}|asf={auto_strength_floor}|ds={decision_smoothing}|eh={evaluator_hash}".encode()
).hexdigest()[:16]
if cache_patches == "enabled" and hasattr(self, '_autotuner_cache') and at_cache_key in self._autotuner_cache:
cached_result, cached_mode = self._autotuner_cache[at_cache_key]
if output_mode == "tuning_only":
logging.info("[LoRA AutoTuner] Using cached result (tuning_only passthrough)")
return (model, clip, cached_result[2], "", cached_result[4], None)
if cached_mode == "merge":
logging.info("[LoRA AutoTuner] Using cached result")
return cached_result
# --- Pass 1: Analysis (run once, reuse for all configs) ---
model_keys = self._get_model_keys(model)
clip_keys = {}
if clip is not None:
clip_keys = comfy.lora.model_lora_keys_clip(clip.cond_stage_model, {})
all_lora_prefixes = self._collect_lora_prefixes(active_loras)
target_groups = self._build_target_groups(all_lora_prefixes, model_keys, clip_keys)
if not target_groups:
return (model, clip, "No compatible LoRA target groups found.", "", None, None)
# Determine compute device
compute_device = self._get_compute_device()
use_gpu = compute_device.type != "cpu"
logging.info(f"[LoRA AutoTuner] Pass 1: Analyzing {len(target_groups)} target groups...")
t_start = time.time()
# Progress bar: analysis groups + top_n merges (+ 1 final merge when subsampling)
n_pbar_merges = top_n + (1 if scoring_speed != "full" and top_n > 1 and output_mode != "tuning_only" else 0)
pbar = comfy.utils.ProgressBar(len(target_groups) + n_pbar_merges)
analysis_data = self._run_group_analysis(
target_groups, active_loras, model, clip, compute_device,
clip_strength_multiplier=clip_strength_multiplier,
merge_refinement="none",
decision_smoothing=decision_smoothing,
progress_cb=lambda: pbar.update(1),
)
all_key_targets = analysis_data["all_key_targets"]
target_groups = analysis_data["target_groups"]
prefix_stats = analysis_data["prefix_stats"]
per_lora_stats = analysis_data["per_lora_stats"]
pair_accum = analysis_data["pair_accum"]
branch_energy = analysis_data["branch_energy"]
all_magnitude_samples = analysis_data["all_magnitude_samples"]
prefix_count = analysis_data["prefix_count"]
skipped_keys = analysis_data["skipped_keys"]
pairs = analysis_data["pairs"]
if prefix_count == 0:
return (model, clip, "No compatible LoRA keys found.", "", None, None)
t_analysis = time.time() - t_start
logging.info(f"[LoRA AutoTuner] Analysis complete: {prefix_count} target groups ({t_analysis:.1f}s)")
# Finalize global stats
l2_means = []
for i, stat in enumerate(per_lora_stats):
l2_mean = sum(stat["l2_norms"]) / len(stat["l2_norms"]) if stat["l2_norms"] else 0
l2_means.append(l2_mean)
total_overlap = sum(pair_accum[p]["overlap"] for p in pairs)
total_conflict = sum(pair_accum[p]["conflict"] for p in pairs)
total_weighted_total = sum(pair_accum[p]["weighted_total"] for p in pairs)
total_weighted_conflict = sum(pair_accum[p]["weighted_conflict"] for p in pairs)
total_expected_conflict_weighted = sum(pair_accum[p]["expected_conflict_weighted"] for p in pairs)
total_excess_conflict_weighted = sum(pair_accum[p]["excess_conflict_weighted"] for p in pairs)
total_subspace_num = sum(pair_accum[p]["subspace_num"] for p in pairs)
total_subspace_den = sum(pair_accum[p]["subspace_den"] for p in pairs)
avg_conflict_ratio = total_conflict / total_overlap if total_overlap > 0 else 0
avg_excess_conflict = total_excess_conflict_weighted / total_weighted_total if total_weighted_total > 0 else 0.0
avg_subspace_overlap = total_subspace_num / total_subspace_den if total_subspace_den > 0 else 0.0
pairwise_similarities = {}
for i, j in pairs:
dot = pair_accum[(i, j)]["dot"]
na_sq = pair_accum[(i, j)]["norm_a_sq"]
nb_sq = pair_accum[(i, j)]["norm_b_sq"]
denom = math.sqrt(na_sq) * math.sqrt(nb_sq)
pairwise_similarities[(i, j)] = dot / denom if denom > 0 else 0.0
branch_measure = branch_energy["model"]["norm_sq"]
model_effective = [
abs(active_loras[i]["strength"]) * math.sqrt(max(branch_measure[i], 0.0))
for i in range(len(active_loras))
]
valid_l2 = [m for m in model_effective if m > 0]
magnitude_ratio = max(valid_l2) / min(valid_l2) if len(valid_l2) >= 2 else 1.0
avg_cos_sim = (sum(pairwise_similarities.values())
/ len(pairwise_similarities)) if pairwise_similarities else 0.0
analysis_summary = {
"n_loras": len(active_loras),
"prefix_count": prefix_count,
"avg_conflict_ratio": avg_conflict_ratio,
"avg_excess_conflict": avg_excess_conflict,
"avg_subspace_overlap": avg_subspace_overlap,
"avg_cosine_sim": avg_cos_sim,
"magnitude_ratio": magnitude_ratio,
"decision_smoothing": decision_smoothing,
}
_analysis_cache = {
"all_key_targets": all_key_targets,
"target_groups": target_groups,
"prefix_stats": prefix_stats,
"per_lora_stats": per_lora_stats,
"pair_accum": pair_accum,
"branch_energy": branch_energy,
"all_magnitude_samples": all_magnitude_samples,
"prefix_count": prefix_count,
"skipped_keys": skipped_keys,
"pairs": pairs,
}
# Resolve architecture preset for scoring
tuner_preset_key, tuner_arch_preset = _resolve_arch_preset(
architecture_preset, getattr(self, '_detected_arch', None) or 'unknown')
logging.info(f"[LoRA AutoTuner] Architecture preset: {tuner_preset_key} ({tuner_arch_preset['display_name']})")
# --- Phase 1: Score all parameter combos (heuristic, fast) ---
logging.info("[LoRA AutoTuner] Phase 1: Scoring parameter grid...")
grid = _generate_param_grid()
scored = []
for config in grid:
h_score = _score_config_heuristic(
config, avg_conflict_ratio, avg_cos_sim,
magnitude_ratio, prefix_stats, arch_preset=tuner_arch_preset,
avg_excess_conflict=avg_excess_conflict,
avg_subspace_overlap=avg_subspace_overlap)
scored.append((h_score, config))
scored.sort(key=lambda x: x[0], reverse=True)
logging.info(f"[LoRA AutoTuner] Scored {len(grid)} combos in {time.time() - t_start:.1f}s")
for i in range(min(5, len(scored))):
c = scored[i][1]
strat_info = f" [{c.get('strategy_set', 'full')}]" if c['optimization_mode'] == 'per_prefix' else ""
logging.info(f"[LoRA AutoTuner] #{i+1} heuristic={scored[i][0]:.3f}: "
f"{c['merge_mode']}/{c['merge_refinement']}"
f"{' +' + c['sparsification'] if c['sparsification'] != 'disabled' else ''}"
f" auto_str={c['auto_strength']} {c['optimization_mode']}{strat_info}")
# Pre-compute per-group density from magnitude samples before freeing them.
# Phase 2's per-group auto_select_params needs density but magnitude_samples
# are large — store the scalar result and free the raw samples.
for pf in prefix_stats.values():
samples = pf.get("magnitude_samples")
if samples and pf.get("n_loras", 0) > 1:
pf["precomputed_density"] = self._estimate_density_from_samples(
samples, arch_preset=tuner_arch_preset)
pf.pop("magnitude_samples", None)
_analysis_cache["all_magnitude_samples"] = []
gc.collect()
# --- Prefix subsampling for Phase 2 (scoring_speed) ---
use_subsampling = scoring_speed != "full" and top_n > 1
scoring_cache = _analysis_cache # default: use full cache
if use_subsampling:
step = {"fast": 2, "turbo": 3, "turbo+": 4}[scoring_speed]
# Separate single-LoRA groups (always included, identical across candidates)
# from multi-LoRA groups (where merge strategy matters)
single_lora_targets = {}
multi_lora_targets = {}
for pfx, tgt in all_key_targets.items():
if prefix_stats.get(pfx, {}).get("n_loras", 0) <= 1:
single_lora_targets[pfx] = tgt
else:
multi_lora_targets[pfx] = tgt
# Sort multi-LoRA groups by conflict_ratio descending (highest conflict first)
sorted_multi = sorted(
multi_lora_targets.keys(),
key=lambda p: prefix_stats.get(p, {}).get("decision_conflict", prefix_stats.get(p, {}).get("conflict_ratio", 0.0)),
reverse=True,
)
# Keep every Nth group from the sorted list
subsampled_multi = {p: multi_lora_targets[p] for p in sorted_multi[::step]}
scoring_targets = {**single_lora_targets, **subsampled_multi}
scoring_target_groups = {
label: target_groups[label]
for label in scoring_targets.keys()
if label in target_groups
}
scoring_cache = {
**_analysis_cache,
"all_key_targets": scoring_targets,
"target_groups": scoring_target_groups,
"prefix_count": len(scoring_target_groups),
}
logging.info(f"[LoRA AutoTuner] Scoring speed '{scoring_speed}': "
f"scoring {len(scoring_targets)}/{len(all_key_targets)} groups "
f"({len(single_lora_targets)} single-LoRA + "
f"{len(subsampled_multi)}/{len(multi_lora_targets)} multi-LoRA)")
# --- Phase 2: Merge top-N and measure ---
# Initialize diff cache for Phase 2
_diff_cache = None
if diff_cache_mode != "disabled":
_diff_cache = _DiffCache(mode=diff_cache_mode, ram_pct=diff_cache_ram_pct)
logging.info(f"[LoRA AutoTuner] Diff cache enabled (mode={diff_cache_mode})")
# Only keep the current best in memory to avoid accumulating ~40GB per candidate.
top_candidates = scored[:top_n]
results = []
best_model = None
best_clip = None
best_lora_data = None
best_analysis_report = ""
best_score = -1.0
best_config = None
logging.info(f"[LoRA AutoTuner] Phase 2: Merging and measuring top {len(top_candidates)} candidates...")
for rank_idx, (h_score, config) in enumerate(top_candidates):
logging.info(f"[LoRA AutoTuner] Candidate {rank_idx + 1}/{len(top_candidates)}: "
f"{config['merge_mode']}, {config['merge_refinement']}"
f"{', ' + config['sparsification'] if config['sparsification'] != 'disabled' else ''}"
f" (heuristic={h_score:.3f})...")
t_merge = time.time()
# In per_prefix mode, let the optimizer auto-select strategy per prefix.
# merge_strategy_override would force one mode everywhere, defeating per-prefix logic.
strategy_override = config["merge_mode"] if config["optimization_mode"] == "global" else ""
merged_model, merged_clip, _report, _, lora_data = super().optimize_merge(
model, lora_stack, output_strength,
clip=clip,
clip_strength_multiplier=clip_strength_multiplier,
auto_strength=config["auto_strength"],
auto_strength_floor=auto_strength_floor,
optimization_mode=config["optimization_mode"],
sparsification=config["sparsification"],
sparsification_density=config["sparsification_density"],
dare_dampening=config["dare_dampening"],
merge_refinement=config["merge_refinement"],
merge_strategy_override=strategy_override,
free_vram_between_passes="disabled",
vram_budget=vram_budget,
cache_patches="disabled",
patch_compression="disabled",
svd_device="gpu",
normalize_keys=normalize_keys,
strategy_set=config.get("strategy_set", "full"),
architecture_preset=architecture_preset,
decision_smoothing=decision_smoothing,
smooth_slerp_gate=smooth_slerp_gate,
_analysis_cache=scoring_cache,
_diff_cache=_diff_cache,
_skip_report=True,
)
# Measure output quality (single-LoRA prefixes may still produce
# LoRAAdapter patches; _score_merge_result handles both formats)
m_patches = lora_data["model_patches"] if lora_data else {}
c_patches = lora_data["clip_patches"] if lora_data else {}
is_ortho_score = (
abs(avg_cos_sim) < tuner_arch_preset["orthogonal_cos_sim_max"]
and avg_subspace_overlap < 0.35
)
# Skip SVD for orthogonal LoRAs — SLERP vs average produce different
# rank profiles that don't correlate with generation quality
compute_svd = scoring_svd == "enabled" and not is_ortho_score
score_dev = torch.device("cuda") if scoring_device == "gpu" and torch.cuda.is_available() else None
t_score = time.time()
score_arch = tuner_arch_preset if scoring_formula == "v2" else None
measured = _score_merge_result(
m_patches, c_patches, compute_svd=compute_svd,
score_device=score_dev, arch_preset=score_arch
)
t_score_elapsed = time.time() - t_score
# --- Post-scoring adjustments ---
if scoring_formula == "v1":
# Legacy scoring: conditional sparsity discount, old composite weights
if config["sparsification"] != "disabled":
measured["sparsity_fit"] *= 0.5
cv_s = max(0.0, 1.0 - measured["norm_cv"])
if measured.get("effective_rank_mean", 0) > 0:
rank_s = min(measured["effective_rank_mean"] / 40.0, 1.0)
measured["composite_score"] = (
rank_s * 0.4 + cv_s * 0.3 + measured["sparsity_fit"] * 0.3
)
else:
measured["composite_score"] = (
cv_s * 0.5 + measured["sparsity_fit"] * 0.5
)
else:
# v2: energy-aware scoring with arch-aware sparsity baseline
# Compute energy_preservation from branch_energy (model + clip).
# Baseline = weighted_average expected energy (not weighted_sum).
# Auto-strength cancels in WA normalization, so same formula for all.
measured_energy_sq = measured.get("norm_energy_sq", 0.0)
measured_energy = math.sqrt(max(measured_energy_sq, 0.0))
model_strengths = [item["strength"] for item in active_loras]
clip_strengths = [
item["clip_strength"] if item["clip_strength"] is not None else item["strength"]
for item in active_loras
]
total_model_w = sum(abs(s) for s in model_strengths)
total_clip_w = sum(abs(s) for s in clip_strengths)
if is_ortho_score:
# Orthogonal: Pythagorean — no cross-terms
expected_model_sq = sum(
model_strengths[i] ** 2 * branch_energy["model"]["norm_sq"][i]
for i in range(len(active_loras))
)
expected_clip_sq = sum(
clip_strengths[i] ** 2 * branch_energy["clip"]["norm_sq"][i]
for i in range(len(active_loras))
)
else:
# Non-orthogonal: include cross-terms
expected_model_sq = sum(
model_strengths[i] ** 2 * branch_energy["model"]["norm_sq"][i]
for i in range(len(active_loras))
)
for (i, j), dot in branch_energy["model"]["dot"].items():
expected_model_sq += 2.0 * model_strengths[i] * model_strengths[j] * dot
expected_clip_sq = sum(
clip_strengths[i] ** 2 * branch_energy["clip"]["norm_sq"][i]
for i in range(len(active_loras))
)
for (i, j), dot in branch_energy["clip"]["dot"].items():
expected_clip_sq += 2.0 * clip_strengths[i] * clip_strengths[j] * dot
# Divide by total_weight^2 per branch for weighted_average baseline
if total_model_w > 0:
expected_model_sq = max(expected_model_sq, 0.0) / (total_model_w ** 2)
if total_clip_w > 0:
expected_clip_sq = max(expected_clip_sq, 0.0) / (total_clip_w ** 2)
expected_energy = math.sqrt(expected_model_sq + expected_clip_sq)
# Scale expected energy by prefix fraction when subsampling
if use_subsampling and prefix_count > 0:
prefix_fraction = len(scoring_cache.get("all_key_targets", all_key_targets)) / len(all_key_targets)
expected_energy *= math.sqrt(prefix_fraction)
energy_ratio = measured_energy / expected_energy if expected_energy > 0 else 1.0
# One-sided: only penalize energy loss below WA baseline (ratio < 1)
# SLERP legitimately boosts energy above WA — don't penalize that
energy_preservation = min(energy_ratio, 1.0)
measured["energy_ratio"] = energy_ratio
measured["energy_preservation"] = energy_preservation
# Discount sparsity_fit when sparsification artificially inflates it
if config["sparsification"] != "disabled":
measured["sparsity_fit"] *= 0.5
# Recompute composite score
cv_s = max(0.0, 1.0 - measured["norm_cv"])
if measured.get("effective_rank_mean", 0) > 0:
# Non-orthogonal (with SVD): energy helps detect destructive merges
rank_s = min(measured["effective_rank_mean"] / 40.0, 1.0)
measured["composite_score"] = (
rank_s * 0.30 + cv_s * 0.25
+ energy_preservation * 0.20 + measured["sparsity_fit"] * 0.25
)
else:
# Orthogonal (without SVD): energy doesn't predict quality here
# (SLERP vs WA energy ≠ quality for orthogonal LoRAs).
# Arch-aware sparsity_fit is the primary discriminator.
measured["composite_score"] = (
cv_s * 0.50 + measured["sparsity_fit"] * 0.50
)
external_eval = _run_autotuner_evaluator(
evaluator, merged_model, merged_clip, lora_data, config, analysis_summary
) if evaluator else None
external_score = external_eval.get("score") if external_eval else None
combine_mode = evaluator.get("combine_mode", "blend") if evaluator else "blend"
eval_weight = max(0.0, min(1.0, float(evaluator.get("weight", 0.5)))) if evaluator else 0.5
final_score = measured["composite_score"]
if external_score is not None:
if combine_mode == "external_only":
final_score = external_score
elif combine_mode == "multiply":
final_score = final_score * external_score
else:
final_score = (1.0 - eval_weight) * final_score + eval_weight * external_score
t_elapsed = time.time() - t_merge
energy_log = f", energy={measured['energy_ratio']:.2f}x" if "energy_ratio" in measured else ""
logging.info(f"[LoRA AutoTuner] Candidate #{rank_idx + 1}: "
f"measured={measured['composite_score']:.3f}"
f"{energy_log}"
f"{f', external={external_score:.3f}' if external_score is not None else ''}"
f", final={final_score:.3f} "
f"(merge {t_elapsed - t_score_elapsed:.1f}s + score {t_score_elapsed:.1f}s)")
pbar.update(1)
if use_subsampling or output_mode == "tuning_only":
# When subsampling, discard all candidates — final full merge comes after
del merged_model, merged_clip, lora_data
elif final_score > best_score:
# Keep only the best model in memory, free the rest immediately
# Free previous best before replacing
del best_model, best_clip, best_lora_data
best_model = merged_model
best_clip = merged_clip
best_lora_data = lora_data
best_analysis_report = _report
else:
# Discard this candidate's heavy objects immediately
del merged_model, merged_clip, lora_data
if final_score > best_score:
best_score = final_score
best_config = config
del m_patches, c_patches # Drop patch-dict references so tensors can free
gc.collect()
if use_gpu:
torch.cuda.empty_cache()
results.append({
"rank": rank_idx + 1,
"score_heuristic": h_score,
"score_measured": measured["composite_score"],
"score_external": external_score,
"score_final": final_score,
"config": config,
"metrics": {
"norm_preservation": measured.get("norm_mean", 0.0),
"effective_rank_mean": measured.get("effective_rank_mean", 0.0),
"sparsity_mean": measured.get("sparsity_mean", 0.0),
"norm_cv": measured.get("norm_cv", 0.0),
**({"energy_ratio": measured.get("energy_ratio", 1.0),
"energy_preservation": measured.get("energy_preservation", 0.5)}
if scoring_formula == "v2" else
{"importance_cv": measured.get("importance_cv", measured.get("norm_cv", 0.0))}),
},
"external_details": external_eval.get("details") if external_eval else None,
})
# Final full merge when subsampling was used
if use_subsampling and best_config is not None and output_mode != "tuning_only":
logging.info(f"[LoRA AutoTuner] Final merge with winning config "
f"({best_config['merge_mode']}, {best_config['merge_refinement']})...")
t_final = time.time()
strategy_override = best_config["merge_mode"] if best_config["optimization_mode"] == "global" else ""
best_model, best_clip, best_analysis_report, _, best_lora_data = super().optimize_merge(
model, lora_stack, output_strength,
clip=clip,
clip_strength_multiplier=clip_strength_multiplier,
auto_strength=best_config["auto_strength"],
auto_strength_floor=auto_strength_floor,
optimization_mode=best_config["optimization_mode"],
sparsification=best_config["sparsification"],
sparsification_density=best_config["sparsification_density"],
dare_dampening=best_config["dare_dampening"],
merge_refinement=best_config["merge_refinement"],
merge_strategy_override=strategy_override,
free_vram_between_passes="disabled",
vram_budget=vram_budget,
cache_patches="disabled",
patch_compression="disabled",
svd_device="gpu",
normalize_keys=normalize_keys,
strategy_set=best_config.get("strategy_set", "full"),
architecture_preset=architecture_preset,
decision_smoothing=decision_smoothing,
smooth_slerp_gate=smooth_slerp_gate,
_analysis_cache=_analysis_cache,
_diff_cache=_diff_cache,
_skip_report=True,
)
logging.info(f"[LoRA AutoTuner] Final merge complete ({time.time() - t_final:.1f}s)")
pbar.update(1)
if _diff_cache is not None:
n_ram = len(_diff_cache._ram_store)
n_disk = len(_diff_cache._disk_store)
logging.info(f"[LoRA AutoTuner] Diff cache: {n_ram + n_disk} entries, "
f"{_diff_cache.size_mb():.1f} MB "
f"({n_ram} ram, {n_disk} disk)")
_diff_cache.clear()
del _diff_cache
del all_magnitude_samples
del _analysis_cache
self.loaded_loras.clear()
gc.collect()
if use_gpu:
torch.cuda.empty_cache()
# Sort by final score
results.sort(key=lambda x: x["score_final"], reverse=True)
for i, r in enumerate(results):
r["rank"] = i + 1
best = results[0]
best["merged_model"] = best_model
best["merged_clip"] = best_clip
best["lora_data"] = best_lora_data
# Build TUNER_DATA (exclude model/clip objects)
tuner_data = {
"version": 1,
"lora_hash": lora_hash,
"source_loras": [{"name": l["name"], "strength": l["strength"]} for l in active_loras],
"normalize_keys": normalize_keys,
"architecture_preset": architecture_preset,
"auto_strength_floor": auto_strength_floor,
"decision_smoothing": decision_smoothing,
"analysis_summary": analysis_summary,
"top_n": [{
"rank": r["rank"],
"score_heuristic": r["score_heuristic"],
"score_measured": r["score_measured"],
"score_external": r.get("score_external"),
"score_final": r["score_final"],
"config": r["config"],
"metrics": r["metrics"],
"external_details": r.get("external_details"),
} for r in results],
}
# Save dataset entry for threshold tuning (opt-in)
if record_dataset == "enabled":
self._save_tuner_dataset_entry(
tuner_data, active_loras, prefix_stats,
getattr(self, '_detected_arch', None))
prefix_stats.clear()
# Build report
suggested_max = best_lora_data.get("suggested_max_strength") if best_lora_data else None
report = self._build_autotuner_report(
results, tuner_data["analysis_summary"], output_strength,
suggested_max_strength=suggested_max, scoring_speed=scoring_speed)
if output_mode == "tuning_only":
for r in results:
r.pop("merged_model", None)
r.pop("merged_clip", None)
r.pop("lora_data", None)
del results, best, best_model, best_clip, best_lora_data, best_analysis_report, active_loras
gc.collect()
if use_gpu:
torch.cuda.empty_cache()
logging.info("[LoRA AutoTuner] tuning_only mode — returning base model (no merge)")
result = (model, clip, report, "", tuner_data, None)
if cache_patches == "enabled":
self._autotuner_cache = {at_cache_key: (result, "tuning_only")}
else:
self._autotuner_cache = {}
return result
# Extract return values, then free heavy intermediates
ret_model = best["merged_model"]
ret_clip = best["merged_clip"]
ret_lora_data = best.get("lora_data")
ret_analysis_report = best_analysis_report
for r in results:
r.pop("merged_model", None)
r.pop("merged_clip", None)
r.pop("lora_data", None)
del results, best, best_model, best_clip, best_lora_data, best_analysis_report, active_loras
gc.collect()
if use_gpu:
torch.cuda.empty_cache()
result = (ret_model, ret_clip, report, ret_analysis_report, tuner_data, ret_lora_data)
if cache_patches == "enabled":
self._autotuner_cache = {at_cache_key: (result, "merge")}
else:
self._autotuner_cache = {}
logging.info("[LoRA AutoTuner] Patch cache disabled — RAM freed after merge")
return result
def _save_tuner_dataset_entry(self, tuner_data, active_loras, prefix_stats,
detected_arch):
"""
Append one JSONL entry to the AutoTuner dataset for threshold tuning.
Each entry records: analysis metrics, per-prefix stats distribution,
detected architecture, all scored configs with measured quality.
Failures are silently logged — never blocks the merge.
"""
try:
user_dir = folder_paths.get_user_directory()
dataset_dir = os.path.join(user_dir, "lora_optimizer_reports")
os.makedirs(dataset_dir, exist_ok=True)
dataset_path = os.path.join(dataset_dir, "autotuner_dataset.jsonl")
# Summarize per-prefix conflict/cosine distributions
conflict_ratios = []
excess_conflicts = []
cos_sims = []
mag_ratios = []
subspace_overlaps = []
for pf in prefix_stats.values():
if pf.get("n_loras", 0) > 1:
conflict_ratios.append(pf.get("decision_conflict", pf["conflict_ratio"]))
excess_conflicts.append(pf.get("excess_conflict", 0.0))
cos_sims.append(pf.get("avg_cos_sim", 0.0))
mag_ratios.append(pf.get("magnitude_ratio", 1.0))
subspace_overlaps.append(pf.get("avg_subspace_overlap", 0.0))
entry = {
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
"detected_arch": detected_arch,
"architecture_preset": tuner_data.get("architecture_preset", "auto"),
"lora_hash": tuner_data.get("lora_hash", ""),
"lora_names": [l["name"] for l in active_loras],
"analysis": tuner_data["analysis_summary"],
"prefix_distributions": {
"conflict_ratios": {
"min": min(conflict_ratios) if conflict_ratios else 0,
"max": max(conflict_ratios) if conflict_ratios else 0,
"mean": sum(conflict_ratios) / len(conflict_ratios) if conflict_ratios else 0,
"std": (sum((x - sum(conflict_ratios) / len(conflict_ratios)) ** 2
for x in conflict_ratios) / len(conflict_ratios)) ** 0.5
if len(conflict_ratios) > 1 else 0,
"n": len(conflict_ratios),
},
"excess_conflicts": {
"min": min(excess_conflicts) if excess_conflicts else 0,
"max": max(excess_conflicts) if excess_conflicts else 0,
"mean": sum(excess_conflicts) / len(excess_conflicts) if excess_conflicts else 0,
"n": len(excess_conflicts),
},
"cos_sims": {
"min": min(cos_sims) if cos_sims else 0,
"max": max(cos_sims) if cos_sims else 0,
"mean": sum(cos_sims) / len(cos_sims) if cos_sims else 0,
"n": len(cos_sims),
},
"subspace_overlaps": {
"min": min(subspace_overlaps) if subspace_overlaps else 0,
"max": max(subspace_overlaps) if subspace_overlaps else 0,
"mean": sum(subspace_overlaps) / len(subspace_overlaps) if subspace_overlaps else 0,
"n": len(subspace_overlaps),
},
"mag_ratios": {
"min": min(mag_ratios) if mag_ratios else 1,
"max": max(mag_ratios) if mag_ratios else 1,
"mean": sum(mag_ratios) / len(mag_ratios) if mag_ratios else 1,
"n": len(mag_ratios),
},
},
"top_n": tuner_data["top_n"],
}
with open(dataset_path, "a") as f:
f.write(json.dumps(entry) + "\n")
logging.info(f"[LoRA AutoTuner] Dataset entry saved to: {dataset_path}")
except Exception as e:
logging.warning(f"[LoRA AutoTuner] Failed to save dataset entry: {e}")
def _build_autotuner_report(self, results, analysis_summary, output_strength,
suggested_max_strength=None, scoring_speed="full"):
"""Build the ranked report for AutoTuner results."""
lines = []
lines.append("=" * 54)
lines.append(" LoRA AutoTuner Results")
lines.append("=" * 54)
lines.append("")
lines.append(" Analysis Summary:")
s = analysis_summary
lines.append(f" LoRAs: {s['n_loras']} | Prefixes: {s['prefix_count']} "
f"| Avg conflict: {s['avg_conflict_ratio']:.1%}")
lines.append(f" Excess conflict: {s.get('avg_excess_conflict', 0.0):.1%} "
f"| Subspace overlap: {s.get('avg_subspace_overlap', 0.0):.2f}")
lines.append(f" Avg cosine similarity: {s['avg_cosine_sim']:.2f} "
f"| Importance ratio: {s['magnitude_ratio']:.1f}x")
lines.append(f" Output strength: {output_strength}")
if suggested_max_strength is not None:
lines.append(f" Suggested max output_strength: {suggested_max_strength:.2f}")
if scoring_speed != "full":
lines.append(f" Scoring speed: {scoring_speed} (subsampled prefix scoring)")
if s.get("decision_smoothing", 0.0) > 0:
lines.append(f" Decision smoothing: {s['decision_smoothing']:.2f}")
lines.append("")
lines.append(" " + "-" * 38)
lines.append(f" Top {len(results)} Configurations")
lines.append(" " + "-" * 38)
for r in results:
lines.append("")
c = r["config"]
m = r["metrics"]
marker = " (applied to output)" if r["rank"] == 1 else ""
star = " \u2605" if r["rank"] == 1 else ""
score_line = f" #{r['rank']}{star}{marker} Score: {r['score_measured']:.2f}"
if r.get("score_external") is not None:
score_line += f" (internal {r['score_measured']:.2f}, external {r['score_external']:.2f})"
lines.append(score_line)
mode_display = "per-prefix (auto)" if c["optimization_mode"] == "per_prefix" else c["merge_mode"]
lines.append(f" Mode: {mode_display} | Refinement: {c['merge_refinement']}")
if c["sparsification"] != "disabled":
spars_info = f"{c['sparsification']} (density={c['sparsification_density']}"
if c["dare_dampening"] > 0:
spars_info += f", dampening={c['dare_dampening']}"
spars_info += ")"
lines.append(f" Sparsification: {spars_info}")
else:
lines.append(f" Sparsification: disabled")
strat_set = c.get('strategy_set', 'full')
strat_label = f" | Strategy: {strat_set}" if c['optimization_mode'] == 'per_prefix' else ""
lines.append(f" Auto-strength: {c['auto_strength']} "
f"| Optimization: {c['optimization_mode']}{strat_label}")
energy_label = f" | Energy: {m['energy_ratio']:.2f}x" if "energy_ratio" in m else ""
if m.get("effective_rank_mean", 0) > 0:
lines.append(f" Effective rank: {m['effective_rank_mean']:.1f} "
f"| Sparsity: {m.get('sparsity_mean', 0):.1%}{energy_label}")
elif energy_label:
lines.append(f" Sparsity: {m.get('sparsity_mean', 0):.1%}{energy_label}")
lines.append("")
lines.append(" To use a different config: connect TUNER_DATA")
lines.append(" to a Merge Selector node and set selection=N")
lines.append("=" * 54)
return "\n".join(lines)
@classmethod
def IS_CHANGED(cls, model, lora_stack, output_strength, clip=None,
clip_strength_multiplier=1.0, top_n=3, normalize_keys="disabled",
scoring_svd="disabled", scoring_device="gpu",
architecture_preset="auto", auto_strength_floor=-1.0, evaluator=None, record_dataset="disabled",
cache_patches="enabled",
diff_cache_mode="disabled", diff_cache_ram_pct=0.5,
vram_budget=0.0, scoring_speed="full", scoring_formula="v2",
output_mode="merge", decision_smoothing=0.25,
smooth_slerp_gate=False):
evaluator_hash = cls._stable_data_hash(evaluator) if evaluator is not None else ""
return (id(model), id(lora_stack), output_strength, clip_strength_multiplier, top_n,
normalize_keys, scoring_svd, scoring_device, architecture_preset,
vram_budget, record_dataset, scoring_speed, scoring_formula, output_mode,
auto_strength_floor, decision_smoothing, evaluator_hash)
class LoRAMergeSelector(LoRAOptimizer):
"""
Applies a specific merge configuration from AutoTuner results.
Connect TUNER_DATA from a LoRA AutoTuner node and set the selection
index to choose which ranked configuration to apply.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL", {
"tooltip": "Base model (same one connected to the AutoTuner)."
}),
"lora_stack": ("LORA_STACK", {
"tooltip": "LoRA stack (same one connected to the AutoTuner)."
}),
"tuner_data": ("TUNER_DATA", {
"tooltip": "Connect from the LoRA AutoTuner's tuner_data output."
}),
"selection": ("INT", {
"default": 1, "min": 1, "max": 10, "step": 1,
"tooltip": "Which ranked configuration to apply (1 = top-ranked, 2 = next-ranked, etc.)."
}),
"output_strength": ("FLOAT", {
"default": 1.0, "min": -1.0, "max": 10.0, "step": 0.05,
"tooltip": "Master volume for the merged result. Set to -1 for auto (uses suggested max strength)."
}),
},
"optional": {
"clip": ("CLIP", {
"tooltip": "Optional CLIP model for text-encoder LoRA keys."
}),
"clip_strength_multiplier": ("FLOAT", {
"default": 1.0, "min": 0.0, "max": 10.0, "step": 0.05,
"tooltip": "Multiplier for CLIP LoRA strengths."
}),
"auto_strength_floor": ("FLOAT", {
"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.05,
"tooltip": "Minimum auto-strength scale factor for orthogonal LoRAs. Leave at -1 to use the AutoTuner's stored setting when available."
}),
"decision_smoothing": ("FLOAT", {
"default": 0.25, "min": 0.0, "max": 1.0, "step": 0.05,
"tooltip": "Must match the AutoTuner run when per-group decisions depend on smoothing. 0 disables smoothing."
}),
"vram_budget": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.05,
"tooltip": "Fraction of free VRAM to use for storing merged patches. 0 = all CPU (default), 1.0 = use all free VRAM. Reduces RAM usage on GPU systems."
}),
},
}
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "LORA_DATA")
RETURN_NAMES = ("model", "clip", "report", "lora_data")
FUNCTION = "select_merge"
CATEGORY = "LoRA Optimizer"
DESCRIPTION = (
"Applies a specific merge configuration from LoRA AutoTuner results. "
"Set selection to choose which ranked configuration to use."
)
def select_merge(self, model, lora_stack, tuner_data, selection,
output_strength, clip=None, clip_strength_multiplier=1.0,
auto_strength_floor=-1.0, decision_smoothing=0.25,
vram_budget=0.0):
import hashlib, json
if tuner_data is None or "top_n" not in tuner_data:
return (model, clip, "Error: No valid TUNER_DATA provided.", None)
# Validate lora_hash (filter zero-strength to match AutoTuner)
nk = tuner_data.get("normalize_keys", "disabled")
all_loras = self._normalize_stack(lora_stack, normalize_keys=nk)
active_loras = [item for item in all_loras if item["strength"] != 0]
hash_input = json.dumps([(l["name"], l["strength"]) for l in active_loras],
sort_keys=True)
current_hash = hashlib.sha256(hash_input.encode()).hexdigest()[:16]
if current_hash != tuner_data.get("lora_hash", ""):
logging.warning("[Merge Selector] LoRA stack has changed since AutoTuner ran. "
"Results may not match. Re-run AutoTuner for accurate results.")
# Get selected config
top_n = tuner_data["top_n"]
if selection < 1 or selection > len(top_n):
return (model, clip,
f"Error: selection={selection} out of range (1-{len(top_n)}).", None)
entry = top_n[selection - 1]
config = entry["config"]
resolved_smoothing = tuner_data.get("decision_smoothing", decision_smoothing)
resolved_auto_strength_floor = tuner_data.get("auto_strength_floor", auto_strength_floor)
logging.info(f"[Merge Selector] Applying config #{selection}: "
f"{config['merge_mode']}, {config['merge_refinement']}")
# Run merge with selected config
# In per_prefix mode, let the optimizer auto-select strategy per prefix.
strategy_override = config["merge_mode"] if config["optimization_mode"] == "global" else ""
merged_model, merged_clip, _report, _, _lora_data = super().optimize_merge(
model, lora_stack, output_strength,
clip=clip,
clip_strength_multiplier=clip_strength_multiplier,
auto_strength=config["auto_strength"],
auto_strength_floor=resolved_auto_strength_floor,
optimization_mode=config["optimization_mode"],
sparsification=config["sparsification"],
sparsification_density=config["sparsification_density"],
dare_dampening=config["dare_dampening"],
merge_refinement=config["merge_refinement"],
merge_strategy_override=strategy_override,
free_vram_between_passes="disabled",
vram_budget=vram_budget,
cache_patches="enabled",
patch_compression="smart",
svd_device="gpu",
normalize_keys=tuner_data.get("normalize_keys", "disabled"),
strategy_set="full",
architecture_preset=tuner_data.get("architecture_preset", "auto"),
decision_smoothing=resolved_smoothing,
)
# Build report for this selection
lines = []
lines.append(f"Merge Selector \u2014 Applied config #{selection}")
mode_display = "per-prefix (auto)" if config["optimization_mode"] == "per_prefix" else config["merge_mode"]
lines.append(f" Mode: {mode_display} | Refinement: {config['merge_refinement']}")
if config["sparsification"] != "disabled":
lines.append(f" Sparsification: {config['sparsification']} "
f"(density={config['sparsification_density']})")
lines.append(f" Auto-strength: {config['auto_strength']} "
f"| Optimization: {config['optimization_mode']}")
lines.append(f" Heuristic score: {entry['score_heuristic']:.3f} "
f"| Final score: {entry.get('score_final', entry['score_measured']):.3f}")
if entry.get("score_external") is not None:
lines.append(f" Internal score: {entry['score_measured']:.3f} "
f"| External score: {entry['score_external']:.3f}")
report = "\n".join(lines)
return (merged_model, merged_clip, report, _lora_data)
@classmethod
def IS_CHANGED(cls, model, lora_stack, tuner_data, selection,
output_strength, clip=None, clip_strength_multiplier=1.0,
auto_strength_floor=-1.0, decision_smoothing=0.25,
vram_budget=0.0):
return (id(tuner_data), selection, output_strength, clip_strength_multiplier,
auto_strength_floor, decision_smoothing)
class WanVideoLoRAOptimizer(LoRAOptimizer):
"""
WanVideo variant of the LoRA Optimizer. Accepts WANVIDEOMODEL instead of
MODEL, skips CLIP, and applies merged LoRA patches in-memory.
All merging algorithms (TIES, DARE/DELLA, SVD compression, auto-strength,
conflict analysis) are inherited from LoRAOptimizer. Wan LoRA key
normalization (LyCORIS, diffusers, Fun LoRA, finetrainer) is
already handled by the parent's _normalize_keys_wan.
"""
@classmethod
def INPUT_TYPES(cls):
base = LoRAOptimizer.INPUT_TYPES()
# Replace MODEL with WANVIDEOMODEL
base["required"]["model"] = ("WANVIDEOMODEL", {
"tooltip": "Your WanVideo model from WanVideoModelLoader."
})
# Remove CLIP-related inputs (WanVideo doesn't use CLIP)
base["optional"].pop("clip", None)
base["optional"].pop("clip_strength_multiplier", None)
base["optional"].pop("free_vram_between_passes", None)
# Change defaults for video models
base["optional"]["cache_patches"] = (["disabled", "enabled"], {
"default": "disabled",
"tooltip": "Keep the merge result in memory so re-running the workflow is instant. Disabled by default for large video models to save RAM."
})
base["optional"]["normalize_keys"] = (["enabled", "disabled"], {
"default": "enabled",
"tooltip": "Normalizes LoRA keys from different training tools (LyCORIS, diffusers, finetrainer, etc.) to a common format. Enabled by default for WanVideo LoRAs."
})
base["optional"]["architecture_preset"] = (["auto", "dit", "sd_unet", "llm"], {
"default": "dit",
"tooltip": "Architecture-aware threshold tuning. Default 'dit' for WanVideo models. "
"'auto' detects from LoRA keys."
})
base["optional"].pop("tuner_data", None)
base["optional"].pop("settings_source", None)
return base
RETURN_TYPES = ("WANVIDEOMODEL", "STRING", "LORA_DATA")
RETURN_NAMES = ("model", "analysis_report", "lora_data")
FUNCTION = "optimize_merge"
CATEGORY = "LoRA Optimizer"
DESCRIPTION = (
"WanVideo LoRA Optimizer — merges multiple WanVideo LoRAs using "
"conflict-aware algorithms (TIES, DARE, auto-strength). "
"Connect after WanVideoModelLoader, before WanVideoSampler."
)
def _get_model_keys(self, model):
model_keys = super()._get_model_keys(model)
# WanVideo models have ._orig_mod. in state_dict keys from torch.compile.
# The core model_lora_keys_unet creates entries WITH _orig_mod, but LoRA
# files have prefixes WITHOUT it. Add stripped versions pointing to the
# original target keys so prefixes match.
augmented = {}
for prefix, target in model_keys.items():
if '._orig_mod.' in prefix or '._orig_mod' in prefix:
stripped = prefix.replace('._orig_mod.', '.').replace('._orig_mod', '')
if stripped not in model_keys:
augmented[stripped] = target
model_keys.update(augmented)
return model_keys
def optimize_merge(self, model, lora_stack, output_strength, **kwargs):
kwargs.pop("clip", None)
kwargs.pop("clip_strength_multiplier", None)
kwargs.pop("free_vram_between_passes", None)
model_out, _clip, report, _, lora_data = super().optimize_merge(
model, lora_stack, output_strength,
clip=None, clip_strength_multiplier=1.0, **kwargs
)
return (model_out, report, lora_data)
@classmethod
def IS_CHANGED(cls, model, lora_stack, output_strength, **kwargs):
kwargs.pop("clip", None)
kwargs.pop("clip_strength_multiplier", None)
kwargs.pop("free_vram_between_passes", None)
return LoRAOptimizer.IS_CHANGED(
model, lora_stack, output_strength,
clip=None, clip_strength_multiplier=0, **kwargs
)
class SaveMergedLoRA:
"""
Saves merged LoRA patches from LoRA Optimizer as a standalone .safetensors
file that can be loaded by any standard LoRA loader.
"""
@classmethod
def INPUT_TYPES(cls):
lora_folders = folder_paths.get_folder_paths("loras")
folder_choices = lora_folders if lora_folders else [os.path.join(folder_paths.models_dir, "loras")]
return {
"required": {
"lora_data": ("LORA_DATA", {"tooltip": "Connect the lora_data output from LoRA Optimizer here."}),
"save_folder": (folder_choices, {"tooltip": "Which loras folder to save into. Lists configured lora paths from ComfyUI and extra_model_paths.yaml."}),
"filename": ("STRING", {"default": "merged_lora", "tooltip": "Name for the saved file. Subdirectories are allowed (e.g. 'merged/my_lora'). Extension .safetensors is added automatically."}),
"save_rank": ("INT", {
"default": 0, "min": 0, "max": 2048, "step": 4,
"tooltip": "0 = auto (adaptively finds the rank needed for <5% reconstruction error — recommended). Non-zero = force this rank for any layers that need compression. Higher values = more accurate but larger file."
}),
"bake_strength": ("BOOLEAN", {
"default": True,
"tooltip": "When enabled, the saved LoRA reproduces your exact merge when loaded at strength 1.0. When disabled, strengths are not baked in — you'll need to set the strength manually when loading."
}),
},
"optional": {
"prompt": ("STRING", {
"default": "",
"multiline": True,
"tooltip": "Example prompt or trigger words to embed in the file metadata. Useful for sharing — some UIs display this automatically."
}),
"description": ("STRING", {
"default": "",
"multiline": True,
"tooltip": "Optional description or notes about this merged LoRA. Stored in file metadata."
}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("file_path",)
FUNCTION = "save_lora"
CATEGORY = "LoRA Optimizer"
OUTPUT_NODE = True
DESCRIPTION = "Saves merged LoRA data as a standalone .safetensors file that can be loaded by any standard LoRA loader."
def save_lora(self, lora_data, save_folder, filename, save_rank=0, bake_strength=True, prompt="", description=""):
if lora_data is None:
logging.warning("[Save Merged LoRA] No lora_data received (optimizer may have returned early). Nothing to save.")
return ("",)
save_path = _resolve_safe_output_path(save_folder, filename, ".safetensors", "Save Merged LoRA")
os.makedirs(os.path.dirname(save_path), exist_ok=True)
model_patches = lora_data["model_patches"]
clip_patches = lora_data["clip_patches"]
key_map = lora_data["key_map"]
output_strength = lora_data["output_strength"]
clip_strength = lora_data["clip_strength"]
auto_rank = save_rank == 0
# Auto mode: adapt rank for any full-rank diffs that need compression.
if auto_rank:
has_diffs = any(
isinstance(patch, tuple) and patch[0] == "diff"
for patch in list(model_patches.values()) + list(clip_patches.values())
)
if has_diffs:
initial_rank = lora_data.get("sum_rank", 128)
fallback_rank = LoRAOptimizer._estimate_save_rank(initial_rank, model_patches, clip_patches)
logging.info(f"[Save Merged LoRA] Auto rank for diffs: {fallback_rank} "
f"(initial estimate {initial_rank}, adapted from sample diffs)")
else:
fallback_rank = lora_data.get("sum_rank", 128)
logging.info(f"[Save Merged LoRA] Auto rank: {fallback_rank} (no full-rank diffs to compress)")
save_dtype = None
for patch in list(model_patches.values()) + list(clip_patches.values()):
if isinstance(patch, tuple) and patch[0] == "diff":
dtype = patch[1][0].dtype
elif hasattr(patch, "weights") and patch.weights[0] is not None:
dtype = patch.weights[0].dtype
else:
continue
if dtype not in (torch.float32, torch.float64):
save_dtype = dtype
break
if save_dtype is None:
save_dtype = torch.float16
logging.info(f"[Save Merged LoRA] Output dtype: {save_dtype}")
state_dict = {}
for is_clip, patches in [(False, model_patches), (True, clip_patches)]:
for target_key, patch in patches.items():
tkey = target_key[0] if isinstance(target_key, tuple) else target_key
key_info = key_map.get(target_key)
if key_info is None:
key_info = key_map.get(tkey, tkey)
if isinstance(key_info, dict):
lora_prefix = key_info.get("canonical_prefix", tkey)
else:
lora_prefix = key_info
if isinstance(patch, (LoKrAdapter, LoHaAdapter)):
diff_tensor = _LoRAMergeBase._expand_patch_to_diff(patch)
rank = fallback_rank if auto_rank else save_rank
compressed = LoRAOptimizer._compress_to_lowrank(diff_tensor, rank, output_dtype=save_dtype)
mat_up, mat_down, alpha, mid, _, _ = compressed.weights
alpha = float(alpha)
elif isinstance(patch, LoRAAdapter):
mat_up, mat_down, alpha, mid, _, _ = patch.weights
alpha = float(alpha) if alpha is not None else float(mat_down.shape[0])
elif isinstance(patch, tuple) and len(patch) == 2 and patch[0] == "diff":
diff_tensor = patch[1][0]
rank = fallback_rank if auto_rank else save_rank
compressed = LoRAOptimizer._compress_to_lowrank(diff_tensor, rank, output_dtype=save_dtype)
mat_up, mat_down, alpha, mid, _, _ = compressed.weights
alpha = float(alpha)
else:
logging.warning(f"[Save Merged LoRA] Skipping unknown patch type for {lora_prefix}: {type(patch)}")
continue
if bake_strength:
strength = clip_strength if is_clip else output_strength
alpha *= strength
state_dict[f"{lora_prefix}.lora_up.weight"] = mat_up.to(save_dtype).cpu().contiguous()
state_dict[f"{lora_prefix}.lora_down.weight"] = mat_down.to(save_dtype).cpu().contiguous()
state_dict[f"{lora_prefix}.alpha"] = torch.tensor(alpha)
unmapped_keys = []
nan_keys = []
zero_keys = []
for is_clip, patches in [(False, model_patches), (True, clip_patches)]:
for target_key, _patch in patches.items():
direct = key_map.get(target_key)
if direct is None:
tkey = target_key[0] if isinstance(target_key, tuple) else target_key
fallback = key_map.get(tkey)
label = f"{'CLIP' if is_clip else 'MODEL'} {tkey}"
if fallback is None:
unmapped_keys.append(f"{label} (using raw key as prefix)")
else:
unmapped_keys.append(f"{label} (fallback to base key)")
for state_key, tensor in state_dict.items():
if state_key.endswith(".alpha"):
continue
if torch.isnan(tensor).any():
nan_keys.append(state_key)
if tensor.abs().max().item() == 0:
zero_keys.append(state_key)
if unmapped_keys:
logging.warning(f"[Save Merged LoRA] {len(unmapped_keys)} keys fell through to fallback mapping:")
for item in unmapped_keys[:5]:
logging.warning(f" {item}")
if nan_keys:
logging.error(f"[Save Merged LoRA] {len(nan_keys)} tensors contain NaN")
for item in nan_keys[:5]:
logging.error(f" {item}")
if zero_keys:
logging.warning(f"[Save Merged LoRA] {len(zero_keys)} tensors are all zeros")
prefixes = sorted(set(key.rsplit(".lora_", 1)[0] for key in state_dict if ".lora_" in key))
if prefixes:
logging.info(f"[Save Merged LoRA] Sample prefixes: {prefixes[:3]} ... ({len(prefixes)} total)")
svd_errors = []
for is_clip, patches in [(False, model_patches), (True, clip_patches)]:
for target_key, patch in patches.items():
key_info = key_map.get(target_key)
if key_info is None:
continue
if isinstance(key_info, dict):
lora_prefix = key_info.get("canonical_prefix")
else:
lora_prefix = key_info
if not lora_prefix:
continue
up_key = f"{lora_prefix}.lora_up.weight"
down_key = f"{lora_prefix}.lora_down.weight"
alpha_key = f"{lora_prefix}.alpha"
if up_key not in state_dict:
continue
saved_up = state_dict[up_key].float()
saved_down = state_dict[down_key].float()
saved_alpha = state_dict[alpha_key].item()
rank = saved_down.shape[0]
scale = saved_alpha / rank
reconstructed = torch.mm(saved_up, saved_down) * scale
if isinstance(patch, tuple) and patch[0] == "diff":
original_diff = patch[1][0].float()
strength = clip_strength if is_clip else output_strength
reference = original_diff * strength if bake_strength else original_diff
original_norm = reference.norm().item()
if original_norm > 0:
error = (reconstructed - reference).norm().item() / original_norm
svd_errors.append(error)
if svd_errors:
avg_error = sum(svd_errors) / len(svd_errors)
max_error = max(svd_errors)
logging.info(f"[Save Merged LoRA] SVD reconstruction error: "
f"avg={avg_error:.4f}, max={max_error:.4f} "
f"({len(svd_errors)} diffs checked)")
# Build safetensors metadata header
metadata = {"tool": "ComfyUI-ZImage-LoRA-Merger"}
merge_meta = lora_data.get("merge_metadata", {})
if merge_meta:
source_loras = merge_meta.get("source_loras", [])
if source_loras:
metadata["source_loras"] = ", ".join(
f"{s['name']} @ {s['strength']}" for s in source_loras
)
for key in ("mode", "optimization_mode", "architecture", "architecture_preset",
"auto_strength", "sparsification", "merge_refinement", "strategy_set"):
val = merge_meta.get(key)
if val is not None:
metadata[f"merge_{key}"] = str(val)
if merge_meta.get("sparsification_density") is not None:
metadata["merge_sparsification_density"] = str(merge_meta["sparsification_density"])
metadata["merge_output_strength"] = str(merge_meta.get("bake_strength_output", output_strength))
metadata["merge_clip_strength"] = str(merge_meta.get("bake_strength_clip", clip_strength))
metadata["merge_bake_strength"] = str(bake_strength)
if prompt.strip():
metadata["prompt"] = prompt.strip()
if description.strip():
metadata["description"] = description.strip()
save_file(state_dict, save_path, metadata=metadata)
logging.info(f"[Save Merged LoRA] Saved {len(state_dict) // 3} LoRA keys to {save_path}")
return (save_path,)
class BuildAutoTunerPythonEvaluator:
"""Builds an external AutoTuner evaluator spec from a Python callable."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"module_path": ("STRING", {
"default": "",
"tooltip": "Python file path or importable module name that contains the evaluator callable."
}),
"callable_name": ("STRING", {
"default": "evaluate_candidate",
"tooltip": "Callable to import. Signature: fn(model=..., clip=..., lora_data=..., config=..., context=..., analysis_summary=...) -> float or {'score': float, 'details': ...}."
}),
},
"optional": {
"combine_mode": (["blend", "external_only", "multiply"], {
"default": "blend",
"tooltip": "How AutoTuner combines the built-in score with the external evaluator score."
}),
"weight": ("FLOAT", {
"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05,
"tooltip": "Blend weight for the external evaluator when combine_mode=blend."
}),
"context_json": ("STRING", {
"default": "{}",
"multiline": True,
"tooltip": "Optional JSON object passed through to the evaluator as context."
}),
},
}
RETURN_TYPES = ("AUTOTUNER_EVALUATOR",)
RETURN_NAMES = ("evaluator",)
FUNCTION = "build"
CATEGORY = "LoRA Optimizer"
DESCRIPTION = "Builds a Python evaluator spec for LoRA AutoTuner so candidate ranking can incorporate prompt/reference scoring from external code."
def build(self, module_path, callable_name, combine_mode="blend", weight=0.5, context_json="{}"):
context = {}
if context_json and context_json.strip():
try:
context = json.loads(context_json)
except Exception as exc:
raise ValueError(f"Invalid evaluator context JSON: {exc}")
return ({
"module_path": module_path.strip(),
"callable_name": callable_name.strip(),
"combine_mode": combine_mode,
"weight": float(weight),
"context": context,
},)
@classmethod
def IS_CHANGED(cls, module_path, callable_name, combine_mode="blend", weight=0.5, context_json="{}"):
return _LoRAMergeBase._stable_data_hash({
"module_path": module_path,
"callable_name": callable_name,
"combine_mode": combine_mode,
"weight": weight,
"context_json": context_json,
})
class SaveTunerData:
"""Saves AutoTuner results (TUNER_DATA) to a .tuner file for later reuse."""
@classmethod
def INPUT_TYPES(cls):
tuner_folders = folder_paths.get_folder_paths("tuner_data")
folder_choices = tuner_folders if tuner_folders else [TUNER_DATA_DIR]
return {
"required": {
"tuner_data": ("TUNER_DATA", {"tooltip": "Connect the tuner_data output from LoRA AutoTuner."}),
"save_folder": (folder_choices, {"tooltip": "Which tuner_data folder to save into. Lists all configured paths (from extra_model_paths.yaml and defaults)."}),
"filename": ("STRING", {"default": "tuner_data", "tooltip": "Filename. Subdirectories allowed (e.g. 'results/experiment1'). Extension .tuner is added automatically (.json also accepted)."}),
"overwrite": ("BOOLEAN", {"default": True, "tooltip": "When enabled, overwrites existing files. When disabled, appends _001, _002, etc. to avoid overwriting."}),
},
"optional": {
"prompt": ("STRING", {
"default": "",
"multiline": True,
"tooltip": "Example prompt or trigger words to embed in the tuner file. Shown when loading."
}),
"description": ("STRING", {
"default": "",
"multiline": True,
"tooltip": "Optional description or notes about this tuner run. Stored in the file."
}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("file_path",)
FUNCTION = "save_tuner_data"
CATEGORY = "LoRA Optimizer"
OUTPUT_NODE = True
DESCRIPTION = "Saves AutoTuner results to a .tuner file so they can be reloaded later without re-running the tuner."
def save_tuner_data(self, tuner_data, save_folder, filename, overwrite=True, prompt="", description=""):
if tuner_data is None:
return ("",)
# Embed user metadata into tuner_data before saving
save_data = dict(tuner_data)
if prompt.strip():
save_data["prompt"] = prompt.strip()
if description.strip():
save_data["description"] = description.strip()
base_dir = os.path.realpath(save_folder)
base_name = filename if filename.endswith((".json", ".tuner")) else f"{filename}.tuner"
save_path = os.path.realpath(os.path.join(base_dir, base_name))
try:
if os.path.commonpath([base_dir, save_path]) != base_dir:
raise ValueError
except ValueError as exc:
raise ValueError(f"[Save Tuner Data] Path escapes tuner_data directory: {filename}") from exc
if not overwrite and os.path.exists(save_path):
stem, ext = os.path.splitext(save_path)
counter = 1
while os.path.exists(save_path):
save_path = f"{stem}_{counter:03d}{ext}"
counter += 1
os.makedirs(os.path.dirname(save_path), exist_ok=True)
with open(save_path, "w") as f:
json.dump(save_data, f, indent=2)
logging.info(f"[Save Tuner Data] Saved to: {save_path}")
return (save_path,)
class LoadTunerData:
"""Loads previously saved AutoTuner results (TUNER_DATA) from a JSON file."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"tuner_data_file": (folder_paths.get_filename_list("tuner_data"), {"tooltip": "Select a saved tuner data file."}),
}
}
RETURN_TYPES = ("TUNER_DATA", "STRING", "STRING", "STRING")
RETURN_NAMES = ("tuner_data", "prompt", "description", "metadata_info")
FUNCTION = "load_tuner_data"
CATEGORY = "LoRA Optimizer"
DESCRIPTION = "Loads saved AutoTuner results from disk so they can be fed to Merge Selector without re-running the tuner."
@classmethod
def IS_CHANGED(cls, tuner_data_file):
load_path = folder_paths.get_full_path_or_raise("tuner_data", tuner_data_file)
return os.path.getmtime(load_path)
def load_tuner_data(self, tuner_data_file):
load_path = folder_paths.get_full_path_or_raise("tuner_data", tuner_data_file)
with open(load_path, "r") as f:
tuner_data = json.load(f)
logging.info(f"[Load Tuner Data] Loaded from: {load_path} "
f"({len(tuner_data.get('top_n', []))} configs)")
prompt = tuner_data.get("prompt", "")
description = tuner_data.get("description", "")
# Build metadata info string
info_lines = []
for key in ("source_loras", "architecture_preset", "normalize_keys",
"auto_strength_floor", "decision_smoothing", "lora_hash"):
val = tuner_data.get(key)
if val is not None:
info_lines.append(f"{key}: {val}")
top_n = tuner_data.get("top_n", [])
if top_n:
info_lines.append(f"configs: {len(top_n)}")
best = top_n[0]
cfg = best.get("config", {})
info_lines.append(f"best score: {best.get('score_final', 'N/A')}")
for k in ("mode", "optimization_mode", "auto_strength", "sparsification",
"merge_refinement", "strategy_set"):
v = cfg.get(k)
if v is not None:
info_lines.append(f"best {k}: {v}")
if prompt:
info_lines.append(f"prompt: {prompt}")
if description:
info_lines.append(f"description: {description}")
metadata_info = "\n".join(info_lines) if info_lines else "No metadata found."
return (tuner_data, prompt, description, metadata_info)
class LoRACompatibilityAnalyzer(LoRAOptimizer):
"""
Standalone pre-merge planning tool. Analyzes pairwise LoRA interactions
and recommends which LoRAs are safe to merge together.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"enabled": ("BOOLEAN", {
"default": False,
"tooltip": "Toggle on to run compatibility analysis. Off by default so normal queue execution stays cheap."
}),
"create_nodes": ("BOOLEAN", {
"default": True,
"tooltip": "Automatically create LoRA Stack and Load LoRA nodes from results."
}),
"model": ("MODEL", {"tooltip": "Base model used for key mapping and target grouping."}),
"lora_stack": ("LORA_STACK", {"tooltip": "LoRA stack to analyze."}),
},
"optional": {
"clip": ("CLIP", {"tooltip": "Optional CLIP model for text-encoder LoRA keys."}),
},
}
RETURN_TYPES = ("STRING", "IMAGE")
RETURN_NAMES = ("report", "compatibility_map")
FUNCTION = "analyze"
CATEGORY = "LoRA Optimizer"
DESCRIPTION = "Analyzes LoRA compatibility and suggests merge groups. No merge is performed."
@classmethod
def IS_CHANGED(cls, enabled, **kwargs):
if not enabled:
return "disabled"
return float("NaN")
def analyze(self, enabled, create_nodes, model, lora_stack, clip=None):
has_clip = [clip is not None]
if not enabled:
msg = "Analysis disabled. Toggle 'enabled' to run."
return {"ui": {"groups": [], "has_clip": has_clip}, "result": (msg, self._empty_image())}
if not lora_stack or len(lora_stack) == 0:
msg = "No LoRAs in stack."
return {"ui": {"groups": [], "has_clip": has_clip}, "result": (msg, self._empty_image())}
normalized_stack = self._normalize_stack(lora_stack)
active_loras = [item for item in normalized_stack if item["strength"] != 0]
if len(active_loras) == 0:
msg = "No active LoRAs in stack (all zero strength)."
return {"ui": {"groups": [], "has_clip": has_clip}, "result": (msg, self._empty_image())}
n_loras = len(active_loras)
detected_arch = getattr(self, "_detected_arch", None) or "unknown"
preset_key, arch_preset = _resolve_arch_preset("auto", detected_arch)
logging.info(f"[Compatibility Analyzer] Architecture preset: {preset_key} ({arch_preset['display_name']})")
if n_loras == 1:
name = active_loras[0]["name"]
report = (
"=" * 55 + "\n"
" LoRA Compatibility Analysis\n"
"=" * 55 + "\n\n"
f"Single LoRA: {name}\n"
"Nothing to compare — add more LoRAs to analyze compatibility.\n"
"\n" + "=" * 55
)
return {"ui": {"groups": [], "has_clip": has_clip}, "result": (report, self._empty_image())}
logging.info(f"[Compatibility Analyzer] Analyzing {n_loras} LoRAs...")
t_start = time.time()
model_keys = self._get_model_keys(model)
clip_keys = {}
if clip is not None:
clip_keys = comfy.lora.model_lora_keys_clip(clip.cond_stage_model, {})
all_lora_prefixes = self._collect_lora_prefixes(active_loras)
target_groups = self._build_target_groups(all_lora_prefixes, model_keys, clip_keys)
if not target_groups:
msg = "No compatible LoRA target groups found. LoRAs may be incompatible with this model architecture."
return {"ui": {"groups": [], "has_clip": has_clip}, "result": (msg, self._empty_image())}
compute_device = self._get_compute_device()
analysis = self._run_group_analysis(
target_groups, active_loras, model, clip, compute_device,
clip_strength_multiplier=1.0,
merge_refinement="none",
decision_smoothing=0.0,
)
prefix_count = analysis["prefix_count"]
if prefix_count == 0:
msg = "No compatible LoRA target groups found. LoRAs may be incompatible with this model architecture."
return {"ui": {"groups": [], "has_clip": has_clip}, "result": (msg, self._empty_image())}
per_lora_stats = analysis["per_lora_stats"]
pair_accum = analysis["pair_accum"]
pairs = analysis["pairs"]
branch_energy = analysis["branch_energy"]
lora_stats = []
l2_means = []
for i, stat in enumerate(per_lora_stats):
avg_rank = sum(stat["ranks"]) / len(stat["ranks"]) if stat["ranks"] else 0
l2_mean = sum(stat["l2_norms"]) / len(stat["l2_norms"]) if stat["l2_norms"] else 0
l2_means.append(l2_mean)
lora_stats.append({
"name": stat["name"],
"strength": stat["strength"],
"key_count": stat["key_count"],
"avg_rank": avg_rank,
"l2_mean": l2_mean,
})
pairwise_conflicts = []
pairwise_similarities = {}
total_overlap = 0
total_conflict = 0
for i, j in pairs:
pair_metrics = pair_accum[(i, j)]
pair_overlap = pair_metrics["overlap"]
pair_conflict = pair_metrics["conflict"]
pair_dot = pair_metrics["dot"]
pair_na_sq = pair_metrics["norm_a_sq"]
pair_nb_sq = pair_metrics["norm_b_sq"]
total_overlap += pair_overlap
total_conflict += pair_conflict
ratio = pair_conflict / pair_overlap if pair_overlap > 0 else 0.0
denom = math.sqrt(pair_na_sq) * math.sqrt(pair_nb_sq)
cos_sim = pair_dot / denom if denom > 0 else 0.0
pairwise_similarities[(i, j)] = cos_sim
pairwise_conflicts.append({
"i": i,
"j": j,
"pair": f"{active_loras[i]['name']} vs {active_loras[j]['name']}",
"overlap": pair_overlap,
"conflicts": pair_conflict,
"ratio": ratio,
"cosine_sim": cos_sim,
})
avg_conflict_ratio = total_conflict / total_overlap if total_overlap > 0 else 0.0
avg_cos_sim = (sum(pairwise_similarities.values()) / len(pairwise_similarities)) if pairwise_similarities else 0.0
valid_l2 = [m for m in l2_means if m > 0]
magnitude_ratio = max(valid_l2) / min(valid_l2) if len(valid_l2) >= 2 else 1.0
collection_stats = {
"n_loras": n_loras,
"total_keys": prefix_count,
"avg_conflict": avg_conflict_ratio,
"avg_cos_sim": avg_cos_sim,
"magnitude_ratio": magnitude_ratio,
}
compat_matrix = self._compute_compat_matrix(pairwise_similarities, pairwise_conflicts, n_loras)
groups = self._cluster_loras(compat_matrix, threshold=0.05)
group_info = []
for group in groups:
info = {"indices": group}
if len(group) == 1:
idx = group[0]
info["type"] = "solo"
info["strength"] = active_loras[idx]["strength"]
opposing = []
for other_idx in range(n_loras):
if other_idx == idx:
continue
key = (min(idx, other_idx), max(idx, other_idx))
cos_sim = pairwise_similarities.get(key, 0.0)
if cos_sim < -0.05:
opposing.append((active_loras[other_idx]["name"], cos_sim))
opposing.sort(key=lambda item: item[1])
info["opposing"] = opposing
else:
info["type"] = "merge"
intra_cos = []
intra_conflict = []
intra_excess = []
intra_subspace = []
model_norm_sq = []
clip_norm_sq = []
model_dots = {}
clip_dots = {}
group_loras = [active_loras[idx] for idx in group]
for pos, orig_idx in enumerate(group):
model_norm_sq.append(branch_energy["model"]["norm_sq"][orig_idx])
clip_norm_sq.append(branch_energy["clip"]["norm_sq"][orig_idx])
for a_pos in range(len(group)):
for b_pos in range(a_pos + 1, len(group)):
left = group[a_pos]
right = group[b_pos]
pair_key = (min(left, right), max(left, right))
metrics = pair_accum.get(pair_key, {})
cos_sim = pairwise_similarities.get(pair_key, 0.0)
ratio = (metrics.get("conflict", 0) / metrics.get("overlap", 1)) if metrics.get("overlap", 0) > 0 else 0.0
weighted_total = metrics.get("weighted_total", 0.0)
excess_conflict = (metrics.get("excess_conflict_weighted", 0.0) / weighted_total) if weighted_total > 0 else 0.0
subspace_overlap = (metrics.get("subspace_num", 0.0) / metrics.get("subspace_den", 1.0)) if metrics.get("subspace_den", 0.0) > 0 else 0.0
intra_cos.append(cos_sim)
intra_conflict.append(ratio)
intra_excess.append(excess_conflict)
intra_subspace.append(subspace_overlap)
model_dots[(a_pos, b_pos)] = branch_energy["model"]["dot"].get(pair_key, 0.0)
clip_dots[(a_pos, b_pos)] = branch_energy["clip"]["dot"].get(pair_key, 0.0)
info["avg_cos_sim"] = sum(intra_cos) / len(intra_cos) if intra_cos else 0.0
info["avg_conflict"] = sum(intra_conflict) / len(intra_conflict) if intra_conflict else 0.0
info["avg_excess_conflict"] = sum(intra_excess) / len(intra_excess) if intra_excess else 0.0
info["avg_subspace_overlap"] = sum(intra_subspace) / len(intra_subspace) if intra_subspace else 0.0
avg_compat = sum(cs * (1.0 - cr) for cs, cr in zip(intra_cos, intra_conflict)) / len(intra_cos) if intra_cos else 0.0
if avg_compat > 0.2:
info["confidence"] = "High"
elif avg_compat > 0.05:
info["confidence"] = "Moderate"
else:
info["confidence"] = "Low"
group_branch_energy = {
"model": {"norm_sq": model_norm_sq, "dot": model_dots},
"clip": {"norm_sq": clip_norm_sq, "dot": clip_dots},
}
auto_strength_info = self._compute_auto_strengths(
group_loras,
group_branch_energy,
arch_preset=arch_preset,
detected_arch=detected_arch,
)
info["strengths"] = {
group[idx]: auto_strength_info["model_strengths"][idx]
for idx in range(len(group))
}
mode, _, _, _ = self._auto_select_params(
info["avg_conflict"],
magnitude_ratio,
magnitude_samples=analysis["all_magnitude_samples"],
avg_cos_sim=info["avg_cos_sim"],
avg_excess_conflict=info["avg_excess_conflict"],
avg_subspace_overlap=info["avg_subspace_overlap"],
arch_preset=arch_preset,
)
info["suggested_merge"] = mode
group_info.append(info)
warnings = []
for pc in pairwise_conflicts:
if pc["cosine_sim"] < -0.1:
warnings.append({
"type": "opposing",
"name_i": active_loras[pc["i"]]["name"],
"name_j": active_loras[pc["j"]]["name"],
"cos_sim": pc["cosine_sim"],
})
strong_loras = {}
if len(l2_means) >= 2:
valid_l2 = [m for m in l2_means if m > 0]
mean_l2 = sum(valid_l2) / len(valid_l2) if valid_l2 else 1.0
lora_avg_ratios = {
idx: (l2 / mean_l2)
for idx, l2 in enumerate(l2_means)
if l2 > 0 and mean_l2 > 0
}
for pc in pairwise_conflicts:
i, j = pc["i"], pc["j"]
l2_i = l2_means[i]
l2_j = l2_means[j]
if l2_i > 0 and l2_j > 0:
ratio = max(l2_i, l2_j) / min(l2_i, l2_j)
if ratio > 3.0:
strong_idx = i if l2_i > l2_j else j
weak_idx = j if l2_i > l2_j else i
weak_name = active_loras[weak_idx]["name"]
strong_loras.setdefault(strong_idx, [])
if weak_name not in [name for name, _ in strong_loras[strong_idx]]:
strong_loras[strong_idx].append((weak_name, ratio))
for strong_idx in sorted(strong_loras.keys()):
warnings.append({
"type": "magnitude_group",
"stronger": active_loras[strong_idx]["name"],
"avg_ratio": lora_avg_ratios.get(strong_idx, 0.0),
"weaker_list": [name for name, _ in strong_loras[strong_idx]],
})
logging.info(f"[Compatibility Analyzer] Analysis complete ({time.time() - t_start:.1f}s)")
report = self._build_compatibility_report(
active_loras, groups, group_info, lora_stats, pairwise_conflicts,
collection_stats, warnings, detected_arch, prefix_count,
)
raw_matrix = self._compute_raw_compat_matrix(pairwise_similarities, pairwise_conflicts, n_loras)
display_names = [os.path.splitext(os.path.basename(item["name"]))[0] for item in active_loras]
heatmap = self._generate_heatmap(raw_matrix, display_names, groups)
groups_for_ui = []
if create_nodes:
for info in group_info:
if info["type"] == "merge":
groups_for_ui.append({
"node_type": "stack",
"loras": [
{"name": active_loras[idx]["name"], "strength": active_loras[idx]["strength"]}
for idx in info["indices"]
],
"suggested_merge": info.get("suggested_merge", "weighted_average"),
"confidence": info.get("confidence", "Low"),
})
elif info["type"] == "solo":
idx = info["indices"][0]
groups_for_ui.append({
"node_type": "loader",
"lora_name": active_loras[idx]["name"],
"strength": active_loras[idx]["strength"],
})
return {"ui": {"groups": groups_for_ui, "has_clip": has_clip}, "result": (report, heatmap)}
@staticmethod
def _empty_image():
return torch.zeros(1, 1, 1, 3, dtype=torch.float32)
@staticmethod
def _compute_compat_matrix(pairwise_similarities, pairwise_conflicts, n_loras):
conflict_by_pair = {(pc["i"], pc["j"]): pc["ratio"] for pc in pairwise_conflicts}
overlap_by_pair = {(pc["i"], pc["j"]): pc["overlap"] for pc in pairwise_conflicts}
matrix = [[0.0] * n_loras for _ in range(n_loras)]
for (i, j), cos_sim in pairwise_similarities.items():
conflict_ratio = conflict_by_pair.get((i, j), 0.0)
overlap = overlap_by_pair.get((i, j), 0)
compat = cos_sim * (1.0 - conflict_ratio)
# Only boost non-opposing pairs that actually share weights
if cos_sim > -0.1 and overlap > 0:
compat += 0.1
matrix[i][j] = compat
matrix[j][i] = compat
return matrix
@staticmethod
def _compute_raw_compat_matrix(pairwise_similarities, pairwise_conflicts, n_loras):
conflict_by_pair = {(pc["i"], pc["j"]): pc["ratio"] for pc in pairwise_conflicts}
matrix = [[0.0] * n_loras for _ in range(n_loras)]
for (i, j), cos_sim in pairwise_similarities.items():
conflict_ratio = conflict_by_pair.get((i, j), 0.0)
compat = cos_sim * (1.0 - conflict_ratio)
matrix[i][j] = compat
matrix[j][i] = compat
return matrix
@staticmethod
def _cluster_loras(compat_matrix, threshold=0.05):
n = len(compat_matrix)
groups = [[i] for i in range(n)]
while len(groups) > 1:
best_score = -float("inf")
best_pair = None
for left_idx in range(len(groups)):
for right_idx in range(left_idx + 1, len(groups)):
total = 0.0
count = 0
for a in groups[left_idx]:
for b in groups[right_idx]:
total += compat_matrix[a][b]
count += 1
avg_compat = total / count if count > 0 else 0.0
if avg_compat > best_score:
best_score = avg_compat
best_pair = (left_idx, right_idx)
if best_score < threshold or best_pair is None:
break
left_idx, right_idx = best_pair
merged = groups[left_idx] + groups[right_idx]
groups = [group for idx, group in enumerate(groups) if idx not in best_pair]
groups.append(merged)
groups.sort(key=lambda group: (-len(group), group[0]))
return groups
@staticmethod
def _generate_heatmap(compat_matrix, lora_names, groups):
from PIL import Image, ImageDraw
import numpy as np
n = len(compat_matrix)
if n == 0:
return torch.zeros(1, 1, 1, 3, dtype=torch.float32)
display_order = []
for group in groups:
display_order.extend(group)
cell_size = 80
tmp_img = Image.new("RGB", (1, 1))
tmp_draw = ImageDraw.Draw(tmp_img)
max_label_w = max(tmp_draw.textlength(name) for name in lora_names) if lora_names else 100
margin = int(max_label_w) + 20
col_header_h = margin
img_w = margin + n * cell_size
img_h = col_header_h + n * cell_size
img = Image.new("RGB", (img_w, img_h), (30, 30, 30))
draw = ImageDraw.Draw(img)
def compat_to_color(value):
t = max(0.0, min(1.0, (value + 0.4) / 0.9))
if t < 0.5:
s = t / 0.5
return (int(200 + 20 * s), int(180 * s), 0)
s = (t - 0.5) / 0.5
return (int(220 * (1 - s)), int(180 + 20 * s), 0)
for row in range(n):
for col in range(n):
orig_i = display_order[row]
orig_j = display_order[col]
x = margin + col * cell_size
y = col_header_h + row * cell_size
if orig_i == orig_j:
color = (80, 80, 80)
value_text = "-"
else:
value = compat_matrix[orig_i][orig_j]
color = compat_to_color(value)
value_text = f"{value:.2f}"
draw.rectangle([x, y, x + cell_size - 1, y + cell_size - 1], fill=color)
bbox = draw.textbbox((0, 0), value_text)
text_w = bbox[2] - bbox[0]
text_h = bbox[3] - bbox[1]
tx = x + (cell_size - text_w) // 2
ty = y + (cell_size - text_h) // 2
brightness = color[0] * 0.299 + color[1] * 0.587 + color[2] * 0.114
text_color = (0, 0, 0) if brightness > 128 else (255, 255, 255)
draw.text((tx, ty), value_text, fill=text_color)
for pos in range(n):
label = lora_names[display_order[pos]]
draw.text((5, col_header_h + pos * cell_size + cell_size // 2 - 5), label, fill=(220, 220, 220))
for pos in range(n):
label = lora_names[display_order[pos]]
bbox = tmp_draw.textbbox((0, 0), label)
text_w = bbox[2] - bbox[0] + 4
text_h = bbox[3] - bbox[1] + 4
txt_img = Image.new("RGBA", (text_w, text_h), (30, 30, 30, 255))
txt_draw = ImageDraw.Draw(txt_img)
txt_draw.text((2, 2), label, fill=(220, 220, 220, 255))
rotated = txt_img.rotate(90, expand=True)
paste_x = margin + pos * cell_size + (cell_size - rotated.width) // 2
paste_y = col_header_h - rotated.height - 5
img.paste(rotated.convert("RGB"), (paste_x, max(0, paste_y)))
pos = 0
for group in groups:
if len(group) > 1:
x0 = margin + pos * cell_size - 1
y0 = col_header_h + pos * cell_size - 1
x1 = margin + (pos + len(group)) * cell_size
y1 = col_header_h + (pos + len(group)) * cell_size
for offset in range(3):
draw.rectangle([x0 - offset, y0 - offset, x1 + offset, y1 + offset], outline=(255, 255, 255))
pos += len(group)
arr = np.array(img, dtype=np.float32) / 255.0
return torch.from_numpy(arr).unsqueeze(0)
def _build_compatibility_report(self, active_loras, groups, group_info,
lora_stats, pairwise_conflicts,
collection_stats, warnings,
detected_arch, prefix_count):
def _display_name(name):
return name.replace(".safetensors", "")
lines = []
lines.append("=" * 55)
lines.append(" LoRA Compatibility Analysis")
lines.append("=" * 55)
lines.append("")
lines.append(f"Analyzed: {len(active_loras)} LoRAs across {prefix_count} target groups")
arch_display = detected_arch.upper() if detected_arch and detected_arch != "unknown" else "Unknown"
lines.append(f"Architecture: {arch_display}")
lines.append(f"Avg conflict: {collection_stats['avg_conflict']:.0%} | Avg cosine similarity: {collection_stats['avg_cos_sim']:.2f}")
lines.append("")
lines.append("-" * 30 + " Recommended Groups " + "-" * 30)
lines.append("")
group_num = 0
solo_entries = []
for info in group_info:
if info["type"] == "solo":
solo_entries.append(info)
continue
group_num += 1
label = "Merge together" if info["confidence"] != "Low" else "Safe to combine"
lines.append(f"Group {group_num} -- {label} (compatibility: {info['confidence']})")
for idx in info["indices"]:
strength = info["strengths"].get(idx, active_loras[idx]["strength"])
lines.append(f" * {_display_name(active_loras[idx]['name']):<28s} strength {strength:.2f}")
lines.append(f" Avg cosine sim: {info['avg_cos_sim']:.2f} | Avg conflict: {info['avg_conflict']:.0%}")
lines.append(f" Suggested merge: {info['suggested_merge']}")
lines.append("")
if solo_entries:
lines.append("Solo -- Use independently")
for info in solo_entries:
idx = info["indices"][0]
lines.append(f" * {_display_name(active_loras[idx]['name']):<28s} strength {info['strength']:.2f}")
if info["opposing"]:
opposing = [f"{_display_name(name)} (cos: {cos:.2f})" for name, cos in info["opposing"][:3]]
lines.append(f" Opposes: {', '.join(opposing)}")
lines.append("")
if warnings:
lines.append("-" * 30 + " Warnings " + "-" * 30)
lines.append("")
for warning in warnings:
if warning["type"] == "opposing":
lines.append(f"! {_display_name(warning['name_i'])} vs {_display_name(warning['name_j'])}: Opposing (cos_sim: {warning['cos_sim']:.2f})")
lines.append(" These cancel each other out and usually degrade quality when merged together.")
lines.append("")
elif warning["type"] == "magnitude_group":
lines.append(f"! {_display_name(warning['stronger'])} ({warning['avg_ratio']:.1f}x avg) overshadows:")
lines.append(f" {', '.join(_display_name(name) for name in warning['weaker_list'])}")
lines.append(" Consider lowering its strength or raising the weaker LoRAs.")
lines.append("")
lines.append("-" * 30 + " Pairwise Compatibility " + "-" * 30)
lines.append("")
sorted_pairs = sorted(pairwise_conflicts, key=lambda item: item["cosine_sim"] * (1.0 - item["ratio"]), reverse=True)
pair_labels = [_display_name(item["pair"]) for item in sorted_pairs]
max_pair_w = max((len(label) for label in pair_labels), default=45)
for item, label in zip(sorted_pairs, pair_labels):
compat = round(item["cosine_sim"] * (1.0 - item["ratio"]), 2) + 0.0
cos_sim = round(item["cosine_sim"], 2) + 0.0
indicator = " [OK]" if compat > 0.2 else (" [!!]" if compat < -0.05 else "")
lines.append(
f" {label:<{max_pair_w}s} cos:{cos_sim:6.2f} "
f"conflict:{item['ratio']:5.0%} compat:{compat:6.2f}{indicator}"
)
lines.append("")
lines.append("=" * 55)
return "\n".join(lines)
class MergedLoRAToHook:
"""Converts merged LoRA data into a conditioning hook for per-conditioning application."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"lora_data": ("LORA_DATA", {
"tooltip": "Connect the lora_data output from LoRA Optimizer."
}),
},
"optional": {
"prev_hooks": ("HOOKS", {
"tooltip": "Optional: chain with existing hooks."
}),
},
}
RETURN_TYPES = ("HOOKS",)
RETURN_NAMES = ("hooks",)
FUNCTION = "convert"
CATEGORY = "LoRA Optimizer"
DESCRIPTION = (
"Wraps merged LoRA patches as a conditioning hook (HOOKS). "
"Connect to 'Cond Set Props' or similar nodes to apply the merged LoRA "
"per-conditioning instead of globally."
)
def convert(self, lora_data, prev_hooks=None):
import comfy.hooks
if prev_hooks is None:
prev_hooks = comfy.hooks.HookGroup()
else:
prev_hooks = prev_hooks.clone()
if lora_data is None:
logging.warning("[MergedLoRAToHook] No lora_data received. Returning empty hooks.")
return (prev_hooks,)
model_patches = lora_data.get("model_patches", {})
clip_patches = lora_data.get("clip_patches", {})
if not model_patches and not clip_patches:
logging.warning("[MergedLoRAToHook] lora_data has no patches. Returning empty hooks.")
return (prev_hooks,)
strength_model = lora_data.get("output_strength", 1.0)
strength_clip = lora_data.get("clip_strength", 1.0)
if strength_clip is None:
strength_clip = 1.0
if strength_model == 0 and strength_clip == 0:
return (prev_hooks,)
hook = comfy.hooks.WeightHook(
strength_model=strength_model,
strength_clip=strength_clip,
)
hook.weights = model_patches
hook.weights_clip = clip_patches
hook.need_weight_init = False
hook_group = comfy.hooks.HookGroup()
hook_group.add(hook)
return (prev_hooks.clone_and_combine(hook_group),)
class MergedLoRAToWanVideo:
"""Applies merged LoRA patches to a WanVideo wrapper model."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"wan_model": ("WANVIDEOMODEL",),
},
"optional": {
"lora_data": ("LORA_DATA",),
},
}
RETURN_TYPES = ("WANVIDEOMODEL",)
RETURN_NAMES = ("model",)
FUNCTION = "apply_patches"
CATEGORY = "LoRA Optimizer"
DESCRIPTION = (
"Bridges merged LoRA patches from the LoRA Optimizer to a WanVideo wrapper model. "
"Use this when the WanVideo wrapper exposes fewer keys than the core MODEL, "
"ensuring all merged patches reach the sampler via set_lora_params."
)
def apply_patches(self, wan_model, lora_data=None):
if lora_data is None:
return (wan_model,)
model_patches = lora_data.get("model_patches", {})
if not model_patches:
return (wan_model,)
output_strength = lora_data.get("output_strength", 1.0)
new_model = wan_model.clone()
# Inject merged patches in the format set_lora_params expects:
# patcher.patches[key] = [(strength, patch_obj, 1.0, None, None)]
#
# set_lora_params (custom_linear.py:101-106) looks up keys as
# "diffusion_model.{module_prefix}weight" and falls back to
# stripping "_orig_mod." — so our core-model keys will match.
applied = 0
for key, patch in model_patches.items():
patch_key = key if isinstance(key, str) else key[0]
current = new_model.patches.get(patch_key, [])
current.append((output_strength, patch, 1.0, None, None))
new_model.patches[patch_key] = current
applied += 1
import uuid
new_model.patches_uuid = uuid.uuid4()
logging.info(f"[MergedLoRAToWanVideo] Applied {applied} merged patches "
f"(output_strength={output_strength})")
return (new_model,)
class LoRAConflictEditor(_LoRAMergeBase):
"""
Analyzes pairwise sign conflicts between LoRAs in a stack and enriches
each entry with an auto-suggested (or manually overridden) conflict_mode.
Connect between a LoRA Stack and the LoRA Optimizer. The editor loads
all LoRA weights, computes full-rank diffs, and measures how much each
pair's weight updates disagree in sign. Based on the results it suggests
per-LoRA conflict modes (all / low_conflict / high_conflict) and a
merge strategy (ties / weighted_average / weighted_sum).
The enriched stack and a human-readable analysis report are passed
downstream so the optimizer can apply the right strategy per LoRA.
"""
MAX_LORAS = 10
@classmethod
def INPUT_TYPES(cls):
inputs = {
"required": {
"lora_stack": ("LORA_STACK", {
"tooltip": "Connect a LoRA Stack node here. The editor will analyze conflicts between these LoRAs."
}),
"merge_strategy": (["auto", "ties", "consensus", "slerp", "weighted_average", "weighted_sum"], {
"default": "auto",
"tooltip": "Merge strategy to pass to the optimizer. "
"'auto': let the optimizer decide based on conflict analysis. "
"'ties': force TIES merging (good for high-conflict stacks). "
"'consensus': force Fisher-proxy + magnitude calibration + spectral cleanup (good for highly similar LoRAs). "
"'slerp': force spherical interpolation (magnitude-preserving, good for low-conflict stacks). "
"'weighted_average': force simple averaging (good for compatible LoRAs). "
"'weighted_sum': force direct addition (preserves all weights exactly)."
}),
}
}
for i in range(1, cls.MAX_LORAS + 1):
inputs["required"][f"conflict_mode_{i}"] = (["auto", "all", "low_conflict", "high_conflict"], {
"default": "auto",
"tooltip": f"LoRA #{i} conflict filter. "
f"'auto': node suggests based on analysis. "
f"'all': apply everywhere. "
f"'low_conflict': only where this LoRA agrees with the majority. "
f"'high_conflict': only where this LoRA disagrees."
})
return inputs
RETURN_TYPES = ("LORA_STACK", "STRING", "STRING")
RETURN_NAMES = ("lora_stack", "analysis_report", "merge_strategy")
FUNCTION = "analyze_and_enrich"
CATEGORY = "LoRA Optimizer"
DESCRIPTION = "Analyzes LoRA conflicts and lets you control per-LoRA conflict modes and merge strategy. Connect between a LoRA Stack and the LoRA Optimizer."
def __init__(self):
super().__init__()
@classmethod
def IS_CHANGED(cls, lora_stack, merge_strategy, **kwargs):
"""Cache key so ComfyUI skips re-execution when nothing changed."""
h = hashlib.sha256()
if lora_stack:
first = lora_stack[0] if len(lora_stack) > 0 else None
entries = []
if isinstance(first, (tuple, list)):
for entry in lora_stack:
cm = entry[3] if len(entry) > 3 else "all"
kf = entry[4] if len(entry) > 4 else "all"
cs = float(entry[2]) if entry[2] is not None else -1.0
entries.append((str(entry[0]), float(entry[1]), cs, cm, kf))
elif isinstance(first, dict):
for item in lora_stack:
cm = item.get("conflict_mode", "all")
kf = item.get("key_filter", "all")
entries.append((str(item.get("name", "")), float(item.get("strength", 0)), cm, kf))
entries.sort()
h.update(json.dumps(entries).encode())
h.update(f"|ms={merge_strategy}".encode())
# Include all conflict_mode widgets
for i in range(1, cls.MAX_LORAS + 1):
cm = kwargs.get(f"conflict_mode_{i}", "auto")
h.update(f"|cm{i}={cm}".encode())
return h.hexdigest()[:16]
def analyze_and_enrich(self, lora_stack, merge_strategy, **kwargs):
"""
Analyze pairwise conflicts in the LoRA stack, auto-suggest conflict
modes, and return an enriched stack with the analysis report.
"""
# --- 1. Normalize and filter ---
normalized = self._normalize_stack(lora_stack)
active_loras = [item for item in normalized if item["strength"] != 0]
n_active = len(active_loras)
if n_active == 0:
return (lora_stack, "No active LoRAs in stack.", "")
if n_active == 1:
# Single LoRA — no pairwise conflicts to analyze
# Find the original stack position (may not be slot 1 if earlier LoRAs have strength=0)
orig_pos = next(i for i, item in enumerate(normalized) if item["strength"] != 0)
cm = kwargs.get(f"conflict_mode_{orig_pos + 1}", "auto")
resolved = "all" if cm == "auto" else cm
first = lora_stack[0]
item = active_loras[0]
if isinstance(first, dict):
enriched = [dict(item, conflict_mode=resolved)]
else:
# clip_strength may be None (dict-format LoRAs use global multiplier);
# preserve None so the optimizer applies clip_strength_multiplier correctly
enriched = [(
item["name"], item["strength"], item["clip_strength"],
resolved, item.get("key_filter", "all")
)]
report = (
"=" * 46 + "\n"
"LORA CONFLICT EDITOR - ANALYSIS REPORT\n"
"=" * 46 + "\n\n"
f"Single LoRA: {item['name']}\n"
f"Conflict mode: {resolved}\n"
f"No pairwise conflicts to analyze."
)
resolved_strategy = merge_strategy if merge_strategy != "auto" else "weighted_average"
return (enriched, report, resolved_strategy)
# Read per-LoRA conflict_mode widgets, mapping by original stack position
# (not active-only index) so widgets align with what the user sees
widget_modes = {}
active_idx = 0
for orig_idx, item in enumerate(normalized):
if item["strength"] != 0:
widget_key = f"conflict_mode_{orig_idx + 1}"
widget_modes[active_idx] = kwargs.get(widget_key, "auto")
active_idx += 1
# --- 2. Collect all unique LoRA prefixes ---
# Must match the optimizer's suffix list (line ~2330) for consistency
all_prefixes = set()
suffixes_to_strip = [
".lora_up.weight", ".lora_down.weight",
".lora_A.weight", ".lora_B.weight",
".lora.up.weight", ".lora.down.weight",
"_lora.up.weight", "_lora.down.weight",
# LoKr (Kronecker)
".lokr_w1", ".lokr_w2",
".lokr_w1_a", ".lokr_w1_b",
".lokr_w2_a", ".lokr_w2_b",
".lokr_t2",
# LoHa (Hadamard)
".hada_w1_a", ".hada_w1_b",
".hada_w2_a", ".hada_w2_b",
".hada_t1", ".hada_t2",
]
for item in active_loras:
for key in item["lora"].keys():
for suffix in suffixes_to_strip:
if key.endswith(suffix):
all_prefixes.add(key[:-len(suffix)])
break
compute_device = self._get_compute_device()
# --- 3. Pairwise conflict analysis ---
# Accumulators: per-pair totals
n_pairs = n_active * (n_active - 1) // 2
pair_overlap = [[0] * n_active for _ in range(n_active)]
pair_conflict = [[0] * n_active for _ in range(n_active)]
pair_dot = [[0.0] * n_active for _ in range(n_active)]
pair_norm_a_sq = [[0.0] * n_active for _ in range(n_active)]
pair_norm_b_sq = [[0.0] * n_active for _ in range(n_active)]
prefixes_analyzed = 0
for prefix in sorted(all_prefixes):
# Compute diffs for each LoRA that has this prefix
diffs = {}
for idx, item in enumerate(active_loras):
info = self._get_lora_key_info(item["lora"], prefix)
if info is not None:
mat_up, mat_down, alpha, mid = info
rank = mat_down.shape[0]
scale = alpha / rank
if compute_device.type != "cpu":
mat_up = mat_up.to(compute_device)
mat_down = mat_down.to(compute_device)
if mid is not None:
mid = mid.to(compute_device)
if mid is not None:
final_shape = [mat_down.shape[1], mat_down.shape[0], mid.shape[2], mid.shape[3]]
mat_down = (
torch.mm(
mat_down.transpose(0, 1).flatten(start_dim=1).float(),
mid.transpose(0, 1).flatten(start_dim=1).float(),
)
.reshape(final_shape)
.transpose(0, 1)
)
diff = torch.mm(
mat_up.flatten(start_dim=1).float(),
mat_down.flatten(start_dim=1).float()
)
del mat_up, mat_down
diff = diff * scale * item["strength"]
diffs[idx] = diff
else:
# Try LoKr / LoHa formats
use_gpu = compute_device.type != "cpu"
alt = self._get_lokr_diff(
item["lora"], prefix,
device=compute_device if use_gpu else None, to_cpu=False,
)
if alt is None:
alt = self._get_loha_diff(
item["lora"], prefix,
device=compute_device if use_gpu else None, to_cpu=False,
)
if alt is not None:
diff, _, _ = alt
diff = diff.float() * item["strength"]
diffs[idx] = diff
if len(diffs) < 2:
continue
prefixes_analyzed += 1
# Pairwise conflict sampling
indices = sorted(diffs.keys())
for ai in range(len(indices)):
for bi in range(ai + 1, len(indices)):
a_idx, b_idx = indices[ai], indices[bi]
result = self._sample_conflict(
diffs[a_idx], diffs[b_idx], device=compute_device
)
n_ov, n_cf, dot, na_sq, nb_sq = result
pair_overlap[a_idx][b_idx] += n_ov
pair_conflict[a_idx][b_idx] += n_cf
pair_dot[a_idx][b_idx] += dot
pair_norm_a_sq[a_idx][b_idx] += na_sq
pair_norm_b_sq[a_idx][b_idx] += nb_sq
# Free diffs for this prefix immediately
del diffs
# Release GPU memory after analysis
compute_device = self._get_compute_device()
if compute_device.type != "cpu":
torch.cuda.empty_cache()
# --- 4. Compute per-LoRA average conflict ratio ---
per_lora_conflict_ratios = [0.0] * n_active
per_lora_pair_count = [0] * n_active
# Also build pairwise summary for the report
pair_summaries = []
for i in range(n_active):
for j in range(i + 1, n_active):
total_ov = pair_overlap[i][j]
total_cf = pair_conflict[i][j]
total_dot = pair_dot[i][j]
total_na = pair_norm_a_sq[i][j]
total_nb = pair_norm_b_sq[i][j]
if total_ov > 0:
conflict_ratio = total_cf / total_ov
denom = math.sqrt(total_na * total_nb) if (total_na > 0 and total_nb > 0) else 1.0
cosine_sim = total_dot / denom if denom > 0 else 0.0
else:
conflict_ratio = 0.0
cosine_sim = 0.0
per_lora_conflict_ratios[i] += conflict_ratio
per_lora_conflict_ratios[j] += conflict_ratio
per_lora_pair_count[i] += 1
per_lora_pair_count[j] += 1
pair_summaries.append({
"i": i, "j": j,
"overlap": total_ov,
"conflict_ratio": conflict_ratio,
"cosine_sim": cosine_sim,
})
# Average per-LoRA conflict ratio
avg_conflict = [0.0] * n_active
for i in range(n_active):
if per_lora_pair_count[i] > 0:
avg_conflict[i] = per_lora_conflict_ratios[i] / per_lora_pair_count[i]
# --- 5. Auto-suggest conflict modes ---
resolved_modes = []
auto_reasons = []
for i in range(n_active):
widget_mode = widget_modes.get(i, "auto")
if widget_mode != "auto":
resolved_modes.append(widget_mode)
auto_reasons.append("manual")
else:
ratio = avg_conflict[i]
if ratio < 0.15:
mode = "all"
reason = f"auto \u2014 avg conflict {ratio:.1%} < 15%"
elif ratio <= 0.40:
mode = "low_conflict"
reason = f"auto \u2014 avg conflict {ratio:.1%} (15\u201340%)"
else:
mode = "high_conflict"
reason = f"auto \u2014 avg conflict {ratio:.1%} > 40%"
resolved_modes.append(mode)
auto_reasons.append(reason)
# --- 6. Resolve merge strategy ---
if merge_strategy == "auto":
# Use the global average conflict across all pairs
if n_pairs > 0 and len(pair_summaries) > 0:
global_avg = sum(p["conflict_ratio"] for p in pair_summaries) / len(pair_summaries)
else:
global_avg = 0.0
if global_avg > 0.25:
resolved_strategy = "ties"
strategy_reason = f"auto \u2014 avg conflict {global_avg:.1%} > 25%"
else:
resolved_strategy = "weighted_average"
strategy_reason = f"auto \u2014 avg conflict {global_avg:.1%} \u2264 25%"
else:
resolved_strategy = merge_strategy
strategy_reason = "manual"
# --- 7. Build enriched output stack ---
first = lora_stack[0] if lora_stack else None
is_dict_format = isinstance(first, dict)
enriched = []
active_idx = 0
for item in normalized:
if item["strength"] == 0:
# Pass through inactive LoRAs unchanged
if is_dict_format:
enriched.append(item)
else:
enriched.append((item["name"], item["strength"],
item["clip_strength"], item.get("conflict_mode", "all"),
item.get("key_filter", "all")))
else:
mode = resolved_modes[active_idx]
if is_dict_format:
enriched_item = dict(item)
enriched_item["conflict_mode"] = mode
enriched.append(enriched_item)
else:
enriched.append((
item["name"],
item["strength"],
item["clip_strength"],
mode,
item.get("key_filter", "all"),
))
active_idx += 1
# --- 8. Build report ---
short_names = []
for item in active_loras:
short_names.append(os.path.splitext(os.path.basename(item["name"]))[0])
report = self._build_conflict_report(
active_loras, short_names, pair_summaries,
resolved_modes, auto_reasons,
resolved_strategy, strategy_reason,
prefixes_analyzed,
)
# Free loaded LoRA state dicts — the editor only needs them during analysis,
# and keeping them would leak hundreds of MB per run for large models.
self.loaded_loras.clear()
return (enriched, report, resolved_strategy)
@staticmethod
def _build_conflict_report(active_loras, short_names, pair_summaries,
resolved_modes, auto_reasons,
resolved_strategy, strategy_reason,
prefixes_analyzed):
"""Build a human-readable conflict analysis report."""
lines = []
lines.append("=" * 60)
lines.append("LoRA Conflict Analysis Report")
lines.append("=" * 60)
# Stack overview
lines.append("")
lines.append("Stack Overview")
lines.append("-" * 40)
for i, item in enumerate(active_loras):
lines.append(f" {i + 1}. {short_names[i]} (strength={item['strength']:.2f})")
lines.append(f" Prefixes analyzed: {prefixes_analyzed}")
# Pairwise conflicts
if pair_summaries:
lines.append("")
lines.append("Pairwise Conflicts")
lines.append("-" * 40)
for p in pair_summaries:
name_a = short_names[p["i"]]
name_b = short_names[p["j"]]
lines.append(
f" {name_a} \u00d7 {name_b}: "
f"overlap={p['overlap']:,} "
f"sign_conflict={p['conflict_ratio']:.1%} "
f"cosine={p['cosine_sim']:.3f}"
)
# Applied conflict modes
lines.append("")
lines.append("Applied Conflict Modes")
lines.append("-" * 40)
for i in range(len(active_loras)):
lines.append(f" {i + 1}. {short_names[i]}: {resolved_modes[i]} ({auto_reasons[i]})")
# Merge strategy
lines.append("")
lines.append("Merge Strategy")
lines.append("-" * 40)
lines.append(f" {resolved_strategy} ({strategy_reason})")
lines.append("")
lines.append("=" * 60)
return "\n".join(lines)
class LoRAMetadataReader:
"""
Passthrough node that reads embedded metadata from LoRAs in a stack.
Outputs the stack unchanged plus extracted prompt and metadata strings.
Works with any safetensors LoRA — not limited to LoRAs saved by this pack.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"lora_stack": ("LORA_STACK", {"tooltip": "Connect a LoRA Stack here. The stack passes through unchanged."}),
},
}
RETURN_TYPES = ("LORA_STACK", "STRING", "STRING", "STRING")
RETURN_NAMES = ("lora_stack", "prompt", "description", "metadata_info")
FUNCTION = "read_metadata"
CATEGORY = "LoRA Optimizer"
DESCRIPTION = "Reads embedded metadata (prompt, description, merge info) from LoRAs in the stack. The stack passes through unchanged."
def read_metadata(self, lora_stack):
if not lora_stack:
return (lora_stack, "", "", "No LoRAs in stack.")
all_prompts = []
all_descriptions = []
info_lines = []
for item in lora_stack:
# Get metadata — either already attached by LoRAStack or read from disk
if isinstance(item, dict):
name = item.get("name", "unknown")
meta = item.get("metadata", {})
if not meta:
# Try reading from disk if not already attached
try:
lora_path = folder_paths.get_full_path_or_raise("loras", name)
meta = _read_safetensors_metadata(lora_path)
except Exception:
meta = {}
elif isinstance(item, (tuple, list)):
name = item[0] if len(item) > 0 else "unknown"
try:
lora_path = folder_paths.get_full_path_or_raise("loras", name)
meta = _read_safetensors_metadata(lora_path)
except Exception:
meta = {}
else:
continue
if not meta:
info_lines.append(f"[{name}] No metadata found")
continue
prompt = meta.get("prompt", "")
desc = meta.get("description", "")
if prompt:
all_prompts.append((name, prompt))
if desc:
all_descriptions.append((name, desc))
# Collect interesting metadata fields (exclude prompt/description — they have dedicated outputs)
entry_lines = [f"[{name}]"]
for key in ("source_loras", "tool",
"merge_mode", "merge_optimization_mode", "merge_architecture",
"merge_auto_strength", "merge_sparsification",
"merge_merge_refinement", "merge_strategy_set",
"merge_output_strength", "merge_clip_strength",
"merge_bake_strength"):
val = meta.get(key)
if val:
display_key = key.replace("merge_", "") if key.startswith("merge_") else key
entry_lines.append(f" {display_key}: {val}")
# Also show any other non-tensor metadata
shown = {"prompt", "description", "source_loras", "tool",
"merge_mode", "merge_optimization_mode", "merge_architecture",
"merge_auto_strength", "merge_sparsification",
"merge_merge_refinement", "merge_strategy_set",
"merge_output_strength", "merge_clip_strength",
"merge_bake_strength"}
for key, val in sorted(meta.items()):
if key not in shown and not key.startswith("__"):
entry_lines.append(f" {key}: {val[:200]}")
info_lines.append("\n".join(entry_lines))
if len(all_prompts) == 1:
combined_prompt = all_prompts[0][1]
else:
combined_prompt = "\n\n".join(f"[{n}]: {p}" for n, p in all_prompts)
if len(all_descriptions) == 1:
combined_desc = all_descriptions[0][1]
else:
combined_desc = "\n\n".join(f"[{n}]: {d}" for n, d in all_descriptions)
metadata_info = "\n\n".join(info_lines)
return (lora_stack, combined_prompt, combined_desc, metadata_info)
# Node registration
NODE_CLASS_MAPPINGS = {
"LoRAStack": LoRAStack,
"LoRAStackDynamic": LoRAStackDynamic,
"LoRAOptimizer": LoRAOptimizer,
"LoRAOptimizerSimple": LoRAOptimizerSimple,
"SaveMergedLoRA": SaveMergedLoRA,
"BuildAutoTunerPythonEvaluator": BuildAutoTunerPythonEvaluator,
"LoRAConflictEditor": LoRAConflictEditor,
"MergedLoRAToHook": MergedLoRAToHook,
"MergedLoRAToWanVideo": MergedLoRAToWanVideo,
"WanVideoLoRAOptimizer": WanVideoLoRAOptimizer,
"LoRAAutoTuner": LoRAAutoTuner,
"LoRAMergeSelector": LoRAMergeSelector,
"SaveTunerData": SaveTunerData,
"LoadTunerData": LoadTunerData,
"LoRACompatibilityAnalyzer": LoRACompatibilityAnalyzer,
"LoRAMergeSettings": LoRAMergeSettings,
"LoRAOptimizerSettings": LoRAOptimizerSettings,
"LoRAAutoTunerSettings": LoRAAutoTunerSettings,
"LoRAMetadataReader": LoRAMetadataReader,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"LoRAStack": "LoRA Stack",
"LoRAStackDynamic": "LoRA Stack (Dynamic)",
"LoRAOptimizer": "LoRA Optimizer (Legacy)",
"LoRAOptimizerSimple": "LoRA Optimizer",
"SaveMergedLoRA": "Save Merged LoRA",
"BuildAutoTunerPythonEvaluator": "Build AutoTuner Python Evaluator",
"LoRAConflictEditor": "LoRA Conflict Editor",
"MergedLoRAToHook": "Merged LoRA to Hook",
"MergedLoRAToWanVideo": "(WIP) Merged LoRA → WanVideo",
"WanVideoLoRAOptimizer": "(WIP) WanVideo LoRA Optimizer",
"LoRAAutoTuner": "LoRA AutoTuner",
"LoRAMergeSelector": "Merge Selector",
"SaveTunerData": "Save Tuner Data",
"LoadTunerData": "Load Tuner Data",
"LoRACompatibilityAnalyzer": "LoRA Compatibility Analyzer",
"LoRAMergeSettings": "LoRA Merge Settings",
"LoRAOptimizerSettings": "LoRA Optimizer Settings",
"LoRAAutoTunerSettings": "LoRA AutoTuner Settings",
"LoRAMetadataReader": "LoRA Metadata Reader",
}