Files

2990 lines
129 KiB
Python

"""
LoRA Resize - Resize existing LoRAs in factor space.
Uses bounded asynchronous UEL streaming and rank-space SVD for fixed rank and
dynamic methods (sv_ratio, sv_fro, sv_cumulative).
"""
import os
import gc
import fnmatch
import re
import logging
from collections import Counter, defaultdict
import torch
import folder_paths
import comfy.utils
from comfy.weight_adapter import LoRAAdapter
from tqdm import tqdm
from comfy_api.latest import io
from .device_utils import estimate_model_size, prepare_for_large_operation, cleanup_after_operation
from unifiedefficientloader import MemoryEfficientSafeOpen, transfer_to_gpu_pinned
from .uel_io import atomic_uel_writer, stream_work_units
from .artifact_paths import canonical_model_artifact_path
from .lora_alpha import lora_alpha_scale, normalize_lora_pair
from .layer_parameters import parameter_input, resolve_layer_parameters
from .quantization_guard import DiffusionQuantization, inspect_low_bit_input, layer_has_low_bit, write_preserved_tensor
from typing import Optional, Dict, Tuple, List
# Reuse rank functions from extraction module
from .lora_extract_svd import (
_index_sv_cumulative,
_index_sv_fro,
_compile_patterns,
)
MIN_SV = 1e-6
class AdapterErrorCapture(logging.Handler):
"""Observe errors ComfyUI logs and swallows for one adapter target."""
def __init__(self, target_key):
super().__init__(level=logging.ERROR)
self.target_key = target_key
self.messages = []
def emit(self, record):
message = record.getMessage()
if message.startswith("ERROR ") and self.target_key in message:
self.messages.append(message)
LORA_PAIR_SUFFIXES = (
(".lora_A.default.weight", ".lora_B.default.weight", ".alpha", "peft"),
(".lora_down.weight", ".lora_up.weight", ".alpha", "comfy"),
("_lora.down.weight", "_lora.up.weight", ".alpha", "diffusers_legacy"),
(".lora_A.weight", ".lora_B.weight", ".alpha", "diffusers"),
(".lora.down.weight", ".lora.up.weight", ".alpha", "huggingface"),
(".lora_A", ".lora_B", ".alpha", "mochi"),
(".lora_linear_layer.down.weight", ".lora_linear_layer.up.weight", ".alpha", "transformers"),
)
LORA_AUX_SUFFIXES = (
(".lora_mid.weight", "mid"),
(".reshape_weight", "reshape"),
(".dora_scale", "dora_scale"),
(".w_norm", "w_norm"),
(".b_norm", "b_norm"),
(".set_weight", "set_weight"),
)
CANONICAL_DOWN_SUFFIX = ".lora_A.weight"
CANONICAL_UP_SUFFIX = ".lora_B.weight"
BASE_MODEL_KEY_PREFIXES = (
"model.diffusion_model.", "diffusion_model.", "transformer.", "model.", "net.",
)
LORA_BLOCK_PREFIXES = (
"lora_unet_", "lora_transformer_", "lora_te1_", "lora_te2_", "lora_te_",
"lycoris_", "diffusion_model.", "transformer.", "unet.",
)
def canonical_lora_block_name(block_name: str) -> str:
"""Return the preferred generic ComfyUI diffusion-model LoRA root."""
root = block_name
wrappers = (
"unet.base_model.model.",
"base_model.model.",
"model.",
)
changed = True
while changed:
changed = False
for prefix in wrappers:
if root.startswith(prefix):
root = root[len(prefix):]
changed = True
break
dotted_prefixes = (
"diffusion_model.",
"transformer.",
"unet.",
"net.",
)
for prefix in dotted_prefixes:
if root.startswith(prefix):
return f"diffusion_model.{root[len(prefix):]}"
# Flattened trainer aliases cannot be expanded to dotted model keys safely
# without a reference model. Keep those roots loadable rather than guessing.
if root.startswith(("lora_unet_", "lora_transformer_", "lycoris_", "lora_te")):
return root
return f"diffusion_model.{root}"
def canonical_lora_key(block_name: str, role: str) -> str:
root = canonical_lora_block_name(block_name)
suffixes = {
"down": CANONICAL_DOWN_SUFFIX,
"up": CANONICAL_UP_SUFFIX,
"alpha": ".alpha",
"mid": ".lora_mid.weight",
"reshape": ".reshape_weight",
"dora_scale": ".dora_scale",
"diff": ".diff",
"diff_b": ".diff_b",
"w_norm": ".w_norm",
"b_norm": ".b_norm",
"set_weight": ".set_weight",
}
return f"{root}{suffixes[role]}"
def _strip_lora_prefix(value: str, prefixes) -> str:
for prefix in prefixes:
if value.startswith(prefix):
return value[len(prefix):]
return value
def build_lora_layer_map(
lora_infos: List[Dict], base_model_path: Optional[str]
) -> Dict[str, List[Tuple[int, str]]]:
"""Map parsed LoRA blocks to a reference model using existing merge rules."""
if not base_model_path:
layer_map = {}
for idx, info in enumerate(lora_infos):
for block_name in info["pairs"]:
layer_map.setdefault(block_name, []).append((idx, block_name))
return layer_map
with MemoryEfficientSafeOpen(base_model_path, low_memory=True) as base_handler:
base_keys = list(base_handler.keys())
normalized_base = {}
for base_key in base_keys:
normalized = _strip_lora_prefix(base_key, BASE_MODEL_KEY_PREFIXES)
normalized_base[normalized] = base_key
normalized_base[normalized.replace(".", "_")] = base_key
layer_map = {}
mapped = set()
for idx, info in enumerate(lora_infos):
for block_name, block_keys in info["pairs"].items():
core = _strip_lora_prefix(block_name, LORA_BLOCK_PREFIXES)
if core.endswith(".lora"):
core = core[:-5]
target = None
if "down" in block_keys or "up" in block_keys:
candidate = f"{core}.weight"
target = normalized_base.get(candidate) or normalized_base.get(candidate.replace(".", "_"))
elif "diff_b" in block_keys or "b_norm" in block_keys:
candidate = f"{core}.bias"
target = normalized_base.get(candidate) or normalized_base.get(candidate.replace(".", "_"))
elif any(role in block_keys for role in ("diff", "w_norm", "set_weight")):
target = normalized_base.get(core) or normalized_base.get(core.replace(".", "_"))
if target is None:
candidate = f"{core}.weight"
target = normalized_base.get(candidate) or normalized_base.get(candidate.replace(".", "_"))
if target is None:
continue
normalized_target = _strip_lora_prefix(target, BASE_MODEL_KEY_PREFIXES)
if ("diff_b" in block_keys or "b_norm" in block_keys) and normalized_target.endswith(".bias"):
output_core = normalized_target[:-5]
elif any(role in block_keys for role in ("diff", "w_norm", "set_weight")) and normalized_target.endswith(".weight"):
output_core = normalized_target[:-7]
elif any(role in block_keys for role in ("diff", "w_norm", "set_weight")):
output_core = normalized_target
else:
output_core = normalized_target[:-7] if normalized_target.endswith(".weight") else normalized_target
output_core = f"diffusion_model.{output_core}"
layer_map.setdefault(output_core, []).append((idx, block_name))
mapped.add((idx, block_name))
for idx, info in enumerate(lora_infos):
for block_name, block_keys in info["pairs"].items():
if (idx, block_name) not in mapped and any(
role in block_keys for role in ("diff", "diff_b", "w_norm", "b_norm", "set_weight")
):
layer_map.setdefault(block_name, []).append((idx, block_name))
return layer_map
def layer_tensor_keys(layer: Dict[str, str]) -> Dict[str, str]:
roles = {
"down", "up", "alpha", "mid", "reshape", "dora_scale",
"diff", "diff_b", "w_norm", "b_norm", "set_weight",
}
return {role: key for role, key in layer.items() if role in roles}
def layer_has_companions(layer: Dict[str, str]) -> bool:
return any(role in layer for role in ("mid", "reshape", "dora_scale", "set_weight"))
def validate_canonical_blocks(block_names, operation: str) -> None:
seen = {}
for block_name in block_names:
canonical = canonical_lora_block_name(block_name)
previous = seen.get(canonical)
if previous is not None and previous != block_name:
raise ValueError(
f"[{operation}] LoRA keys '{previous}' and '{block_name}' both normalize to '{canonical}'"
)
seen[canonical] = block_name
def is_direct_diff_key(key: str) -> bool:
return key.endswith((".diff", ".diff_b", ".w_norm", ".b_norm"))
def select_output_dtype(
source_dtypes: List[torch.dtype],
requested_dtype: torch.dtype,
force: bool = False,
is_1d_diff: bool = False,
) -> torch.dtype:
"""Select an output dtype without losing precision-sensitive LoRA deltas."""
if is_1d_diff:
return torch.float32
if not force and torch.float32 in source_dtypes:
return torch.float32
floating_dtypes = {torch.float16, torch.bfloat16, torch.float32, torch.float64}
if source_dtypes and not any(dtype in floating_dtypes for dtype in source_dtypes):
return source_dtypes[0]
return requested_dtype
# =============================================================================
# LoRA Format Detection - Support various LoRA sources
# =============================================================================
def detect_lora_format(keys: List[str]) -> Dict:
"""
Detect LoRA format and key patterns from various sources.
Supports:
- Kohya/A1111: lora_unet_*, lora_te_*
- Diffusers: *.lora_A.weight, *.lora_B.weight
- PEFT: base_model.model.*.lora_A/B
- ComfyUI native: *.lora_down.weight, *.lora_up.weight
- HuggingFace: *.lora.down.weight, *.lora.up.weight (dots instead of underscores)
- Full diff: *.diff, *.diff_b (full weight differences, not low-rank)
- Flux/SDXL: Various transformer key patterns
Returns:
Dict with format info and key patterns
"""
format_info = {
"format": "unknown",
"down_suffix": ".lora_down.weight",
"up_suffix": ".lora_up.weight",
"alpha_suffix": ".alpha",
"has_alpha": False,
"key_count": 0,
"is_full_diff": False, # New flag for full diff format
}
# Check for alpha keys
alpha_keys = [k for k in keys if '.alpha' in k or k.endswith('alpha')]
format_info["has_alpha"] = len(alpha_keys) > 0
detected = []
for down_suffix, up_suffix, alpha_suffix, name in LORA_PAIR_SUFFIXES:
if any(k.endswith(down_suffix) or k.endswith(up_suffix) for k in keys):
detected.append((down_suffix, up_suffix, alpha_suffix, name))
has_full_diff = any(is_direct_diff_key(k) for k in keys)
if has_full_diff:
detected.append((None, None, None, "full_diff"))
if len(detected) > 1:
format_info["format"] = "mixed"
elif detected:
down_suffix, up_suffix, alpha_suffix, name = detected[0]
format_info.update({
"format": name,
"down_suffix": down_suffix,
"up_suffix": up_suffix,
"alpha_suffix": alpha_suffix,
})
format_info["is_full_diff"] = has_full_diff and not any(item[0] for item in detected)
pairs, _ = parse_lora_layers(keys)
format_info["key_count"] = len(pairs)
return format_info
def parse_lora_layers(keys: List[str]) -> Tuple[Dict[str, Dict[str, str]], List[str]]:
"""Parse low-rank, direct-diff, and pass-through tensors in one full scan."""
layers: Dict[str, Dict[str, str]] = {}
consumed = set()
for key in keys:
if key.endswith(".diff_b"):
block_name = key[:-7]
layers.setdefault(block_name, {})["diff_b"] = key
consumed.add(key)
continue
if key.endswith(".diff"):
block_name = key[:-5]
layers.setdefault(block_name, {})["diff"] = key
consumed.add(key)
continue
matched_aux = False
for suffix, role in LORA_AUX_SUFFIXES:
if key.endswith(suffix):
block_name = key[:-len(suffix)]
layers.setdefault(block_name, {})[role] = key
consumed.add(key)
matched_aux = True
break
if matched_aux:
continue
for down_suffix, up_suffix, alpha_suffix, format_name in LORA_PAIR_SUFFIXES:
if key.endswith(down_suffix):
block_name = key[:-len(down_suffix)]
layer = layers.setdefault(block_name, {})
layer.update({
"down": key,
"down_suffix": down_suffix,
"up_suffix": up_suffix,
"alpha_suffix": alpha_suffix,
"format": format_name,
})
consumed.add(key)
break
if key.endswith(up_suffix):
block_name = key[:-len(up_suffix)]
layer = layers.setdefault(block_name, {})
layer.update({
"up": key,
"down_suffix": down_suffix,
"up_suffix": up_suffix,
"alpha_suffix": alpha_suffix,
"format": format_name,
})
consumed.add(key)
break
for key in keys:
if not key.endswith(".alpha"):
continue
block_name = key[:-6]
if block_name in layers:
layers[block_name]["alpha"] = key
consumed.add(key)
# Incomplete or auxiliary-only groups cannot be transformed, but all their
# source tensors must survive as pass-through data.
for block_name, layer in list(layers.items()):
has_pair = "down" in layer and "up" in layer
has_direct = any(
role in layer
for role in ("diff", "diff_b", "w_norm", "b_norm", "set_weight")
)
if has_pair or has_direct:
continue
for key in layer_tensor_keys(layer).values():
consumed.discard(key)
del layers[block_name]
return layers, [key for key in keys if key not in consumed]
def extract_lora_pairs(keys: List[str], format_info: Optional[Dict] = None) -> Dict[str, Dict[str, str]]:
"""
Group LoRA keys into down/up/alpha pairs.
For full_diff format, groups .diff and .diff_b keys.
Returns:
Dict[block_name, {"down": key, "up": key, "alpha": key}]
For full_diff: {"diff": key, "diff_b": key}
"""
pairs, _ = parse_lora_layers(keys)
return pairs
def detect_lora_rank(
handler: MemoryEfficientSafeOpen,
pairs: Dict,
low_bit_keys: Optional[set[str]] = None,
) -> Tuple[int, float]:
"""
Detect the rank and alpha of an existing LoRA.
Returns:
(network_dim, network_alpha)
"""
network_dim = None
network_alpha = None
for block_name, block_keys in pairs.items():
if "down" not in block_keys:
continue
# Get dim from down weight shape
if network_dim is None:
down_key = block_keys["down"]
shape = handler.get_shape(down_key)
# Linear: [rank, in_features] or Conv: [rank, in_ch, k, k]
network_dim = shape[0]
if network_dim is not None:
break
# Default alpha to dim if not found
if network_alpha is None:
network_alpha = float(network_dim) if network_dim else 1.0
if network_dim is None:
network_dim = 1
return network_dim, network_alpha
# =============================================================================
# Factor-space resize engine
# =============================================================================
def _is_cuda_oom(error: BaseException) -> bool:
return isinstance(error, torch.cuda.OutOfMemoryError) or (
isinstance(error, RuntimeError)
and "out of memory" in str(error).lower()
and "cuda" in str(error).lower()
)
def _release_cuda_after_oom() -> None:
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
def _factor_matrices(
lora_down: torch.Tensor,
lora_up: torch.Tensor,
) -> Tuple[torch.Tensor, torch.Tensor, Optional[Tuple[int, int, int]]]:
"""Return down/up matrices and optional convolution output layout."""
if lora_down.ndim == 2:
if lora_up.ndim != 2:
raise ValueError("Linear LoRA up/down tensors must both be two-dimensional")
if lora_down.shape[0] != lora_up.shape[1]:
raise ValueError(
f"LoRA rank mismatch: down={tuple(lora_down.shape)}, "
f"up={tuple(lora_up.shape)}"
)
return lora_down, lora_up, None
if lora_down.ndim != 4 or lora_up.ndim != 4:
raise ValueError("LoRA resize supports linear and convolutional factor pairs")
if tuple(lora_up.shape[2:]) != (1, 1):
raise ValueError("Convolutional LoRA up tensor must use a 1x1 kernel")
rank, in_channels, kernel_h, kernel_w = lora_down.shape
if lora_up.shape[1] != rank:
raise ValueError(
f"LoRA rank mismatch: down={tuple(lora_down.shape)}, "
f"up={tuple(lora_up.shape)}"
)
return (
lora_down.reshape(rank, in_channels * kernel_h * kernel_w),
lora_up.reshape(lora_up.shape[0], rank),
(in_channels, kernel_h, kernel_w),
)
def _resize_factors_on_device(
lora_down: torch.Tensor,
lora_up: torch.Tensor,
max_rank: int,
dynamic_method: Optional[str],
dynamic_param: Optional[float],
scale: float,
min_rank: int,
process_device: str,
) -> Dict:
"""Resize a LoRA pair using only its rank-space core."""
down_matrix, up_matrix, conv_layout = _factor_matrices(lora_down, lora_up)
if str(process_device).startswith("cuda"):
down_device = transfer_to_gpu_pinned(
down_matrix, process_device, torch.float32
)
up_device = transfer_to_gpu_pinned(
up_matrix, process_device, torch.float32
)
else:
down_device = down_matrix.to(device=process_device, dtype=torch.float32)
up_device = up_matrix.to(device=process_device, dtype=torch.float32)
q_up, r_up = torch.linalg.qr(up_device, mode="reduced")
q_down, r_down = torch.linalg.qr(down_device.T, mode="reduced")
core = r_up @ r_down.T
core_u, singular_values, core_vh = torch.linalg.svd(
core, full_matrices=False
)
new_rank, new_alpha, stats = _compute_resize(
singular_values,
max_rank,
dynamic_method,
dynamic_param,
scale,
min_rank,
)
new_up = (q_up @ core_u[:, :new_rank]) * singular_values[:new_rank]
new_down = core_vh[:new_rank, :] @ q_down.T
if conv_layout is not None:
in_channels, kernel_h, kernel_w = conv_layout
new_up = new_up.reshape(new_up.shape[0], new_rank, 1, 1)
new_down = new_down.reshape(
new_rank, in_channels, kernel_h, kernel_w
)
result = {
"lora_down": new_down.cpu().contiguous(),
"lora_up": new_up.cpu().contiguous(),
"new_rank": new_rank,
"new_alpha": new_alpha,
**stats,
}
del (
down_device,
up_device,
q_up,
r_up,
q_down,
r_down,
core,
core_u,
singular_values,
core_vh,
new_up,
new_down,
)
return result
def _resize_lora_factors(
lora_down: torch.Tensor,
lora_up: torch.Tensor,
max_rank: int,
dynamic_method: Optional[str],
dynamic_param: Optional[float],
scale: float,
min_rank: int = 1,
process_device: str = "cpu",
) -> Dict:
"""Resize one pair with per-layer CUDA OOM fallback."""
try:
result = _resize_factors_on_device(
lora_down,
lora_up,
max_rank,
dynamic_method,
dynamic_param,
scale,
min_rank,
process_device,
)
result["cpu_fallback"] = False
return result
except BaseException as error:
if not str(process_device).startswith("cuda") or not _is_cuda_oom(error):
raise
error.__traceback__ = None
_release_cuda_after_oom()
try:
result = _resize_factors_on_device(
lora_down,
lora_up,
max_rank,
dynamic_method,
dynamic_param,
scale,
min_rank,
"cpu",
)
except BaseException as cpu_error:
raise RuntimeError(
f"CPU fallback failed while resizing LoRA layer: {cpu_error}"
) from error
result["cpu_fallback"] = True
return result
def _compute_resize(
S: torch.Tensor,
max_rank: int,
dynamic_method: Optional[str],
dynamic_param: Optional[float],
scale: float,
min_rank: int = 1,
) -> Tuple[int, float, Dict]:
"""Compute new rank and alpha based on resize method."""
if dynamic_method == "sv_ratio" and dynamic_param is not None:
# Use kohya convention: S[0]/ratio
min_sv = S[0] / dynamic_param
new_rank = max(1, int(torch.sum(S > min_sv).item()))
elif dynamic_method == "sv_cumulative" and dynamic_param is not None:
new_rank = _index_sv_cumulative(S, dynamic_param, max_rank)
elif dynamic_method == "sv_fro" and dynamic_param is not None:
new_rank = _index_sv_fro(S, dynamic_param, max_rank)
else:
new_rank = max_rank
available_rank = max(1, len(S))
effective_min_rank = min(max(1, min_rank), max_rank, available_rank)
if S[0] < MIN_SV:
new_rank = effective_min_rank
else:
new_rank = max(
effective_min_rank,
min(new_rank, max_rank, available_rank),
)
new_alpha = float(scale * new_rank)
# Compute retention stats
s_sum = float(torch.sum(torch.abs(S)))
s_rank = float(torch.sum(torch.abs(S[:new_rank]))) if new_rank <= len(S) else s_sum
S_sq = S.pow(2)
s_fro = float(torch.sqrt(torch.sum(S_sq)))
s_red_fro = float(torch.sqrt(torch.sum(S_sq[:new_rank]))) if new_rank <= len(S) else s_fro
stats = {
"sum_retained": s_rank / s_sum if s_sum > 0 else 1.0,
"fro_retained": s_red_fro / s_fro if s_fro > 0 else 1.0,
}
return new_rank, new_alpha, stats
def _resize_filter_inputs():
return [
io.String.Input("exclude_patterns", default="", multiline=True, tooltip="One pattern per line. Matching layer names or source/canonical tensor keys keep the whole layer at its original rank and precision, including alpha."),
io.String.Input("discard_patterns", default="", multiline=True, tooltip="One pattern per line. Remove the whole matching layer, including factors and alpha. Discard takes precedence over exclude."),
io.Boolean.Input("glob_patterns", default=False, tooltip="Use shell-style glob patterns instead of regular expressions for exclude and discard filters."),
io.Boolean.Input("include_mode", default=False, tooltip="Treat exclude patterns as an include list. Matching layers are resized; nonmatching layers are preserved unchanged. Discard patterns still remove matching layers."),
]
def _build_resize_work_units(
handler, pairs, passthrough_keys, low_bit_keys,
exclude_patterns="", discard_patterns="", glob_patterns=False,
include_mode=False, layer_parameters=None,
):
"""Plan raw-copy and streamed work without loading tensor payloads."""
raw_units = []
stream_units = []
claimed = set()
rank_counts = Counter()
preserved_companion_groups = 0
excludes, discards = [
[re.compile(fnmatch.translate(line.strip()) if glob_patterns else line.strip())
for line in text.splitlines() if line.strip()]
for text in (exclude_patterns, discard_patterns)
]
def matches(patterns, names):
return any(pattern.search(name) for pattern in patterns for name in names)
for block_name, block_keys in pairs.items():
tensor_keys = layer_tensor_keys(block_keys)
if "down" in block_keys:
rank_counts[int(handler.get_shape(block_keys["down"])[0])] += 1
entries = [
(role, key, canonical_lora_key(block_name, role))
for role, key in tensor_keys.items()
]
related_passthrough = [
(None, key, key)
for key in passthrough_keys
if key.startswith(f"{block_name}.") and key not in claimed
]
group_entries = entries + related_passthrough
names = [block_name, canonical_lora_block_name(block_name)]
names.extend(name for _, key, output_key in group_entries for name in (key, output_key))
if matches(discards, names):
claimed.update(key for _, key, _ in group_entries)
continue
matched = matches(excludes, names)
preserve = matched != include_mode
if layer_has_low_bit(block_keys, low_bit_keys):
raw_entries = entries + related_passthrough
raw_units.append({"entries": raw_entries})
claimed.update(key for _, key, _ in raw_entries)
continue
if preserve:
stream_units.append({"kind": "preserve", "entries": group_entries})
claimed.update(key for _, key, _ in group_entries)
elif layer_has_companions(block_keys):
preserved_companion_groups += 1
stream_units.append({"kind": "preserve", "entries": entries})
elif "down" in block_keys and "up" in block_keys:
stream_units.append({
"kind": "resize",
"entries": entries,
"resize_parameters": (layer_parameters or {}).get(
canonical_lora_block_name(block_name)
),
"alpha_output": (
canonical_lora_key(block_name, "alpha")
if "alpha" in block_keys
else None
),
})
else:
stream_units.append({"kind": "copy", "entries": entries})
claimed.update(key for _, key, _ in entries)
for key in passthrough_keys:
if key in claimed:
continue
if matches(discards, [key]):
continue
entry = (None, key, key)
matched = matches(excludes, [key])
if key in low_bit_keys:
raw_units.append({"entries": [entry]})
else:
stream_units.append({
"kind": "preserve" if matched != include_mode else "copy",
"entries": [entry],
})
claimed.add(key)
return raw_units, stream_units, rank_counts, preserved_companion_groups
def _rank_summary(rank_counts: Counter) -> str:
if not rank_counts:
return "none"
return ", ".join(
f"{rank}x{count}" for rank, count in sorted(rank_counts.items())
)
# =============================================================================
# Main Resize Function
# =============================================================================
def normalize_lora_alpha_file(
lora_path: str,
output_filename: str,
verbose: bool = True,
reference_model_path: Optional[str] = None,
) -> str:
"""Materialize LoRA alpha scaling and remove alpha tensors."""
handler = MemoryEfficientSafeOpen(lora_path, low_memory=True)
try:
low_bit_keys = inspect_low_bit_input(
handler, f"LoRA ({lora_path})", "LoRA Alpha Normalize"
)
metadata = (handler.metadata() or {}).copy()
all_keys = handler.keys()
pairs, _ = parse_lora_layers(all_keys)
output_keys = {}
mapped_blocks = {}
if reference_model_path:
layer_map = build_lora_layer_map(
[{"pairs": pairs}], reference_model_path
)
validate_canonical_blocks(layer_map, "LoRA Alpha Normalize")
mapped_blocks = {
source_block: output_block
for output_block, sources in layer_map.items()
for _, source_block in sources
}
for block_name, output_block in mapped_blocks.items():
for role, source_key in layer_tensor_keys(pairs[block_name]).items():
output_keys[source_key] = canonical_lora_key(output_block, role)
work_units = []
normalized_up_keys = set()
consumed_alpha_keys = set()
for block_name, block_keys in pairs.items():
alpha_key = block_keys.get("alpha")
if alpha_key is None:
continue
down_key = block_keys.get("down")
up_key = block_keys.get("up")
if down_key is None or up_key is None:
raise ValueError(
f"Alpha tensor for '{block_name}' has no complete LoRA pair."
)
if down_key in low_bit_keys or up_key in low_bit_keys:
raise ValueError(
f"Cannot alpha-normalize low-bit LoRA factors for '{block_name}'."
)
down_shape = handler.get_shape(down_key)
up_shape = handler.get_shape(up_key)
if len(down_shape) < 2 or len(up_shape) < 2:
raise ValueError(
f"LoRA factors for '{block_name}' must have at least two dimensions."
)
rank = int(down_shape[0])
if rank <= 0 or int(up_shape[1]) != rank:
raise ValueError(f"LoRA factor rank mismatch for '{block_name}'.")
work_units.append((block_name, {0: [up_key, alpha_key]}))
normalized_up_keys.add(up_key)
consumed_alpha_keys.add(alpha_key)
all_alpha_keys = {key for key in all_keys if key.endswith(".alpha")}
orphan_alpha_keys = sorted(all_alpha_keys - consumed_alpha_keys)
if orphan_alpha_keys:
raise ValueError(
f"Alpha tensor has no complete LoRA pair: {orphan_alpha_keys[0]}"
)
if not work_units:
raise ValueError("Input LoRA contains no alpha tensors.")
metadata["alpha_normalized"] = "true"
metadata["alpha_normalization"] = (
"up factor multiplied by alpha divided by rank; alpha tensors removed"
)
output_path, _ = canonical_model_artifact_path("loras", output_filename)
with atomic_uel_writer(output_path, metadata) as writer:
for key in all_keys:
if key in all_alpha_keys or key in normalized_up_keys:
continue
write_preserved_tensor(
writer,
key,
handler,
output_key=output_keys.get(key),
force_raw=True,
)
with torch.no_grad():
iterator = stream_work_units(
{0: handler}, work_units, pin_memory=False
)
try:
for block_name, loaded in iterator:
block_keys = pairs[block_name]
up_key = block_keys["up"]
alpha_key = block_keys["alpha"]
rank = int(handler.get_shape(block_keys["down"])[0])
up = loaded[(0, up_key)]
alpha = loaded[(0, alpha_key)]
if alpha.numel() != 1 or not torch.isfinite(alpha).all().item():
raise ValueError(
f"LoRA alpha for '{block_name}' must be a finite scalar."
)
scale = lora_alpha_scale(alpha, rank, layer=block_name)
output = up
try:
if scale != 1.0:
output = (
up.to(dtype=torch.float32)
.mul_(scale)
.to(dtype=up.dtype)
.contiguous()
)
writer.write_batch([(
output_keys.get(up_key, up_key),
output.cpu().contiguous(),
)])
finally:
del output
del up
del alpha
finally:
iterator.close()
if verbose:
print(
f"[LoRA Alpha Normalize] Removed {len(work_units)} alpha tensors"
)
if reference_model_path:
print(
f"[LoRA Alpha Normalize] Reference-mapped {len(mapped_blocks)} LoRA layers"
)
print(f"[LoRA Alpha Normalize] Saved to {output_path}")
return output_path
finally:
handler.__exit__(None, None, None)
cleanup_after_operation()
def _compile_alpha_filter_patterns(
exclude_patterns: str, discard_patterns: str, glob_patterns: bool
):
return [
[
re.compile(
fnmatch.translate(line.strip()) if glob_patterns else line.strip()
)
for line in text.splitlines()
if line.strip()
]
for text in (exclude_patterns, discard_patterns)
]
def _matches_alpha_patterns(patterns, names) -> bool:
return any(pattern.search(name) for pattern in patterns for name in names)
def _compute_modified_alpha(
old_alpha: float, rank: int, alpha_mode: str, alpha_value: float
) -> float:
val = float(alpha_value)
if alpha_mode == "scale":
return old_alpha * val
if alpha_mode == "shift":
return old_alpha + val
if alpha_mode == "set_alpha":
return val
if alpha_mode == "rank_ratio":
return float(rank) * val
raise ValueError(f"Unsupported alpha_mode '{alpha_mode}'.")
def modify_lora_alpha_file(
lora_path: str,
output_filename: str,
alpha_mode: str = "scale",
alpha_value: float = 1.0,
reference_model_path: Optional[str] = None,
exclude_patterns: str = "",
discard_patterns: str = "",
glob_patterns: bool = False,
include_mode: bool = False,
verbose: bool = True,
) -> str:
"""Create or modify LoRA alpha scalars while preserving factor weights byte-exact."""
if alpha_mode not in ("scale", "shift", "set_alpha", "rank_ratio"):
raise ValueError(f"Unsupported alpha_mode '{alpha_mode}'.")
handler = MemoryEfficientSafeOpen(lora_path, low_memory=True)
try:
metadata = (handler.metadata() or {}).copy()
all_keys = handler.keys()
pairs, passthrough_keys = parse_lora_layers(all_keys)
output_keys: Dict[str, str] = {}
mapped_blocks: Dict[str, str] = {}
if reference_model_path:
layer_map = build_lora_layer_map(
[{"pairs": pairs}], reference_model_path
)
validate_canonical_blocks(layer_map, "LoRA Modify Alpha")
mapped_blocks = {
source_block: output_block
for output_block, sources in layer_map.items()
for _, source_block in sources
}
for block_name, output_block in mapped_blocks.items():
for role, source_key in layer_tensor_keys(pairs[block_name]).items():
output_keys[source_key] = canonical_lora_key(output_block, role)
consumed_alpha_keys = {
block_keys["alpha"]
for block_keys in pairs.values()
if "alpha" in block_keys and "down" in block_keys and "up" in block_keys
}
all_alpha_keys = {key for key in all_keys if key.endswith(".alpha")}
orphan_alpha_keys = sorted(all_alpha_keys - consumed_alpha_keys)
if orphan_alpha_keys:
raise ValueError(
f"Alpha tensor has no complete LoRA pair: {orphan_alpha_keys[0]}"
)
excludes, discards = _compile_alpha_filter_patterns(
exclude_patterns, discard_patterns, glob_patterns
)
discarded_keys = set()
replaced_alpha_keys = set()
stream_units = []
synthesized_units = []
alpha_out_map: Dict[str, Tuple[int, str, torch.dtype]] = {}
claimed_passthrough = set()
for block_name, block_keys in pairs.items():
tensor_keys = layer_tensor_keys(block_keys)
related_pt = [
key
for key in passthrough_keys
if key.startswith(f"{block_name}.") and key not in claimed_passthrough
]
claimed_passthrough.update(related_pt)
group_keys = list(tensor_keys.values()) + related_pt
names = [block_name, canonical_lora_block_name(block_name)]
if block_name in mapped_blocks:
names.append(mapped_blocks[block_name])
for k in group_keys:
names.append(k)
if k in output_keys:
names.append(output_keys[k])
if _matches_alpha_patterns(discards, names):
discarded_keys.update(group_keys)
continue
matched = _matches_alpha_patterns(excludes, names)
preserve = matched != include_mode
if preserve or not ("down" in block_keys and "up" in block_keys):
continue
down_key = block_keys["down"]
up_key = block_keys["up"]
down_shape = handler.get_shape(down_key)
up_shape = handler.get_shape(up_key)
if len(down_shape) < 2 or len(up_shape) < 2:
raise ValueError(
f"LoRA factors for '{block_name}' must have at least two dimensions."
)
rank = int(down_shape[0])
if rank <= 0 or int(up_shape[1]) != rank:
raise ValueError(f"LoRA factor rank mismatch for '{block_name}'.")
if block_name in mapped_blocks:
alpha_out_key = canonical_lora_key(mapped_blocks[block_name], "alpha")
elif "alpha" in block_keys:
alpha_out_key = block_keys["alpha"]
else:
alpha_out_key = f"{block_name}.alpha"
up_dtype = handler.get_dtype(up_key)
default_dtype = (
up_dtype
if up_dtype in (torch.float16, torch.bfloat16, torch.float32)
else torch.float32
)
alpha_key = block_keys.get("alpha")
if alpha_key is not None:
replaced_alpha_keys.add(alpha_key)
stream_units.append((block_name, {0: [alpha_key]}))
alpha_out_map[block_name] = (rank, alpha_out_key, default_dtype)
else:
synthesized_units.append((block_name, rank, alpha_out_key, default_dtype))
for key in passthrough_keys:
if key in claimed_passthrough:
continue
if _matches_alpha_patterns(discards, [key]):
discarded_keys.add(key)
if not stream_units and not synthesized_units:
raise ValueError("No matching LoRA pairs found to modify alpha.")
metadata["alpha_normalized"] = "false"
metadata["alpha_modified"] = f"{alpha_mode}:{alpha_value}"
output_path, _ = canonical_model_artifact_path("loras", output_filename)
with atomic_uel_writer(output_path, metadata) as writer:
for key in all_keys:
if key in discarded_keys or key in replaced_alpha_keys:
continue
write_preserved_tensor(
writer,
key,
handler,
output_key=output_keys.get(key),
force_raw=True,
)
if stream_units:
with torch.no_grad():
iterator = stream_work_units(
{0: handler}, stream_units, pin_memory=False
)
try:
for block_name, loaded in iterator:
alpha_key = pairs[block_name]["alpha"]
rank, alpha_out_key, default_dtype = alpha_out_map[block_name]
alpha = loaded[(0, alpha_key)]
try:
if (
alpha.numel() != 1
or not torch.isfinite(alpha).all().item()
):
raise ValueError(
f"LoRA alpha for '{block_name}' must be a finite scalar."
)
old_alpha = float(alpha.to(dtype=torch.float32).item())
new_alpha = _compute_modified_alpha(
old_alpha, rank, alpha_mode, alpha_value
)
out_dtype = (
alpha.dtype
if alpha.dtype
in (torch.float16, torch.bfloat16, torch.float32)
else default_dtype
)
writer.write_batch(
[
(
alpha_out_key,
torch.tensor(new_alpha, dtype=out_dtype),
)
]
)
finally:
del alpha
finally:
iterator.close()
if synthesized_units:
synth_batch = []
for block_name, rank, alpha_out_key, default_dtype in synthesized_units:
new_alpha = _compute_modified_alpha(
float(rank), rank, alpha_mode, alpha_value
)
synth_batch.append(
(alpha_out_key, torch.tensor(new_alpha, dtype=default_dtype))
)
writer.write_batch(synth_batch)
if verbose:
total_modified = len(stream_units) + len(synthesized_units)
print(
f"[LoRA Modify Alpha] Updated {total_modified} alpha tensors ({alpha_mode}={alpha_value})"
)
print(f"[LoRA Modify Alpha] Saved to {output_path}")
return output_path
finally:
handler.__exit__(None, None, None)
cleanup_after_operation()
def denormalize_lora_alpha_file(
lora_path: str,
output_filename: str,
alpha_mode: str = "set_alpha",
alpha_value: float = 1.0,
reference_model_path: Optional[str] = None,
exclude_patterns: str = "",
discard_patterns: str = "",
glob_patterns: bool = False,
include_mode: bool = False,
verbose: bool = True,
) -> str:
"""Set target LoRA alpha tensors and counter-scale up factors to preserve effective delta."""
if alpha_mode not in ("set_alpha", "rank_ratio"):
raise ValueError(
f"Unsupported alpha_mode '{alpha_mode}' for denormalization; expected 'set_alpha' or 'rank_ratio'."
)
if float(alpha_value) == 0.0:
raise ValueError(
"Target alpha value must be non-zero to denormalize LoRA factors."
)
handler = MemoryEfficientSafeOpen(lora_path, low_memory=True)
try:
low_bit_keys = inspect_low_bit_input(
handler, f"LoRA ({lora_path})", "LoRA Alpha Denormalize"
)
metadata = (handler.metadata() or {}).copy()
all_keys = handler.keys()
pairs, passthrough_keys = parse_lora_layers(all_keys)
output_keys: Dict[str, str] = {}
mapped_blocks: Dict[str, str] = {}
if reference_model_path:
layer_map = build_lora_layer_map(
[{"pairs": pairs}], reference_model_path
)
validate_canonical_blocks(layer_map, "LoRA Alpha Denormalize")
mapped_blocks = {
source_block: output_block
for output_block, sources in layer_map.items()
for _, source_block in sources
}
for block_name, output_block in mapped_blocks.items():
for role, source_key in layer_tensor_keys(pairs[block_name]).items():
output_keys[source_key] = canonical_lora_key(output_block, role)
consumed_alpha_keys = {
block_keys["alpha"]
for block_keys in pairs.values()
if "alpha" in block_keys and "down" in block_keys and "up" in block_keys
}
all_alpha_keys = {key for key in all_keys if key.endswith(".alpha")}
orphan_alpha_keys = sorted(all_alpha_keys - consumed_alpha_keys)
if orphan_alpha_keys:
raise ValueError(
f"Alpha tensor has no complete LoRA pair: {orphan_alpha_keys[0]}"
)
excludes, discards = _compile_alpha_filter_patterns(
exclude_patterns, discard_patterns, glob_patterns
)
discarded_keys = set()
replaced_keys = set()
work_units = []
unit_meta: Dict[str, Tuple[int, str, str]] = {}
claimed_passthrough = set()
for block_name, block_keys in pairs.items():
tensor_keys = layer_tensor_keys(block_keys)
related_pt = [
key
for key in passthrough_keys
if key.startswith(f"{block_name}.") and key not in claimed_passthrough
]
claimed_passthrough.update(related_pt)
group_keys = list(tensor_keys.values()) + related_pt
names = [block_name, canonical_lora_block_name(block_name)]
if block_name in mapped_blocks:
names.append(mapped_blocks[block_name])
for k in group_keys:
names.append(k)
if k in output_keys:
names.append(output_keys[k])
if _matches_alpha_patterns(discards, names):
discarded_keys.update(group_keys)
continue
matched = _matches_alpha_patterns(excludes, names)
preserve = matched != include_mode
if preserve or not ("down" in block_keys and "up" in block_keys):
continue
down_key = block_keys["down"]
up_key = block_keys["up"]
if down_key in low_bit_keys or up_key in low_bit_keys:
raise ValueError(
f"Cannot denormalize low-bit LoRA factors for '{block_name}'."
)
down_shape = handler.get_shape(down_key)
up_shape = handler.get_shape(up_key)
if len(down_shape) < 2 or len(up_shape) < 2:
raise ValueError(
f"LoRA factors for '{block_name}' must have at least two dimensions."
)
rank = int(down_shape[0])
if rank <= 0 or int(up_shape[1]) != rank:
raise ValueError(f"LoRA factor rank mismatch for '{block_name}'.")
up_out_key = output_keys.get(up_key, up_key)
if block_name in mapped_blocks:
alpha_out_key = canonical_lora_key(mapped_blocks[block_name], "alpha")
elif "alpha" in block_keys:
alpha_out_key = block_keys["alpha"]
else:
alpha_out_key = f"{block_name}.alpha"
alpha_key = block_keys.get("alpha")
replaced_keys.add(up_key)
if alpha_key is not None:
replaced_keys.add(alpha_key)
work_units.append((block_name, {0: [up_key, alpha_key]}))
else:
work_units.append((block_name, {0: [up_key]}))
unit_meta[block_name] = (rank, up_out_key, alpha_out_key)
for key in passthrough_keys:
if key in claimed_passthrough:
continue
if _matches_alpha_patterns(discards, [key]):
discarded_keys.add(key)
if not work_units:
raise ValueError("No matching LoRA pairs found to denormalize alpha.")
metadata["alpha_normalized"] = "false"
metadata["alpha_denormalization"] = (
f"alpha set via {alpha_mode}={alpha_value}; up factor counter-scaled to preserve delta"
)
output_path, _ = canonical_model_artifact_path("loras", output_filename)
with atomic_uel_writer(output_path, metadata) as writer:
for key in all_keys:
if key in discarded_keys or key in replaced_keys:
continue
write_preserved_tensor(
writer,
key,
handler,
output_key=output_keys.get(key),
force_raw=True,
)
with torch.no_grad():
iterator = stream_work_units(
{0: handler}, work_units, pin_memory=False
)
try:
for block_name, loaded in iterator:
block_keys = pairs[block_name]
up_key = block_keys["up"]
alpha_key = block_keys.get("alpha")
rank, up_out_key, alpha_out_key = unit_meta[block_name]
up = loaded[(0, up_key)]
alpha = loaded.get((0, alpha_key)) if alpha_key else None
output_up = up
try:
if alpha is not None:
if (
alpha.numel() != 1
or not torch.isfinite(alpha).all().item()
):
raise ValueError(
f"LoRA alpha for '{block_name}' must be a finite scalar."
)
old_alpha = float(alpha.to(dtype=torch.float32).item())
else:
old_alpha = float(rank)
new_alpha = _compute_modified_alpha(
old_alpha, rank, alpha_mode, alpha_value
)
factor_scale = old_alpha / new_alpha
if factor_scale != 1.0:
output_up = (
up.to(dtype=torch.float32)
.mul_(factor_scale)
.to(dtype=up.dtype)
.contiguous()
)
alpha_dtype = (
up.dtype
if up.dtype
in (torch.float16, torch.bfloat16, torch.float32)
else torch.float32
)
writer.write_batch(
[
(up_out_key, output_up.cpu().contiguous()),
(
alpha_out_key,
torch.tensor(new_alpha, dtype=alpha_dtype),
),
]
)
finally:
del output_up
del up
if alpha is not None:
del alpha
finally:
iterator.close()
if verbose:
print(
f"[LoRA Denormalize Alpha] Updated {len(work_units)} LoRA pairs ({alpha_mode}={alpha_value})"
)
print(f"[LoRA Denormalize Alpha] Saved to {output_path}")
return output_path
finally:
handler.__exit__(None, None, None)
cleanup_after_operation()
def resize_lora_file(
lora_path: str,
new_rank: int,
dynamic_method: Optional[str],
dynamic_param: Optional[float],
device: str,
save_dtype: torch.dtype,
output_filename: str,
verbose: bool = True,
force_clear_cache: bool = False,
min_rank: int = 1,
exclude_patterns: str = "",
discard_patterns: str = "",
glob_patterns: bool = False,
include_mode: bool = False,
layer_parameters=None,
) -> str:
"""Resize a LoRA through one bounded UEL stream and factor-space SVD."""
if min_rank < 1:
raise ValueError("min_rank must be at least 1")
if min_rank > new_rank:
raise ValueError("min_rank cannot exceed max rank")
parameter_profiles = {
None: ("resize:fixed", {"new_rank": new_rank}),
"sv_ratio": (
"resize:ratio", {"max_rank": new_rank, "ratio": dynamic_param}
),
"sv_fro": (
"resize:frobenius", {
"max_rank": new_rank,
"min_rank": min_rank,
"target": dynamic_param,
},
),
"sv_cumulative": (
"resize:cumulative", {"max_rank": new_rank, "target": dynamic_param}
),
}
try:
parameter_profile, parameter_defaults = parameter_profiles[dynamic_method]
except KeyError as error:
raise ValueError(f"Unsupported resize method: {dynamic_method}") from error
lora_size_gb = estimate_model_size(lora_path)
prepare_for_large_operation(lora_size_gb, torch.device(device))
handler = MemoryEfficientSafeOpen(lora_path, low_memory=True)
try:
low_bit_keys = inspect_low_bit_input(handler, f"LoRA ({lora_path})", "LoRA Resize")
metadata = (handler.metadata() or {}).copy()
all_keys = handler.keys()
format_info = detect_lora_format(all_keys)
pairs, passthrough_keys = parse_lora_layers(all_keys)
validate_canonical_blocks(pairs, "LoRA Resize")
resolved_layer_parameters = resolve_layer_parameters(
layer_parameters,
parameter_profile,
(canonical_lora_block_name(block_name) for block_name in pairs),
parameter_defaults,
node_name="LoRA Resize",
)
raw_units, stream_units, rank_counts, preserved_companion_groups = (
_build_resize_work_units(
handler, pairs, passthrough_keys, low_bit_keys,
exclude_patterns, discard_patterns, glob_patterns, include_mode,
resolved_layer_parameters,
)
)
rank_text = _rank_summary(rank_counts)
if verbose:
method_str = f"{dynamic_method}: {dynamic_param}" if dynamic_method else "fixed"
print(f"[LoRA Resize] Format: {format_info['format']}, layers: {format_info['key_count']}")
print(f"[LoRA Resize] Source ranks: {rank_text}")
print(
f"[LoRA Resize] Resizing with method={method_str}, "
f"min_rank={min_rank}, max_rank={new_rank}"
)
if dynamic_method:
resize_description = f"Dynamic resize with {dynamic_method}: {dynamic_param}"
else:
resize_description = f"Fixed resize with maximum rank {new_rank}"
metadata["ss_training_comment"] = (
f"{resize_description}; source ranks {rank_text}"
)
metadata["ss_network_dim"] = "Dynamic"
metadata["ss_network_alpha"] = "Dynamic"
metadata["modelutils_resize_filter_mode"] = (
"include" if include_mode else "exclude"
)
output_path, _ = canonical_model_artifact_path("loras", output_filename)
fro_list = []
cpu_fallbacks = 0
total_units = len(raw_units) + len(stream_units)
pbar = comfy.utils.ProgressBar(total_units)
writer_context = atomic_uel_writer(output_path, metadata)
writer = writer_context.__enter__()
stream = None
try:
with torch.no_grad():
for unit in raw_units:
for _, key, output_key in unit["entries"]:
write_preserved_tensor(
writer,
key,
handler,
output_key,
force_raw=True,
)
pbar.update(1)
stream_keys = [
key
for unit in stream_units
for _, key, _ in unit["entries"]
]
if stream_keys:
stream = handler.async_stream(
stream_keys,
batch_size=1,
prefetch_batches=1,
pin_memory=str(device).startswith("cuda"),
)
stream_iterator = iter(stream)
iterator = tqdm(
stream_units, desc="Resizing layers", unit="layers"
)
for unit in iterator:
loaded = {}
unit_keys = []
outputs = []
result = None
try:
for role, expected_key, output_key in unit["entries"]:
batch = next(stream_iterator)
if len(batch) != 1 or batch[0][0] != expected_key:
raise RuntimeError(
"UEL resize stream returned tensors out of order"
)
loaded[expected_key] = batch[0][1]
unit_keys.append(expected_key)
source_dtypes = [
handler.get_dtype(key) for key in unit_keys
]
if unit["kind"] == "preserve":
outputs = [
(output_key, loaded[key].cpu().contiguous())
for _, key, output_key in unit["entries"]
]
elif unit["kind"] == "copy":
for role, key, output_key in unit["entries"]:
tensor = loaded[key]
target_dtype = select_output_dtype(
source_dtypes,
save_dtype,
is_1d_diff=(
role in {"diff", "diff_b", "w_norm", "b_norm"}
and tensor.ndim == 1
),
)
outputs.append(
(
output_key,
tensor.to(target_dtype).cpu().contiguous(),
)
)
else:
by_role = {
role: (key, output_key)
for role, key, output_key in unit["entries"]
if role is not None
}
down_key, down_output = by_role["down"]
up_key, up_output = by_role["up"]
source_rank = int(loaded[down_key].shape[0])
if "alpha" in by_role:
alpha_key, _ = by_role["alpha"]
scale = float(loaded[alpha_key].item()) / source_rank
else:
scale = 1.0
resize_parameters = unit["resize_parameters"]
if resize_parameters is None:
layer_max_rank = new_rank
layer_dynamic_param = dynamic_param
layer_min_rank = min_rank
elif dynamic_method is None:
layer_max_rank = resize_parameters["new_rank"]
layer_dynamic_param = None
layer_min_rank = 1
elif dynamic_method == "sv_ratio":
layer_max_rank = resize_parameters["max_rank"]
layer_dynamic_param = resize_parameters["ratio"]
layer_min_rank = 1
elif dynamic_method == "sv_fro":
layer_max_rank = resize_parameters["max_rank"]
layer_dynamic_param = resize_parameters["target"]
layer_min_rank = resize_parameters["min_rank"]
else:
layer_max_rank = resize_parameters["max_rank"]
layer_dynamic_param = resize_parameters["target"]
layer_min_rank = 1
result = _resize_lora_factors(
loaded[down_key],
loaded[up_key],
layer_max_rank,
dynamic_method,
layer_dynamic_param,
scale,
layer_min_rank,
device,
)
fro_list.append(result["fro_retained"])
cpu_fallbacks += int(result["cpu_fallback"])
layer_dtype = select_output_dtype(
source_dtypes, save_dtype
)
outputs.extend(
[
(
down_output,
result["lora_down"].to(layer_dtype),
),
(
up_output,
result["lora_up"].to(layer_dtype),
),
]
)
if unit["alpha_output"] is not None:
outputs.append(
(
unit["alpha_output"],
torch.tensor(
result["new_alpha"],
dtype=layer_dtype,
),
)
)
for role in ("diff", "diff_b", "w_norm", "b_norm"):
if role not in by_role:
continue
key, output_key = by_role[role]
tensor = loaded[key]
target_dtype = select_output_dtype(
source_dtypes,
save_dtype,
is_1d_diff=tensor.ndim == 1,
)
outputs.append(
(
output_key,
tensor.to(target_dtype).cpu().contiguous(),
)
)
writer.write_batch(outputs)
finally:
outputs.clear()
if result is not None:
result.clear()
loaded.clear()
for key in unit_keys:
handler.mark_processed(key)
if force_clear_cache:
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
pbar.update(1)
try:
next(stream_iterator)
except StopIteration:
pass
else:
raise RuntimeError("UEL resize stream returned extra tensors")
except BaseException as exc:
if stream is not None:
close_stream = getattr(stream, "close", None)
if close_stream is not None:
close_stream()
writer_context.__exit__(type(exc), exc, exc.__traceback__)
raise
else:
if stream is not None:
close_stream = getattr(stream, "close", None)
if close_stream is not None:
close_stream()
writer_context.__exit__(None, None, None)
if verbose and fro_list:
avg_fro = sum(fro_list) / len(fro_list)
variance = sum((value - avg_fro) ** 2 for value in fro_list) / len(fro_list)
std_fro = variance ** 0.5
print(f"[LoRA Resize] Average Frobenius retention: {avg_fro:.1%} ± {std_fro:.3f}")
if preserved_companion_groups:
logging.warning(
"[LoRA Resize] Preserved %d companion-bearing LoRA group(s) without rank transformation",
preserved_companion_groups,
)
if verbose:
print(f"[LoRA Resize] CUDA OOM CPU fallbacks: {cpu_fallbacks}")
print(f"[LoRA Resize] Saved to {output_path}")
return output_path
finally:
handler.__exit__(None, None, None)
cleanup_after_operation()
# =============================================================================
# Node Definitions
# =============================================================================
class LoRANormalizeAlpha(io.ComfyNode):
"""Materialize LoRA alpha scaling into its up factors."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="LoRANormalizeAlpha",
display_name="LoRA Normalize Alpha",
category="ModelUtils/LoRA/Utilities",
description="Apply each alpha divided by rank scale to its LoRA up factor and save an alpha-free LoRA.",
inputs=[
io.Combo.Input(
"lora_name",
options=folder_paths.get_filename_list("loras"),
tooltip="LoRA whose per-layer alpha scaling will be materialized.",
),
io.String.Input(
"output_filename",
default="normalized_lora",
tooltip="Output filename without extension, written under ComfyUI's LoRA directory.",
),
io.Combo.Input(
"reference_model",
options=["None", *folder_paths.get_filename_list("diffusion_models")],
default="None",
tooltip="Optional diffusion model used to normalize flattened LoRA layer names to its exact model paths.",
),
],
outputs=[io.AnyType.Output(display_name="output_path")],
is_output_node=True,
)
@classmethod
def execute(cls, lora_name, output_filename, reference_model="None") -> io.NodeOutput:
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
reference_path = None
if reference_model != "None":
reference_path = folder_paths.get_full_path_or_raise(
"diffusion_models", reference_model
)
normalize_lora_alpha_file(
lora_path,
output_filename,
reference_model_path=reference_path,
)
_, output_name = canonical_model_artifact_path("loras", output_filename)
return io.NodeOutput(output_name)
class LoRAModifyAlpha(io.ComfyNode):
"""Modify or create per-layer LoRA alpha scalars to adjust influence without changing factor weights."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="LoRAModifyAlpha",
display_name="LoRA Modify Alpha",
category="ModelUtils/LoRA/Utilities",
description="Create or modify LoRA alpha scalars to scale, shift, or set layer influence while keeping factor weights byte-exact.",
inputs=[
io.Combo.Input(
"lora_name",
options=folder_paths.get_filename_list("loras"),
tooltip="LoRA whose per-layer alpha scalars will be modified or created.",
),
io.Combo.Input(
"alpha_mode",
options=["scale", "shift", "set_alpha", "rank_ratio"],
default="scale",
tooltip="How to compute new alpha from existing alpha (or rank when absent): scale multiplies, shift adds, set_alpha sets a fixed scalar, rank_ratio sets rank * value.",
),
io.Float.Input(
"alpha_value",
default=1.0,
min=-10000.0,
max=10000.0,
step=0.01,
tooltip="Multiplier, additive shift, fixed alpha, or alpha/rank ratio applied to matched LoRA layers.",
),
io.String.Input(
"output_filename",
default="modified_alpha_lora",
tooltip="Output filename without extension, written under ComfyUI's LoRA directory.",
),
io.Combo.Input(
"reference_model",
options=["None", *folder_paths.get_filename_list("diffusion_models")],
default="None",
tooltip="Optional diffusion model used to normalize flattened LoRA layer names to its exact model paths.",
),
*_resize_filter_inputs(),
],
outputs=[io.AnyType.Output(display_name="output_path")],
is_output_node=True,
)
@classmethod
def execute(
cls,
lora_name,
alpha_mode="scale",
alpha_value=1.0,
output_filename="modified_alpha_lora",
reference_model="None",
exclude_patterns="",
discard_patterns="",
glob_patterns=False,
include_mode=False,
) -> io.NodeOutput:
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
reference_path = None
if reference_model != "None":
reference_path = folder_paths.get_full_path_or_raise(
"diffusion_models", reference_model
)
modify_lora_alpha_file(
lora_path,
output_filename,
alpha_mode=alpha_mode,
alpha_value=alpha_value,
reference_model_path=reference_path,
exclude_patterns=exclude_patterns,
discard_patterns=discard_patterns,
glob_patterns=glob_patterns,
include_mode=include_mode,
)
_, output_name = canonical_model_artifact_path("loras", output_filename)
return io.NodeOutput(output_name)
class LoRADenormalizeAlpha(io.ComfyNode):
"""Set explicit LoRA alpha scalars and counter-scale up factors to preserve the effective delta."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="LoRADenormalizeAlpha",
display_name="LoRA Denormalize Alpha",
category="ModelUtils/LoRA/Utilities",
description="Inverse of LoRA Normalize Alpha: write target alpha scalars and counter-scale LoRA up factors so effective layer deltas remain unchanged.",
inputs=[
io.Combo.Input(
"lora_name",
options=folder_paths.get_filename_list("loras"),
tooltip="LoRA whose alpha tensors will be restored or rebased while preserving effective layer deltas.",
),
io.Combo.Input(
"alpha_mode",
options=["set_alpha", "rank_ratio"],
default="set_alpha",
tooltip="set_alpha writes a fixed alpha value; rank_ratio writes alpha = rank * value.",
),
io.Float.Input(
"alpha_value",
default=1.0,
min=-10000.0,
max=10000.0,
step=0.01,
tooltip="Non-zero target alpha value or target alpha/rank ratio.",
),
io.String.Input(
"output_filename",
default="denormalized_alpha_lora",
tooltip="Output filename without extension, written under ComfyUI's LoRA directory.",
),
io.Combo.Input(
"reference_model",
options=["None", *folder_paths.get_filename_list("diffusion_models")],
default="None",
tooltip="Optional diffusion model used to normalize flattened LoRA layer names to its exact model paths.",
),
*_resize_filter_inputs(),
],
outputs=[io.AnyType.Output(display_name="output_path")],
is_output_node=True,
)
@classmethod
def execute(
cls,
lora_name,
alpha_mode="set_alpha",
alpha_value=1.0,
output_filename="denormalized_alpha_lora",
reference_model="None",
exclude_patterns="",
discard_patterns="",
glob_patterns=False,
include_mode=False,
) -> io.NodeOutput:
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
reference_path = None
if reference_model != "None":
reference_path = folder_paths.get_full_path_or_raise(
"diffusion_models", reference_model
)
denormalize_lora_alpha_file(
lora_path,
output_filename,
alpha_mode=alpha_mode,
alpha_value=alpha_value,
reference_model_path=reference_path,
exclude_patterns=exclude_patterns,
discard_patterns=discard_patterns,
glob_patterns=glob_patterns,
include_mode=include_mode,
)
_, output_name = canonical_model_artifact_path("loras", output_filename)
return io.NodeOutput(output_name)
class LoRAResizeFixed(io.ComfyNode):
"""Resize LoRA to a fixed rank."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="LoRAResizeFixed",
display_name="LoRA Resize (Fixed Rank)",
category="ModelUtils/LoRA/Resize",
description="Resize existing LoRA to a specific supported rank using factor-space SVD.",
inputs=[
io.Combo.Input("lora_name", options=folder_paths.get_filename_list("loras"),
tooltip="LoRA to resize"),
io.Int.Input("new_rank", default=64, min=1, max=3072,
tooltip="Target rank"),
io.String.Input("output_filename", default="resized_lora", tooltip="Output filename without extension, written under ComfyUI's LoRA directory."),
io.Combo.Input("save_dtype", options=["fp16", "bf16", "fp32"], default="fp16", tooltip="Data type used to save resized LoRA tensors."),
io.Combo.Input("device", options=["cuda", "cpu"], default="cuda", tooltip="Device used for per-layer resize arithmetic; CUDA out-of-memory retries the affected layer on CPU."),
io.Boolean.Input("force_clear_cache", default=False, tooltip="Clear CUDA cache after each layer (slower but saves VRAM)"),
*_resize_filter_inputs(),
parameter_input("resize:fixed"),
],
outputs=[io.AnyType.Output(display_name="output_path")],
is_output_node=True,
)
@classmethod
def execute(cls, lora_name, new_rank, output_filename, save_dtype, device, force_clear_cache, exclude_patterns="", discard_patterns="", glob_patterns=False, include_mode=False, layer_parameters=None) -> io.NodeOutput:
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
dtype = {"fp16": torch.float16, "bf16": torch.bfloat16, "fp32": torch.float32}[save_dtype]
resize_lora_file(
lora_path, new_rank, None, None, device, dtype, output_filename,
force_clear_cache=force_clear_cache,
exclude_patterns=exclude_patterns,
discard_patterns=discard_patterns,
glob_patterns=glob_patterns,
include_mode=include_mode,
layer_parameters=layer_parameters,
)
_, output_name = canonical_model_artifact_path("loras", output_filename)
return io.NodeOutput(output_name)
class LoRAResizeRatio(io.ComfyNode):
"""Resize LoRA keeping SVs above ratio threshold."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="LoRAResizeRatio",
display_name="LoRA Resize (SV Ratio)",
category="ModelUtils/LoRA/Resize",
description="Dynamically resize LoRA, keeping singular values where S[i] > S[0]/ratio.",
inputs=[
io.Combo.Input("lora_name", options=folder_paths.get_filename_list("loras"),
tooltip="LoRA to resize"),
io.Int.Input("max_rank", default=128, min=1, max=3072,
tooltip="Maximum allowed rank"),
io.Float.Input("ratio", default=2.0, min=1.0, max=100.0, step=0.1,
tooltip="Keep SVs where S[i] > S[0]/ratio"),
io.String.Input("output_filename", default="resized_lora_ratio", tooltip="Output filename without extension, written under ComfyUI's LoRA directory."),
io.Combo.Input("save_dtype", options=["fp16", "bf16", "fp32"], default="fp16", tooltip="Data type used to save resized LoRA tensors."),
io.Combo.Input("device", options=["cuda", "cpu"], default="cuda", tooltip="Device used for per-layer resize arithmetic; CUDA out-of-memory retries the affected layer on CPU."),
io.Boolean.Input("force_clear_cache", default=False, tooltip="Clear CUDA cache after each layer (slower but saves VRAM)"),
*_resize_filter_inputs(),
parameter_input("resize:ratio"),
],
outputs=[io.AnyType.Output(display_name="output_path")],
is_output_node=True,
)
@classmethod
def execute(cls, lora_name, max_rank, ratio, output_filename, save_dtype, device, force_clear_cache, exclude_patterns="", discard_patterns="", glob_patterns=False, include_mode=False, layer_parameters=None) -> io.NodeOutput:
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
dtype = {"fp16": torch.float16, "bf16": torch.bfloat16, "fp32": torch.float32}[save_dtype]
resize_lora_file(
lora_path, max_rank, "sv_ratio", ratio, device, dtype, output_filename,
force_clear_cache=force_clear_cache,
exclude_patterns=exclude_patterns,
discard_patterns=discard_patterns,
glob_patterns=glob_patterns,
include_mode=include_mode,
layer_parameters=layer_parameters,
)
_, output_name = canonical_model_artifact_path("loras", output_filename)
return io.NodeOutput(output_name)
class LoRAResizeFrobenius(io.ComfyNode):
"""Resize LoRA to preserve Frobenius norm target."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="LoRAResizeFrobenius",
display_name="LoRA Resize (Frobenius)",
category="ModelUtils/LoRA/Resize",
description="Dynamically resize LoRA to preserve target fraction of Frobenius norm.",
inputs=[
io.Combo.Input("lora_name", options=folder_paths.get_filename_list("loras"),
tooltip="LoRA to resize"),
io.Int.Input("max_rank", default=128, min=1, max=3072,
tooltip="Maximum allowed rank"),
io.Int.Input("min_rank", default=1, min=1, max=3072,
tooltip="Minimum retained rank when layer dimensions permit"),
io.Float.Input("target", default=0.9, min=0.1, max=1.0, step=0.01,
tooltip="Target Frobenius norm retention (0.9 = 90%)"),
io.String.Input("output_filename", default="resized_lora_fro", tooltip="Output filename without extension, written under ComfyUI's LoRA directory."),
io.Combo.Input("save_dtype", options=["fp16", "bf16", "fp32"], default="fp16", tooltip="Data type used to save resized LoRA tensors."),
io.Combo.Input("device", options=["cuda", "cpu"], default="cuda", tooltip="Device used for per-layer resize arithmetic; CUDA out-of-memory retries the affected layer on CPU."),
io.Boolean.Input("force_clear_cache", default=False, tooltip="Clear CUDA cache after each layer (slower but saves VRAM)"),
*_resize_filter_inputs(),
parameter_input("resize:frobenius"),
],
outputs=[io.AnyType.Output(display_name="output_path")],
is_output_node=True,
)
@classmethod
def execute(cls, lora_name, max_rank, min_rank, target, output_filename, save_dtype, device, force_clear_cache, exclude_patterns="", discard_patterns="", glob_patterns=False, include_mode=False, layer_parameters=None) -> io.NodeOutput:
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
dtype = {"fp16": torch.float16, "bf16": torch.bfloat16, "fp32": torch.float32}[save_dtype]
resize_lora_file(
lora_path, max_rank, "sv_fro", target, device, dtype, output_filename,
force_clear_cache=force_clear_cache,
exclude_patterns=exclude_patterns,
discard_patterns=discard_patterns,
glob_patterns=glob_patterns,
include_mode=include_mode,
min_rank=min_rank,
layer_parameters=layer_parameters,
)
_, output_name = canonical_model_artifact_path("loras", output_filename)
return io.NodeOutput(output_name)
class LoRAResizeCumulative(io.ComfyNode):
"""Resize LoRA to preserve cumulative SV target."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="LoRAResizeCumulative",
display_name="LoRA Resize (Cumulative)",
category="ModelUtils/LoRA/Resize",
description="Dynamically resize LoRA to preserve target fraction of cumulative singular values.",
inputs=[
io.Combo.Input("lora_name", options=folder_paths.get_filename_list("loras"),
tooltip="LoRA to resize"),
io.Int.Input("max_rank", default=128, min=1, max=3072,
tooltip="Maximum allowed rank"),
io.Float.Input("target", default=0.9, min=0.1, max=1.0, step=0.01,
tooltip="Target cumulative SV retention (0.9 = 90%)"),
io.String.Input("output_filename", default="resized_lora_cumulative", tooltip="Output filename without extension, written under ComfyUI's LoRA directory."),
io.Combo.Input("save_dtype", options=["fp16", "bf16", "fp32"], default="fp16", tooltip="Data type used to save resized LoRA tensors."),
io.Combo.Input("device", options=["cuda", "cpu"], default="cuda", tooltip="Device used for per-layer resize arithmetic; CUDA out-of-memory retries the affected layer on CPU."),
io.Boolean.Input("force_clear_cache", default=False, tooltip="Clear CUDA cache after each layer (slower but saves VRAM)"),
*_resize_filter_inputs(),
parameter_input("resize:cumulative"),
],
outputs=[io.AnyType.Output(display_name="output_path")],
is_output_node=True,
)
@classmethod
def execute(cls, lora_name, max_rank, target, output_filename, save_dtype, device, force_clear_cache, exclude_patterns="", discard_patterns="", glob_patterns=False, include_mode=False, layer_parameters=None) -> io.NodeOutput:
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
dtype = {"fp16": torch.float16, "bf16": torch.bfloat16, "fp32": torch.float32}[save_dtype]
resize_lora_file(
lora_path, max_rank, "sv_cumulative", target, device, dtype, output_filename,
force_clear_cache=force_clear_cache,
exclude_patterns=exclude_patterns,
discard_patterns=discard_patterns,
glob_patterns=glob_patterns,
include_mode=include_mode,
layer_parameters=layer_parameters,
)
_, output_name = canonical_model_artifact_path("loras", output_filename)
return io.NodeOutput(output_name)
# =============================================================================
# LoRA Merge To Model (Save merged model, skip extraction)
# =============================================================================
def merge_loras_to_model(
lora_paths: List[str],
lora_weights: List[float],
base_model_path: str,
device: str,
save_dtype: torch.dtype,
output_filename: str,
skip_patterns_str: str = "",
verbose: bool = True,
lazy_load: bool = True,
force_clear_cache: bool = False,
include_1d_diffs: bool = False,
return_report: bool = False,
include_mode: bool = False,
) -> str | tuple[str, str]:
"""
Merge multiple LoRAs into a base model and save the result directly.
Unlike merge_multi_loras_via_base, this function:
- Does NOT extract the result back to a LoRA
- Saves the merged full model to the base model's directory
Args:
lora_paths: List of paths to LoRA files
lora_weights: List of weight strengths (0.0-2.0) for each LoRA
base_model_path: Path to base model
device: Processing device
save_dtype: Output dtype
output_filename: Output filename (without extension)
skip_patterns_str: Regex patterns for layers to skip
verbose: Print progress info
lazy_load: Low memory mode: load tensors from disk on demand
force_clear_cache: Clear CUDA cache after each layer
return_report: Return the path and merge report instead of only the path
include_mode: Use skip_patterns_str to select matching base keys instead
Returns:
Path to saved merged model, optionally paired with the merge report
"""
# Estimate memory and prepare
total_size_gb = estimate_model_size(base_model_path)
for lp in lora_paths:
total_size_gb += estimate_model_size(lp)
if verbose:
print(f"[LoRA Merge To Model] Preparing memory for {total_size_gb:.2f}GB operation...")
print(f"[LoRA Merge To Model] Merging {len(lora_paths)} LoRAs with weights: {lora_weights}")
prepare_for_large_operation(total_size_gb * 1.5, torch.device(device))
# Open all files - use low_memory for base model to avoid OS page caching
base_handler = MemoryEfficientSafeOpen(base_model_path, low_memory=lazy_load)
lora_handlers = [MemoryEfficientSafeOpen(lp, low_memory=lazy_load) for lp in lora_paths]
try:
base_quant = DiffusionQuantization(
base_handler, base_model_path, "LoRA Merge To Model base model"
)
base_low_bit_keys = base_quant.isolated_low_bit_keys
lora_low_bit_keys = [
inspect_low_bit_input(
handler, f"LoRA {i + 1} ({lora_paths[i]})", "LoRA Merge To Model"
)
for i, handler in enumerate(lora_handlers)
]
# Detect format and extract pairs for each LoRA
lora_infos = []
for i, handler in enumerate(lora_handlers):
keys = handler.keys()
format_info = detect_lora_format(keys)
pairs, _ = parse_lora_layers(keys)
network_dim, network_alpha = detect_lora_rank(
handler, pairs, lora_low_bit_keys[i]
)
lora_infos.append({
"index": i,
"path": lora_paths[i],
"handler": handler,
"format_info": format_info,
"pairs": pairs,
"network_dim": network_dim,
"network_alpha": network_alpha,
"weight": lora_weights[i],
"low_bit_keys": lora_low_bit_keys[i],
})
if verbose:
print(f"[LoRA Merge To Model] LoRA {i+1}: {format_info['format']}, {len(pairs)} layers, dim={network_dim}")
# Common prefixes
BASE_PREFIXES = ["model.diffusion_model.", "diffusion_model.", "transformer.", "model.", "net."]
LORA_PREFIXES = [
"lora_unet_", "lora_transformer_", "lora_te1_", "lora_te2_", "lora_te_",
"lycoris_", "model.diffusion_model.", "diffusion_model.",
"unet.base_model.model.", "base_model.model.", "transformer.", "unet."
]
def extract_core_layer_base(key: str) -> str:
result = key
if result.endswith(".weight"):
result = result[:-7]
elif result.endswith(".bias"):
result = result[:-5]
for prefix in BASE_PREFIXES:
if result.startswith(prefix):
result = result[len(prefix):]
break
return result
def extract_core_layer_lora(block_name: str) -> str:
result = block_name
changed = True
while changed:
changed = False
for prefix in LORA_PREFIXES:
if result.startswith(prefix):
result = result[len(prefix):]
changed = True
break
# Strip trailing .lora suffix (HuggingFace format leaves this after suffix removal)
if result.endswith(".lora"):
result = result[:-5]
return result.replace(".", "_")
base_keys = list(base_quant.data_keys)
base_aliases = set()
for key in base_keys:
normalized = key
for prefix in BASE_PREFIXES:
if normalized.startswith(prefix):
normalized = normalized[len(prefix):]
break
base_aliases.add(normalized)
base_aliases.add(normalized.replace(".", "_"))
# Build lookups for low-rank layers and exact direct patches.
lora_lookup = {}
direct_lookup = {}
mapped_groups = set()
applied_groups = set()
group_catalog = {}
group_events = defaultdict(list)
for info in lora_infos:
for block_name, block_keys in info["pairs"].items():
group_catalog[(info["index"], block_name)] = (info, block_keys)
selected_weight_direct = next(
(role for role in ("set_weight", "diff", "w_norm") if role in block_keys),
None,
)
if ("down" in block_keys or "up" in block_keys) and selected_weight_direct is None:
core = extract_core_layer_lora(block_name)
lora_lookup.setdefault(core, []).append((info, block_name, block_keys))
direct_roles = []
if selected_weight_direct is not None:
direct_roles.append(selected_weight_direct)
selected_bias_direct = next(
(role for role in ("diff_b", "b_norm") if role in block_keys),
None,
)
if selected_bias_direct is not None:
direct_roles.append(selected_bias_direct)
for direct_name in direct_roles:
if direct_name not in block_keys:
continue
direct_key = block_keys[direct_name]
core = block_name
changed = True
while changed:
changed = False
for prefix in LORA_PREFIXES:
if core.startswith(prefix):
core = core[len(prefix):]
changed = True
break
if direct_name in ("diff_b", "b_norm"):
targets = [f"{core}.bias"]
elif direct_name in ("w_norm", "set_weight"):
targets = [f"{core}.weight"]
else:
# Exact non-weight state keys win; module diffs fall back to .weight.
exact_aliases = {core, core.replace(".", "_")}
targets = [core] if exact_aliases & base_aliases else [f"{core}.weight"]
contribution = (info, block_name, direct_name, direct_key, block_keys)
for target in targets:
direct_lookup.setdefault(target, []).append(contribution)
underscored_target = target.replace(".", "_")
if underscored_target != target:
direct_lookup.setdefault(underscored_target, []).append(contribution)
# Compile skip patterns
skip_patterns = _compile_patterns(skip_patterns_str)
# Preserve metadata from base model
base_metadata = base_quant.output_metadata()
base_metadata["merge_comment"] = f"Merged {len(lora_paths)} LoRAs with weights: {lora_weights}"
# Build output path before loop
output_path, _ = canonical_model_artifact_path(
"diffusion_models", output_filename
)
base_outcomes = {
"PATCHED": [],
"BASE-ONLY BIASES RETAINED": [],
"OTHER BASE-ONLY TENSORS RETAINED": [],
"GUARDED": [],
"MAPPED BUT UNCHANGED": [],
"OMITTED BY SKIP PATTERN": [],
}
def record_group_event(info, block_name, status, target, reason):
group_id = (info["index"], block_name)
mapped_groups.add(group_id)
group_events[group_id].append({
"status": status,
"target": target,
"reason": reason,
})
def low_bit_description(info, block_keys):
affected = sorted(set(block_keys.values()) & info["low_bit_keys"])
return ", ".join(
f"{key} ({info['handler'].get_dtype(key)})" for key in affected
)
def group_description(group_id, events):
info, block_keys = group_catalog[group_id]
block_name = group_id[1]
sources = "\n".join(
f" source {role}: {key}"
for role, key in sorted(layer_tensor_keys(block_keys).items())
)
details = "\n".join(
f" target {event['target']}: {event['reason']}" for event in events
)
return f"L{info['index'] + 1}::{block_name}\n{sources}\n{details}"
stats = {
"merged": 0,
"base_bias_retained": 0,
"base_only_retained": 0,
"guarded_retained": 0,
"mapped_unmodified": 0,
"skipped": 0,
"disabled_1d": 0,
"shape_mismatch": 0,
}
pbar = comfy.utils.ProgressBar(len(base_keys))
if verbose:
print(f"[LoRA Merge To Model] Processing {len(base_keys)} base model keys...")
handler_map = {-1: base_handler}
handler_map.update({info["index"]: info["handler"] for info in lora_infos})
work_units = []
for planned_base_key in base_keys:
planned_core = planned_base_key
for prefix in BASE_PREFIXES:
if planned_core.startswith(prefix):
planned_core = planned_core[len(prefix):]
break
planned_direct = direct_lookup.get(planned_core, [])
if not planned_direct:
planned_direct = direct_lookup.get(planned_core.replace(".", "_"), [])
planned_low_rank = []
if planned_base_key.endswith(".weight"):
core = extract_core_layer_base(planned_base_key)
planned_low_rank = lora_lookup.get(core.replace(".", "_"), [])
skipped = any(pattern.search(planned_base_key) for pattern in skip_patterns) != include_mode
entries = {}
if planned_base_key not in base_low_bit_keys and not skipped:
entries[-1] = base_quant.required_keys(planned_base_key)
for info, _, _, direct_key, block_keys in planned_direct:
if not layer_has_low_bit(block_keys, info["low_bit_keys"]):
entries.setdefault(info["index"], []).append(direct_key)
for info, _, block_keys in planned_low_rank:
if not layer_has_low_bit(block_keys, info["low_bit_keys"]):
entries.setdefault(info["index"], []).extend(
layer_tensor_keys(block_keys).values()
)
entries = {
source: list(dict.fromkeys(keys)) for source, keys in entries.items()
}
work_units.append((planned_base_key, entries))
streamed_units = stream_work_units(
handler_map,
work_units,
pin_memory=str(device).startswith("cuda"),
)
writer_context = atomic_uel_writer(output_path, base_metadata)
writer = writer_context.__enter__()
try:
with torch.no_grad():
for base_key, loaded in tqdm(
streamed_units,
total=len(work_units),
desc="Merging to model",
unit="keys",
):
core_with_suffix = base_key
for prefix in BASE_PREFIXES:
if core_with_suffix.startswith(prefix):
core_with_suffix = core_with_suffix[len(prefix):]
break
direct_contributions = direct_lookup.get(core_with_suffix, [])
if not direct_contributions:
direct_contributions = direct_lookup.get(core_with_suffix.replace(".", "_"), [])
low_rank_contributions = []
if base_key.endswith(".weight"):
core = extract_core_layer_base(base_key)
low_rank_contributions = lora_lookup.get(core.replace(".", "_"), [])
mapped_groups.update(
(info["index"], block_name)
for info, block_name, _, _, _ in direct_contributions
)
mapped_groups.update(
(info["index"], block_name)
for info, block_name, _ in low_rank_contributions
)
if base_key in base_low_bit_keys:
write_preserved_tensor(writer, base_key, base_handler)
stats["guarded_retained"] += 1
base_outcomes["GUARDED"].append(
f"{base_key}: isolated low-bit base tensor "
f"({base_handler.get_dtype(base_key)}) preserved unchanged"
)
for info, block_name, _, direct_key, _ in direct_contributions:
record_group_event(
info, block_name, "guarded", base_key,
f"base target is low-bit; source={direct_key}",
)
for info, block_name, block_keys in low_rank_contributions:
record_group_event(
info, block_name, "guarded", base_key,
"base target is low-bit; sources="
+ ", ".join(sorted(layer_tensor_keys(block_keys).values())),
)
pbar.update(1)
continue
skip_pattern = next(
(pattern.pattern for pattern in skip_patterns if pattern.search(base_key)),
None,
)
if (skip_pattern is not None) != include_mode:
skip_reason = "no include pattern matched" if include_mode else f"pattern={skip_pattern}"
stats["skipped"] += 1
base_outcomes["OMITTED BY SKIP PATTERN"].append(
f"{base_key}: {skip_reason}"
)
for info, block_name, _, direct_key, _ in direct_contributions:
record_group_event(
info, block_name, "skipped", base_key,
f"target omitted: {skip_reason}; source={direct_key}",
)
for info, block_name, block_keys in low_rank_contributions:
record_group_event(
info, block_name, "skipped", base_key,
f"target omitted: {skip_reason}; sources="
+ ", ".join(sorted(layer_tensor_keys(block_keys).values())),
)
pbar.update(1)
continue
# Load base weight only after guarded and skipped keys are classified.
cpu_base = (
base_quant.decode(
base_key,
{source_key: loaded[(-1, source_key)] for source_key in base_quant.required_keys(base_key)},
torch.float32,
)
if base_key in base_quant.quantized_keys else loaded[(-1, base_key)]
)
if direct_contributions or low_rank_contributions:
if device == 'cuda':
base_weight = transfer_to_gpu_pinned(cpu_base, device, torch.float32)
else:
base_weight = cpu_base.to(device=device, dtype=torch.float32)
del cpu_base
base_dtype = base_quant.logical_dtype(base_key, save_dtype)
applied_1d_diff = False
applied = False
for info, block_name, direct_role, direct_key, block_keys in direct_contributions:
if layer_has_low_bit(block_keys, info["low_bit_keys"]):
record_group_event(
info, block_name, "guarded", base_key,
"adapter group contains isolated low-bit tensor(s): "
+ low_bit_description(info, block_keys),
)
continue
cpu_patch = loaded[(info["index"], direct_key)]
is_additive = direct_role != "set_weight"
if is_additive and cpu_patch.ndim == 1 and not include_1d_diffs:
stats["disabled_1d"] += 1
record_group_event(
info, block_name, "disabled_1d", base_key,
f"source={direct_key}, shape={tuple(cpu_patch.shape)}; "
"include_1d_diffs is disabled",
)
del cpu_patch
continue
if tuple(cpu_patch.shape) != tuple(base_weight.shape):
logging.warning(
"[LoRA Merge To Model] Shape mismatch for %s: %s != %s; skipped",
direct_key,
tuple(cpu_patch.shape),
tuple(base_weight.shape),
)
stats["shape_mismatch"] += 1
record_group_event(
info, block_name, "shape_mismatch", base_key,
f"source={direct_key}, source_shape={tuple(cpu_patch.shape)}, "
f"target_shape={tuple(base_weight.shape)}",
)
del cpu_patch
continue
applied_1d_diff = applied_1d_diff or (
is_additive and cpu_patch.ndim == 1
)
if device == 'cuda':
patch_tensor = transfer_to_gpu_pinned(cpu_patch, device, torch.float32)
else:
patch_tensor = cpu_patch.to(device=device, dtype=torch.float32)
del cpu_patch
if direct_role == "set_weight":
base_weight.copy_(patch_tensor)
else:
base_weight.add_(patch_tensor, alpha=info["weight"])
del patch_tensor
applied = True
applied_groups.add((info["index"], block_name))
record_group_event(
info, block_name, "applied", base_key,
f"role={direct_role}, source={direct_key}, "
f"strength={info['weight']}",
)
# Let ComfyUI's LoRAAdapter own alpha, LoCon, reshape,
# and DoRA reconstruction while retaining this node's
# streaming file pipeline.
for info, block_name, block_keys in low_rank_contributions:
if "down" not in block_keys or "up" not in block_keys:
record_group_event(
info, block_name, "load_failure", base_key,
"recognized low-rank group is missing an A/down or B/up tensor",
)
continue
if layer_has_low_bit(block_keys, info["low_bit_keys"]):
record_group_event(
info, block_name, "guarded", base_key,
"adapter group contains isolated low-bit tensor(s): "
+ low_bit_description(info, block_keys),
)
continue
tensor_keys = layer_tensor_keys(block_keys)
tensors = {
key: loaded[(info["index"], key)]
for key in tensor_keys.values()
}
down_key = block_keys["down"]
up_key = block_keys["up"]
alpha_tensor = tensors.get(block_keys.get("alpha"))
tensors[down_key], tensors[up_key] = normalize_lora_pair(
tensors[down_key],
tensors[up_key],
alpha_tensor,
layer=block_name,
)
loaded[(info["index"], down_key)] = tensors[down_key]
loaded[(info["index"], up_key)] = tensors[up_key]
if "alpha" in block_keys:
tensors.pop(block_keys["alpha"], None)
alpha = None
dora_scale = tensors.get(block_keys.get("dora_scale"))
try:
adapter = LoRAAdapter.load(
block_name,
tensors,
alpha,
dora_scale,
set(),
)
except Exception as exc:
logging.exception(
"[LoRA Merge To Model] Failed to load adapter group %s",
block_name,
)
record_group_event(
info, block_name, "load_failure", base_key,
f"{type(exc).__name__}: {exc}",
)
del tensors
continue
if adapter is None:
logging.warning(
"[LoRA Merge To Model] Recognized LoRA pair '%s' could not be loaded; skipped",
block_name,
)
record_group_event(
info, block_name, "load_failure", base_key,
"ComfyUI LoRAAdapter.load returned no adapter",
)
del tensors
continue
candidate_weight = base_weight.clone()
error_capture = AdapterErrorCapture(base_key)
root_logger = logging.getLogger()
root_logger.addHandler(error_capture)
raised_error = None
try:
candidate_weight = adapter.calculate_weight(
candidate_weight,
base_key,
info["weight"],
1.0,
None,
lambda value: value,
torch.float32,
None,
)
except Exception as exc:
raised_error = exc
logging.exception(
"[LoRA Merge To Model] Adapter calculation failed for %s",
base_key,
)
finally:
root_logger.removeHandler(error_capture)
calculation_errors = list(error_capture.messages)
if raised_error is not None:
calculation_errors.append(
f"{type(raised_error).__name__}: {raised_error}"
)
if calculation_errors:
record_group_event(
info, block_name, "calculation_failure", base_key,
" | ".join(calculation_errors),
)
del candidate_weight, adapter, tensors
continue
del base_weight
base_weight = candidate_weight
del adapter, tensors
applied = True
applied_groups.add((info["index"], block_name))
record_group_event(
info, block_name, "applied", base_key,
"ComfyUI LoRAAdapter calculation completed; sources="
+ ", ".join(sorted(tensor_keys.values()))
+ f", strength={info['weight']}",
)
target_dtype = select_output_dtype(
[base_dtype],
save_dtype,
is_1d_diff=applied_1d_diff,
)
writer.write(base_key, base_weight.to(target_dtype).cpu().contiguous())
del base_weight
stats["merged" if applied else "mapped_unmodified"] += 1
if applied:
sources = []
for group_id, events in group_events.items():
if any(
event["target"] == base_key
and event["status"] == "applied"
for event in events
):
sources.append(
f"LoRA {group_id[0] + 1}::{group_id[1]}"
)
base_outcomes["PATCHED"].append(
f"{base_key} <- {', '.join(sources)}"
)
else:
reasons = []
for events in group_events.values():
reasons.extend(
event["reason"] for event in events
if event["target"] == base_key
)
base_outcomes["MAPPED BUT UNCHANGED"].append(
f"{base_key}: {' | '.join(reasons)}"
)
else:
target_dtype = select_output_dtype(
[base_quant.logical_dtype(base_key, save_dtype)],
save_dtype,
)
writer.write(base_key, cpu_base.to(target_dtype).contiguous())
del cpu_base
if base_key.endswith(".bias"):
stats["base_bias_retained"] += 1
base_outcomes["BASE-ONLY BIASES RETAINED"].append(base_key)
else:
stats["base_only_retained"] += 1
base_outcomes["OTHER BASE-ONLY TENSORS RETAINED"].append(base_key)
if force_clear_cache:
import gc
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
pbar.update(1)
except BaseException as exc:
streamed_units.close()
writer_context.__exit__(type(exc), exc, exc.__traceback__)
raise
else:
streamed_units.close()
writer_context.__exit__(None, None, None)
adapter_outcomes = {
"APPLIED": [],
"PARTIALLY APPLIED": [],
"1D DISABLED": [],
"GUARDED": [],
"SKIPPED BY PATTERN": [],
"SHAPE MISMATCH": [],
"LOAD FAILURE": [],
"CALCULATION FAILURE": [],
"MAPPED BUT UNCHANGED": [],
"UNRESOLVED": [],
}
status_section = {
"disabled_1d": "1D DISABLED",
"guarded": "GUARDED",
"skipped": "SKIPPED BY PATTERN",
"shape_mismatch": "SHAPE MISMATCH",
"load_failure": "LOAD FAILURE",
"calculation_failure": "CALCULATION FAILURE",
}
for group_id in group_catalog:
events = group_events.get(group_id, [])
statuses = [event["status"] for event in events]
if not events:
info, block_keys = group_catalog[group_id]
sources = "\n".join(
f" source {role}: {key}"
for role, key in sorted(layer_tensor_keys(block_keys).items())
)
adapter_outcomes["UNRESOLVED"].append(
f"L{info['index'] + 1}::{group_id[1]}\n"
f"{sources}\n reason: no matching base tensor"
)
elif all(status == "applied" for status in statuses):
adapter_outcomes["APPLIED"].append(group_description(group_id, events))
elif "applied" in statuses:
adapter_outcomes["PARTIALLY APPLIED"].append(
group_description(group_id, events)
)
else:
section = next(
(
status_section[status]
for status in (
"calculation_failure", "load_failure", "shape_mismatch",
"guarded", "disabled_1d", "skipped",
)
if status in statuses
),
"MAPPED BUT UNCHANGED",
)
adapter_outcomes[section].append(group_description(group_id, events))
error_sections = (
"PARTIALLY APPLIED", "SHAPE MISMATCH", "LOAD FAILURE",
"CALCULATION FAILURE", "MAPPED BUT UNCHANGED", "UNRESOLVED",
)
exclusion_sections = ("1D DISABLED", "GUARDED", "SKIPPED BY PATTERN")
has_errors = any(adapter_outcomes[name] for name in error_sections)
has_exclusions = any(adapter_outcomes[name] for name in exclusion_sections)
if has_errors:
outcome = "COMPLETED WITH ERRORS"
elif has_exclusions:
outcome = "COMPLETED WITH EXCLUSIONS"
else:
outcome = "SUCCESS"
total_groups = len(group_catalog)
fully_applied = len(adapter_outcomes["APPLIED"])
report_lines = [
f"{outcome}: {fully_applied}/{total_groups} adapter groups fully applied",
"",
"SUMMARY",
f"- Base tensors: {len(base_keys)}",
f"- Base tensors patched: {len(base_outcomes['PATCHED'])}",
f"- Adapter groups fully applied: {fully_applied}",
f"- Adapter groups partially applied: {len(adapter_outcomes['PARTIALLY APPLIED'])}",
f"- Adapter groups excluded: {sum(len(adapter_outcomes[name]) for name in exclusion_sections)}",
f"- Adapter groups failed or unresolved: {sum(len(adapter_outcomes[name]) for name in error_sections)}",
"",
"INPUTS",
]
report_lines.extend(
f"- L{info['index'] + 1}: {os.path.basename(info['path'])}, "
f"strength={info['weight']}"
for info in lora_infos
)
base_detail_sections = (
"BASE-ONLY BIASES RETAINED",
"OTHER BASE-ONLY TENSORS RETAINED",
"GUARDED",
"MAPPED BUT UNCHANGED",
"OMITTED BY SKIP PATTERN",
)
adapter_detail_sections = tuple(
section for section in adapter_outcomes if section != "APPLIED"
)
detailed_base = [
(section, base_outcomes[section])
for section in base_detail_sections
if base_outcomes[section]
]
detailed_adapter = [
(section, adapter_outcomes[section])
for section in adapter_detail_sections
if adapter_outcomes[section]
]
report_lines.extend(["", "BASE TENSORS NOT PATCHED"])
if detailed_base:
for section, entries in detailed_base:
report_lines.append(f"[{section}] ({len(entries)})")
report_lines.extend(f"- {entry}" for entry in entries)
else:
report_lines.append("- None")
report_lines.extend(["", "ADAPTER GROUP ISSUES"])
if detailed_adapter:
for section, entries in detailed_adapter:
report_lines.append(f"[{section}] ({len(entries)})")
report_lines.extend(f"- {entry}" for entry in entries)
else:
report_lines.append("- None")
report = "\n".join(report_lines).rstrip()
if verbose:
retained = sum(len(base_outcomes[name]) for name in base_detail_sections)
issues = sum(len(entries) for _, entries in detailed_adapter)
print(
f"[LoRA Merge To Model] {outcome}: "
f"{fully_applied}/{total_groups} groups applied, "
f"{len(base_outcomes['PATCHED'])} tensors patched, "
f"{retained} base tensors not patched, {issues} adapter issues"
)
print("[LoRA Merge To Model] Detailed diagnostics returned in node output.")
print(f"[LoRA Merge To Model] Saved to {output_path}")
return (output_path, report) if return_report else output_path
finally:
base_handler.__exit__(None, None, None)
for handler in lora_handlers:
handler.__exit__(None, None, None)
cleanup_after_operation()
class LoRAMergeToModel(io.ComfyNode):
"""Merge multiple LoRAs into base model and save as full model."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="LoRAMergeToModel",
display_name="LoRA Merge To Model",
category="ModelUtils/LoRA/Merge",
description="Merge 1-8 LoRAs into a base model and save the result. Saves to base model directory.",
inputs=[
io.Combo.Input("base_model", options=folder_paths.get_filename_list("diffusion_models"),
tooltip="Base model the LoRAs were trained on"),
io.Combo.Input("lora_count", options=["1", "2", "3", "4", "5", "6", "7", "8"], default="2",
tooltip="Number of LoRAs to merge"),
# LoRA 1
io.Combo.Input("lora_1", options=folder_paths.get_filename_list("loras"),
tooltip="First LoRA"),
io.Float.Input("weight_1", default=1.0, min=-10.0, max=10.0, step=0.01,
tooltip="Weight strength for LoRA 1"),
# LoRA 2
io.Combo.Input("lora_2", options=["None"] + folder_paths.get_filename_list("loras"),
default="None", tooltip="Second LoRA"),
io.Float.Input("weight_2", default=1.0, min=-10.0, max=10.0, step=0.01,
tooltip="Weight strength for LoRA 2"),
# LoRA 3
io.Combo.Input("lora_3", options=["None"] + folder_paths.get_filename_list("loras"),
default="None", tooltip="Third LoRA"),
io.Float.Input("weight_3", default=1.0, min=-10.0, max=10.0, step=0.01,
tooltip="Weight strength for LoRA 3"),
# LoRA 4
io.Combo.Input("lora_4", options=["None"] + folder_paths.get_filename_list("loras"),
default="None", tooltip="Fourth LoRA"),
io.Float.Input("weight_4", default=1.0, min=-10.0, max=10.0, step=0.01,
tooltip="Weight strength for LoRA 4"),
# LoRA 5
io.Combo.Input("lora_5", options=["None"] + folder_paths.get_filename_list("loras"),
default="None", tooltip="Fifth LoRA"),
io.Float.Input("weight_5", default=1.0, min=-10.0, max=10.0, step=0.01,
tooltip="Weight strength for LoRA 5"),
# LoRA 6
io.Combo.Input("lora_6", options=["None"] + folder_paths.get_filename_list("loras"),
default="None", tooltip="Sixth LoRA"),
io.Float.Input("weight_6", default=1.0, min=-10.0, max=10.0, step=0.01,
tooltip="Weight strength for LoRA 6"),
# LoRA 7
io.Combo.Input("lora_7", options=["None"] + folder_paths.get_filename_list("loras"),
default="None", tooltip="Seventh LoRA"),
io.Float.Input("weight_7", default=1.0, min=-10.0, max=10.0, step=0.01,
tooltip="Weight strength for LoRA 7"),
# LoRA 8
io.Combo.Input("lora_8", options=["None"] + folder_paths.get_filename_list("loras"),
default="None", tooltip="Eighth LoRA"),
io.Float.Input("weight_8", default=1.0, min=-10.0, max=10.0, step=0.01,
tooltip="Weight strength for LoRA 8"),
# Settings
io.String.Input("skip_patterns", default="", multiline=True,
tooltip="Regex patterns for base tensor keys to omit, or to include when Include Mode is enabled. Guarded low-bit base tensors are always preserved."),
io.String.Input("output_filename", default="merged_model", tooltip="Output filename without extension, written under ComfyUI's diffusion-model directory."),
io.Combo.Input("save_dtype", options=["fp16", "bf16", "fp32"], default="fp16", tooltip="Data type used to save the model after applying the LoRA."),
io.Combo.Input("device", options=["cuda", "cpu"], default="cuda", tooltip="Device used for per-layer LoRA application; CUDA out-of-memory retries the affected layer on CPU."),
io.Boolean.Input("lazy_load", default=True, tooltip="Low memory mode: load tensors from disk on demand"),
io.Boolean.Input("force_clear_cache", default=False, tooltip="Clear CUDA cache after each layer (slower but saves VRAM)"),
io.Boolean.Input("include_1d_diffs", default=False,
tooltip="Apply 1D direct-diff tensors as FP32. Disabled preserves prior behavior."),
io.Boolean.Input("include_mode", default=False,
tooltip="Use Skip Patterns as an include-only filter. Only matching base tensors are processed and saved; an empty filter selects nothing. Guarded low-bit base tensors remain preserved."),
],
outputs=[
io.AnyType.Output(display_name="output_path"),
io.String.Output(display_name="merge_report"),
],
is_output_node=True,
)
@classmethod
def execute(cls, base_model, lora_count,
lora_1, weight_1, lora_2, weight_2, lora_3, weight_3, lora_4, weight_4,
lora_5, weight_5, lora_6, weight_6, lora_7, weight_7, lora_8, weight_8,
skip_patterns, output_filename, save_dtype, device, lazy_load, force_clear_cache,
include_1d_diffs, include_mode=False) -> io.NodeOutput:
# Build LoRA list based on count
count = int(lora_count)
lora_names = [lora_1, lora_2, lora_3, lora_4, lora_5, lora_6, lora_7, lora_8][:count]
lora_weights = [weight_1, weight_2, weight_3, weight_4, weight_5, weight_6, weight_7, weight_8][:count]
# Filter out "None" entries
valid_loras = [(name, weight) for name, weight in zip(lora_names, lora_weights) if name != "None"]
if not valid_loras:
raise ValueError("At least one LoRA must be selected")
lora_names, lora_weights = zip(*valid_loras)
lora_paths = [folder_paths.get_full_path_or_raise("loras", name) for name in lora_names]
base_path = folder_paths.get_full_path_or_raise("diffusion_models", base_model)
dtype = {"fp16": torch.float16, "bf16": torch.bfloat16, "fp32": torch.float32}[save_dtype]
path, report = merge_loras_to_model(
lora_paths=list(lora_paths),
lora_weights=list(lora_weights),
base_model_path=base_path,
device=device,
save_dtype=dtype,
output_filename=output_filename,
skip_patterns_str=skip_patterns,
lazy_load=lazy_load,
force_clear_cache=force_clear_cache,
include_1d_diffs=include_1d_diffs,
return_report=True,
include_mode=include_mode,
)
_, output_name = canonical_model_artifact_path("diffusion_models", output_filename)
return io.NodeOutput(output_name, report)