AB overlap

This commit is contained in:
JJ
2026-03-31 20:40:35 +01:00
parent f334ab749c
commit 7f97af8467
11 changed files with 3124 additions and 84 deletions
+1
View File
@@ -13,6 +13,7 @@ from .nodes.video_inpaint import *
from .nodes.matanyone2 import *
from .nodes.lora_extract_v2 import *
from .nodes.lora_merge_ltx import *
from .nodes.lora_change import *
from .nodes.lora_combine import *
from .nodes.math_nodes import *
+591
View File
@@ -0,0 +1,591 @@
"""
LTX2 LoRA Select - Simplified LTX2 LoRA loader with metadata pass-through.
Designed for daisy-chaining multiple LoRA loaders with combined metadata tracking.
"""
import os
import sys
from pathlib import Path
# Add parent directory to sys.path to import utility module
parent_dir = Path(__file__).resolve().parent.parent
if str(parent_dir) not in sys.path:
sys.path.insert(0, str(parent_dir))
import torch
import json
from safetensors.torch import save_file
import comfy.lora
import comfy.utils
import comfy.sd
import folder_paths
from utility.ltx import merge_lora_state_dicts, count_layer_types
class LoadModelMeta:
"""
Loads a checkpoint model and stores the full directory path in metadata.
Similar to checkpoint loader but with metadata tracking.
"""
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"checkpoint_name": (
folder_paths.get_filename_list("checkpoints"),
{"tooltip": "The name of the checkpoint file."},
),
},
}
RETURN_TYPES = ("MODEL",)
FUNCTION = "load_checkpoint"
CATEGORY = "KJNodes/ltxv"
def load_checkpoint(self, checkpoint_name):
"""
Load checkpoint and store the full path in model_options.
"""
# Get full path to checkpoint
checkpoint_path = folder_paths.get_full_path("checkpoints", checkpoint_name)
# Load the checkpoint
model = comfy.sd.load_checkpoint_guess_config(
checkpoint_path, output_vae=True, output_clip=True
)[0]
# Store checkpoint path in model_options for LTX2LoRASelect to read
if not hasattr(model, "model_options"):
model.model_options = {}
# Store the full path as checkpoint_name
model.model_options["checkpoint_name"] = checkpoint_path
return (model,)
class LTX2LoRASelect:
"""
Simplified LTX2 LoRA loader with per-block strength control.
Outputs model with attached metadata for daisy-chaining multiple LoRA loaders.
"""
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"lora_name": (
folder_paths.get_filename_list("loras"),
{"tooltip": "The name of the LoRA file."},
),
"model": (
"MODEL",
{"tooltip": "The diffusion model to apply LoRA to."},
),
"strength_model": (
"FLOAT",
{
"default": 1.0,
"min": -100.0,
"max": 100.0,
"step": 0.01,
"tooltip": "Overall LoRA strength multiplier.",
},
),
"video": (
"FLOAT",
{
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": "Strength for video attention layers.",
},
),
"video_to_audio": (
"FLOAT",
{
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": "Strength for video to audio cross-attention layers.",
},
),
"audio": (
"FLOAT",
{
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": "Strength for audio attention layers.",
},
),
"audio_to_video": (
"FLOAT",
{
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": "Strength for audio to video cross-attention layers.",
},
),
"other": (
"FLOAT",
{
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": "Strength for layers not caught by other layer filters.",
},
),
},
}
RETURN_TYPES = ("MODEL",)
FUNCTION = "apply_lora"
CATEGORY = "KJNodes/ltxv"
def apply_lora(
self,
model,
lora_name,
strength_model,
video,
video_to_audio,
audio,
audio_to_video,
other,
):
"""
Load and apply LoRA with layer-specific strength control.
Attaches metadata to the model for daisy-chaining multiple LoRA loaders.
"""
# Load LoRA file
lora_path = folder_paths.get_full_path("loras", lora_name)
lora = comfy.utils.load_torch_file(lora_path, safe_load=True)
# Get rank info for metadata
rank = "unknown"
weight_key = next((key for key in lora.keys() if key.endswith("weight")), None)
if weight_key:
rank = str(lora[weight_key].shape[0])
# Build key map for current model
key_map = {}
if model is not None:
key_map = comfy.lora.model_lora_keys_unet(model.model, key_map)
# Load LoRA weights
loaded = comfy.lora.load_lora(lora, key_map)
keys_to_delete = []
# Apply layer-based attention strength filtering
for key in list(loaded.keys()):
if key in keys_to_delete:
continue
key_str = (
key
if isinstance(key, str)
else (key[0] if isinstance(key, tuple) else str(key))
)
# Determine strength multiplier based on layer name
strength_multiplier = None
# Video to audio cross-attention (check first - most specific)
if "video_to_audio_attn" in key_str:
strength_multiplier = video_to_audio
# Audio to video cross-attention
elif "audio_to_video_attn" in key_str:
strength_multiplier = audio_to_video
# Audio layers
elif "audio_attn" in key_str or "audio_ff.net" in key_str:
strength_multiplier = audio
# Video layers (check last - most general)
elif "attn" in key_str or "ff.net" in key_str:
strength_multiplier = video
# Everything else not caught by above filters
else:
strength_multiplier = other
# Apply strength or mark for deletion
if strength_multiplier is not None:
if strength_multiplier == 0:
keys_to_delete.append(key)
elif strength_multiplier != 1.0:
value = loaded[key]
if hasattr(value, "weights"):
weights_list = list(value.weights)
# Handle case where alpha (weights[2]) might be None
current_alpha = (
weights_list[2] if weights_list[2] is not None else 1.0
)
weights_list[2] = current_alpha * strength_multiplier
loaded[key].weights = tuple(weights_list)
for key in keys_to_delete:
if key in loaded:
del loaded[key]
# Apply patches to model
if model is not None:
new_modelpatcher = model.clone()
new_modelpatcher.add_patches(loaded, strength_model)
# Get connected model info for metadata
model_id = "unknown"
checkpoint_path = ""
if hasattr(model, "model_options"):
# Try to get checkpoint path from model_options (set by LoadModelMeta)
checkpoint_path = model.model_options.get("checkpoint_name", "")
# Get model type as fallback
if hasattr(model.model, "model_config"):
model_id = str(type(model.model).__name__)
elif hasattr(model.model, "load_device"):
model_id = f"{type(model.model).__name__}"
elif hasattr(model, "model"):
if hasattr(model.model, "model_config"):
model_id = str(type(model.model).__name__)
elif hasattr(model.model, "load_device"):
model_id = f"{type(model.model).__name__}"
# Create and attach metadata for daisy-chaining
lora_metadata = {
"lora_name": lora_name,
"strength_model": strength_model,
"video": video,
"video_to_audio": video_to_audio,
"audio": audio,
"audio_to_video": audio_to_video,
"other": other,
"rank": rank,
"applied_keys_count": len(loaded),
"connected_model": model_id,
"checkpoint_path": checkpoint_path,
}
# Attach metadata to model_options for daisy-chaining
if not hasattr(new_modelpatcher, "model_options"):
new_modelpatcher.model_options = {}
# Preserve checkpoint_name from input model
if checkpoint_path:
new_modelpatcher.model_options["checkpoint_name"] = checkpoint_path
if "ltx2_lora_chain" not in new_modelpatcher.model_options:
new_modelpatcher.model_options["ltx2_lora_chain"] = []
# Append this LoRA's metadata to the chain
new_modelpatcher.model_options["ltx2_lora_chain"].append(lora_metadata)
else:
new_modelpatcher = None
return (new_modelpatcher,)
class LoraMergeLTX:
"""
Merges multiple LoRAs that were applied via LTX2LoRASelect into a single LoRA file.
This node reads the ltx2_lora_chain metadata from a model that has had
multiple LoRAs applied, then merges them into a single safetensors file.
"""
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model_with_loras": (
"MODEL",
{"tooltip": "The model with LoRAs applied (from LTX2LoRASelect)."},
),
"output_prefix": (
"STRING",
{
"default": "merged_lora",
"tooltip": "Prefix for the merged LoRA filename.",
},
),
"rank": (
"INT",
{
"default": 64,
"min": 1,
"max": 512,
"step": 8,
"tooltip": "Target rank of the merged LoRA.",
},
),
"output_dir": (
"STRING",
{
"default": "loras",
"tooltip": "Output directory (relative to ComfyUI root). Use 'loras' for auto-discovery.",
},
),
"merge_on_model": (
"BOOLEAN",
{
"default": False,
"tooltip": "Merge on model weights (more accurate) vs pure LoRA SVD merge (faster).",
},
),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("merged_lora_path",)
FUNCTION = "merge_loras"
CATEGORY = "KJNodes/ltxv"
OUTPUT_NODE = True
def merge_loras(self, model_with_loras, output_prefix, rank, output_dir, merge_on_model):
"""
Merge multiple LoRAs from ltx2_lora_chain into a single safetensors file.
The merge process:
1. Extract the base model's state dict
2. For each LoRA in the chain, compute its effective weight contribution
(considering layer-specific strengths)
3. Sum all contributions to get merged delta weights
4. Apply SVD decomposition to factorize into up/down matrices at target rank
5. Save as safetensors with metadata
"""
# Validate input
if model_with_loras is None:
raise ValueError("No model provided for merging")
if not hasattr(model_with_loras, "model_options"):
raise ValueError("Model has no model_options attribute")
lora_chain = model_with_loras.model_options.get("ltx2_lora_chain", [])
if not lora_chain:
raise ValueError(
"No LoRA chain found in model options. Apply LoRAs first using LTX2LoRASelect."
)
print(f"\n{'=' * 60}")
print(f"LTX2 LoRA Merge - Starting")
print(f"{'=' * 60}")
print(f"Found {len(lora_chain)} LoRA(s) to merge:")
for i, lora_info in enumerate(lora_chain, 1):
lora_name = lora_info.get('lora_name', 'unknown')
strength_model = lora_info.get('strength_model', 0)
layer_strs = [
f"v:{lora_info.get('video', 1.0):.2f}",
f"a:{lora_info.get('audio', 1.0):.2f}",
f"v>a:{lora_info.get('video_to_audio', 1.0):.2f}",
f"a>v:{lora_info.get('audio_to_video', 1.0):.2f}",
f"o:{lora_info.get('other', 1.0):.2f}",
]
print(
f" {i}. {lora_name} (strength: {strength_model:.2f} | {', '.join(layer_strs)})"
)
print(f"Target rank: {rank}")
print(f"Output prefix: {output_prefix}")
print(f"Output directory: {output_dir}")
print(f"Merge mode: {'Model-weight (accurate)' if merge_on_model else 'LoRA SVD (fast)'}")
print(f"{'=' * 60}\n")
# Determine output path early to show user where file will be saved
output_path = self._get_output_path(output_prefix, output_dir)
print(f"Output path: {output_path}")
print()
# Get device for computation
device = "cuda" if torch.cuda.is_available() else "cpu"
# Load all LoRA files and build merged state dict
lora_data_list = [] # List of (lora_sd, metadata, layer_strengths)
for lora_info in lora_chain:
lora_name = lora_info.get("lora_name", "")
if not lora_name:
continue
# Load LoRA file
try:
lora_path = folder_paths.get_full_path("loras", lora_name)
lora_sd, _ = self._load_lora_file(lora_path)
layer_strengths = {
"video": lora_info.get("video", 1.0),
"video_to_audio": lora_info.get("video_to_audio", 1.0),
"audio": lora_info.get("audio", 1.0),
"audio_to_video": lora_info.get("audio_to_video", 1.0),
"other": lora_info.get("other", 1.0),
}
lora_data_list.append(
{
"sd": lora_sd,
"name": lora_name,
"strength_model": lora_info.get("strength_model", 1.0),
"layer_strengths": layer_strengths,
}
)
except Exception as e:
print(f"Warning: Could not load LoRA '{lora_name}': {e}")
continue
if not lora_data_list:
raise ValueError("No valid LoRAs found to merge")
# Get checkpoint path for model-weight merge
checkpoint_path = model_with_loras.model_options.get("checkpoint_name", "")
# Debug: Show sample LoRA keys from first loaded LoRA
if lora_data_list:
sample_keys = list(lora_data_list[0]["sd"].keys())[:5]
print(f"Sample LoRA keys from '{lora_data_list[0]['name']}':")
for k in sample_keys:
print(f" - {k}")
# Build merged state dict by combining all LoRA contributions
if merge_on_model and checkpoint_path:
print(f"Checkpoint path: {checkpoint_path}")
if not Path(checkpoint_path).exists():
raise ValueError(f"Checkpoint path not found: {checkpoint_path}")
merged_sd = merge_lora_state_dicts(
lora_data_list, rank, device, checkpoint_path=checkpoint_path
)
else:
if merge_on_model:
print("Warning: merge_on_model=True but no checkpoint path found. Using LoRA SVD merge instead.")
merged_sd = merge_lora_state_dicts(lora_data_list, rank, device)
# Print layer count summary
print(f"\nMerge Summary:")
print(f" Total merged: {len(merged_sd) // 3} layers")
# Save merged LoRA
self._save_merged_lora(merged_sd, output_path, lora_chain, rank)
print(f"\n{'=' * 60}")
print(f"Merge complete!")
print(f"Output: {output_path}")
print(f"{'=' * 60}\n")
return (str(output_path),)
def _load_lora_file(self, path):
"""Load a LoRA file (safetensors or pickle)."""
from safetensors import safe_open as st_safe_open
path = Path(path)
if path.suffix == ".safetensors":
with st_safe_open(path, framework="pt", device="cpu") as f:
sd = {k: f.get_tensor(k) for k in f.keys()}
metadata = f.metadata()
else:
sd = torch.load(path, map_location="cpu", weights_only=True)
if isinstance(sd, dict) and "state_dict" in sd:
sd = sd["state_dict"]
metadata = None
return sd, metadata
def _get_output_path(self, prefix, output_dir="loras"):
"""
Determine the output path for the merged LoRA.
Args:
prefix: Filename prefix
output_dir: Output directory - either 'output' for ComfyUI output folder,
'loras' for ComfyUI loras folder, or a custom relative/absolute path
"""
from datetime import datetime
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{prefix}_{timestamp}.safetensors"
# Handle special directory names
if output_dir.lower() == "loras":
# Save to ComfyUI loras folder for auto-discovery
try:
loras_dirs = folder_paths.get_folder_paths("loras")
if loras_dirs:
target_dir = os.path.join(loras_dirs[0], prefix)
os.makedirs(target_dir, exist_ok=True)
return os.path.join(target_dir, filename)
except Exception as e:
print(f"Warning: Could not access loras directory: {e}")
# Fallback to output/loras if folder_paths doesn't work
output_base = folder_paths.get_output_directory()
target_dir = os.path.join(output_base, "loras", prefix)
elif output_dir.lower() == "output":
# Save to ComfyUI output folder using standard method
try:
full_output_folder, _, _, _ = folder_paths.get_save_image_path(prefix, None)
return os.path.join(full_output_folder, filename)
except Exception as e:
print(f"Warning: Could not get save image path: {e}")
output_base = folder_paths.get_output_directory()
target_dir = os.path.join(output_base, prefix)
else:
# Custom directory - treat as relative to ComfyUI output
output_base = folder_paths.get_output_directory()
target_dir = os.path.join(output_base, output_dir, prefix)
os.makedirs(target_dir, exist_ok=True)
return os.path.join(target_dir, filename)
def _save_merged_lora(self, merged_sd, output_path, lora_chain, rank):
"""
Save the merged LoRA as a safetensors file with metadata.
"""
# Build metadata
metadata = {
"ss_network_module": "diffusers",
"ss_network_dim": str(rank),
"ss_network_alpha": str(rank),
"ss_merge_source": "LTX2LoRASelect Merge",
"ss_merged_loras": json.dumps([l.get("lora_name", "unknown") for l in lora_chain]),
"format": "diffusers",
}
# Convert tensors to cpu and ensure proper format
processed_sd = {}
for k, v in merged_sd.items():
if isinstance(v, torch.Tensor):
processed_sd[k] = v.cpu().contiguous()
else:
processed_sd[k] = v
# Save as safetensors
save_file(processed_sd, str(output_path), metadata=metadata)
# Register the nodes
NODE_CLASS_MAPPINGS = {
"LoadModelMeta": LoadModelMeta,
"LTX2LoRASelect": LTX2LoRASelect,
"LoraMergeLTX": LoraMergeLTX,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"LoadModelMeta": "Load Model Meta",
"LTX2LoRASelect": "LTX2 LORA Select",
"LoraMergeLTX": "Lora Merge LTX",
}
+1102 -84
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
"""LTX utilities for LoRA operations."""
from .core.ops import (
merge_linear_block,
decompose_to_rank,
resize_lora_block,
apply_dare,
apply_magprune,
apply_lambda_scaling,
apply_ties,
decoupled_magnitude_direction_merge
)
from .core.merger import merge_lora_state_dicts, count_layer_types
from .utils.io import load_lora, save_lora
from .utils.naming import (
LORA_DOWN_UP_FORMATS,
is_audio_layer,
is_video_ff_layer,
is_video_only_layer,
normalize_lora_key,
build_block_map
)
from .utils.progress import ProgressBar
__all__ = [
'merge_linear_block',
'decompose_to_rank',
'resize_lora_block',
'apply_dare',
'apply_magprune',
'apply_lambda_scaling',
'apply_ties',
'decoupled_magnitude_direction_merge',
'merge_lora_state_dicts',
'count_layer_types',
'load_lora',
'save_lora',
'LORA_DOWN_UP_FORMATS',
'is_audio_layer',
'is_video_ff_layer',
'is_video_only_layer',
'normalize_lora_key',
'build_block_map',
'ProgressBar'
]
+23
View File
@@ -0,0 +1,23 @@
"""LTX core operations for LoRA merging."""
from .ops import (
merge_linear_block,
decompose_to_rank,
resize_lora_block,
apply_dare,
apply_magprune,
apply_lambda_scaling,
apply_ties,
decoupled_magnitude_direction_merge
)
__all__ = [
'merge_linear_block',
'decompose_to_rank',
'resize_lora_block',
'apply_dare',
'apply_magprune',
'apply_lambda_scaling',
'apply_ties',
'decoupled_magnitude_direction_merge'
]
+436
View File
@@ -0,0 +1,436 @@
"""LoRA merging functionality for LTX models."""
import torch
from ..utils.progress import ProgressBar
from ..utils.naming import LORA_DOWN_UP_FORMATS
DEFAULT_PROCESS_DTYPE = torch.bfloat16
def get_layer_strength(block_name, layer_strengths):
"""
Get the appropriate strength multiplier for a given layer name.
Args:
block_name: Name of the layer block
layer_strengths: Dictionary of layer-specific strengths
Returns:
The strength multiplier for this layer type
"""
key_str = str(block_name).lower()
# Video to audio cross-attention (most specific)
if "video_to_audio_attn" in key_str:
return layer_strengths["video_to_audio"]
# Audio to video cross-attention (new in updated LTX model)
elif "audio_to_video_attn" in key_str:
return layer_strengths["audio_to_video"]
# Audio attention layers
elif ".audio_attn" in key_str or "audio_ff.net" in key_str:
return layer_strengths["audio"]
# Video attention and FF layers (most general)
elif ".attn" in key_str or ".ff.net" in key_str:
return layer_strengths["video"]
# Everything else
else:
return layer_strengths["other"]
def merge_on_model_weights(all_blocks, lora_data_list, target_rank, device, checkpoint_path, show_progress):
"""
Merge LoRAs on model weights (more accurate than pure LoRA SVD merge).
Algorithm:
1. Load base model weight for current block only (block swapping to save VRAM)
2. Apply LoRAs to base weight: W_modified = W_base + sum(strength_i * (up_i @ down_i))
3. Extract delta: delta = W_modified - W_base
4. Apply SVD to delta to get new up/down matrices
5. Move result to CPU, free memory, repeat for next block
Args:
all_blocks: Dictionary of block_name -> list of LoRA data
lora_data_list: List of LoRA data dictionaries
target_rank: Target rank for merged LoRA
device: Device to use for computation
checkpoint_path: Path to base model checkpoint
show_progress: Whether to show progress bar
Returns:
Merged state dictionary
"""
from safetensors.torch import safe_open
if show_progress:
print(f"Loading base model from: {checkpoint_path}")
# Open checkpoint once, but load weights one at a time (block swapping)
with safe_open(checkpoint_path, framework="pt", device="cpu") as f:
checkpoint_keys = list(f.keys())
if show_progress:
print(f"Checkpoint has {len(checkpoint_keys)} total keys")
merged_sd = {}
total_blocks = len(all_blocks)
if show_progress:
print(f"\nMerging {total_blocks} layers on model weights (block-swapping mode)...")
progress_bar = ProgressBar(total_blocks, "Model-weight merge")
progress_bar.start()
processed_count = 0
skipped_count = 0
for block_idx, (block_name, blocks) in enumerate(all_blocks.items()):
if not blocks:
continue
is_conv2d = blocks[0]["is_conv2d"]
down_name = blocks[0]["down_name"]
up_name = blocks[0]["up_name"]
# Map LoRA keys to model keys
model_key = (
down_name
.replace(".lora_down", "")
.replace(".lora_up", "")
.replace(".lora_A", "")
.replace(".lora_B", "")
.replace(".down", "")
.replace(".up", "")
)
# Handle checkpoint prefix mismatch
if not model_key.startswith("model."):
model_key = "model." + model_key
if model_key not in f.keys():
skipped_count += 1
if show_progress and skipped_count <= 3:
print(f" Warning: Model key not found for block '{block_name}'")
print(f" Expected checkpoint key: {model_key}")
if show_progress:
progress_bar.update(1)
continue
# Load ONLY this block's weight from checkpoint (block swapping!)
base_weight = f.get_tensor(model_key).to(DEFAULT_PROCESS_DTYPE).to(device)
# Start with base weight
modified_weight = base_weight.clone()
# Apply each LoRA: W += strength * (up @ down)
for block in blocks:
strength = block["strength"]
if abs(strength) < 1e-6:
continue
down = block["down"].to(device).to(DEFAULT_PROCESS_DTYPE)
up = block["up"].to(device).to(DEFAULT_PROCESS_DTYPE)
if is_conv2d:
down_flat = down.reshape(down.shape[0], -1)
up_flat = up.reshape(up.shape[0], -1)
delta = (up_flat @ down_flat) * strength
delta = delta.reshape(base_weight.shape[0], base_weight.shape[1],
base_weight.shape[2], base_weight.shape[3])
else:
delta = (up @ down) * strength
modified_weight += delta
# Extract delta
delta_weight = modified_weight - base_weight
# Clear memory before SVD
del base_weight, modified_weight
if str(device) == "cuda":
torch.cuda.empty_cache()
if delta_weight.norm() < 1e-8:
# Exception: preserve to_gate_logits layers even with low norm
is_gate_logits = "to_gate_logits" in block_name.lower()
if not is_gate_logits:
del delta_weight
if show_progress:
progress_bar.update(1)
continue
# Apply SVD to factorize at target rank (SVD requires float32)
# For to_gate_logits, use higher effective rank to preserve information
is_gate_logits = "to_gate_logits" in block_name.lower()
effective_rank = target_rank if not is_gate_logits else max(target_rank * 4, 256)
if is_conv2d:
out_c = delta_weight.shape[0]
in_c = delta_weight.shape[1]
kh, kw = delta_weight.shape[2], delta_weight.shape[3]
delta_flat = delta_weight.reshape(out_c, -1)
U, S, Vh = torch.linalg.svd(delta_flat.float())
actual_rank = min(effective_rank, len(S), U.shape[1], Vh.shape[0])
U = U[:, :actual_rank] @ torch.diag(S[:actual_rank])
Vh = Vh[:actual_rank, :]
new_up = U.reshape(out_c, actual_rank, 1, 1).to(DEFAULT_PROCESS_DTYPE).cpu()
new_down = Vh.reshape(actual_rank, in_c, kh, kw).to(DEFAULT_PROCESS_DTYPE).cpu()
else:
U, S, Vh = torch.linalg.svd(delta_weight.float())
actual_rank = min(effective_rank, len(S), U.shape[1], Vh.shape[0])
U = U[:, :actual_rank] @ torch.diag(S[:actual_rank])
Vh = Vh[:actual_rank, :]
new_up = U.to(DEFAULT_PROCESS_DTYPE).cpu()
new_down = Vh.to(DEFAULT_PROCESS_DTYPE).cpu()
# Clear memory and save to CPU
del delta_weight, U, S, Vh
if str(device) == "cuda":
torch.cuda.empty_cache()
merged_sd[down_name] = new_down
merged_sd[up_name] = new_up
merged_sd[f"{block_name}.alpha"] = torch.tensor(actual_rank, dtype=torch.int)
processed_count += 1
if show_progress:
progress_bar.update(1)
if show_progress:
progress_bar.finish()
print(f" Processed: {processed_count} layers, Skipped (not in model): {skipped_count} layers")
return merged_sd
def merge_lora_state_dicts(lora_data_list, target_rank, device, show_progress=True, checkpoint_path=None):
"""
Merge multiple LoRA state dicts into one.
For each block (down/up pair), we:
1. Compute the full weight matrix: W = up @ down for each LoRA
2. Apply layer-specific and overall strength multipliers
3. Sum all contributions
4. Apply SVD to factorize at target rank
Args:
lora_data_list: List of dictionaries with 'sd', 'strength_model', and 'layer_strengths'
target_rank: Target rank for the merged LoRA
device: Device to use for computation
show_progress: Whether to show progress bar
Returns:
Merged state dictionary
"""
# Build a map of block_name -> list of (down, up, alpha) from each LoRA
all_blocks = {}
for lora_data in lora_data_list:
sd = lora_data["sd"]
strength_model = lora_data["strength_model"]
layer_strengths = lora_data["layer_strengths"]
for key in sd.keys():
if key.endswith(".alpha"):
continue
key_parts = key.split(".")
for fmt_down, fmt_up in LORA_DOWN_UP_FORMATS:
if len(key_parts) >= 2 and key_parts[-2] == fmt_down:
block_name = ".".join(key_parts[:-2])
weight_suffix = (
"." + key_parts[-1]
if key_parts[-1] in ["weight", "bias"]
else ""
)
up_key = f"{block_name}.{fmt_up}{weight_suffix}"
if up_key in sd:
if block_name not in all_blocks:
all_blocks[block_name] = []
# Get layer-specific strength
layer_str = get_layer_strength(block_name, layer_strengths)
combined_strength = strength_model * layer_str
all_blocks[block_name].append({
"down": sd[key],
"up": sd[up_key],
"alpha": sd.get(f"{block_name}.alpha"),
"strength": combined_strength,
"is_conv2d": len(sd[key].shape) == 4,
"down_name": key,
"up_name": up_key,
})
break
# Model-weight merge if checkpoint_path is provided
if checkpoint_path:
return merge_on_model_weights(
all_blocks, lora_data_list, target_rank, device, checkpoint_path, show_progress
)
# Initialize merged state dict
merged_sd = {}
# Separate single-LoRA blocks from multi-LoRA blocks
single_lora_blocks = []
multi_lora_blocks = []
for block_name, blocks in all_blocks.items():
if len(blocks) == 1:
single_lora_blocks.append((block_name, blocks[0]))
else:
multi_lora_blocks.append((block_name, blocks))
# Process single-LoRA blocks (no SVD needed, just scale)
for block_name, block in single_lora_blocks:
strength = block["strength"]
is_conv2d = block["is_conv2d"]
down_name = block["down_name"]
up_name = block["up_name"]
alpha = block["alpha"]
# For single LoRA, just scale the down matrix (faster than SVD)
merged_sd[down_name] = block["down"] * strength
merged_sd[up_name] = block["up"]
if alpha is not None:
merged_sd[f"{block_name}.alpha"] = alpha * strength
# Process multi-LoRA blocks with SVD
if multi_lora_blocks:
total_blocks = len(multi_lora_blocks)
if show_progress:
print(f"\nMerging {total_blocks} multi-LoRA layers...")
progress_bar = ProgressBar(total_blocks, "Merging")
progress_bar.start()
processed_count = 0
for block_name, blocks in multi_lora_blocks:
if not blocks:
continue
processed_count += 1
is_conv2d = blocks[0]["is_conv2d"]
down_name = blocks[0]["down_name"]
up_name = blocks[0]["up_name"]
# Compute merged weight matrix by summing all contributions
merged_weight = None
for block in blocks:
if abs(block["strength"]) < 1e-6:
continue
down = block["down"].to(device).to(DEFAULT_PROCESS_DTYPE)
up = block["up"].to(device).to(DEFAULT_PROCESS_DTYPE)
strength = block["strength"]
if is_conv2d:
# Flatten conv for matrix multiplication
up_flat = up.reshape(up.shape[0], -1)
down_flat = down.reshape(down.shape[0], -1)
weight_contrib = (up_flat @ down_flat) * strength
else:
weight_contrib = (up @ down) * strength
if merged_weight is None:
merged_weight = weight_contrib
else:
merged_weight = merged_weight + weight_contrib
if merged_weight is None or merged_weight.norm() < 1e-8:
# Exception: preserve to_gate_logits layers even with low norm
# (they may have important gating information)
is_gate_logits = "to_gate_logits" in block_name.lower()
if not is_gate_logits:
if show_progress:
progress_bar.update(1)
continue
# Apply SVD to factorize at target rank
# For to_gate_logits, use higher effective rank to preserve information
is_gate_logits = "to_gate_logits" in block_name.lower()
effective_rank = target_rank if not is_gate_logits else max(target_rank * 4, 256)
if is_conv2d:
out_c = merged_weight.shape[0]
in_c = merged_weight.shape[1]
# Get kernel size from original down
_, _, kh, kw = blocks[0]["down"].shape
U, S, Vh = torch.linalg.svd(merged_weight.float())
actual_rank = min(effective_rank, len(S), U.shape[1], Vh.shape[0])
# Truncate and apply singular values to U
U = U[:, :actual_rank] @ torch.diag(S[:actual_rank])
Vh = Vh[:actual_rank, :]
new_up = U.reshape(out_c, actual_rank, 1, 1).to(DEFAULT_PROCESS_DTYPE).cpu()
new_down = Vh.reshape(actual_rank, in_c, kh, kw).to(DEFAULT_PROCESS_DTYPE).cpu()
else:
U, S, Vh = torch.linalg.svd(merged_weight.float())
actual_rank = min(effective_rank, len(S), U.shape[1], Vh.shape[0])
# Truncate and apply singular values to U
U = U[:, :actual_rank] @ torch.diag(S[:actual_rank])
Vh = Vh[:actual_rank, :]
new_up = U.to(DEFAULT_PROCESS_DTYPE).cpu()
new_down = Vh.to(DEFAULT_PROCESS_DTYPE).cpu()
# Store in merged state dict
merged_sd[down_name] = new_down
merged_sd[up_name] = new_up
merged_sd[f"{block_name}.alpha"] = torch.tensor(actual_rank, dtype=torch.int)
if show_progress:
progress_bar.update(1)
if show_progress:
progress_bar.finish()
if show_progress:
progress_bar.finish()
return merged_sd
def count_layer_types(all_blocks):
"""
Count the number of layers by type.
Args:
all_blocks: Dictionary of block_name -> list of blocks
Returns:
Dictionary with layer type counts
"""
layer_counts = {
"video": 0,
"audio": 0,
"video_to_audio": 0,
"audio_to_video": 0,
"other": 0,
}
for block_name in all_blocks.keys():
key_lower = block_name.lower()
if "video_to_audio_attn" in key_lower:
layer_counts["video_to_audio"] += 1
elif "audio_to_video_attn" in key_lower:
layer_counts["audio_to_video"] += 1
elif ".audio_attn" in key_lower or "audio_ff.net" in key_lower:
layer_counts["audio"] += 1
elif ".attn" in key_lower or ".ff.net" in key_lower:
layer_counts["video"] += 1
else:
layer_counts["other"] += 1
return layer_counts
+552
View File
@@ -0,0 +1,552 @@
import torch
DEFAULT_PROCESS_DTYPE = torch.bfloat16
def merge_linear_block(block_loras, device, process_dtype=DEFAULT_PROCESS_DTYPE):
"""Core math for merging Linear LoRA layers."""
first_lora = block_loras[0]
in_rank, in_size = first_lora["down"].shape
out_size, out_rank = first_lora["up"].shape
merged_weight = torch.zeros(out_size, in_size, dtype=process_dtype, device=device)
for lora_data in block_loras:
strength = lora_data["strength"]
if abs(strength) < 1e-6: continue
lora_down = lora_data["down"].to(process_dtype).to(device)
lora_up = lora_data["up"].to(process_dtype).to(device)
merged_weight += (lora_up @ lora_down) * strength
return merged_weight
def decompose_to_rank(merged_weight, target_rank):
"""Performs SVD and truncates to target_rank."""
U, S, Vh = torch.linalg.svd(merged_weight.float())
actual_rank = min(target_rank, len(S))
U = U[:, :actual_rank] @ torch.diag(S[:actual_rank])
Vh = Vh[:actual_rank, :]
return U, Vh, actual_rank
def resize_lora_block(lora_down, lora_up, new_rank, alpha=None, device="cpu", process_dtype=DEFAULT_PROCESS_DTYPE, create_alpha=True):
"""Resize a single LoRA block to new rank using SVD.
Args:
lora_down: Down projection weight tensor
lora_up: Up projection weight tensor
new_rank: Target rank for resize
alpha: Original alpha value (tensor or scalar)
device: Device to use for computation
process_dtype: Data type for computation
create_alpha: If True, calculate and return new_alpha. If False, return None.
Returns:
tuple: (new_down, new_up, new_alpha) where new_alpha may be None if create_alpha=False
"""
conv2d = len(lora_down.size()) == 4
lora_down = lora_down.to(process_dtype).to(device)
lora_up = lora_up.to(process_dtype).to(device)
if conv2d:
out_size, in_rank_up, _, _ = lora_up.shape
in_rank_down, in_size, kh, kw = lora_down.shape
merged = lora_up.reshape(out_size, -1) @ lora_down.reshape(in_rank_down, -1)
else:
merged = lora_up @ lora_down
U, Vh, actual_rank = decompose_to_rank(merged, new_rank)
if conv2d:
new_down = Vh.reshape(actual_rank, in_size, kh, kw).cpu()
new_up = U.reshape(out_size, actual_rank, 1, 1).cpu()
else:
new_down = Vh.cpu()
new_up = U.cpu()
# Only calculate alpha if requested
new_alpha = None
if create_alpha:
orig_rank = lora_down.shape[0]
alpha_val = alpha.item() if isinstance(alpha, torch.Tensor) else (alpha or orig_rank)
new_alpha = float(alpha_val * actual_rank / orig_rank)
return new_down, new_up, new_alpha
def apply_dare(weight, drop_rate=0.9):
"""
Applies DARE (Drop and REscale) to a weight matrix.
Args:
weight: The reconstructed weight matrix (Delta).
drop_rate: Proportion of weights to drop (0.9 = 90% dropped).
Returns:
The thinned weight matrix with same dtype as input.
"""
if drop_rate <= 0:
return weight
original_dtype = weight.dtype
weight = weight.float()
# Create a random mask the same shape as the weight
mask = torch.bernoulli(torch.full_like(weight, 1 - drop_rate))
# Apply mask and rescale
rescale_factor = 1 / (1 - drop_rate)
result = (weight * mask) * rescale_factor
return result.to(original_dtype)
def apply_magprune(weight, drop_rate=0.9, epsilon=0.5, row_wise=True):
"""
Applies MagPrune (DELLA-style magnitude-based pruning) to a weight matrix.
DELLA = Drop and rEscaLe via sampLing with mAgnitude
Instead of random dropping (DARE), ranks weights by magnitude and assigns
higher drop probability to low-magnitude weights. Each weight gets its own
rescaling factor based on its drop probability.
Args:
weight: The reconstructed weight matrix (Delta).
drop_rate: Average drop probability (p). Default 0.9 = 90% average drop.
epsilon: Spread of drop probabilities. Higher = more aggressive magnitude bias.
0.0 = uniform (same as DARE), 1.0 = wide spread.
row_wise: If True, rank within each row (better per DELLA paper).
If False, rank globally across the entire matrix.
Returns:
The thinned weight matrix with same dtype as input.
Reference:
"DELLA-Merging: Reducing Interference in Model Merging through Magnitude-Based Sampling"
https://arxiv.org/abs/2406.11617
"""
if drop_rate <= 0:
return weight
if drop_rate >= 1:
return torch.zeros_like(weight)
original_dtype = weight.dtype
weight = weight.float()
# Step 1: Rank weights by magnitude
if row_wise:
# Row-wise ranking (better per DELLA paper)
# For each row, rank elements by their absolute magnitude
n = weight.shape[1]
# argsort twice to get ranks: 0 = highest magnitude, n-1 = lowest
ranks = torch.argsort(torch.argsort(weight.abs(), dim=1, descending=True), dim=1)
# Calculate drop probabilities: p_i = p_min + (epsilon/n) * rank_i
# where p_min = drop_rate - epsilon/2
p_min = drop_rate - epsilon / 2
delta = epsilon / n
drop_probs = p_min + delta * ranks
else:
# Global ranking across the entire matrix
flat = weight.flatten()
ranks = torch.argsort(torch.argsort(flat.abs(), descending=True))
n = len(flat)
p_min = drop_rate - epsilon / 2
delta = epsilon / n
drop_probs_flat = p_min + delta * ranks
drop_probs = drop_probs_flat.reshape(weight.shape)
# Clamp probabilities to valid range [0, 1]
drop_probs = drop_probs.clamp(0, 1)
# Step 2: Sample mask and apply per-weight rescaling
mask = torch.bernoulli(1 - drop_probs)
# Per-weight rescaling factor: 1 / (1 - p_i)
rescale_factor = 1 / (1 - drop_probs)
# Avoid division by zero
rescale_factor = torch.where(drop_probs >= 1, torch.zeros_like(rescale_factor), rescale_factor)
result = (weight * mask) * rescale_factor
return result.to(original_dtype)
def apply_lambda_scaling(weight, lambda_val=1.0, mode="uniform", min_scale=None, top_k=None):
"""
Applies lambda scaling to a weight matrix with multiple modes.
DELLA found that constant lambda scaling after merge significantly
improves performance over adaptive scaling. The optimal lambda is
typically found by sweeping values like [0.5, 0.7, 1.0, 1.3, 1.5].
Args:
weight: The weight matrix to scale.
lambda_val: Scaling factor. 1.0 = no change.
< 1.0 = dampen, > 1.0 = amplify.
mode: Scaling mode. Options:
- "uniform": Constant scaling (default, original behavior)
- "magnitude": Magnitude-aware scaling - preserves small weights
- "singular": Scale only top-k singular values
- "clipped": Prevents weights from being scaled below threshold
min_scale: For "clipped" mode, minimum scale factor (e.g., 0.5)
top_k: For "singular" mode, number of singular values to scale (None = all)
Returns:
The scaled weight matrix with same dtype as input.
Reference:
DELLA-Merging paper, Section 4 "Adaptive vs Constant lambda"
"""
if lambda_val == 1.0:
return weight
if mode == "uniform":
return weight * lambda_val
original_dtype = weight.dtype
weight_float = weight.float()
if mode == "magnitude":
# Magnitude-aware scaling: Scale less aggressively for small weights
# This preserves fine details while still applying scaling to strong weights
with torch.no_grad():
abs_weight = weight_float.abs()
max_abs = abs_weight.max().clamp(min=1e-8)
# Normalize to [0, 1], then apply magnitude-aware scaling
# Small weights get scale closer to 1.0, large weights get full lambda
normalized = abs_weight / max_abs
# Interpolate between 1.0 and lambda based on magnitude
# Small magnitude -> scale closer to 1.0 (preserve)
# Large magnitude -> scale closer to lambda (full effect)
scale_per_weight = 1.0 + (lambda_val - 1.0) * normalized
result = weight_float * scale_per_weight
return result.to(original_dtype)
elif mode == "singular":
# Singular value scaling: Scale only the top-k singular values
# This preserves the rank structure while still applying scaling
U, S, Vh = torch.linalg.svd(weight_float, full_matrices=False)
# Scale singular values
S_scaled = S.clone()
if top_k is None:
S_scaled = S * lambda_val
else:
k = min(top_k, len(S))
S_scaled[:k] = S[:k] * lambda_val
# Remaining singular values unchanged
# Reconstruct
result = U @ torch.diag(S_scaled) @ Vh
return result.to(original_dtype)
elif mode == "clipped":
# Clipped scaling: Ensure small weights aren't scaled below threshold
# min_scale acts as a floor - no weight gets scaled more than this
if min_scale is None:
min_scale = 0.5
# For lambda < 1 (reduction), min_scale is the maximum reduction
# For lambda > 1 (amplification), we clamp the lower bound
if lambda_val < 1.0:
# When reducing, don't reduce below min_scale
effective_scale = max(lambda_val, min_scale)
else:
# When amplifying, min_scale prevents small weights from getting too small
# This is more about preserving the minimum magnitude
with torch.no_grad():
abs_weight = weight_float.abs()
max_abs = abs_weight.max().clamp(min=1e-8)
normalized = abs_weight / max_abs
# Small weights get less amplification
scale_per_weight = 1.0 + (lambda_val - 1.0) * normalized
# Apply minimum scale floor
scale_per_weight = scale_per_weight.clamp(min=min_scale)
result = weight_float * scale_per_weight
return result.to(original_dtype)
return weight * effective_scale
else:
raise ValueError(f"Unknown scaling mode: {mode}. Choose from: uniform, magnitude, singular, clipped")
def apply_magnitude_scaling(weight, lambda_val=1.0):
"""
Magnitude-aware scaling: Preserves small weights while scaling large ones.
Small weights are scaled less aggressively than large weights, preventing
fine details from vanishing when lambda < 1.0, and preventing small weights
from becoming too large when lambda > 1.0.
Args:
weight: The weight matrix to scale.
lambda_val: Scaling factor (1.0 = no change).
Returns:
The scaled weight matrix with same dtype as input.
"""
return apply_lambda_scaling(weight, lambda_val, mode="magnitude")
def apply_singular_value_scaling(weight, lambda_val=1.0, top_k=None):
"""
Singular value scaling: Scales only the top-k singular values.
This preserves the rank structure of the weight matrix while applying
scaling to the most important directions. The singular values represent
the "energy" in each direction - scaling them preserves the structure.
Args:
weight: The weight matrix to scale.
lambda_val: Scaling factor (1.0 = no change).
top_k: Number of top singular values to scale (None = all).
Returns:
The scaled weight matrix with same dtype as input.
"""
return apply_lambda_scaling(weight, lambda_val, mode="singular", top_k=top_k)
def apply_clipped_scaling(weight, lambda_val=1.0, min_scale=0.5):
"""
Clipped scaling: Prevents weights from being scaled below a threshold.
When reducing weights (lambda < 1.0), ensures no weight is scaled below
min_scale. This prevents small weights from vanishing completely.
Args:
weight: The weight matrix to scale.
lambda_val: Scaling factor (1.0 = no change).
min_scale: Minimum scale factor (default 0.5).
Returns:
The scaled weight matrix with same dtype as input.
"""
return apply_lambda_scaling(weight, lambda_val, mode="clipped", min_scale=min_scale)
# ============================================================================
# DO-Merge: Magnitude & Direction Decoupling (from AIT/MergeOrthogonalProcess)
# ============================================================================
def frobenius_norm_product(U: torch.Tensor, D: torch.Tensor) -> torch.Tensor:
"""
Computes the Frobenius norm of (U @ D) in O(R^2 * d) time instead of O(d^2) time.
norm(U @ D)^2 = tr(D^T U^T U D) = tr(U^T U D D^T)
This is used in DO-Merge to efficiently compute LoRA magnitudes without
expanding to the full [d_out, d_in] matrix.
Args:
U: Up projection matrix [d_out, rank]
D: Down projection matrix [rank, d_in]
Returns:
Frobenius norm of the product U @ D
"""
UT_U = torch.matmul(U.t(), U)
D_DT = torch.matmul(D, D.t())
return torch.sqrt(torch.abs(torch.trace(torch.matmul(UT_U, D_DT))))
def trace_product(U1: torch.Tensor, D1: torch.Tensor, U2: torch.Tensor, D2: torch.Tensor) -> torch.Tensor:
"""
Computes the trace of (U1 @ D1)^T @ (U2 @ D2) efficiently.
tr(D1^T U1^T U2 D2) = tr(D2 D1^T U1^T U2)
This measures the alignment between two LoRA weight matrices without
expanding to full size. Used in DO-Merge to compute directional similarity.
Args:
U1, D1: Up and down matrices of first LoRA
U2, D2: Up and down matrices of second LoRA
Returns:
Trace value representing alignment (-1 to 1 range approximately)
"""
U1T_U2 = torch.matmul(U1.t(), U2)
D2_D1T = torch.matmul(D2, D1.t())
return torch.trace(torch.matmul(D2_D1T, U1T_U2))
def decoupled_magnitude_direction_merge(
W_up1: torch.Tensor, W_up2: torch.Tensor,
W_down1: torch.Tensor, W_down2: torch.Tensor
) -> tuple[torch.Tensor, torch.Tensor, int]:
"""
Magnitude/Direction Decoupling (DO-Merge style) with Exact Rank Preservation.
This solves the issue where one LoRA has massive weight magnitudes and overpowers
the other during structure merging, even if their directional vectors are aligned.
By distributing the calculated DO-Merge scalars directly into the down-projection
matrices, we achieve mathematically perfect concatenation with ZERO data loss,
ZERO approximation error, and instantaneous calculation speed (bypassing SVD).
Algorithm:
1. Calculate true magnitudes efficiently using frobenius_norm_product
2. Calculate direction alignment via trace_product
3. Compute balanced target magnitude using geometric mean
4. Calculate scaling factors for each LoRA to achieve exact DO-Merge equation
5. Apply scalars and concatenate (100% exact math, 0 data loss, 0 SVD)
Args:
W_up1: Up matrix of first LoRA [d_out, rank1]
W_up2: Up matrix of second LoRA [d_out, rank2]
W_down1: Down matrix of first LoRA [rank1, d_in]
W_down2: Down matrix of second LoRA [rank2, d_in]
Returns:
tuple: (lora_up_new, lora_down_new, optimal_rank)
- lora_up_new: Concatenated up matrix [d_out, rank1 + rank2]
- lora_down_new: Scaled and concatenated down matrix [rank1 + rank2, d_in]
- optimal_rank: rank1 + rank2 (exact preservation)
Reference:
DO-Merge: "Decoupling Magnitude and Direction for Efficient Model Merging"
AIT implementation: MergeOrthogonalProcess.py lines 61-100
"""
# 1. Calculate true magnitudes efficiently without expanding to full matrices
mag1 = frobenius_norm_product(W_up1.float(), W_down1.float()) + 1e-8
mag2 = frobenius_norm_product(W_up2.float(), W_down2.float()) + 1e-8
# 2. Calculate the direction sum norm efficiently
# norm(dir1 + dir2)^2 = 2 + 2 * tr(dir1^T dir2)
cross_trace = trace_product(W_up1.float(), W_down1.float(), W_up2.float(), W_down2.float())
norm_dir_sum_sq = 2.0 + 2.0 * cross_trace / (mag1 * mag2)
norm_dir_sum_sq = torch.clamp(norm_dir_sum_sq, min=1e-8)
norm_dir_sum = torch.sqrt(norm_dir_sum_sq)
# 3. Calculate the balanced target magnitude (Geometric Mean)
merged_mag = torch.sqrt(mag1 * mag2)
# 4. Calculate final scaling factors for each LoRA to achieve the exact DO-Merge equation
global_scalar = merged_mag / norm_dir_sum
scale1 = global_scalar / mag1
scale2 = global_scalar / mag2
# 5. Apply scalars to the down matrices and concatenate (100% exact math, 0 data loss, 0 SVD)
down1_new = W_down1.float() * scale1
down2_new = W_down2.float() * scale2
lora_up_new = torch.cat([W_up1.float(), W_up2.float()], dim=1)
lora_down_new = torch.cat([down1_new, down2_new], dim=0)
optimal_rank = lora_up_new.shape[1] # rank1 + rank2 exactly
return lora_up_new.to(W_up1.dtype), lora_down_new.to(W_down1.dtype), optimal_rank
def apply_ties_delta_scaling(delta_weights, densities, lambda_val=1.0):
"""
Applies DELLA-style lambda scaling to TIES-merged deltas.
After the TIES merge (Trim, Elect, Merge), applies a constant
lambda scaling factor to boost or dampen the merged result.
Args:
delta_weights: List of reconstructed weight matrices (Deltas) from each LoRA
densities: List of densities (0.0 to 1.0) for trimming
lambda_val: Post-merge scaling factor
Returns:
Scaled merged weight matrix.
"""
merged = apply_ties(delta_weights, densities)
return apply_lambda_scaling(merged, lambda_val)
def apply_ties(delta_weights, densities):
"""
Applies TIES (Trim, Elect, and Merge) to multiple weight matrices.
TIES treats weight deltas like a democratic election to decide which
LoRA gets to control which specific parameter.
Args:
delta_weights: List of reconstructed weight matrices (Deltas) from each LoRA
densities: List of floats (0.0 to 1.0) for trimming noise per LoRA.
0.2 = keep top 20% most significant weights, zero out the rest.
Returns:
A single consensus weight matrix.
The three stages:
1. Trim: Keep only top k% magnitudes per LoRA (remove noise)
2. Elect: Sign consensus - positive vs negative "votes" per parameter
3. Merge: Disjoint merge - average only values that agree with winning sign
"""
if len(delta_weights) == 0:
raise ValueError("No delta weights provided")
if len(delta_weights) == 1:
return delta_weights[0]
# Store original dtype for consistent output
original_dtype = delta_weights[0].dtype
# ==============================
# Stage 1: Trim - Keep only top k% magnitudes per LoRA
# ==============================
trimmed_deltas = []
for delta, density in zip(delta_weights, densities):
if density >= 1.0:
trimmed_deltas.append(delta)
continue
if density <= 0.0:
trimmed_deltas.append(torch.zeros_like(delta))
continue
# Convert to float for consistent threshold computation
delta_float = delta.float()
flat_delta = delta_float.abs().flatten()
# Calculate k (number of elements to keep)
k = int(density * flat_delta.numel())
if k == 0:
trimmed_deltas.append(torch.zeros_like(delta))
continue
# Get threshold value at the k-th position
threshold = torch.topk(flat_delta, k).values[-1]
# Create mask and apply
mask = (delta_float.abs() >= threshold).to(delta_float.dtype)
trimmed_deltas.append((delta_float * mask).to(delta.dtype))
# ==============================
# Stage 2: Elect - Sign Consensus
# ==============================
stacked_deltas = torch.stack(trimmed_deltas)
signs = torch.sign(stacked_deltas)
# Sum the magnitudes for positive and negative directions
# This creates the "vote count" for each direction
pos_mask = (signs > 0).float()
neg_mask = (signs < 0).float()
pos_sum = (stacked_deltas.abs() * pos_mask).sum(dim=0)
neg_sum = (stacked_deltas.abs() * neg_mask).sum(dim=0)
# The winning sign is the one with more total magnitude
final_sign = torch.where(pos_sum >= neg_sum, 1.0, -1.0)
# ==============================
# Stage 3: Disjoint Merge - Average only agreeing values
# ==============================
# Create mask where LoRA agrees with the winning sign
consensus_mask = (signs == final_sign).float()
# Mask out disagreeing values and sum across LoRAs
sum_agreed = (stacked_deltas * consensus_mask).sum(dim=0)
# Count how many LoRAs contributed to each parameter's agreement
count_agreed = consensus_mask.sum(dim=0).clamp(min=1.0)
# Average only the values that agreed with the winning sign
result = sum_agreed / count_agreed
return result.to(original_dtype)
+22
View File
@@ -0,0 +1,22 @@
"""LTX utilities for I/O and naming."""
from .io import load_lora, save_lora
from .naming import (
LORA_DOWN_UP_FORMATS,
is_audio_layer,
is_video_ff_layer,
is_video_only_layer,
normalize_lora_key,
build_block_map
)
__all__ = [
'load_lora',
'save_lora',
'LORA_DOWN_UP_FORMATS',
'is_audio_layer',
'is_video_ff_layer',
'is_video_only_layer',
'normalize_lora_key',
'build_block_map'
]
+65
View File
@@ -0,0 +1,65 @@
import torch
from safetensors.torch import save_file, safe_open
from pathlib import Path
from typing import Optional, Dict
def load_lora(path, device="cpu"):
"""Load LoRA from safetensors or pytorch file."""
path = Path(path)
if path.suffix == ".safetensors":
with safe_open(path, framework="pt", device=device) as f:
sd = {k: f.get_tensor(k) for k in f.keys()}
metadata = f.metadata()
else:
sd = torch.load(path, map_location=device)
if isinstance(sd, dict) and "state_dict" in sd:
sd = sd["state_dict"]
metadata = None
return sd, metadata
def save_lora(sd, output_path, metadata=None, dtype=None, output_format="lora"):
"""
Save state dict to safetensors with optional conversion.
Args:
sd: State dict to save
output_path: Output file path
metadata: Optional metadata dict
dtype: Optional dtype for conversion
output_format: Format to save as - "lora" or "lokr"
"""
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
# Convert to LoKR format if requested
if output_format == "lokr":
from ltx_merger.utils.lokr import convert_lora_to_lokr
sd = convert_lora_to_lokr(sd)
# Update metadata for LoKR
from ltx_merger.utils.lokr import get_lokr_metadata
factor = None
conv_dim = None
# Try to extract factor from metadata or state dict
if metadata:
if "ss_network_dim" in metadata:
factor = int(metadata.get("ss_network_dim", factor))
if "lokr_conv_dim" in metadata:
conv_dim = int(metadata.get("lokr_conv_dim", conv_dim))
metadata = get_lokr_metadata(factor=factor, conv_dim=conv_dim, original_metadata=metadata)
processed_sd = {}
for k, v in sd.items():
if isinstance(v, torch.Tensor):
v = v.cpu()
if dtype is not None and v.dtype.is_floating_point:
v = v.to(dtype)
processed_sd[k] = v
else:
processed_sd[k] = v
save_file(processed_sd, str(output_path), metadata=metadata)
+203
View File
@@ -0,0 +1,203 @@
import torch
LORA_DOWN_UP_FORMATS = [
("lora_down", "lora_up"), # sd-scripts LoRA
("lora_A", "lora_B"), # PEFT LoRA
("down", "up"), # ControlLoRA
]
def is_audio_layer(key):
"""Check if a key corresponds to an audio/video layer."""
# Use dot-notation checks to avoid false positives (e.g., "audio" in other contexts)
key_lower = key.lower()
return (
".audio_attn" in key_lower or
"audio_ff" in key_lower or
"audio_to_video_attn" in key_lower or
"video_to_audio_attn" in key_lower
)
def is_audio_ff_layer(key: str) -> bool:
"""Detect audio FF layers specifically (audio_ff in key)."""
return ".audio_ff." in key
def is_video_ff_layer(key: str) -> bool:
"""Detect video FF layers (ff layers that are NOT audio_ff).
Note: to_gate_logits is part of attention layers (not FF), so we
explicitly exclude it from FF classification.
"""
# Check for actual FF/MLP layer patterns (using dot notation to avoid false matches)
key_lower = key.lower()
# Exclude to_gate_logits (part of attention, not FF)
if "to_gate_logits" in key_lower:
return False
# Look for actual FF/MLP patterns (e.g., ".ff.", ".feed_forward.", ".mlp.", ".gate.")
# Using dot notation avoids false matches like "ff" in "diffusion_model"
# Also exclude audio_ff since those are audio layers, not video FF
has_ff_pattern = (".ff." in key_lower or ".feed_forward." in key_lower or ".mlp." in key_lower or ".gate." in key_lower)
is_not_audio_ff = ".audio_ff." not in key_lower
return has_ff_pattern and is_not_audio_ff
# ============================================================================
# Semantic Layer Detection (for AIT-style smart merging)
# ============================================================================
def is_cross_attention_layer(key: str) -> bool:
"""
Detect cross-attention layers (prompt routing).
Cross-attention layers handle text-to-image/video conditioning and act
as "trigger routers" - they control which prompts activate which features.
These benefit from Bilateral Subspace Orthogonalization (BSO) to prevent
trigger conflicts between merged LoRAs.
"""
key_lower = key.lower()
return any(
indicator in key_lower
for indicator in ["attn2", "txt_attn", "context", "txt_in", "enc"]
)
def is_temporal_layer(key: str) -> bool:
"""
Detect temporal/motion layers.
Temporal layers handle video motion and timing. Like audio, they act as
style controllers and benefit from BSO to prevent motion style conflicts.
"""
key_lower = key.lower()
return any(
indicator in key_lower
for indicator in ["temp", "time", "video", "motion"]
)
def is_mlp_layer(key: str) -> bool:
"""
Detect MLP/structural layers.
MLP layers handle the actual feature transformation (not routing). They
benefit from Magnitude/Direction Decoupling (DO-Merge) to prevent one
LoRA's magnitude from overpowering another.
Note: to_gate_logits is part of attention layers (not MLP/FF), so we
explicitly exclude it from MLP classification.
"""
key_lower = key.lower()
# Exclude to_gate_logits (part of attention, not MLP/FF)
if "to_gate_logits" in key_lower:
return False
# Look for actual FF/MLP patterns (using dot notation to avoid false matches)
# Note: audio_ff is also an MLP/FF layer, so we include it
return any(
f".{indicator}." in key_lower
for indicator in ["ff", "audio_ff", "feed_forward", "mlp", "gate"]
)
def is_video_only_layer(key: str) -> bool:
"""
Detect video-only layers (non-FF, non-audio).
Video-only layers are cross-attention, temporal, and other structural
layers that are NOT audio layers and NOT FF/MLP layers.
"""
return not is_audio_layer(key) and not is_mlp_layer(key)
def get_layer_semantic_type(key: str) -> str:
"""
Determine the semantic type of a layer for smart merging.
Returns:
"cross_attn": Cross-attention (prompt routing) → use BSO
"audio": Audio layers → use BSO
"temporal": Temporal/motion layers → use BSO
"mlp": Structural/MLP layers → use DO-Merge
"other": Other layers → default behavior
"""
if is_audio_layer(key):
return "audio"
if is_cross_attention_layer(key):
return "cross_attn"
if is_temporal_layer(key):
return "temporal"
if is_mlp_layer(key):
return "mlp"
return "other"
def normalize_lora_key(key):
"""Normalize a LoRA key to a standard format for matching."""
normalized = key
for fmt in LORA_DOWN_UP_FORMATS:
normalized = normalized.replace(f".{fmt[0]}", ".lora_down")
normalized = normalized.replace(f".{fmt[1]}", ".lora_up")
return normalized
def get_layer_info(lora_sd):
"""Analyze LoRA layers and count different types."""
layer_counts = {
"attn1": 0, "attn2": 0, "ff": 0, "audio": 0, "other": 0, "total": 0
}
blocks = set()
for key in lora_sd.keys():
if key.endswith(".alpha"): continue
key_parts = key.split(".")
for fmt_down, _ in LORA_DOWN_UP_FORMATS:
if len(key_parts) >= 2 and fmt_down == key_parts[-2]:
block_name = ".".join(key_parts[:-2])
if block_name not in blocks:
blocks.add(block_name)
if is_audio_layer(block_name):
layer_counts["audio"] += 1
elif "attn1" in block_name:
layer_counts["attn1"] += 1
elif "attn2" in block_name:
layer_counts["attn2"] += 1
elif any(x in block_name for x in ["ff", "feed_forward"]):
layer_counts["ff"] += 1
else:
layer_counts["other"] += 1
break
layer_counts["total"] = len(blocks)
return layer_counts
def build_block_map(lora_sd):
"""
Build a block map from a single LoRA state dict.
Returns dict mapping block_name -> block_data with down/up weights.
Used by both pruner.py and merger.py.
"""
blocks = {}
for key in lora_sd.keys():
if key.endswith(".alpha"): continue
key_parts = key.split(".")
for fmt_down, fmt_up in LORA_DOWN_UP_FORMATS:
if len(key_parts) >= 2 and fmt_down == key_parts[-2]:
block_name = ".".join(key_parts[:-2])
# Determine suffix (.weight or empty)
weight_suffix = "." + key_parts[-1] if key_parts[-1] in ["weight", "bias"] else ""
up_key = f"{block_name}.{fmt_up}{weight_suffix}"
if up_key in lora_sd:
blocks[block_name] = {
"down": lora_sd[key],
"up": lora_sd[up_key],
"alpha": lora_sd.get(f"{block_name}.alpha"),
"down_name": key,
"up_name": up_key,
"is_conv2d": len(lora_sd[key].shape) == 4,
"is_audio": is_audio_layer(block_name)
}
break
return blocks
+81
View File
@@ -0,0 +1,81 @@
"""Unified progress bar for all LTX merger operations."""
import sys
import time
class ProgressBar:
"""Simple, reusable progress bar for console output."""
def __init__(self, total, desc="Processing", width=40):
self.total = total
self.current = 0
self.desc = desc
self.width = width
self.start_time = None
def start(self):
"""Initialize the progress bar."""
self.start_time = time.time()
self.update(0)
def update(self, n=1):
"""Advance progress by n steps."""
self.current += n
self._draw()
def set(self, value):
"""Set progress to specific value."""
self.current = value
self._draw()
def _draw(self):
"""Draw the progress bar."""
if self.total == 0:
return
percent = min(1.0, self.current / self.total)
filled = int(self.width * percent)
bar = "█" * filled + "░" * (self.width - filled)
# Calculate elapsed time
elapsed = time.time() - self.start_time if self.start_time else 0
if self.current > 0 and percent < 1.0:
eta = elapsed / percent * (1 - percent)
time_str = f" {int(eta)}s"
else:
time_str = ""
# Clear line and draw
sys.stdout.write(f"\r{self.desc}: [{bar}] {self.current}/{self.total} ({percent*100:.1f}%){time_str}")
sys.stdout.flush()
# New line when complete
if self.current >= self.total:
sys.stdout.write(f"\n{self.desc} completed in {int(elapsed)}s\n")
sys.stdout.flush()
def finish(self):
"""Force completion of the progress bar."""
self.current = self.total
self._draw()
def progress_iterator(items, desc="Processing"):
"""Wrap an iterable with a progress bar.
Usage:
for item in progress_iterator(my_list, "Loading"):
process(item)
"""
total = len(items) if hasattr(items, '__len__') else None
if total is None:
# No progress bar for unknown length iterables
for item in items:
yield item
return
bar = ProgressBar(total, desc)
bar.start()
for item in items:
yield item
bar.update(1)