Files
2026-05-19 22:27:40 +03:00

1047 lines
53 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Checkpoint Triple Merging Methods for Easy Checkpoint Merger
Adapted from triple_methods.py for full model checkpoints (no LoRA-specific scaling).
This module is now a thin wrapper around engine.triple_merge_core,
preserving the same public API (merge_triple_method returning 2 values)
while eliminating ~700 lines of duplicated merge function bodies.
"""
import gc
import math
import time
from typing import List, Dict, Optional, Tuple, Callable
import torch
# Import cleanup_memory for GPU memory management before returning to weaver
try:
from ..utils import cleanup_memory
except ImportError:
from utils import cleanup_memory
# Maximum reasonable single tensor size: 4 GB
# Tensors exceeding this are likely corrupted (absurd shape metadata).
# Prevents CUDA OOM from corrupt safetensors headers.
MAX_TENSOR_BYTES = 4 * 1024**3
# ── Phase 3: Density sparsification guard ──────────────────────────
# Maximum tensor size (bytes) for density sparsification via torch.topk.
# Beyond this, topk on full-size checkpoint weights becomes prohibitively
# expensive (O(n log k) on tensors with millions of elements).
MAX_SPARSIFY_BYTES = 50 * 1024 * 1024 # 50 MB
# ── Phase 3: Shape-based batching ──────────────────────────────────
# Minimum number of same-shaped keys required to attempt batched merge
# (torch.stack → single merge call → unstack).
MIN_BATCH_SHAPE_GROUP_SIZE = 2
# ── Phase 3: Methods incompatible with shape batching ──────────────
# These methods have dimension-specific logic (SVD decomposition, block
# dimensions) that produces incorrect results when stacking produces 3D
# [batch, rows, cols] tensors. They fall back to the safe sequential path.
# - svd_preserve: batched SVD misinterprets batch dim (Bug 1)
# - block_swap: dimension-specific logic assumes 2D/4D only (Bug 2)
BATCH_INCOMPATIBLE_METHODS = {"svd_preserve", "block_swap"}
from ..config import MergeMethodRegistry
from ..utils import (
categorize_checkpoint_key as categorize_key,
apply_component_scaling,
compute_component_energy_ratios,
DeviceManager,
comfyui_yield,
)
from .methods import (
apply_magnitude_scaling,
ensure_shape_match,
)
# ── Import shared merge functions and helpers from unified core ──────────
from .triple_merge_core import (
merge_linear,
merge_cross,
merge_ties_strict,
merge_ties_gentle,
merge_ties_contrast,
merge_slerp,
merge_feature_mix,
merge_magnitude,
merge_subtract,
merge_dare_rescale,
merge_dare_lite,
merge_svd_preserve,
merge_block_swap,
merge_noise_aware,
merge_gradient_alignment,
ensure_energy_preservation_triple,
MERGE_METHOD_REGISTRY as _CORE_MERGE_REGISTRY,
)
# ── Backward-compatible aliases (so internal references still work) ─────
# These allow `triple_merge_svd_preserve` to call `triple_merge_linear`,
# and `merge_triple_method` to use `triple_merge_linear` as fallback.
triple_merge_linear = merge_linear
triple_merge_cross = merge_cross
triple_merge_ties_strict = merge_ties_strict
triple_merge_ties_gentle = merge_ties_gentle
triple_merge_ties_contrast = merge_ties_contrast
triple_merge_slerp = merge_slerp
triple_merge_feature_mix = merge_feature_mix
triple_merge_magnitude = merge_magnitude
triple_merge_subtract = merge_subtract
triple_merge_dare_rescale = merge_dare_rescale
triple_merge_dare_lite = merge_dare_lite
triple_merge_svd_preserve = merge_svd_preserve
triple_merge_block_swap = merge_block_swap
triple_merge_noise_aware = merge_noise_aware
triple_merge_gradient_alignment = merge_gradient_alignment
# ==================== HELPER FUNCTIONS ====================
def adjust_ranks(tensors: List[torch.Tensor], key: str) -> List[torch.Tensor]:
"""
For checkpoints, rank adjustment is not needed (no LoRA rank).
Return tensors unchanged.
"""
return tensors
def _check_shape_consistency(
tensors: List[torch.Tensor], key: str
) -> List[torch.Tensor]:
"""
Ensure all tensors for a key have matching shapes, padding if needed.
When shapes differ across checkpoints (e.g. different attention head
dimensions), pads the smaller tensor with zeros to match the larger.
Returns a new list of tensors all sharing the same shape.
"""
if len(tensors) < 2:
return tensors
result = [tensors[0]]
for t in tensors[1:]:
if t.shape != result[0].shape:
print(f" ⚠️ Shape mismatch for '{key}': {result[0].shape} vs {t.shape} — padding")
_, padded = ensure_shape_match(result[0].clone(), t.clone())
result.append(padded)
else:
result.append(t)
return result
def _ensure_compute_dtype(tensor: torch.Tensor,
resolved_dtype: Optional[torch.dtype] = None) -> torch.Tensor:
"""Convert FP8 tensors to a compute-capable dtype before merge operations.
CUDA does not implement mul/add kernels for Float8_e4m3fn or Float8_e5m2.
If the tensor is FP8, convert to bfloat16 (or float16 fallback) regardless
of resolved_dtype. Non-FP8 tensors are left unchanged if already in a
compute-capable dtype — resolved_dtype is NEVER used as a target when it
is an FP8 dtype, to avoid converting bf16/float32 tensors back to FP8.
Args:
tensor: Input tensor, possibly FP8.
resolved_dtype: Target dtype from checkpoint_weaver (may also be FP8).
Returns:
Tensor in a compute-capable dtype.
"""
if tensor.dtype in (torch.float8_e4m3fn, torch.float8_e5m2):
# FP8 is not computable on CUDA — always convert to bfloat16/float16
fallback = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
print(f" 🔄 FP8 tensor converted to {fallback} for merge computation")
return tensor.to(dtype=fallback)
# Non-FP8: determine safe compute dtype
# Never convert TO an FP8 dtype — resolved_dtype may be float8_e4m3fn
# when the user selected fp8 mode. In that case the weaver has already
# pre-converted tensors to bf16, so we must NOT re-quantize them.
safe_dtype = resolved_dtype
if safe_dtype is None or safe_dtype in (torch.float8_e4m3fn, torch.float8_e5m2):
safe_dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
if tensor.dtype != safe_dtype:
return tensor.to(dtype=safe_dtype)
return tensor
# ==================== REGISTRY ====================
# Re-export the core registry unchanged.
TRIPLE_METHOD_REGISTRY = dict(_CORE_MERGE_REGISTRY)
# ── Triple-capability warnings ────────────────────────────────────────
METHOD_3WAY_WARNINGS: Dict[str, str] = {
"slerp": (
"slerp only works with exactly 2 active LoRAs. "
"When merging 3+ LoRAs, only the first 2 are used."
),
}
def _warn_triple_capability(method: str, num_tensors: int) -> None:
"""Emit warnings for methods with limited 3-way support."""
if num_tensors > 2 and method in METHOD_3WAY_WARNINGS:
print(f"⚠️ WARNING [{method}]: {METHOD_3WAY_WARNINGS[method]}")
# ==================== MAIN TRIPLE MERGE FUNCTION ====================
def merge_triple_method(sds: List[Dict[str, torch.Tensor]],
weights: List[float],
method: str = "linear",
density: float = 1.0,
uniqueness: float = 0.7,
threshold: float = 0.0,
blend: float = 0.5,
blend_mode: str = "auto",
device: str = "auto",
precision: str = "auto",
magnitude_scaling: str = "none",
max_scaling_factor: float = 10.0,
batch_size: int = 64,
streaming: bool = True,
energy_preservation: bool = True,
balancing_mode: str = "disabled",
weight_unet: float = 1.0,
weight_clip: float = 1.0,
weight_vae: float = 1.0,
weight_te: float = 1.0,
mappings: Optional[List[Dict[str, str]]] = None,
original_sds: Optional[List[Dict[str, torch.Tensor]]] = None,
metas: Optional[List[Dict[str, str]]] = None,
on_substep: Optional[Callable[[int], None]] = None,
resolved_device: Optional[torch.device] = None,
resolved_dtype: Optional[torch.dtype] = None,
sequential_only: bool = False) -> Tuple[Dict[str, torch.Tensor], List[str]]:
"""
Core triple merge logic for checkpoints with component scaling (Weight Block Map).
Parameters:
sds: List of state dictionaries (normalized).
weights: List of weights for each checkpoint.
method: Merge method name.
density: Density parameter (for DARE and sparsity).
uniqueness: Uniqueness parameter for feature_mix.
threshold: Threshold parameter for subtract.
blend: Blend parameter for magnitude.
blend_mode: Blend mode ("active" or "dense").
magnitude_scaling: Magnitude scaling mode ("none", "rms", "top_5%", etc.).
max_scaling_factor: Maximum scaling factor for magnitude equalization.
batch_size: Number of keys to process per batch (streaming).
streaming: Whether to process keys in batches to save VRAM.
energy_preservation: Whether to apply energy preservation safety check.
balancing_mode: Weight balancing mode ("disabled", "safe", "creative").
weight_unet: Global scaling factor for UNET components.
weight_clip: Global scaling factor for CLIP visual components.
weight_vae: Global scaling factor for VAE components.
weight_te: Global scaling factor for Text Encoder components.
mappings: Optional list of key mappings per source.
original_sds: Optional list of original state dicts per source.
metas: Optional list of metadata dicts.
on_substep: Optional callback invoked as on_substep(n) where n is the
number of keys processed. Enables fine-grained progress
reporting without coupling to ProgressTracker.
resolved_device: Pre-resolved target device (bypasses DeviceManager.get_device).
Provided by checkpoint_weaver for device consistency.
resolved_dtype: Pre-resolved target dtype (bypasses hardcoded torch.bfloat16).
Provided by checkpoint_weaver for dtype consistency.
Returns:
Merged state dictionary.
"""
# Emit triple-capability warning (e.g., slerp only works with 2 LoRAs)
_warn_triple_capability(method, len(sds))
all_keys = set()
for sd in sds:
all_keys.update(sd.keys())
# Auto-weight balancing based on energy ratios
if balancing_mode != "disabled" and len(sds) == 3:
# Keys present in all three checkpoints
common_keys = [key for key in all_keys if all(key in sd for sd in sds)]
if common_keys:
print(f" ⚖️ Auto-weight balancing ({balancing_mode}) analyzing {len(common_keys)} common keys...")
print(f" Energy computed via per-element mean (rank-independent) on shared layers only")
# Use shared utility for rank-independent mean energy computation
# Checkpoints have no LoRA alpha/rank scaling, so original_sds/mappings are omitted
energy_by_component = compute_component_energy_ratios(
norm_sds=sds,
common_keys=common_keys,
original_sds=None,
mappings=None,
converted_flags=None,
key_categorizer=categorize_key,
)
# Accumulate global energy across all components
energy_per_checkpoint = [0.0, 0.0, 0.0]
for component, energies in energy_by_component.items():
for i in range(3):
energy_per_checkpoint[i] += energies[i]
epsilon = 1e-12
# Compute average energy
avg_energy = sum(energy_per_checkpoint) / 3.0
# Compute scaling factors per checkpoint based on balancing_mode
factors = [1.0, 1.0, 1.0]
if balancing_mode == "safe":
# Safe mode: reduce weight of louder checkpoints to match average
for i in range(3):
if energy_per_checkpoint[i] > avg_energy and energy_per_checkpoint[i] > epsilon:
ratio = math.sqrt(avg_energy / energy_per_checkpoint[i])
factors[i] = max(0.1, min(10.0, ratio))
print(f" Checkpoint {i} energy {energy_per_checkpoint[i]:.2e} is {energy_per_checkpoint[i]/avg_energy:.2f}x average, weight scaled by {factors[i]:.2f}")
elif balancing_mode == "creative":
# Creative mode: compromise scaling for all checkpoints
for i in range(3):
if energy_per_checkpoint[i] > epsilon:
ratio = math.sqrt(avg_energy / energy_per_checkpoint[i])
creative_factor = (1.0 + ratio) / 2.0 # 50% compromise
factors[i] = max(0.1, min(10.0, creative_factor))
print(f" Checkpoint {i} energy {energy_per_checkpoint[i]:.2e} → compromise factor {factors[i]:.2f}")
# Apply factors to weights
adjusted_weights = [weights[i] * factors[i] for i in range(3)]
print(f" ⚖️ Original weights: {weights}")
print(f" ⚖️ Adjusted weights: {adjusted_weights}")
# Replace weights for the rest of the merge
weights = adjusted_weights
# Use DeviceManager instead of hardcoded inline resolution.
# If resolved_device is provided (from checkpoint_weaver), use it directly
# to ensure device consistency across the merge pipeline (L2).
if resolved_device is not None:
device = resolved_device
else:
device = DeviceManager.get_device(device)
merged = {}
_mt_t0 = time.time()
print(f" 🕐 [t={0.0:.1f}s] merge_triple_method: starting with {len(sds)} sources, method={method}")
# Convert to list for deterministic ordering
key_list = list(all_keys)
total_keys = len(key_list)
# Memory guard helper — checks VRAM pressure, forces GC + empty_cache if >75%
def memory_guard():
if device.type == 'cuda':
allocated = torch.cuda.memory_allocated()
total = torch.cuda.get_device_properties(0).total_memory
ratio = allocated / total if total > 0 else 0
if ratio > 0.75: # Lowered from 0.8 for earlier intervention
print(f"⚠️ High GPU memory: {allocated/1e9:.2f} GB / {total/1e9:.2f} GB ({ratio:.1%}). Forcing cleanup.")
gc.collect()
torch.cuda.empty_cache()
# Re-check after cleanup
allocated = torch.cuda.memory_allocated()
ratio = allocated / total if total > 0 else 0
if ratio > 0.85:
print(f"⚠️ GPU memory still high ({ratio:.1%}) after cleanup — risk of OOM")
# ── Phase 3: Shape-based batching pre-scan ──────────────────────────
# Group keys by tensor shape for potential batched merge.
# Same-shaped keys from the same architecture (e.g., all attention
# projection weights of the same layer) can be stacked into a 3D
# tensor and processed with a single merge call, reducing per-key
# Python overhead and improving GPU utilization.
shape_to_keys: Dict[Tuple[int, ...], List[str]] = {}
for key in key_list:
for sd in sds:
if key in sd:
shape = sd[key].shape
if shape not in shape_to_keys:
shape_to_keys[shape] = []
shape_to_keys[shape].append(key)
break
# Separate batchable groups (size ≥ MIN_BATCH_SHAPE_GROUP_SIZE)
# from sequential keys (small groups + singletons).
batchable_shapes: Dict[Tuple[int, ...], List[str]] = {}
sequential_key_list: List[str] = []
for shape, keys in shape_to_keys.items():
if len(keys) >= MIN_BATCH_SHAPE_GROUP_SIZE:
batchable_shapes[shape] = keys
else:
sequential_key_list.extend(keys)
# Free intermediate structure — no longer needed after batching setup
del shape_to_keys
print(f" 🕐 [t={time.time()-_mt_t0:.1f}s] merge_triple_method: shape-batch distribution complete ({len(batchable_shapes)} groups, {len(sequential_key_list)} sequential)")
# ── VRAM check: only batch if the largest group fits ───────────────
batching_enabled = False
if not sequential_only and batchable_shapes and device.type == 'cuda' and method not in BATCH_INCOMPATIBLE_METHODS:
largest_shape, largest_keys = max(
batchable_shapes.items(),
key=lambda x: x[0].numel() * len(x[1])
)
# Method-specific safety factors for stacked tensor intermediates.
# Different merge methods create vastly different intermediate tensor sizes:
# - default 2: element-wise ops (linear, subtract, dare_*, ties_*) create
# only ~1 temporary copy of input — peak ~2× stacked input
# - feature_mix 6: creates [3, batch, ...] intermediates (magnitudes,
# shares) plus int64 dominant_idx (4× per element) — peak ~16× stacked
# - svd_preserve 6: float32 copies (4× bf16) + batched SVD U/S/Vh
# outputs — peak similarly large
# - magnitude 4: [3, batch, ...] stacked + int64 argmax, no shares copy
# - gradient_alignment 4: 3× float32 copies for per-channel alignment
_METHOD_SAFETY_FACTORS = {
"feature_mix": 6,
"svd_preserve": 6,
"magnitude": 4,
"gradient_alignment": 4,
}
base_factor = _METHOD_SAFETY_FACTORS.get(method, 2)
# Active blend mode creates up to 10 boolean masks via _compute_active_masks()
# (same shape as stacked input) plus _weight_tensors() creates 3 weighted copies.
# These are NOT accounted for by the base safety factor (which only covers
# merge-method-specific intermediates), so we add a flat +2 penalty so the VRAM
# estimate correctly reflects the actual peak memory usage of active-blend merges.
blend_penalty = 2 if blend_mode == 'active' else 0
safety_factor = base_factor + blend_penalty
# Stacked tensors + method-specific intermediates estimate.
# Source tensors are on CPU (loaded via safe_open), not GPU prior to this.
# The stacked source tensors ARE the GPU footprint — they replace per-key
# individual tensors that would otherwise be loaded one-at-a-time in
# sequential mode. Merge intermediates add safety_factor × stacked size.
# Use the actual number of active sources (not hardcoded 3) so two-way
# merges get a realistic estimate.
num_sources = len(sds)
stacked_bytes = num_sources * len(largest_keys) * largest_shape.numel() * 2 * safety_factor
est_bytes = stacked_bytes
# Compute actual available VRAM as total - current_allocated.
# get_free_vram returns total - reserved which overestimates free
# memory after empty_cache (cached pool != actually allocatable space).
total_memory = torch.cuda.get_device_properties(device).total_memory
current_allocated = torch.cuda.memory_allocated(device)
free_vram = total_memory - current_allocated
# Safety margin: only batch if stacked+intermediates fit within free VRAM.
# We compare est_bytes directly against free_vram (NOT current_allocated +
# est_bytes) because the stacked source tensors REPLACE per-key GPU tensors
# that sequential mode would load anyway — there's no additive cost for the
# source data itself; only the merge intermediates are extra.
# Using free_vram × 50% gives headroom for merge intermediates that
# briefly spike during the merge_fn call (temp1 + temp2 + result can
# temporarily reach 3-4× the stacked source size before Python's
# reference counting deallocates the temporaries).
# Previously 80% — caused CUDA OOM on large shape groups because
# cp_buffers (now freed) and merge intermediate spikes were unaccounted.
if free_vram > 0 and est_bytes < free_vram * 0.5:
batching_enabled = True
shape_str = '×'.join(str(d) for d in largest_shape)
print(f" 🚀 Shape batching enabled: {len(batchable_shapes)} groups, "
f"largest = {shape_str} × {len(largest_keys)} keys "
f"(~{est_bytes / 1e9:.2f} GB est, "
f"{current_allocated / 1e9:.2f} GB allocated, "
f"{free_vram / 1e9:.2f} GB free, "
f"within 50% threshold)")
else:
shape_str = '×'.join(str(d) for d in largest_shape)
pct = 100.0 * est_bytes / free_vram if free_vram > 0 else float('inf')
print(f" ℹ️ Shape batching skipped: "
f"~{est_bytes / 1e9:.2f} GB est would use {pct:.0f}% of "
f"{free_vram / 1e9:.2f} GB free VRAM (50% threshold) "
f"— falling back to sequential")
# ── Shared merge helpers ───────────────────────────────────────────
# Resolve merge function once (same for all keys)
merge_fn = TRIPLE_METHOD_REGISTRY.get(method, triple_merge_linear)
def _build_merge_kwargs(method_name: str) -> dict:
"""Build method-specific kwargs for the merge function."""
kw = {}
if method_name == "feature_mix":
kw["uniqueness"] = uniqueness
elif method_name == "magnitude":
kw["blend"] = blend
elif method_name == "subtract":
kw["threshold"] = threshold
elif method_name in ("dare_rescale", "dare_lite"):
kw["density"] = density
return kw
merge_kwargs = _build_merge_kwargs(method)
def _process_single_key(
key: str,
cp_tensors: List[torch.Tensor],
cp_weights: List[float],
) -> torch.Tensor:
"""Process a single key: scaling → magnitude → merge → energy."""
# Component scaling
for idx in range(len(cp_tensors)):
cp_tensors[idx] = apply_component_scaling(
cp_tensors[idx], key,
weight_unet, weight_clip, weight_vae, weight_te)
# Magnitude equalization
if magnitude_scaling != "none" and len(cp_tensors) >= 2:
orig_indices = [i for i, sd in enumerate(sds) if key in sd]
if 0 in orig_indices:
ref_idx = orig_indices.index(0)
cp_tensors = apply_magnitude_scaling(
cp_tensors, cp_weights,
magnitude_scaling, max_scaling_factor,
ref_idx=ref_idx)
# Rank adjustment (no-op for checkpoints)
cp_tensors = adjust_ranks(cp_tensors, key)
# Shape consistency guard — pad mismatched tensors across checkpoints
cp_tensors = _check_shape_consistency(cp_tensors, key)
# Merge with CUDA OOM fallback (L1)
try:
result = merge_fn(cp_tensors, cp_weights,
blend_mode=blend_mode, **merge_kwargs)
except (torch.cuda.OutOfMemoryError, RuntimeError) as e:
if "CUDA out of memory" in str(e):
print(f" ⚠️ CUDA OOM on key '{key}' — falling back to CPU")
cp_tensors_cpu = [t.cpu() for t in cp_tensors]
result = merge_fn(cp_tensors_cpu, cp_weights,
blend_mode=blend_mode, **merge_kwargs)
result = result.to(device)
else:
raise
# Energy safety
if energy_preservation:
result = ensure_energy_preservation_triple(
result, cp_tensors, cp_weights,
threshold=0.8, gain_min=1.0, target='avg')
return result
def _apply_density(result: torch.Tensor, key: str) -> torch.Tensor:
"""Apply density sparsification with size guard (Phase 3.2)."""
if density >= 1.0 or method in ("dare_rescale", "dare_lite"):
return result
# Phase 3.2: Skip sparsification for oversized tensors.
# torch.topk on full-size checkpoint weights is O(n log k) with
# millions of elements — prohibitively expensive.
result_bytes = result.numel() * result.element_size()
if result_bytes > MAX_SPARSIFY_BYTES:
param_count = result.numel()
print(f" ⚠️ WARNING: density={density} sparsification skipped for "
f"'{key}' — tensor is {result_bytes / (1024**2):.1f} MB "
f"({param_count / 1e6:.1f}M params), exceeds "
f"{MAX_SPARSIFY_BYTES / (1024**2):.0f} MB limit")
return result
# Phase 3.2: Enhanced warning for very large tensors (>100M params)
if result.numel() > 100_000_000:
print(f" ⚠️ WARNING: density={density} on "
f"{result.numel() / 1e6:.0f}M-param tensor '{key}'. "
f"topk on full-size checkpoint weights is computationally expensive.")
flat = result.abs().flatten()
k = max(1, int(flat.numel() * density))
threshold_val = torch.topk(flat, k).values.min()
mask = result.abs() >= threshold_val
return result * mask
# ── Merge loop ─────────────────────────────────────────────────────
corrupted_keys = []
# ═══════════════════════════════════════════════════════════════════
# PHASE A: Batched processing (shape-group stacking)
# ═══════════════════════════════════════════════════════════════════
if batching_enabled:
total_batched = sum(len(keys) for keys in batchable_shapes.values())
total_sequential = len(sequential_key_list)
# No internal tracker — on_substep callback handles progress reporting.
# ── Process each batchable shape group ────────────────────
for shape, keys in batchable_shapes.items():
memory_guard()
# Per-checkpoint collections for this shape group
# Dynamically sized to match the actual number of sources (may be 2
# or 3 for two-way / three-way merge). Previously hardcoded to 3
# slots, which was safe only because unused slots remained empty.
num_sources = len(sds)
cp_buffers: List[List[torch.Tensor]] = [[] for _ in range(num_sources)]
cp_weight_vals: List[List[float]] = [[] for _ in range(num_sources)]
batch_keys: List[str] = []
# ── Load per-key tensors to GPU ───────────────────────
# try/except catches CUDA OOM during .to(device) for large shape
# groups (e.g. 24576×4096 × 4 keys in Flux). Previous OOMs at this
# stage propagated to the weave-level handler, forcing ALL subsequent
# batches to CPU sequential. Now we catch per-group and fall back to
# per-key sequential on CPU for just this shape group.
try:
for key in keys:
collected_t: List[torch.Tensor] = []
collected_w: List[float] = []
skip_key = False
for i, sd in enumerate(sds):
if key in sd:
t = sd[key]
t_bytes = t.numel() * t.element_size()
if t_bytes > MAX_TENSOR_BYTES:
print(f" ⚠️ Corrupt tensor '{key}' in source {i}: "
f"{t_bytes / (1024**3):.2f} GB exceeds "
f"{MAX_TENSOR_BYTES / (1024**3):.0f} GB limit – skipping")
if key not in corrupted_keys:
corrupted_keys.append(key)
skip_key = True
break
# Guard redundant .to() calls — no-op on correct device/dtype
if t.device != device:
t = t.to(device)
t = _ensure_compute_dtype(t, resolved_dtype)
collected_t.append(t)
collected_w.append(weights[i])
if skip_key:
if on_substep:
on_substep(1)
continue
if len(collected_t) < 2:
# Unique key — process individually
if collected_t:
scaled = apply_component_scaling(
collected_t[0], key,
weight_unet, weight_clip, weight_vae, weight_te)
merged[key] = (scaled * collected_w[0]).cpu()
if on_substep:
on_substep(1)
continue
batch_keys.append(key)
for idx, (t, w) in enumerate(zip(collected_t, collected_w)):
cp_buffers[idx].append(t)
cp_weight_vals[idx].append(w)
except (torch.cuda.OutOfMemoryError, RuntimeError) as e:
if isinstance(e, torch.cuda.OutOfMemoryError) or "CUDA out of memory" in str(e):
print(f" ⚠️ CUDA OOM loading keys for shape {shape} — per-key CPU fallback")
# Clean up partially-loaded GPU tensors in cp_buffers
del cp_buffers
del cp_weight_vals
gc.collect()
torch.cuda.empty_cache()
# Process all keys in this shape group individually.
# Tensors in sds are still on CPU (the failed .to(device)
# left them there), so we pass CPU tensors to
# _process_single_key which handles GPU transfer per-key.
for key in keys:
t_list = []
w_list = []
corrupt = False
for i, sd in enumerate(sds):
if key in sd:
t = sd[key]
t_bytes = t.numel() * t.element_size()
if t_bytes > MAX_TENSOR_BYTES:
if key not in corrupted_keys:
corrupted_keys.append(key)
corrupt = True
break
t = _ensure_compute_dtype(t, resolved_dtype)
t_list.append(t)
w_list.append(weights[i])
if corrupt:
if on_substep:
on_substep(1)
continue
if len(t_list) >= 2:
result = _process_single_key(key, t_list, w_list)
result = _apply_density(result, key)
merged[key] = result.cpu()
elif len(t_list) == 1:
scaled = apply_component_scaling(
t_list[0], key,
weight_unet, weight_clip, weight_vae, weight_te)
merged[key] = (scaled * w_list[0]).cpu()
# Free source tensor references after processing
for sd in sds:
sd.pop(key, None)
if on_substep:
on_substep(1)
cleanup_memory()
continue
else:
raise
if len(batch_keys) < MIN_BATCH_SHAPE_GROUP_SIZE:
# Not enough for efficient batching — process individually
for ki, key in enumerate(batch_keys):
t_list = [cb[ki] for cb in cp_buffers if ki < len(cb)]
w_list = [cw[ki] for cw in cp_weight_vals if ki < len(cw)]
if len(t_list) >= 2:
result = _process_single_key(key, t_list, w_list)
result = _apply_density(result, key)
merged[key] = result.cpu()
if on_substep:
on_substep(1)
continue
# ── Shape consistency: verify all keys have matching shapes across ──
# ── checkpoints. If any key has shape mismatches, fall back to ──
# ── sequential processing (each key goes through _process_single_key ──
# ── which pads mismatched shapes via _check_shape_consistency). ──
shape_diverged = False
for ki, key in enumerate(batch_keys):
t_list = [cb[ki] for cb in cp_buffers if ki < len(cb)]
if len(t_list) >= 2:
shapes = set(t.shape for t in t_list)
if len(shapes) > 1:
shape_diverged = True
break
if shape_diverged:
for ki, key in enumerate(batch_keys):
t_list = [cb[ki] for cb in cp_buffers if ki < len(cb)]
w_list = [cw[ki] for cw in cp_weight_vals if ki < len(cw)]
if len(t_list) >= 2:
result = _process_single_key(key, t_list, w_list)
result = _apply_density(result, key)
merged[key] = result.cpu()
elif len(t_list) == 1:
scaled = apply_component_scaling(
t_list[0], key,
weight_unet, weight_clip, weight_vae, weight_te)
merged[key] = (scaled * w_list[0]).cpu()
if on_substep:
on_substep(1)
continue
# ── Stack tensors per-checkpoint: [batch, rows, cols] ──
# try/except covers stacking AND merge to handle OOM during
# torch.stack() (previously outside the merge_fn try/except).
# If stacking itself OOMs, cp_buffers still exists and we extract
# from cp_buffers directly. If merge_fn OOMs, cp_buffers was freed
# and we extract from stacked_cp instead.
try:
stacked_cp: List[torch.Tensor] = []
stacked_cp_weights: List[float] = []
for cp_idx in range(num_sources):
if cp_buffers[cp_idx]:
stacked_cp.append(
torch.stack(cp_buffers[cp_idx], dim=0))
# All keys share the same checkpoint weight
stacked_cp_weights.append(cp_weight_vals[cp_idx][0])
# Free individual GPU tensors — only stacked version needed.
# Without this, cp_buffers (~4 GB for largest Flux groups) coexists
# with stacked_cp (~4 GB), doubling source-data GPU memory and
# causing CUDA OOM when merge intermediates are added on top.
del cp_buffers
del cp_weight_vals
if len(stacked_cp) < 2:
# Not enough sources for batched merge — process individually
# to avoid dropping unique keys (e.g. position embeddings
# present in only 1 checkpoint).
# Note: cp_buffers was freed above — extract from stacked_cp instead.
for ki, key in enumerate(batch_keys):
t_list = [sc[ki] for sc in stacked_cp if ki < sc.size(0)]
w_list = [stacked_cp_weights[si] for si, sc in enumerate(stacked_cp) if ki < sc.size(0)]
if len(t_list) >= 2:
result = _process_single_key(key, t_list, w_list)
result = _apply_density(result, key)
merged[key] = result.cpu()
elif len(t_list) == 1:
# Single-source key: component-scale and weight
scaled = apply_component_scaling(
t_list[0], key,
weight_unet, weight_clip, weight_vae, weight_te)
merged[key] = (scaled * w_list[0]).cpu()
if on_substep:
on_substep(1)
continue
# Component scaling — verify all keys belong to same component
rep_key = batch_keys[0]
rep_category = categorize_key(rep_key)
all_same_comp = all(categorize_key(k) == rep_category for k in batch_keys)
if not all_same_comp:
# Mixed-component group — fall back to per-key processing
# Note: cp_buffers was freed above — extract from stacked_cp instead.
for ki, key in enumerate(batch_keys):
t_list = [sc[ki] for sc in stacked_cp if ki < sc.size(0)]
w_list = [stacked_cp_weights[si] for si, sc in enumerate(stacked_cp) if ki < sc.size(0)]
if len(t_list) >= 2:
result = _process_single_key(key, t_list, w_list)
result = _apply_density(result, key)
merged[key] = result.cpu()
elif len(t_list) == 1:
# Single-source key: component-scale and weight
scaled = apply_component_scaling(
t_list[0], key,
weight_unet, weight_clip, weight_vae, weight_te)
merged[key] = (scaled * w_list[0]).cpu()
if on_substep:
on_substep(1)
continue
# All same component — proceed with batched scaling
for idx in range(len(stacked_cp)):
stacked_cp[idx] = apply_component_scaling(
stacked_cp[idx], rep_key,
weight_unet, weight_clip, weight_vae, weight_te)
# Magnitude equalization
if magnitude_scaling != "none" and len(stacked_cp) >= 2:
orig_indices = [i for i, sd in enumerate(sds)
if rep_key in sd]
if 0 in orig_indices:
ref_idx = orig_indices.index(0)
stacked_cp = apply_magnitude_scaling(
stacked_cp, stacked_cp_weights,
magnitude_scaling, max_scaling_factor,
ref_idx=ref_idx)
# Rank adjustment (no-op)
stacked_cp = adjust_ranks(stacked_cp, rep_key)
# ── Single merge call on stacked 3D tensors ────────────
memory_guard() # Check right before the big allocation
batch_result = merge_fn(
stacked_cp, stacked_cp_weights,
blend_mode=blend_mode, **merge_kwargs)
except (torch.cuda.OutOfMemoryError, RuntimeError) as e:
if isinstance(e, torch.cuda.OutOfMemoryError) or "CUDA out of memory" in str(e):
print(f" ⚠️ CUDA OOM on shape group {shape} — falling back to per-key CPU processing")
# Determine whether cp_buffers was already freed (OOM at merge_fn
# or during scaling) or still exists (OOM at torch.stack above).
try:
_ = cp_buffers
cp_freed = False
except NameError:
cp_freed = True
if cp_freed:
# cp_buffers was deleted — extract from stacked_cp
for ki, key in enumerate(batch_keys):
t_list = [sc[ki].cpu() for sc in stacked_cp if ki < sc.size(0)]
w_list = [stacked_cp_weights[si] for si, sc in enumerate(stacked_cp) if ki < sc.size(0)]
if len(t_list) >= 2:
result = _process_single_key(key, t_list, w_list)
result = _apply_density(result, key)
merged[key] = result.cpu()
elif len(t_list) == 1:
scaled = apply_component_scaling(
t_list[0], key,
weight_unet, weight_clip, weight_vae, weight_te)
merged[key] = (scaled * w_list[0]).cpu()
if on_substep:
on_substep(1)
else:
# cp_buffers still exists — extract directly (OOM at torch.stack)
for ki, key in enumerate(batch_keys):
t_list = [cb[ki].cpu() for cb in cp_buffers if ki < len(cb)]
w_list = [cw[ki] for cw in cp_weight_vals if ki < len(cw)]
if len(t_list) >= 2:
result = _process_single_key(key, t_list, w_list)
result = _apply_density(result, key)
merged[key] = result.cpu()
elif len(t_list) == 1:
scaled = apply_component_scaling(
t_list[0], key,
weight_unet, weight_clip, weight_vae, weight_te)
merged[key] = (scaled * w_list[0]).cpu()
if on_substep:
on_substep(1)
# Free source tensor references before continuing
# (the normal cleanup after the try/except is skipped by this continue)
for key in keys:
for sd in sds:
sd.pop(key, None)
continue
else:
raise
# ── Unstack results and assign per key ─────────────────
for ki, key in enumerate(batch_keys):
key_result = batch_result[ki]
# Per-key energy preservation (not per-batch), to avoid
# over/under-correcting individual keys with different
# energy profiles within the same shape batch.
if energy_preservation:
# Extract per-key tensors from stacked_cp (cp_buffers was freed
# after stacking to eliminate double GPU allocation).
per_key_tensors = [sc[ki] for sc in stacked_cp if ki < sc.size(0)]
key_result = ensure_energy_preservation_triple(
key_result, per_key_tensors, stacked_cp_weights,
threshold=0.8, gain_min=1.0, target='avg')
key_result = _apply_density(key_result, key)
merged[key] = key_result.cpu()
if on_substep:
on_substep(len(batch_keys))
print(f" 🕐 [t={time.time()-_mt_t0:.1f}s] merge_triple_method: shape-batch group done ({len(batch_keys)} keys, shape={shape})")
comfyui_yield() # Prevent UI stalling between shape groups
# ── Free GPU memory before next shape group ──
# try/except safety net: torch.cuda.empty_cache() triggers a CUDA
# sync which can surface ASYNCHRONOUS CUDA OOM errors from the
# merge_fn that just completed. Without this guard, such delayed
# OOMs propagate past both Fix 7 and Fix 5 to the weave-level
# handler, causing unnecessary CPU fallback for ALL remaining batches.
try:
if device.type == 'cuda':
# Free stacked/intermediate merge tensors
del stacked_cp
del batch_result
# Free processed source tensors from GPU (results are in merged dict on CPU)
for key in keys:
for sd in sds:
sd.pop(key, None)
torch.cuda.empty_cache()
except (torch.cuda.OutOfMemoryError, RuntimeError) as e:
if isinstance(e, torch.cuda.OutOfMemoryError) or "CUDA out of memory" in str(e):
# Delayed OOM from a previous operation surfacing at sync point.
# The shape group already completed successfully (merged dict populated).
# Just log and continue — no data loss.
print(f" ⚠️ Delayed CUDA OOM during cleanup after shape group {shape} — continuing (no data loss)")
else:
raise
# ═══════════════════════════════════════════════════════════
# PHASE B: Sequential processing (remaining keys)
# ═══════════════════════════════════════════════════════════
if sequential_key_list:
effective_bs = batch_size if streaming else len(sequential_key_list)
for batch_start in range(0, len(sequential_key_list), effective_bs):
memory_guard()
seq_keys = sequential_key_list[
batch_start:batch_start + effective_bs]
for key in seq_keys:
tensors = []
valid_weights = []
for i, sd in enumerate(sds):
if key in sd:
t = sd[key]
t_bytes = t.numel() * t.element_size()
if t_bytes > MAX_TENSOR_BYTES:
print(f" ⚠️ Corrupt tensor '{key}' in source {i}: "
f"{t_bytes / (1024**3):.2f} GB exceeds "
f"{MAX_TENSOR_BYTES / (1024**3):.0f} GB limit – skipping")
if key not in corrupted_keys:
corrupted_keys.append(key)
continue
# Guard redundant .to() calls — no-op on correct device/dtype
if t.device != device:
t = t.to(device)
t = _ensure_compute_dtype(t, resolved_dtype)
tensors.append(t)
valid_weights.append(weights[i])
if len(tensors) < 2:
if tensors:
scaled = apply_component_scaling(
tensors[0], key,
weight_unet, weight_clip, weight_vae, weight_te)
merged[key] = (scaled * valid_weights[0]).cpu()
# Free GPU tensor reference even for single-source keys
for sd in sds:
sd.pop(key, None)
continue
result = _process_single_key(key, tensors, valid_weights)
result = _apply_density(result, key)
merged[key] = result.cpu()
# Free GPU tensor references immediately — matches batched path behavior
for sd in sds:
sd.pop(key, None)
del tensors
if on_substep:
on_substep(1)
comfyui_yield() # Prevent UI stalling between sequential batches
if streaming and device.type == 'cuda':
torch.cuda.empty_cache()
# No internal tracker cleanup — on_substep callback handles all advancement.
# ═══════════════════════════════════════════════════════════════════
# Fallback: no batching — pure sequential (existing behavior)
# ═══════════════════════════════════════════════════════════════════
else:
print(f" 🕐 [t={time.time()-_mt_t0:.1f}s] merge_triple_method: shape batching not viable — using pure sequential")
effective_bs = batch_size if streaming else total_keys
for batch_start in range(0, total_keys, effective_bs):
memory_guard()
batch_keys = key_list[batch_start:batch_start + effective_bs]
for key in batch_keys:
tensors = []
valid_weights = []
for i, sd in enumerate(sds):
if key in sd:
t = sd[key]
t_bytes = t.numel() * t.element_size()
if t_bytes > MAX_TENSOR_BYTES:
print(f" ⚠️ Corrupt tensor '{key}' in source {i}: "
f"{t_bytes / (1024**3):.2f} GB exceeds "
f"{MAX_TENSOR_BYTES / (1024**3):.0f} GB limit – skipping")
if key not in corrupted_keys:
corrupted_keys.append(key)
# Free the corrupt tensor reference from sds
for sd in sds:
sd.pop(key, None)
continue
# Guard redundant .to() calls — no-op on correct device/dtype
if t.device != device:
t = t.to(device)
t = _ensure_compute_dtype(t, resolved_dtype)
tensors.append(t)
valid_weights.append(weights[i])
if len(tensors) < 2:
if tensors:
scaled = apply_component_scaling(
tensors[0], key,
weight_unet, weight_clip, weight_vae, weight_te)
merged[key] = (scaled * valid_weights[0]).cpu()
# Free GPU tensor reference even for single-source keys
for sd in sds:
sd.pop(key, None)
continue
result = _process_single_key(key, tensors, valid_weights)
result = _apply_density(result, key)
merged[key] = result.cpu()
# Free GPU tensor references immediately
for sd in sds:
sd.pop(key, None)
del tensors
if on_substep:
on_substep(1)
comfyui_yield() # Prevent UI stalling between sequential batches
if streaming and device.type == 'cuda':
torch.cuda.empty_cache()
# No internal tracker cleanup — on_substep callback handles all advancement.
# Free intermediate structures — both paths (Phase B and Fallback) are done
del batchable_shapes
del sequential_key_list
# Belt-and-suspenders: ensure any lingering GPU tensors are freed
# before returning to the weaver
cleanup_memory()
if corrupted_keys:
print(f" ⚠️ {len(corrupted_keys)} corrupted/absurd tensors skipped during merge")
print(f" 🕐 [t={time.time()-_mt_t0:.1f}s] merge_triple_method returning ({len(merged)} keys)")
return merged, corrupted_keys