Compare commits

...
Author SHA1 Message Date
silveroxides 4d49477f58 Fix models getting stuck in memory when merging 2026-03-07 16:32:43 +01:00
Silver 6c4d505d84 Bump version from 0.2.7 to 0.2.8 2026-02-24 17:41:25 +01:00
Silver 1e53a87427 Merge pull request #8 from silveroxides/feature/expand-lora-functionality
expand on functionality and reduce restrictions when extracting LoRA
2026-02-24 17:40:47 +01:00
silveroxides b497022c3c expand on functionality and reduce restrictions when extracting LoRA 2026-02-24 17:39:48 +01:00
Silver 5df8c6711a Merge pull request #7 from silveroxides/fix/loading-imporovements
improve memory efficiency
2026-02-12 08:07:39 +01:00
Silver f4cecdc49b Merge branch 'main' into fix/loading-imporovements 2026-02-12 08:07:31 +01:00
Silver 8b34d57003 Merge pull request #6 from silveroxides/patch/lora-changes
changes to LoRAs
2026-02-12 08:05:19 +01:00
silveroxides 0d011d1232 improve memory efficiency 2026-02-07 16:01:00 +01:00
7 changed files with 428 additions and 362 deletions
+83 -61
View File
@@ -187,10 +187,10 @@ def _svd_extract_linear_lowrank(
# Clamp rank to valid range # Clamp rank to valid range
max_possible_rank = min(out_dim, in_dim) max_possible_rank = min(out_dim, in_dim)
rank = max(1, min(rank, max_possible_rank - 1)) rank = max(1, min(rank, max_possible_rank))
# Check if decomposition is worthwhile # Check if decomposition is worthwhile
if rank >= max_possible_rank // 2: if rank >= max_possible_rank:
return weight, "full" return weight, "full"
# Use svd_lowrank for faster low-rank approximation # Use svd_lowrank for faster low-rank approximation
@@ -257,7 +257,7 @@ def _svd_extract_linear(
rank = _compute_rank(S, mode, mode_param, max_rank) rank = _compute_rank(S, mode, mode_param, max_rank)
# Check if decomposition is worthwhile # Check if decomposition is worthwhile
if rank >= min(out_dim, in_dim) // 2: if rank >= min(out_dim, in_dim):
return weight, "full" return weight, "full"
# Truncate to rank # Truncate to rank
@@ -303,7 +303,7 @@ def _svd_extract_conv(
# Flatten for SVD # Flatten for SVD
if is_1x1: if is_1x1:
mat = weight.squeeze() # [out_ch, in_ch] mat = weight.view(out_ch, in_ch) # [out_ch, in_ch]
else: else:
mat = weight.reshape(out_ch, -1) # [out_ch, in_ch*k*k] mat = weight.reshape(out_ch, -1) # [out_ch, in_ch*k*k]
@@ -382,8 +382,11 @@ def _extract_chunked_layer(
return None, None, 0 return None, None, 0
# Combine chunks # Combine chunks
# Note: Using the first chunk's down matrix as the basis for all chunks
# is a better approximation than a simple mean if the chunks share the same input space (like QKV).
# Ideally, we would use a more sophisticated joint SVD, but this is a reasonable fallback.
combined_lora_up = torch.cat(all_lora_up, dim=0) combined_lora_up = torch.cat(all_lora_up, dim=0)
combined_lora_down = torch.stack(all_lora_down, dim=0).mean(dim=0) combined_lora_down = all_lora_down[0]
rank = combined_lora_down.shape[0] rank = combined_lora_down.shape[0]
return combined_lora_up, combined_lora_down, rank return combined_lora_up, combined_lora_down, rank
@@ -433,6 +436,8 @@ def extract_lora_from_files(
mismatch_mode: str = "skip", mismatch_mode: str = "skip",
chunk_large_layers: bool = True, chunk_large_layers: bool = True,
svd_niter: int = 2, svd_niter: int = 2,
lazy_load: bool = True,
force_clear_cache: bool = True,
) -> dict[str, torch.Tensor]: ) -> dict[str, torch.Tensor]:
""" """
Extract LoRA from difference between two models. Extract LoRA from difference between two models.
@@ -468,41 +473,42 @@ def extract_lora_from_files(
print(f"[LoRA Extract] Preparing memory for {total_size_gb:.2f}GB operation...") print(f"[LoRA Extract] Preparing memory for {total_size_gb:.2f}GB operation...")
prepare_for_large_operation(total_size_gb * 1.5, torch.device(device)) # 1.5x for SVD headroom prepare_for_large_operation(total_size_gb * 1.5, torch.device(device)) # 1.5x for SVD headroom
handler_a = MemoryEfficientSafeOpen(model_a_path) handler_a = MemoryEfficientSafeOpen(model_a_path, low_memory=lazy_load)
handler_b = MemoryEfficientSafeOpen(model_b_path) handler_b = MemoryEfficientSafeOpen(model_b_path, low_memory=lazy_load)
try: try:
keys_a = set(handler_a.keys()) keys_a = set(handler_a.keys())
keys_b = set(handler_b.keys()) keys_b = set(handler_b.keys())
weight_keys = [k for k in keys_a if k.endswith(".weight")] weight_keys = [k for k in keys_a if k.endswith(".weight")]
pbar = comfy.utils.ProgressBar(len(weight_keys)) pbar = comfy.utils.ProgressBar(len(weight_keys))
stats = {"extracted": 0, "full": 0, "skipped": 0, "chunked": 0} stats = {"extracted": 0, "full": 0, "skipped": 0, "chunked": 0}
for key in tqdm(weight_keys, desc="Extracting LoRA", unit="layers"): def _process_layer(key):
lora_name = _format_lora_key(key) lora_name = _format_lora_key(key)
if _matches_any_pattern(key, skip_patterns): if _matches_any_pattern(key, skip_patterns):
stats["skipped"] += 1 return "skipped", None
pbar.update(1)
continue
# Load tensors with pinned memory for CUDA # Load tensors with pinned memory for CUDA
use_pinned = device == 'cuda' use_pinned = device == 'cuda'
if key not in keys_b: if key not in keys_b:
if mismatch_mode == "skip": if mismatch_mode == "skip":
stats["skipped"] += 1 return "skipped", None
pbar.update(1) if mismatch_mode == "error":
continue raise ValueError(f"Key {key} not found in model B")
cpu_a = handler_a.get_tensor(key) cpu_a = handler_a.get_tensor(key)
if use_pinned: if use_pinned:
weight_diff = transfer_to_gpu_pinned(cpu_a, device, torch.float32) weight_diff = transfer_to_gpu_pinned(cpu_a, device, torch.float32)
else: else:
weight_diff = cpu_a.to(device=device, dtype=torch.float32) weight_diff = cpu_a.to(device=device, dtype=torch.float32)
del cpu_a del cpu_a
if mismatch_mode == "zeros":
# For zeros mode, we treat the missing base as a zeroed tensor of same shape
# which is already captured by weight_diff = tensor_a
pass
else: else:
cpu_a = handler_a.get_tensor(key) cpu_a = handler_a.get_tensor(key)
cpu_b = handler_b.get_tensor(key) cpu_b = handler_b.get_tensor(key)
@@ -517,10 +523,12 @@ def extract_lora_from_files(
if tensor_a.shape != tensor_b.shape: if tensor_a.shape != tensor_b.shape:
if mismatch_mode == "skip": if mismatch_mode == "skip":
stats["skipped"] += 1
pbar.update(1)
del tensor_a, tensor_b del tensor_a, tensor_b
continue return "skipped", None
if mismatch_mode == "error":
raise ValueError(f"Shape mismatch for {key}: {tensor_a.shape} vs {tensor_b.shape}")
# For zeros/fallback, use tensor_a as the difference
weight_diff = tensor_a weight_diff = tensor_a
del tensor_b del tensor_b
else: else:
@@ -529,19 +537,16 @@ def extract_lora_from_files(
# Skip small differences # Skip small differences
if min_diff > 0 and weight_diff.abs().max() < min_diff: if min_diff > 0 and weight_diff.abs().max() < min_diff:
stats["skipped"] += 1
del weight_diff del weight_diff
pbar.update(1) return "skipped", None
continue
# Skip 1D tensors # Skip 1D tensors
if weight_diff.ndim < 2: if weight_diff.ndim < 2:
stats["skipped"] += 1
del weight_diff del weight_diff
pbar.update(1) return "skipped", None
continue
is_conv = weight_diff.ndim == 4 is_conv = weight_diff.ndim == 4
layer_results = {}
try: try:
if is_conv: if is_conv:
@@ -562,32 +567,42 @@ def extract_lora_from_files(
weight_diff, num_chunks, mode, linear_param, device, linear_max_rank weight_diff, num_chunks, mode, linear_param, device, linear_max_rank
) )
if lora_up is not None: if lora_up is not None:
output_sd[f"{lora_name}.lora_up.weight"] = lora_up.to(save_torch_dtype).cpu().contiguous() layer_results[f"{lora_name}.lora_up.weight"] = lora_up.to(save_torch_dtype).cpu().contiguous()
output_sd[f"{lora_name}.lora_down.weight"] = lora_down.to(save_torch_dtype).cpu().contiguous() layer_results[f"{lora_name}.lora_down.weight"] = lora_down.to(save_torch_dtype).cpu().contiguous()
output_sd[f"{lora_name}.alpha"] = torch.tensor(rank).to(save_torch_dtype) layer_results[f"{lora_name}.alpha"] = torch.tensor(rank).to(save_torch_dtype)
stats["chunked"] += 1
del weight_diff del weight_diff
pbar.update(1) return "chunked", layer_results
continue
print(f"[LoRA Extract] Failed: {key}: {e}") print(f"[LoRA Extract] Failed: {key}: {e}")
stats["skipped"] += 1
del weight_diff del weight_diff
pbar.update(1) return "skipped", None
continue
# Store result # Store result
if mode_str == "full": if mode_str == "full":
output_sd[f"{lora_name}.diff"] = weight_diff.to(save_torch_dtype).cpu().contiguous() layer_results[f"{lora_name}.diff"] = weight_diff.to(save_torch_dtype).cpu().contiguous()
stats["full"] += 1 status = "full"
else: else:
lora_down, lora_up, _ = result lora_down, lora_up, _ = result
output_sd[f"{lora_name}.lora_down.weight"] = lora_down.to(save_torch_dtype).cpu().contiguous() layer_results[f"{lora_name}.lora_down.weight"] = lora_down.to(save_torch_dtype).cpu().contiguous()
output_sd[f"{lora_name}.lora_up.weight"] = lora_up.to(save_torch_dtype).cpu().contiguous() layer_results[f"{lora_name}.lora_up.weight"] = lora_up.to(save_torch_dtype).cpu().contiguous()
output_sd[f"{lora_name}.alpha"] = torch.tensor(lora_down.shape[0]).to(save_torch_dtype) layer_results[f"{lora_name}.alpha"] = torch.tensor(lora_down.shape[0]).to(save_torch_dtype)
stats["extracted"] += 1 status = "extracted"
del weight_diff del weight_diff
return status, layer_results
for key in tqdm(weight_keys, desc="Extracting LoRA", unit="layers"):
status, layer_sd = _process_layer(key)
stats[status] += 1
if layer_sd:
output_sd.update(layer_sd)
if force_clear_cache:
import gc
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
pbar.update(1) pbar.update(1)
print(f"[LoRA Extract] Done: {stats['extracted']} extracted, {stats['chunked']} chunked, " print(f"[LoRA Extract] Done: {stats['extracted']} extracted, {stats['chunked']} chunked, "
@@ -709,6 +724,8 @@ def _format_lora_key(key: str) -> str:
def _get_common_inputs(): def _get_common_inputs():
return [ return [
io.Boolean.Input("lazy_load", default=True, tooltip="Low memory mode: load tensors from disk on demand"),
io.Boolean.Input("force_clear_cache", default=True, tooltip="Clear CUDA cache after each layer"),
io.Boolean.Input("chunk_large_layers", default=False, io.Boolean.Input("chunk_large_layers", default=False,
tooltip="Split large fused layers (QKV, MLP) into chunks"), tooltip="Split large fused layers (QKV, MLP) into chunks"),
io.Float.Input("clamp_quantile", default=0.99, min=0.5, max=1.0, step=0.01, io.Float.Input("clamp_quantile", default=0.99, min=0.5, max=1.0, step=0.01,
@@ -736,9 +753,9 @@ class LoRAExtractFixed(io.ComfyNode):
description="Extract LoRA with specified fixed rank for each layer type.", description="Extract LoRA with specified fixed rank for each layer type.",
inputs=[ inputs=[
*_get_model_inputs(), *_get_model_inputs(),
io.Int.Input("linear_dim", default=64, min=1, max=4096, io.Int.Input("linear_dim", default=64, min=1, max=16384,
tooltip="Rank for linear/attention layers"), tooltip="Rank for linear/attention layers"),
io.Int.Input("conv_dim", default=32, min=1, max=4096, io.Int.Input("conv_dim", default=32, min=1, max=16384,
tooltip="Rank for conv layers"), tooltip="Rank for conv layers"),
io.Int.Input("svd_niter", default=2, min=0, max=10, io.Int.Input("svd_niter", default=2, min=0, max=10,
tooltip="SVD power iterations (higher = more accurate but slower)"), tooltip="SVD power iterations (higher = more accurate but slower)"),
@@ -751,7 +768,7 @@ class LoRAExtractFixed(io.ComfyNode):
@classmethod @classmethod
def execute(cls, model_a, model_b, linear_dim, conv_dim, svd_niter, chunk_large_layers, def execute(cls, model_a, model_b, linear_dim, conv_dim, svd_niter, chunk_large_layers,
clamp_quantile, min_diff, mismatch_mode, output_filename, clamp_quantile, min_diff, mismatch_mode, output_filename,
save_dtype, device, skip_patterns) -> io.NodeOutput: save_dtype, device, skip_patterns, lazy_load, force_clear_cache) -> io.NodeOutput:
model_a_path = folder_paths.get_full_path_or_raise("diffusion_models", model_a) model_a_path = folder_paths.get_full_path_or_raise("diffusion_models", model_a)
model_b_path = folder_paths.get_full_path_or_raise("diffusion_models", model_b) model_b_path = folder_paths.get_full_path_or_raise("diffusion_models", model_b)
@@ -759,7 +776,8 @@ class LoRAExtractFixed(io.ComfyNode):
output_sd = extract_lora_from_files( output_sd = extract_lora_from_files(
model_a_path, model_b_path, "fixed", linear_dim, conv_dim, model_a_path, model_b_path, "fixed", linear_dim, conv_dim,
device, save_dtype, linear_dim, conv_dim, device, save_dtype, linear_dim, conv_dim,
clamp_quantile, min_diff, skip_patterns, mismatch_mode, chunk_large_layers, svd_niter clamp_quantile, min_diff, skip_patterns, mismatch_mode, chunk_large_layers, svd_niter,
lazy_load, force_clear_cache
) )
return io.NodeOutput(_save_lora(output_sd, output_filename)) return io.NodeOutput(_save_lora(output_sd, output_filename))
@@ -781,9 +799,9 @@ class LoRAExtractRatio(io.ComfyNode):
tooltip="Ratio threshold for linear layers (higher = more SVs kept)"), tooltip="Ratio threshold for linear layers (higher = more SVs kept)"),
io.Float.Input("conv_ratio", default=2.0, min=1.0, max=100.0, step=0.1, io.Float.Input("conv_ratio", default=2.0, min=1.0, max=100.0, step=0.1,
tooltip="Ratio threshold for conv layers (higher = more SVs kept)"), tooltip="Ratio threshold for conv layers (higher = more SVs kept)"),
io.Int.Input("linear_max_rank", default=128, min=1, max=3072, io.Int.Input("linear_max_rank", default=128, min=1, max=16384,
tooltip="Maximum rank for linear layers"), tooltip="Maximum rank for linear layers"),
io.Int.Input("conv_max_rank", default=128, min=1, max=3072, io.Int.Input("conv_max_rank", default=128, min=1, max=16384,
tooltip="Maximum rank for conv layers"), tooltip="Maximum rank for conv layers"),
*_get_common_inputs(), *_get_common_inputs(),
], ],
@@ -794,7 +812,7 @@ class LoRAExtractRatio(io.ComfyNode):
@classmethod @classmethod
def execute(cls, model_a, model_b, linear_ratio, conv_ratio, linear_max_rank, conv_max_rank, def execute(cls, model_a, model_b, linear_ratio, conv_ratio, linear_max_rank, conv_max_rank,
chunk_large_layers, clamp_quantile, min_diff, mismatch_mode, output_filename, chunk_large_layers, clamp_quantile, min_diff, mismatch_mode, output_filename,
save_dtype, device, skip_patterns) -> io.NodeOutput: save_dtype, device, skip_patterns, lazy_load, force_clear_cache) -> io.NodeOutput:
model_a_path = folder_paths.get_full_path_or_raise("diffusion_models", model_a) model_a_path = folder_paths.get_full_path_or_raise("diffusion_models", model_a)
model_b_path = folder_paths.get_full_path_or_raise("diffusion_models", model_b) model_b_path = folder_paths.get_full_path_or_raise("diffusion_models", model_b)
@@ -802,7 +820,8 @@ class LoRAExtractRatio(io.ComfyNode):
output_sd = extract_lora_from_files( output_sd = extract_lora_from_files(
model_a_path, model_b_path, "ratio", linear_ratio, conv_ratio, model_a_path, model_b_path, "ratio", linear_ratio, conv_ratio,
device, save_dtype, linear_max_rank, conv_max_rank, device, save_dtype, linear_max_rank, conv_max_rank,
clamp_quantile, min_diff, skip_patterns, mismatch_mode, chunk_large_layers clamp_quantile, min_diff, skip_patterns, mismatch_mode, chunk_large_layers,
lazy_load=lazy_load, force_clear_cache=force_clear_cache
) )
return io.NodeOutput(_save_lora(output_sd, output_filename)) return io.NodeOutput(_save_lora(output_sd, output_filename))
@@ -824,9 +843,9 @@ class LoRAExtractQuantile(io.ComfyNode):
tooltip="Target cumulative % for linear layers"), tooltip="Target cumulative % for linear layers"),
io.Float.Input("conv_quantile", default=0.9, min=0.0, max=1.0, step=0.01, io.Float.Input("conv_quantile", default=0.9, min=0.0, max=1.0, step=0.01,
tooltip="Target cumulative % for conv layers"), tooltip="Target cumulative % for conv layers"),
io.Int.Input("linear_max_rank", default=128, min=1, max=3072, io.Int.Input("linear_max_rank", default=128, min=1, max=16384,
tooltip="Maximum rank for linear layers"), tooltip="Maximum rank for linear layers"),
io.Int.Input("conv_max_rank", default=128, min=1, max=3072, io.Int.Input("conv_max_rank", default=128, min=1, max=16384,
tooltip="Maximum rank for conv layers"), tooltip="Maximum rank for conv layers"),
*_get_common_inputs(), *_get_common_inputs(),
], ],
@@ -837,7 +856,7 @@ class LoRAExtractQuantile(io.ComfyNode):
@classmethod @classmethod
def execute(cls, model_a, model_b, linear_quantile, conv_quantile, linear_max_rank, conv_max_rank, def execute(cls, model_a, model_b, linear_quantile, conv_quantile, linear_max_rank, conv_max_rank,
chunk_large_layers, clamp_quantile, min_diff, mismatch_mode, output_filename, chunk_large_layers, clamp_quantile, min_diff, mismatch_mode, output_filename,
save_dtype, device, skip_patterns) -> io.NodeOutput: save_dtype, device, skip_patterns, lazy_load, force_clear_cache) -> io.NodeOutput:
model_a_path = folder_paths.get_full_path_or_raise("diffusion_models", model_a) model_a_path = folder_paths.get_full_path_or_raise("diffusion_models", model_a)
model_b_path = folder_paths.get_full_path_or_raise("diffusion_models", model_b) model_b_path = folder_paths.get_full_path_or_raise("diffusion_models", model_b)
@@ -845,7 +864,8 @@ class LoRAExtractQuantile(io.ComfyNode):
output_sd = extract_lora_from_files( output_sd = extract_lora_from_files(
model_a_path, model_b_path, "quantile", linear_quantile, conv_quantile, model_a_path, model_b_path, "quantile", linear_quantile, conv_quantile,
device, save_dtype, linear_max_rank, conv_max_rank, device, save_dtype, linear_max_rank, conv_max_rank,
clamp_quantile, min_diff, skip_patterns, mismatch_mode, chunk_large_layers clamp_quantile, min_diff, skip_patterns, mismatch_mode, chunk_large_layers,
lazy_load=lazy_load, force_clear_cache=force_clear_cache
) )
return io.NodeOutput(_save_lora(output_sd, output_filename)) return io.NodeOutput(_save_lora(output_sd, output_filename))
@@ -865,9 +885,9 @@ class LoRAExtractKnee(io.ComfyNode):
*_get_model_inputs(), *_get_model_inputs(),
io.Combo.Input("knee_method", options=["sv_knee", "sv_cumulative_knee"], io.Combo.Input("knee_method", options=["sv_knee", "sv_cumulative_knee"],
default="sv_knee", tooltip="Knee detection method"), default="sv_knee", tooltip="Knee detection method"),
io.Int.Input("linear_max_rank", default=128, min=1, max=3072, io.Int.Input("linear_max_rank", default=128, min=1, max=16384,
tooltip="Maximum rank for linear layers"), tooltip="Maximum rank for linear layers"),
io.Int.Input("conv_max_rank", default=128, min=1, max=3072, io.Int.Input("conv_max_rank", default=128, min=1, max=16384,
tooltip="Maximum rank for conv layers"), tooltip="Maximum rank for conv layers"),
*_get_common_inputs(), *_get_common_inputs(),
], ],
@@ -878,7 +898,7 @@ class LoRAExtractKnee(io.ComfyNode):
@classmethod @classmethod
def execute(cls, model_a, model_b, knee_method, linear_max_rank, conv_max_rank, def execute(cls, model_a, model_b, knee_method, linear_max_rank, conv_max_rank,
chunk_large_layers, clamp_quantile, min_diff, mismatch_mode, output_filename, chunk_large_layers, clamp_quantile, min_diff, mismatch_mode, output_filename,
save_dtype, device, skip_patterns) -> io.NodeOutput: save_dtype, device, skip_patterns, lazy_load, force_clear_cache) -> io.NodeOutput:
model_a_path = folder_paths.get_full_path_or_raise("diffusion_models", model_a) model_a_path = folder_paths.get_full_path_or_raise("diffusion_models", model_a)
model_b_path = folder_paths.get_full_path_or_raise("diffusion_models", model_b) model_b_path = folder_paths.get_full_path_or_raise("diffusion_models", model_b)
@@ -886,7 +906,8 @@ class LoRAExtractKnee(io.ComfyNode):
output_sd = extract_lora_from_files( output_sd = extract_lora_from_files(
model_a_path, model_b_path, knee_method, 0, 0, model_a_path, model_b_path, knee_method, 0, 0,
device, save_dtype, linear_max_rank, conv_max_rank, device, save_dtype, linear_max_rank, conv_max_rank,
clamp_quantile, min_diff, skip_patterns, mismatch_mode, chunk_large_layers clamp_quantile, min_diff, skip_patterns, mismatch_mode, chunk_large_layers,
lazy_load=lazy_load, force_clear_cache=force_clear_cache
) )
return io.NodeOutput(_save_lora(output_sd, output_filename)) return io.NodeOutput(_save_lora(output_sd, output_filename))
@@ -908,9 +929,9 @@ class LoRAExtractFrobenius(io.ComfyNode):
tooltip="Target Frobenius norm fraction for linear"), tooltip="Target Frobenius norm fraction for linear"),
io.Float.Input("conv_target", default=0.9, min=0.0, max=1.0, step=0.01, io.Float.Input("conv_target", default=0.9, min=0.0, max=1.0, step=0.01,
tooltip="Target Frobenius norm fraction for conv"), tooltip="Target Frobenius norm fraction for conv"),
io.Int.Input("linear_max_rank", default=128, min=1, max=3072, io.Int.Input("linear_max_rank", default=128, min=1, max=16384,
tooltip="Maximum rank for linear layers"), tooltip="Maximum rank for linear layers"),
io.Int.Input("conv_max_rank", default=128, min=1, max=3072, io.Int.Input("conv_max_rank", default=128, min=1, max=16384,
tooltip="Maximum rank for conv layers"), tooltip="Maximum rank for conv layers"),
*_get_common_inputs(), *_get_common_inputs(),
], ],
@@ -921,7 +942,7 @@ class LoRAExtractFrobenius(io.ComfyNode):
@classmethod @classmethod
def execute(cls, model_a, model_b, linear_target, conv_target, linear_max_rank, conv_max_rank, def execute(cls, model_a, model_b, linear_target, conv_target, linear_max_rank, conv_max_rank,
chunk_large_layers, clamp_quantile, min_diff, mismatch_mode, output_filename, chunk_large_layers, clamp_quantile, min_diff, mismatch_mode, output_filename,
save_dtype, device, skip_patterns) -> io.NodeOutput: save_dtype, device, skip_patterns, lazy_load, force_clear_cache) -> io.NodeOutput:
model_a_path = folder_paths.get_full_path_or_raise("diffusion_models", model_a) model_a_path = folder_paths.get_full_path_or_raise("diffusion_models", model_a)
model_b_path = folder_paths.get_full_path_or_raise("diffusion_models", model_b) model_b_path = folder_paths.get_full_path_or_raise("diffusion_models", model_b)
@@ -929,7 +950,8 @@ class LoRAExtractFrobenius(io.ComfyNode):
output_sd = extract_lora_from_files( output_sd = extract_lora_from_files(
model_a_path, model_b_path, "sv_fro", linear_target, conv_target, model_a_path, model_b_path, "sv_fro", linear_target, conv_target,
device, save_dtype, linear_max_rank, conv_max_rank, device, save_dtype, linear_max_rank, conv_max_rank,
clamp_quantile, min_diff, skip_patterns, mismatch_mode, chunk_large_layers clamp_quantile, min_diff, skip_patterns, mismatch_mode, chunk_large_layers,
lazy_load=lazy_load, force_clear_cache=force_clear_cache
) )
return io.NodeOutput(_save_lora(output_sd, output_filename)) return io.NodeOutput(_save_lora(output_sd, output_filename))
+18 -2
View File
@@ -48,7 +48,7 @@ def merge_multi_loras(
prepare_for_large_operation(total_size_gb * 2.5, torch.device(device)) prepare_for_large_operation(total_size_gb * 2.5, torch.device(device))
handlers = [MemoryEfficientSafeOpen(p) for p in lora_paths] handlers = [MemoryEfficientSafeOpen(p, low_memory=True) for p in lora_paths]
try: try:
# 1. Analyze all LoRAs # 1. Analyze all LoRAs
@@ -190,6 +190,12 @@ def merge_multi_loras(
del merged_down, merged_up del merged_down, merged_up
for d, _ in downs: del d for d, _ in downs: del d
for u, _ in ups: del u for u, _ in ups: del u
downs.clear()
ups.clear()
import gc
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
pbar.update(1) pbar.update(1)
@@ -412,7 +418,7 @@ def merge_multi_loras_dare(
print(f"[LoRA Multi-Merge DARE] Drop rate: {drop_rate}, Trim quantile: {trim_quantile}") print(f"[LoRA Multi-Merge DARE] Drop rate: {drop_rate}, Trim quantile: {trim_quantile}")
prepare_for_large_operation(total_size_gb * 2.5, torch.device(device)) prepare_for_large_operation(total_size_gb * 2.5, torch.device(device))
handlers = [MemoryEfficientSafeOpen(p) for p in lora_paths] handlers = [MemoryEfficientSafeOpen(p, low_memory=True) for p in lora_paths]
rng = torch.Generator(device=device).manual_seed(seed) rng = torch.Generator(device=device).manual_seed(seed)
@@ -523,6 +529,16 @@ def merge_multi_loras_dare(
output_sd[f"{core}.lora_up.weight"] = merged_up.to(save_dtype).cpu().contiguous() output_sd[f"{core}.lora_up.weight"] = merged_up.to(save_dtype).cpu().contiguous()
output_sd[f"{core}.alpha"] = torch.tensor(float(max_rank), dtype=save_dtype) output_sd[f"{core}.alpha"] = torch.tensor(float(max_rank), dtype=save_dtype)
del merged_down, merged_up
for d, _ in downs: del d
for u, _ in ups: del u
downs.clear()
ups.clear()
import gc
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
pbar.update(1) pbar.update(1)
# Final Summary # Final Summary
+35 -4
View File
@@ -461,6 +461,12 @@ def resize_lora_file(
if alpha_suffix: if alpha_suffix:
output_sd[f"{new_block_name}{alpha_suffix}"] = torch.tensor(result["new_alpha"]).to(save_dtype) output_sd[f"{new_block_name}{alpha_suffix}"] = torch.tensor(result["new_alpha"]).to(save_dtype)
del result
import gc
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
pbar.update(1) pbar.update(1)
if verbose and fro_list: if verbose and fro_list:
@@ -854,6 +860,10 @@ def merge_loras_to_model(
del cpu_base del cpu_base
stats["copied"] += 1 stats["copied"] += 1
import gc
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
pbar.update(1) pbar.update(1)
@@ -886,11 +896,11 @@ class LoRAMergeToModel(io.ComfyNode):
node_id="LoRAMergeToModel", node_id="LoRAMergeToModel",
display_name="LoRA Merge To Model", display_name="LoRA Merge To Model",
category="ModelUtils/LoRA/Merge", category="ModelUtils/LoRA/Merge",
description="Merge 1-4 LoRAs into a base model and save the result. Saves to base model directory.", description="Merge 1-8 LoRAs into a base model and save the result. Saves to base model directory.",
inputs=[ inputs=[
io.Combo.Input("base_model", options=folder_paths.get_filename_list("diffusion_models"), io.Combo.Input("base_model", options=folder_paths.get_filename_list("diffusion_models"),
tooltip="Base model the LoRAs were trained on"), tooltip="Base model the LoRAs were trained on"),
io.Combo.Input("lora_count", options=["1", "2", "3", "4"], default="2", io.Combo.Input("lora_count", options=["1", "2", "3", "4", "5", "6", "7", "8"], default="2",
tooltip="Number of LoRAs to merge"), tooltip="Number of LoRAs to merge"),
# LoRA 1 # LoRA 1
io.Combo.Input("lora_1", options=folder_paths.get_filename_list("loras"), io.Combo.Input("lora_1", options=folder_paths.get_filename_list("loras"),
@@ -912,6 +922,26 @@ class LoRAMergeToModel(io.ComfyNode):
default="None", tooltip="Fourth LoRA"), default="None", tooltip="Fourth LoRA"),
io.Float.Input("weight_4", default=1.0, min=-10.0, max=10.0, step=0.01, io.Float.Input("weight_4", default=1.0, min=-10.0, max=10.0, step=0.01,
tooltip="Weight strength for LoRA 4"), tooltip="Weight strength for LoRA 4"),
# LoRA 5
io.Combo.Input("lora_5", options=["None"] + folder_paths.get_filename_list("loras"),
default="None", tooltip="Fifth LoRA"),
io.Float.Input("weight_5", default=1.0, min=-10.0, max=10.0, step=0.01,
tooltip="Weight strength for LoRA 5"),
# LoRA 6
io.Combo.Input("lora_6", options=["None"] + folder_paths.get_filename_list("loras"),
default="None", tooltip="Sixth LoRA"),
io.Float.Input("weight_6", default=1.0, min=-10.0, max=10.0, step=0.01,
tooltip="Weight strength for LoRA 6"),
# LoRA 7
io.Combo.Input("lora_7", options=["None"] + folder_paths.get_filename_list("loras"),
default="None", tooltip="Seventh LoRA"),
io.Float.Input("weight_7", default=1.0, min=-10.0, max=10.0, step=0.01,
tooltip="Weight strength for LoRA 7"),
# LoRA 8
io.Combo.Input("lora_8", options=["None"] + folder_paths.get_filename_list("loras"),
default="None", tooltip="Eighth LoRA"),
io.Float.Input("weight_8", default=1.0, min=-10.0, max=10.0, step=0.01,
tooltip="Weight strength for LoRA 8"),
# Settings # Settings
io.String.Input("skip_patterns", default="", multiline=True, io.String.Input("skip_patterns", default="", multiline=True,
tooltip="Regex patterns for layers to skip"), tooltip="Regex patterns for layers to skip"),
@@ -926,12 +956,13 @@ class LoRAMergeToModel(io.ComfyNode):
@classmethod @classmethod
def execute(cls, base_model, lora_count, def execute(cls, base_model, lora_count,
lora_1, weight_1, lora_2, weight_2, lora_3, weight_3, lora_4, weight_4, lora_1, weight_1, lora_2, weight_2, lora_3, weight_3, lora_4, weight_4,
lora_5, weight_5, lora_6, weight_6, lora_7, weight_7, lora_8, weight_8,
skip_patterns, output_filename, save_dtype, device) -> io.NodeOutput: skip_patterns, output_filename, save_dtype, device) -> io.NodeOutput:
# Build LoRA list based on count # Build LoRA list based on count
count = int(lora_count) count = int(lora_count)
lora_names = [lora_1, lora_2, lora_3, lora_4][:count] lora_names = [lora_1, lora_2, lora_3, lora_4, lora_5, lora_6, lora_7, lora_8][:count]
lora_weights = [weight_1, weight_2, weight_3, weight_4][:count] lora_weights = [weight_1, weight_2, weight_3, weight_4, weight_5, weight_6, weight_7, weight_8][:count]
# Filter out "None" entries # Filter out "None" entries
valid_loras = [(name, weight) for name, weight in zip(lora_names, lora_weights) if name != "None"] valid_loras = [(name, weight) for name, weight in zip(lora_names, lora_weights) if name != "None"]
+70 -17
View File
@@ -83,11 +83,11 @@ class MergerLogic:
path = folder_paths.get_full_path(model_type, name) path = folder_paths.get_full_path(model_type, name)
if not path: if not path:
raise FileNotFoundError(f"Model '{name}' not found.") raise FileNotFoundError(f"Model '{name}' not found.")
handlers[name] = MemoryEfficientSafeOpen(path) handlers[name] = MemoryEfficientSafeOpen(path, low_memory=True)
primary_handler = handlers[primary_model_name] primary_handler = handlers[primary_model_name]
all_keys = primary_handler.keys() all_keys = primary_handler.keys()
metadata = primary_handler.header.get("__metadata__", {}) metadata = primary_handler.metadata()
# Convert mismatch_mode string to enum # Convert mismatch_mode string to enum
mismatch_mode_str = recipe_params.get('mismatch_mode', 'skip') mismatch_mode_str = recipe_params.get('mismatch_mode', 'skip')
@@ -124,6 +124,7 @@ class MergerLogic:
discarded_keys = 0 discarded_keys = 0
error_keys = [] error_keys = []
with torch.no_grad():
for key in tqdm(all_keys, desc="Merging layers", unit="layers"): for key in tqdm(all_keys, desc="Merging layers", unit="layers"):
# Check discard patterns first - skip entirely # Check discard patterns first - skip entirely
if _matches_any_pattern(key, discard_patterns): if _matches_any_pattern(key, discard_patterns):
@@ -141,7 +142,7 @@ class MergerLogic:
# Check exclude patterns - use Model A only, no merge # Check exclude patterns - use Model A only, no merge
if _matches_any_pattern(key, exclude_patterns): if _matches_any_pattern(key, exclude_patterns):
merged_state_dict[key] = tensor_a.to(save_torch_dtype).cpu().clone() merged_state_dict[key] = tensor_a.detach().to(save_torch_dtype).cpu().clone()
excluded_keys += 1 excluded_keys += 1
pbar.update(1) pbar.update(1)
continue continue
@@ -167,7 +168,7 @@ class MergerLogic:
if isinstance(result, dict): if isinstance(result, dict):
for r_key, r_tensor in result.items(): for r_key, r_tensor in result.items():
merged_state_dict[r_key] = r_tensor.to(save_torch_dtype).cpu().clone() merged_state_dict[r_key] = r_tensor.detach().to(save_torch_dtype).cpu().clone()
else: else:
# Ensure compatibility with Model A's architecture. # Ensure compatibility with Model A's architecture.
# If alignment_mode is 'pad/crop', we crop results that were padded. # If alignment_mode is 'pad/crop', we crop results that were padded.
@@ -178,10 +179,22 @@ class MergerLogic:
slices = tuple(slice(0, min(res_s, tgt_s)) for res_s, tgt_s in zip(result.shape, target_shape)) slices = tuple(slice(0, min(res_s, tgt_s)) for res_s, tgt_s in zip(result.shape, target_shape))
result = result[slices] result = result[slices]
merged_state_dict[key] = result.to(save_torch_dtype).cpu().clone() merged_state_dict[key] = result.detach().to(save_torch_dtype).cpu().clone()
# Clean up tensor_a reference to allow GC # Clean up references to allow GC immediately.
# Local loop variables must be explicitly deleted to prevent PyTorch from keeping tensors in VRAM.
recipe.clean()
del recipe_params['_tensor_a'] del recipe_params['_tensor_a']
del tensor_a
del recipe
del result
if recipe_params.get('force_clear_cache', True):
import gc
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
pbar.update(1) pbar.update(1)
# Log summary # Log summary
@@ -240,6 +253,8 @@ class CheckpointTwoMerger(io.ComfyNode):
io.Combo.Input("process_device", options=["cuda", "cpu"]), io.Combo.Input("process_device", options=["cuda", "cpu"]),
io.String.Input("exclude_patterns", default="", multiline=True), io.String.Input("exclude_patterns", default="", multiline=True),
io.String.Input("discard_patterns", default="", multiline=True), io.String.Input("discard_patterns", default="", multiline=True),
io.Boolean.Input("lazy_load", default=True, tooltip="Low memory mode: load tensors from disk on demand"),
io.Boolean.Input("force_clear_cache", default=True, tooltip="Clear CUDA cache after each layer"),
], ],
outputs=[ outputs=[
io.String.Output(display_name="output_filename"), io.String.Output(display_name="output_filename"),
@@ -251,7 +266,8 @@ class CheckpointTwoMerger(io.ComfyNode):
def execute(cls, execution_mode: str, model_a: str, model_b: str, def execute(cls, execution_mode: str, model_a: str, model_b: str,
calc_mode: str, mismatch_mode: str, alignment_mode: str, alpha: float, beta: float, calc_mode: str, mismatch_mode: str, alignment_mode: str, alpha: float, beta: float,
gamma: float, delta: float, seed: int, output_filename: str, save_dtype: str, gamma: float, delta: float, seed: int, output_filename: str, save_dtype: str,
process_device: str, exclude_patterns: str, discard_patterns: str) -> io.NodeOutput: process_device: str, exclude_patterns: str, discard_patterns: str,
lazy_load: bool, force_clear_cache: bool) -> io.NodeOutput:
doc = load_documentation_from_file('merger_2_model_modes.md') doc = load_documentation_from_file('merger_2_model_modes.md')
if execution_mode == "DOCUMENTATION ONLY": if execution_mode == "DOCUMENTATION ONLY":
return io.NodeOutput("Documentation mode active. No merge performed.", doc) return io.NodeOutput("Documentation mode active. No merge performed.", doc)
@@ -263,6 +279,7 @@ class CheckpointTwoMerger(io.ComfyNode):
"output_filename": output_filename, "save_dtype": save_dtype, "output_filename": output_filename, "save_dtype": save_dtype,
"device": process_device, "dtype": torch.float32, "device": process_device, "dtype": torch.float32,
"exclude_patterns": exclude_patterns, "discard_patterns": discard_patterns, "exclude_patterns": exclude_patterns, "discard_patterns": discard_patterns,
"lazy_load": lazy_load, "force_clear_cache": force_clear_cache,
} }
model_names = {"model_a": model_a, "model_b": model_b} model_names = {"model_a": model_a, "model_b": model_b}
filename = MergerLogic.execute_merge(model_names, calc_mode, TWO_MODEL_MODES, recipe_params, cls.MODEL_TYPE) filename = MergerLogic.execute_merge(model_names, calc_mode, TWO_MODEL_MODES, recipe_params, cls.MODEL_TYPE)
@@ -295,6 +312,8 @@ class ModelTwoMerger(io.ComfyNode):
io.Combo.Input("process_device", options=["cuda", "cpu"]), io.Combo.Input("process_device", options=["cuda", "cpu"]),
io.String.Input("exclude_patterns", default="", multiline=True), io.String.Input("exclude_patterns", default="", multiline=True),
io.String.Input("discard_patterns", default="", multiline=True), io.String.Input("discard_patterns", default="", multiline=True),
io.Boolean.Input("lazy_load", default=True, tooltip="Low memory mode: load tensors from disk on demand"),
io.Boolean.Input("force_clear_cache", default=True, tooltip="Clear CUDA cache after each layer"),
], ],
outputs=[ outputs=[
io.String.Output(display_name="output_filename"), io.String.Output(display_name="output_filename"),
@@ -306,7 +325,8 @@ class ModelTwoMerger(io.ComfyNode):
def execute(cls, execution_mode: str, model_a: str, model_b: str, def execute(cls, execution_mode: str, model_a: str, model_b: str,
calc_mode: str, mismatch_mode: str, alignment_mode: str, alpha: float, beta: float, calc_mode: str, mismatch_mode: str, alignment_mode: str, alpha: float, beta: float,
gamma: float, delta: float, seed: int, output_filename: str, save_dtype: str, gamma: float, delta: float, seed: int, output_filename: str, save_dtype: str,
process_device: str, exclude_patterns: str, discard_patterns: str) -> io.NodeOutput: process_device: str, exclude_patterns: str, discard_patterns: str,
lazy_load: bool, force_clear_cache: bool) -> io.NodeOutput:
doc = load_documentation_from_file('merger_2_model_modes.md') doc = load_documentation_from_file('merger_2_model_modes.md')
if execution_mode == "DOCUMENTATION ONLY": if execution_mode == "DOCUMENTATION ONLY":
return io.NodeOutput("Documentation mode active. No merge performed.", doc) return io.NodeOutput("Documentation mode active. No merge performed.", doc)
@@ -318,6 +338,7 @@ class ModelTwoMerger(io.ComfyNode):
"output_filename": output_filename, "save_dtype": save_dtype, "output_filename": output_filename, "save_dtype": save_dtype,
"device": process_device, "dtype": torch.float32, "device": process_device, "dtype": torch.float32,
"exclude_patterns": exclude_patterns, "discard_patterns": discard_patterns, "exclude_patterns": exclude_patterns, "discard_patterns": discard_patterns,
"lazy_load": lazy_load, "force_clear_cache": force_clear_cache,
} }
model_names = {"model_a": model_a, "model_b": model_b} model_names = {"model_a": model_a, "model_b": model_b}
filename = MergerLogic.execute_merge(model_names, calc_mode, TWO_MODEL_MODES, recipe_params, cls.MODEL_TYPE) filename = MergerLogic.execute_merge(model_names, calc_mode, TWO_MODEL_MODES, recipe_params, cls.MODEL_TYPE)
@@ -350,6 +371,8 @@ class TextEncoderTwoMerger(io.ComfyNode):
io.Combo.Input("process_device", options=["cuda", "cpu"]), io.Combo.Input("process_device", options=["cuda", "cpu"]),
io.String.Input("exclude_patterns", default="", multiline=True), io.String.Input("exclude_patterns", default="", multiline=True),
io.String.Input("discard_patterns", default="", multiline=True), io.String.Input("discard_patterns", default="", multiline=True),
io.Boolean.Input("lazy_load", default=True, tooltip="Low memory mode: load tensors from disk on demand"),
io.Boolean.Input("force_clear_cache", default=True, tooltip="Clear CUDA cache after each layer"),
], ],
outputs=[ outputs=[
io.String.Output(display_name="output_filename"), io.String.Output(display_name="output_filename"),
@@ -361,7 +384,8 @@ class TextEncoderTwoMerger(io.ComfyNode):
def execute(cls, execution_mode: str, model_a: str, model_b: str, def execute(cls, execution_mode: str, model_a: str, model_b: str,
calc_mode: str, mismatch_mode: str, alignment_mode: str, alpha: float, beta: float, calc_mode: str, mismatch_mode: str, alignment_mode: str, alpha: float, beta: float,
gamma: float, delta: float, seed: int, output_filename: str, save_dtype: str, gamma: float, delta: float, seed: int, output_filename: str, save_dtype: str,
process_device: str, exclude_patterns: str, discard_patterns: str) -> io.NodeOutput: process_device: str, exclude_patterns: str, discard_patterns: str,
lazy_load: bool, force_clear_cache: bool) -> io.NodeOutput:
doc = load_documentation_from_file('merger_2_model_modes.md') doc = load_documentation_from_file('merger_2_model_modes.md')
if execution_mode == "DOCUMENTATION ONLY": if execution_mode == "DOCUMENTATION ONLY":
return io.NodeOutput("Documentation mode active. No merge performed.", doc) return io.NodeOutput("Documentation mode active. No merge performed.", doc)
@@ -373,6 +397,7 @@ class TextEncoderTwoMerger(io.ComfyNode):
"output_filename": output_filename, "save_dtype": save_dtype, "output_filename": output_filename, "save_dtype": save_dtype,
"device": process_device, "dtype": torch.float32, "device": process_device, "dtype": torch.float32,
"exclude_patterns": exclude_patterns, "discard_patterns": discard_patterns, "exclude_patterns": exclude_patterns, "discard_patterns": discard_patterns,
"lazy_load": lazy_load, "force_clear_cache": force_clear_cache,
} }
model_names = {"model_a": model_a, "model_b": model_b} model_names = {"model_a": model_a, "model_b": model_b}
filename = MergerLogic.execute_merge(model_names, calc_mode, TWO_MODEL_MODES, recipe_params, cls.MODEL_TYPE) filename = MergerLogic.execute_merge(model_names, calc_mode, TWO_MODEL_MODES, recipe_params, cls.MODEL_TYPE)
@@ -405,6 +430,8 @@ class LoRATwoMerger(io.ComfyNode):
io.Combo.Input("process_device", options=["cuda", "cpu"]), io.Combo.Input("process_device", options=["cuda", "cpu"]),
io.String.Input("exclude_patterns", default="", multiline=True), io.String.Input("exclude_patterns", default="", multiline=True),
io.String.Input("discard_patterns", default="", multiline=True), io.String.Input("discard_patterns", default="", multiline=True),
io.Boolean.Input("lazy_load", default=True, tooltip="Low memory mode: load tensors from disk on demand"),
io.Boolean.Input("force_clear_cache", default=True, tooltip="Clear CUDA cache after each layer"),
], ],
outputs=[ outputs=[
io.String.Output(display_name="output_filename"), io.String.Output(display_name="output_filename"),
@@ -416,7 +443,8 @@ class LoRATwoMerger(io.ComfyNode):
def execute(cls, execution_mode: str, model_a: str, model_b: str, def execute(cls, execution_mode: str, model_a: str, model_b: str,
calc_mode: str, mismatch_mode: str, alignment_mode: str, alpha: float, beta: float, calc_mode: str, mismatch_mode: str, alignment_mode: str, alpha: float, beta: float,
gamma: float, delta: float, seed: int, output_filename: str, save_dtype: str, gamma: float, delta: float, seed: int, output_filename: str, save_dtype: str,
process_device: str, exclude_patterns: str, discard_patterns: str) -> io.NodeOutput: process_device: str, exclude_patterns: str, discard_patterns: str,
lazy_load: bool, force_clear_cache: bool) -> io.NodeOutput:
doc = load_documentation_from_file('merger_2_model_modes.md') doc = load_documentation_from_file('merger_2_model_modes.md')
if execution_mode == "DOCUMENTATION ONLY": if execution_mode == "DOCUMENTATION ONLY":
return io.NodeOutput("Documentation mode active. No merge performed.", doc) return io.NodeOutput("Documentation mode active. No merge performed.", doc)
@@ -428,6 +456,7 @@ class LoRATwoMerger(io.ComfyNode):
"output_filename": output_filename, "save_dtype": save_dtype, "output_filename": output_filename, "save_dtype": save_dtype,
"device": process_device, "dtype": torch.float32, "device": process_device, "dtype": torch.float32,
"exclude_patterns": exclude_patterns, "discard_patterns": discard_patterns, "exclude_patterns": exclude_patterns, "discard_patterns": discard_patterns,
"lazy_load": lazy_load, "force_clear_cache": force_clear_cache,
} }
model_names = {"model_a": model_a, "model_b": model_b} model_names = {"model_a": model_a, "model_b": model_b}
filename = MergerLogic.execute_merge(model_names, calc_mode, TWO_MODEL_MODES, recipe_params, cls.MODEL_TYPE) filename = MergerLogic.execute_merge(model_names, calc_mode, TWO_MODEL_MODES, recipe_params, cls.MODEL_TYPE)
@@ -460,6 +489,8 @@ class EmbeddingTwoMerger(io.ComfyNode):
io.Combo.Input("process_device", options=["cuda", "cpu"]), io.Combo.Input("process_device", options=["cuda", "cpu"]),
io.String.Input("exclude_patterns", default="", multiline=True), io.String.Input("exclude_patterns", default="", multiline=True),
io.String.Input("discard_patterns", default="", multiline=True), io.String.Input("discard_patterns", default="", multiline=True),
io.Boolean.Input("lazy_load", default=True, tooltip="Low memory mode: load tensors from disk on demand"),
io.Boolean.Input("force_clear_cache", default=True, tooltip="Clear CUDA cache after each layer"),
], ],
outputs=[ outputs=[
io.String.Output(display_name="output_filename"), io.String.Output(display_name="output_filename"),
@@ -471,7 +502,8 @@ class EmbeddingTwoMerger(io.ComfyNode):
def execute(cls, execution_mode: str, model_a: str, model_b: str, def execute(cls, execution_mode: str, model_a: str, model_b: str,
calc_mode: str, mismatch_mode: str, alignment_mode: str, alpha: float, beta: float, calc_mode: str, mismatch_mode: str, alignment_mode: str, alpha: float, beta: float,
gamma: float, delta: float, seed: int, output_filename: str, save_dtype: str, gamma: float, delta: float, seed: int, output_filename: str, save_dtype: str,
process_device: str, exclude_patterns: str, discard_patterns: str) -> io.NodeOutput: process_device: str, exclude_patterns: str, discard_patterns: str,
lazy_load: bool, force_clear_cache: bool) -> io.NodeOutput:
doc = load_documentation_from_file('merger_2_model_modes.md') doc = load_documentation_from_file('merger_2_model_modes.md')
if execution_mode == "DOCUMENTATION ONLY": if execution_mode == "DOCUMENTATION ONLY":
return io.NodeOutput("Documentation mode active. No merge performed.", doc) return io.NodeOutput("Documentation mode active. No merge performed.", doc)
@@ -483,6 +515,7 @@ class EmbeddingTwoMerger(io.ComfyNode):
"output_filename": output_filename, "save_dtype": save_dtype, "output_filename": output_filename, "save_dtype": save_dtype,
"device": process_device, "dtype": torch.float32, "device": process_device, "dtype": torch.float32,
"exclude_patterns": exclude_patterns, "discard_patterns": discard_patterns, "exclude_patterns": exclude_patterns, "discard_patterns": discard_patterns,
"lazy_load": lazy_load, "force_clear_cache": force_clear_cache,
} }
model_names = {"model_a": model_a, "model_b": model_b} model_names = {"model_a": model_a, "model_b": model_b}
filename = MergerLogic.execute_merge(model_names, calc_mode, TWO_MODEL_MODES, recipe_params, cls.MODEL_TYPE) filename = MergerLogic.execute_merge(model_names, calc_mode, TWO_MODEL_MODES, recipe_params, cls.MODEL_TYPE)
@@ -518,6 +551,8 @@ class CheckpointThreeMerger(io.ComfyNode):
io.Combo.Input("process_device", options=["cuda", "cpu"]), io.Combo.Input("process_device", options=["cuda", "cpu"]),
io.String.Input("exclude_patterns", default="", multiline=True), io.String.Input("exclude_patterns", default="", multiline=True),
io.String.Input("discard_patterns", default="", multiline=True), io.String.Input("discard_patterns", default="", multiline=True),
io.Boolean.Input("lazy_load", default=True, tooltip="Low memory mode: load tensors from disk on demand"),
io.Boolean.Input("force_clear_cache", default=True, tooltip="Clear CUDA cache after each layer"),
], ],
outputs=[ outputs=[
io.String.Output(display_name="output_filename"), io.String.Output(display_name="output_filename"),
@@ -529,7 +564,8 @@ class CheckpointThreeMerger(io.ComfyNode):
def execute(cls, execution_mode: str, model_a: str, model_b: str, model_c: str, def execute(cls, execution_mode: str, model_a: str, model_b: str, model_c: str,
calc_mode: str, mismatch_mode: str, alignment_mode: str, alpha: float, beta: float, calc_mode: str, mismatch_mode: str, alignment_mode: str, alpha: float, beta: float,
gamma: float, delta: float, seed: int, output_filename: str, save_dtype: str, gamma: float, delta: float, seed: int, output_filename: str, save_dtype: str,
process_device: str, exclude_patterns: str, discard_patterns: str) -> io.NodeOutput: process_device: str, exclude_patterns: str, discard_patterns: str,
lazy_load: bool, force_clear_cache: bool) -> io.NodeOutput:
doc = load_documentation_from_file('merger_3_model_modes.md') doc = load_documentation_from_file('merger_3_model_modes.md')
if execution_mode == "DOCUMENTATION ONLY": if execution_mode == "DOCUMENTATION ONLY":
return io.NodeOutput("Documentation mode active. No merge performed.", doc) return io.NodeOutput("Documentation mode active. No merge performed.", doc)
@@ -541,6 +577,7 @@ class CheckpointThreeMerger(io.ComfyNode):
"output_filename": output_filename, "save_dtype": save_dtype, "output_filename": output_filename, "save_dtype": save_dtype,
"device": process_device, "dtype": torch.float32, "device": process_device, "dtype": torch.float32,
"exclude_patterns": exclude_patterns, "discard_patterns": discard_patterns, "exclude_patterns": exclude_patterns, "discard_patterns": discard_patterns,
"lazy_load": lazy_load, "force_clear_cache": force_clear_cache,
} }
model_names = {"model_a": model_a, "model_b": model_b, "model_c": model_c} model_names = {"model_a": model_a, "model_b": model_b, "model_c": model_c}
filename = MergerLogic.execute_merge(model_names, calc_mode, THREE_MODEL_MODES, recipe_params, cls.MODEL_TYPE) filename = MergerLogic.execute_merge(model_names, calc_mode, THREE_MODEL_MODES, recipe_params, cls.MODEL_TYPE)
@@ -574,6 +611,8 @@ class ModelThreeMerger(io.ComfyNode):
io.Combo.Input("process_device", options=["cuda", "cpu"]), io.Combo.Input("process_device", options=["cuda", "cpu"]),
io.String.Input("exclude_patterns", default="", multiline=True), io.String.Input("exclude_patterns", default="", multiline=True),
io.String.Input("discard_patterns", default="", multiline=True), io.String.Input("discard_patterns", default="", multiline=True),
io.Boolean.Input("lazy_load", default=True, tooltip="Low memory mode: load tensors from disk on demand"),
io.Boolean.Input("force_clear_cache", default=True, tooltip="Clear CUDA cache after each layer"),
], ],
outputs=[ outputs=[
io.String.Output(display_name="output_filename"), io.String.Output(display_name="output_filename"),
@@ -585,7 +624,8 @@ class ModelThreeMerger(io.ComfyNode):
def execute(cls, execution_mode: str, model_a: str, model_b: str, model_c: str, def execute(cls, execution_mode: str, model_a: str, model_b: str, model_c: str,
calc_mode: str, mismatch_mode: str, alignment_mode: str, alpha: float, beta: float, calc_mode: str, mismatch_mode: str, alignment_mode: str, alpha: float, beta: float,
gamma: float, delta: float, seed: int, output_filename: str, save_dtype: str, gamma: float, delta: float, seed: int, output_filename: str, save_dtype: str,
process_device: str, exclude_patterns: str, discard_patterns: str) -> io.NodeOutput: process_device: str, exclude_patterns: str, discard_patterns: str,
lazy_load: bool, force_clear_cache: bool) -> io.NodeOutput:
doc = load_documentation_from_file('merger_3_model_modes.md') doc = load_documentation_from_file('merger_3_model_modes.md')
if execution_mode == "DOCUMENTATION ONLY": if execution_mode == "DOCUMENTATION ONLY":
return io.NodeOutput("Documentation mode active. No merge performed.", doc) return io.NodeOutput("Documentation mode active. No merge performed.", doc)
@@ -597,6 +637,7 @@ class ModelThreeMerger(io.ComfyNode):
"output_filename": output_filename, "save_dtype": save_dtype, "output_filename": output_filename, "save_dtype": save_dtype,
"device": process_device, "dtype": torch.float32, "device": process_device, "dtype": torch.float32,
"exclude_patterns": exclude_patterns, "discard_patterns": discard_patterns, "exclude_patterns": exclude_patterns, "discard_patterns": discard_patterns,
"lazy_load": lazy_load, "force_clear_cache": force_clear_cache,
} }
model_names = {"model_a": model_a, "model_b": model_b, "model_c": model_c} model_names = {"model_a": model_a, "model_b": model_b, "model_c": model_c}
filename = MergerLogic.execute_merge(model_names, calc_mode, THREE_MODEL_MODES, recipe_params, cls.MODEL_TYPE) filename = MergerLogic.execute_merge(model_names, calc_mode, THREE_MODEL_MODES, recipe_params, cls.MODEL_TYPE)
@@ -622,7 +663,7 @@ class TextEncoderThreeMerger(io.ComfyNode):
io.Combo.Input("alignment_mode", options=["pad/crop", "interpolate"], default="pad/crop"), io.Combo.Input("alignment_mode", options=["pad/crop", "interpolate"], default="pad/crop"),
io.Float.Input("alpha", default=0.5, min=-2.0, max=3.0, step=0.01), io.Float.Input("alpha", default=0.5, min=-2.0, max=3.0, step=0.01),
io.Float.Input("beta", default=0.5, min=-2.0, max=3.0, step=0.01), io.Float.Input("beta", default=0.5, min=-2.0, max=3.0, step=0.01),
io.Float.Input("gamma", default=0.5, min=-2.0, max=3.0, step=0.01), io.Float.Input("gamma", default=0.99, min=0.0, max=1.0, step=0.001),
io.Float.Input("delta", default=0.5, min=-2.0, max=3.0, step=0.01), io.Float.Input("delta", default=0.5, min=-2.0, max=3.0, step=0.01),
io.Int.Input("seed", default=0, min=0, max=0xffffffffffffffff), io.Int.Input("seed", default=0, min=0, max=0xffffffffffffffff),
io.String.Input("output_filename", default="merged_3_textencoder"), io.String.Input("output_filename", default="merged_3_textencoder"),
@@ -630,6 +671,8 @@ class TextEncoderThreeMerger(io.ComfyNode):
io.Combo.Input("process_device", options=["cuda", "cpu"]), io.Combo.Input("process_device", options=["cuda", "cpu"]),
io.String.Input("exclude_patterns", default="", multiline=True), io.String.Input("exclude_patterns", default="", multiline=True),
io.String.Input("discard_patterns", default="", multiline=True), io.String.Input("discard_patterns", default="", multiline=True),
io.Boolean.Input("lazy_load", default=True, tooltip="Low memory mode: load tensors from disk on demand"),
io.Boolean.Input("force_clear_cache", default=True, tooltip="Clear CUDA cache after each layer"),
], ],
outputs=[ outputs=[
io.String.Output(display_name="output_filename"), io.String.Output(display_name="output_filename"),
@@ -641,7 +684,8 @@ class TextEncoderThreeMerger(io.ComfyNode):
def execute(cls, execution_mode: str, model_a: str, model_b: str, model_c: str, def execute(cls, execution_mode: str, model_a: str, model_b: str, model_c: str,
calc_mode: str, mismatch_mode: str, alignment_mode: str, alpha: float, beta: float, calc_mode: str, mismatch_mode: str, alignment_mode: str, alpha: float, beta: float,
gamma: float, delta: float, seed: int, output_filename: str, save_dtype: str, gamma: float, delta: float, seed: int, output_filename: str, save_dtype: str,
process_device: str, exclude_patterns: str, discard_patterns: str) -> io.NodeOutput: process_device: str, exclude_patterns: str, discard_patterns: str,
lazy_load: bool, force_clear_cache: bool) -> io.NodeOutput:
doc = load_documentation_from_file('merger_3_model_modes.md') doc = load_documentation_from_file('merger_3_model_modes.md')
if execution_mode == "DOCUMENTATION ONLY": if execution_mode == "DOCUMENTATION ONLY":
return io.NodeOutput("Documentation mode active. No merge performed.", doc) return io.NodeOutput("Documentation mode active. No merge performed.", doc)
@@ -653,6 +697,7 @@ class TextEncoderThreeMerger(io.ComfyNode):
"output_filename": output_filename, "save_dtype": save_dtype, "output_filename": output_filename, "save_dtype": save_dtype,
"device": process_device, "dtype": torch.float32, "device": process_device, "dtype": torch.float32,
"exclude_patterns": exclude_patterns, "discard_patterns": discard_patterns, "exclude_patterns": exclude_patterns, "discard_patterns": discard_patterns,
"lazy_load": lazy_load, "force_clear_cache": force_clear_cache,
} }
model_names = {"model_a": model_a, "model_b": model_b, "model_c": model_c} model_names = {"model_a": model_a, "model_b": model_b, "model_c": model_c}
filename = MergerLogic.execute_merge(model_names, calc_mode, THREE_MODEL_MODES, recipe_params, cls.MODEL_TYPE) filename = MergerLogic.execute_merge(model_names, calc_mode, THREE_MODEL_MODES, recipe_params, cls.MODEL_TYPE)
@@ -686,6 +731,8 @@ class LoRAThreeMerger(io.ComfyNode):
io.Combo.Input("process_device", options=["cuda", "cpu"]), io.Combo.Input("process_device", options=["cuda", "cpu"]),
io.String.Input("exclude_patterns", default="", multiline=True), io.String.Input("exclude_patterns", default="", multiline=True),
io.String.Input("discard_patterns", default="", multiline=True), io.String.Input("discard_patterns", default="", multiline=True),
io.Boolean.Input("lazy_load", default=True, tooltip="Low memory mode: load tensors from disk on demand"),
io.Boolean.Input("force_clear_cache", default=True, tooltip="Clear CUDA cache after each layer"),
], ],
outputs=[ outputs=[
io.String.Output(display_name="output_filename"), io.String.Output(display_name="output_filename"),
@@ -697,7 +744,8 @@ class LoRAThreeMerger(io.ComfyNode):
def execute(cls, execution_mode: str, model_a: str, model_b: str, model_c: str, def execute(cls, execution_mode: str, model_a: str, model_b: str, model_c: str,
calc_mode: str, mismatch_mode: str, alignment_mode: str, alpha: float, beta: float, calc_mode: str, mismatch_mode: str, alignment_mode: str, alpha: float, beta: float,
gamma: float, delta: float, seed: int, output_filename: str, save_dtype: str, gamma: float, delta: float, seed: int, output_filename: str, save_dtype: str,
process_device: str, exclude_patterns: str, discard_patterns: str) -> io.NodeOutput: process_device: str, exclude_patterns: str, discard_patterns: str,
lazy_load: bool, force_clear_cache: bool) -> io.NodeOutput:
doc = load_documentation_from_file('merger_3_model_modes.md') doc = load_documentation_from_file('merger_3_model_modes.md')
if execution_mode == "DOCUMENTATION ONLY": if execution_mode == "DOCUMENTATION ONLY":
return io.NodeOutput("Documentation mode active. No merge performed.", doc) return io.NodeOutput("Documentation mode active. No merge performed.", doc)
@@ -709,6 +757,7 @@ class LoRAThreeMerger(io.ComfyNode):
"output_filename": output_filename, "save_dtype": save_dtype, "output_filename": output_filename, "save_dtype": save_dtype,
"device": process_device, "dtype": torch.float32, "device": process_device, "dtype": torch.float32,
"exclude_patterns": exclude_patterns, "discard_patterns": discard_patterns, "exclude_patterns": exclude_patterns, "discard_patterns": discard_patterns,
"lazy_load": lazy_load, "force_clear_cache": force_clear_cache,
} }
model_names = {"model_a": model_a, "model_b": model_b, "model_c": model_c} model_names = {"model_a": model_a, "model_b": model_b, "model_c": model_c}
filename = MergerLogic.execute_merge(model_names, calc_mode, THREE_MODEL_MODES, recipe_params, cls.MODEL_TYPE) filename = MergerLogic.execute_merge(model_names, calc_mode, THREE_MODEL_MODES, recipe_params, cls.MODEL_TYPE)
@@ -742,6 +791,8 @@ class EmbeddingThreeMerger(io.ComfyNode):
io.Combo.Input("process_device", options=["cuda", "cpu"]), io.Combo.Input("process_device", options=["cuda", "cpu"]),
io.String.Input("exclude_patterns", default="", multiline=True), io.String.Input("exclude_patterns", default="", multiline=True),
io.String.Input("discard_patterns", default="", multiline=True), io.String.Input("discard_patterns", default="", multiline=True),
io.Boolean.Input("lazy_load", default=True, tooltip="Low memory mode: load tensors from disk on demand"),
io.Boolean.Input("force_clear_cache", default=True, tooltip="Clear CUDA cache after each layer"),
], ],
outputs=[ outputs=[
io.String.Output(display_name="output_filename"), io.String.Output(display_name="output_filename"),
@@ -753,7 +804,8 @@ class EmbeddingThreeMerger(io.ComfyNode):
def execute(cls, execution_mode: str, model_a: str, model_b: str, model_c: str, def execute(cls, execution_mode: str, model_a: str, model_b: str, model_c: str,
calc_mode: str, mismatch_mode: str, alignment_mode: str, alpha: float, beta: float, calc_mode: str, mismatch_mode: str, alignment_mode: str, alpha: float, beta: float,
gamma: float, delta: float, seed: int, output_filename: str, save_dtype: str, gamma: float, delta: float, seed: int, output_filename: str, save_dtype: str,
process_device: str, exclude_patterns: str, discard_patterns: str) -> io.NodeOutput: process_device: str, exclude_patterns: str, discard_patterns: str,
lazy_load: bool, force_clear_cache: bool) -> io.NodeOutput:
doc = load_documentation_from_file('merger_3_model_modes.md') doc = load_documentation_from_file('merger_3_model_modes.md')
if execution_mode == "DOCUMENTATION ONLY": if execution_mode == "DOCUMENTATION ONLY":
return io.NodeOutput("Documentation mode active. No merge performed.", doc) return io.NodeOutput("Documentation mode active. No merge performed.", doc)
@@ -765,6 +817,7 @@ class EmbeddingThreeMerger(io.ComfyNode):
"output_filename": output_filename, "save_dtype": save_dtype, "output_filename": output_filename, "save_dtype": save_dtype,
"device": process_device, "dtype": torch.float32, "device": process_device, "dtype": torch.float32,
"exclude_patterns": exclude_patterns, "discard_patterns": discard_patterns, "exclude_patterns": exclude_patterns, "discard_patterns": discard_patterns,
"lazy_load": lazy_load, "force_clear_cache": force_clear_cache,
} }
model_names = {"model_a": model_a, "model_b": model_b, "model_c": model_c} model_names = {"model_a": model_a, "model_b": model_b, "model_c": model_c}
filename = MergerLogic.execute_merge(model_names, calc_mode, THREE_MODEL_MODES, recipe_params, cls.MODEL_TYPE) filename = MergerLogic.execute_merge(model_names, calc_mode, THREE_MODEL_MODES, recipe_params, cls.MODEL_TYPE)
+28 -3
View File
@@ -4,6 +4,8 @@ import torch.nn.functional as F
from enum import Enum from enum import Enum
from .merger_utils import transfer_to_gpu_pinned
class MissingTensorBehavior(Enum): class MissingTensorBehavior(Enum):
"""Controls behavior when a tensor key is missing from a model.""" """Controls behavior when a tensor key is missing from a model."""
ERROR = "error" # Raise exception (strict mode) ERROR = "error" # Raise exception (strict mode)
@@ -127,12 +129,21 @@ class Operation:
raise NotImplementedError raise NotImplementedError
def recurse(self, operation): def recurse(self, operation):
source_tensors = [source_oper.merge() for source_oper in operation.sources] self._source_tensors = [source_oper.merge() for source_oper in operation.sources]
return operation.oper(*source_tensors) return operation.oper(*self._source_tensors)
def merge(self): def merge(self):
return self.merge_func(self) return self.merge_func(self)
def clean(self):
if hasattr(self, '_source_tensors'):
for t in self._source_tensors:
del t
del self._source_tensors
for source in self.sources:
if hasattr(source, 'clean'):
source.clean()
class LoadTensor(Operation): class LoadTensor(Operation):
def __init__(self, key, model_name, handlers, device, dtype, def __init__(self, key, model_name, handlers, device, dtype,
on_missing=MissingTensorBehavior.ERROR, fallback_shape=None, fallback_dtype=None): on_missing=MissingTensorBehavior.ERROR, fallback_shape=None, fallback_dtype=None):
@@ -165,7 +176,17 @@ class LoadTensor(Operation):
dtype = self.fallback_dtype if self.fallback_dtype else self.dtype dtype = self.fallback_dtype if self.fallback_dtype else self.dtype
return torch.zeros(self.fallback_shape, device=self.device, dtype=dtype) return torch.zeros(self.fallback_shape, device=self.device, dtype=dtype)
return handler.get_tensor(self.key).to(device=self.device, dtype=self.dtype) cpu_tensor = handler.get_tensor(self.key)
if self.device == 'cuda':
self._tensor = transfer_to_gpu_pinned(cpu_tensor, self.device, self.dtype)
else:
self._tensor = cpu_tensor.to(device=self.device, dtype=self.dtype)
del cpu_tensor
return self._tensor
def clean(self):
if hasattr(self, '_tensor'):
del self._tensor
class Multiply(Operation): class Multiply(Operation):
def __init__(self, key, alpha, *sources): def __init__(self, key, alpha, *sources):
@@ -409,6 +430,10 @@ class PreloadedTensor(Operation):
def merge(self) -> torch.Tensor: def merge(self) -> torch.Tensor:
return self.tensor return self.tensor
def clean(self):
if hasattr(self, 'tensor'):
del self.tensor
class WeightSum(CalcMode): class WeightSum(CalcMode):
name = 'Weight-Sum' name = 'Weight-Sum'
description = 'A * (1 - α) + B * α. Simple linear interpolation.' description = 'A * (1 - α) + B * α. Simple linear interpolation.'
+58 -139
View File
@@ -1,133 +1,89 @@
""" """
Memory-efficient safetensors utilities with parallel I/O and pinned memory support. Memory-efficient safetensors utilities with pinned memory support.
Forces direct file reads to minimize OS page cache usage and avoids VRAM accumulation.
Based on benchmarking, parallel reading with ThreadPoolExecutor provides 2-4x speedup
for large models compared to sequential reads.
Worker count is automatically optimized based on device capabilities when workers=None.
Pinned memory enables faster CPU→GPU transfers (2-3x improvement).
""" """
import os import os
import mmap import gc
import json import json
import struct import struct
import torch import torch
from concurrent.futures import ThreadPoolExecutor, as_completed from safetensors import safe_open
from typing import Dict, List, Optional, Any, Tuple from typing import Dict, List, Optional, Any
# Import ComfyUI's memory pinning functions if available # Module-level configuration for pinned transfer
try: _verbose = False
from comfy.model_management import pin_memory, unpin_memory, is_device_cuda _pinned_transfer_stats = {"pinned": 0, "fallback": 0}
PINNING_AVAILABLE = True
except ImportError:
PINNING_AVAILABLE = False
def pin_memory(tensor): return False
def unpin_memory(tensor): return False
def is_device_cuda(device): return str(device).startswith('cuda')
def set_verbose(enabled: bool):
"""Enable/disable verbose output for pinned transfers."""
global _verbose
_verbose = enabled
class PinnedTensor: def get_pinned_transfer_stats():
"""Context manager for automatic pinned memory management. """Return pinned transfer statistics."""
return _pinned_transfer_stats.copy()
Usage:
with PinnedTensor(cpu_tensor) as pinned:
gpu_tensor = pinned.to('cuda')
# Memory automatically unpinned after transfer
"""
def __init__(self, tensor: torch.Tensor, auto_pin: bool = True):
self.tensor = tensor
self.is_pinned = False
self.auto_pin = auto_pin and PINNING_AVAILABLE
def __enter__(self) -> torch.Tensor:
if self.auto_pin and self.tensor.device.type == 'cpu':
self.is_pinned = pin_memory(self.tensor)
return self.tensor
def __exit__(self, exc_type, exc_val, exc_tb):
if self.is_pinned:
unpin_memory(self.tensor)
self.is_pinned = False
def reset_pinned_transfer_stats():
"""Reset transfer statistics."""
global _pinned_transfer_stats
_pinned_transfer_stats = {"pinned": 0, "fallback": 0}
def transfer_to_gpu_pinned( def transfer_to_gpu_pinned(
tensor: torch.Tensor, tensor: torch.Tensor,
device: str = 'cuda', device: str = 'cuda',
dtype: Optional[torch.dtype] = None dtype: Optional[torch.dtype] = None
) -> torch.Tensor: ) -> torch.Tensor:
"""Transfer tensor to GPU using pinned memory for faster transfer. """Transfer tensor to GPU using pinned memory for faster transfer."""
global _pinned_transfer_stats
Args: if tensor.device.type != 'cpu' or not torch.cuda.is_available():
tensor: CPU tensor to transfer if dtype is not None:
device: Target GPU device return tensor.to(device=device, dtype=dtype)
dtype: Optional dtype conversion return tensor.to(device=device)
Returns: if not str(device).startswith('cuda'):
Tensor on GPU device if dtype is not None:
"""
if not PINNING_AVAILABLE or not is_device_cuda(torch.device(device)):
# Fall back to regular transfer
if dtype:
return tensor.to(device=device, dtype=dtype) return tensor.to(device=device, dtype=dtype)
return tensor.to(device=device) return tensor.to(device=device)
# Pin memory for faster transfer
was_pinned = pin_memory(tensor)
try: try:
if dtype: pinned = tensor.pin_memory()
result = tensor.to(device=device, dtype=dtype, non_blocking=True) if dtype is not None:
result = pinned.to(device=device, dtype=dtype, non_blocking=True)
else: else:
result = tensor.to(device=device, non_blocking=True) result = pinned.to(device=device, non_blocking=True)
# Sync to ensure transfer is complete before unpinning
if was_pinned:
torch.cuda.current_stream().synchronize() torch.cuda.current_stream().synchronize()
_pinned_transfer_stats["pinned"] += 1
return result return result
finally: except Exception as e:
if was_pinned: _pinned_transfer_stats["fallback"] += 1
unpin_memory(tensor) if dtype is not None:
return tensor.to(device=device, dtype=dtype)
return tensor.to(device=device)
class MemoryEfficientSafeOpen: class MemoryEfficientSafeOpen:
"""Memory-efficient safetensors file reader with mmap, parallel I/O, and pinned memory support. """Memory-efficient safetensors file reader with streaming and pinned memory support.
Features: Operates strictly in streaming mode (low memory). Tensors are read byte-by-byte from
- mmap mode: Zero-copy tensor access via memory-mapped file their exact offsets without using safe_open or memory mapping, ensuring tensors do not
- low_memory mode: Direct file reads to minimize OS page cache usage remain persistently in OS page caches or RAM.
- Parallel loading: Multi-threaded tensor reads for 2-4x speedup
- Sorted batch reads: Keys sorted by file offset for sequential I/O
- Auto-optimized workers: Adjusts parallelism based on device capabilities
- Pinned memory: Faster CPU→GPU transfers when transferring to CUDA
Args:
filename: Path to safetensors file
device: Target device (default 'cpu')
mmap_mode: Use memory-mapped file for zero-copy (default True)
low_memory: Use direct file reads to minimize memory (overrides mmap_mode)
""" """
def __init__(self, filename: str, device: str = 'cpu', mmap_mode: bool = True, low_memory: bool = False): def __init__(self, filename: str, device: str = 'cpu', **kwargs):
# Ignore mmap_mode, low_memory, or any other legacy kwargs. We always stream.
self.filename = filename self.filename = filename
self.device = device self.device = device
# low_memory mode forces mmap off to avoid OS page caching
self.low_memory = low_memory
self.mmap_mode = mmap_mode and not low_memory
self.header, self.header_size = self._read_header() self.header, self.header_size = self._read_header()
self.file = open(filename, "rb") self.file = open(filename, "rb")
self.mmap_obj = None
if self.mmap_mode:
self.mmap_obj = mmap.mmap(self.file.fileno(), 0, access=mmap.ACCESS_READ)
def __enter__(self): def __enter__(self):
return self return self
def __exit__(self, exc_type, exc_val, exc_tb): def __exit__(self, exc_type, exc_val, exc_tb):
if self.mmap_obj:
self.mmap_obj.close()
self.file.close() self.file.close()
def keys(self) -> List[str]: def keys(self) -> List[str]:
"""Return all tensor keys (excluding metadata).""" """Return all tensor keys (excluding metadata)."""
return [k for k in self.header.keys() if k != "__metadata__"] return [k for k in self.header.keys() if k != "__metadata__"]
@@ -136,8 +92,17 @@ class MemoryEfficientSafeOpen:
"""Return file metadata.""" """Return file metadata."""
return self.header.get("__metadata__", {}) return self.header.get("__metadata__", {})
def get_shape(self, key: str) -> tuple:
"""Get tensor shape without loading tensor data."""
if key not in self.header:
raise KeyError(f"Tensor '{key}' not found in file")
return tuple(self.header[key]["shape"])
def get_ndim(self, key: str) -> int:
"""Get tensor ndim without loading tensor data."""
return len(self.get_shape(key))
def keys_sorted_by_offset(self) -> List[str]: def keys_sorted_by_offset(self) -> List[str]:
"""Return keys sorted by file offset for optimal sequential I/O."""
keys_with_offsets = [] keys_with_offsets = []
for key in self.keys(): for key in self.keys():
offset = self.header[key]["data_offsets"][0] offset = self.header[key]["data_offsets"][0]
@@ -146,48 +111,28 @@ class MemoryEfficientSafeOpen:
return [k for k, _ in keys_with_offsets] return [k for k, _ in keys_with_offsets]
def get_tensor(self, key: str) -> torch.Tensor: def get_tensor(self, key: str) -> torch.Tensor:
"""Load a single tensor by key.""" """Load a single tensor by key directly from file byte offset."""
if key not in self.header: if key not in self.header:
raise KeyError(f"Tensor '{key}' not found in the file") raise KeyError(f"Tensor '{key}' not found in the file")
metadata = self.header[key] metadata = self.header[key]
offset_start, offset_end = metadata["data_offsets"] offset_start, offset_end = metadata["data_offsets"]
if self.mmap_mode and self.mmap_obj:
if offset_start != offset_end:
start = self.header_size + 8 + offset_start
end = self.header_size + 8 + offset_end
tensor_bytes = memoryview(self.mmap_obj)[start:end]
else:
tensor_bytes = None
else:
# Non-mmap mode: use pre-allocated bytearray with readinto for minimal copies
tensor_bytes = None
if offset_start != offset_end: if offset_start != offset_end:
self.file.seek(self.header_size + 8 + offset_start) self.file.seek(self.header_size + 8 + offset_start)
# Pre-allocate writable buffer and read directly into it
tensor_bytes = bytearray(offset_end - offset_start) tensor_bytes = bytearray(offset_end - offset_start)
self.file.readinto(tensor_bytes) self.file.readinto(tensor_bytes)
else:
tensor_bytes = None
return self._deserialize_tensor(tensor_bytes, metadata) return self._deserialize_tensor(tensor_bytes, metadata)
def get_tensor_to_gpu( def get_tensor_to_gpu(
self, self,
key: str, key: str,
device: str = 'cuda', device: str = 'cuda',
dtype: Optional[torch.dtype] = None dtype: Optional[torch.dtype] = None
) -> torch.Tensor: ) -> torch.Tensor:
"""Load a tensor and transfer to GPU using pinned memory for faster transfer.
Args:
key: Tensor key to load
device: Target GPU device (default 'cuda')
dtype: Optional dtype conversion
Returns:
Tensor on GPU device
"""
cpu_tensor = self.get_tensor(key) cpu_tensor = self.get_tensor(key)
return transfer_to_gpu_pinned(cpu_tensor, device, dtype) return transfer_to_gpu_pinned(cpu_tensor, device, dtype)
@@ -197,20 +142,9 @@ class MemoryEfficientSafeOpen:
workers: Optional[int] = None, workers: Optional[int] = None,
sort_by_offset: bool = True sort_by_offset: bool = True
) -> Dict[str, torch.Tensor]: ) -> Dict[str, torch.Tensor]:
"""Load multiple tensors in parallel using ThreadPoolExecutor.
Args:
keys: List of keys to load (default: all keys)
workers: Number of worker threads (default: auto-calculated based on device)
sort_by_offset: Sort keys by file offset before loading (default: True)
Returns:
Dict mapping key -> tensor
"""
if keys is None: if keys is None:
keys = self.keys() keys = self.keys()
# Auto-calculate workers based on device capabilities
if workers is None: if workers is None:
try: try:
from .device_utils import get_optimal_workers, estimate_model_size from .device_utils import get_optimal_workers, estimate_model_size
@@ -220,13 +154,11 @@ class MemoryEfficientSafeOpen:
workers = min(4, os.cpu_count() or 4) workers = min(4, os.cpu_count() or 4)
if sort_by_offset: if sort_by_offset:
# Sort by offset for better I/O patterns
keys_with_offsets = [(k, self.header[k]["data_offsets"][0]) for k in keys] keys_with_offsets = [(k, self.header[k]["data_offsets"][0]) for k in keys]
keys_with_offsets.sort(key=lambda x: x[1]) keys_with_offsets.sort(key=lambda x: x[1])
keys = [k for k, _ in keys_with_offsets] keys = [k for k, _ in keys_with_offsets]
result = {} result = {}
with ThreadPoolExecutor(max_workers=workers) as executor: with ThreadPoolExecutor(max_workers=workers) as executor:
futures = { futures = {
executor.submit( executor.submit(
@@ -251,7 +183,6 @@ class MemoryEfficientSafeOpen:
return result return result
def get_tensor_as_dict(self, key: str) -> Dict[str, Any]: def get_tensor_as_dict(self, key: str) -> Dict[str, Any]:
"""Load a tensor and decode as JSON dict (for config tensors)."""
if key not in self.header: if key not in self.header:
raise KeyError(f"Tensor '{key}' not found") raise KeyError(f"Tensor '{key}' not found")
@@ -261,14 +192,8 @@ class MemoryEfficientSafeOpen:
if offset_start == offset_end: if offset_start == offset_end:
return {} return {}
if self.mmap_mode and self.mmap_obj:
start = self.header_size + 8 + offset_start
end = self.header_size + 8 + offset_end
tensor_bytes = bytes(self.mmap_obj[start:end])
else:
self.file.seek(self.header_size + 8 + offset_start) self.file.seek(self.header_size + 8 + offset_start)
tensor_bytes = self.file.read(offset_end - offset_start) tensor_bytes = self.file.read(offset_end - offset_start)
return json.loads(tensor_bytes.decode("utf-8")) return json.loads(tensor_bytes.decode("utf-8"))
def _read_header(self): def _read_header(self):
@@ -285,19 +210,13 @@ class MemoryEfficientSafeOpen:
if tensor_bytes is None: if tensor_bytes is None:
byte_tensor = torch.empty(0, dtype=torch.uint8) byte_tensor = torch.empty(0, dtype=torch.uint8)
else: else:
# Avoid extra copy if already a bytearray (low_memory mode)
if isinstance(tensor_bytes, bytearray):
byte_tensor = torch.frombuffer(tensor_bytes, dtype=torch.uint8) byte_tensor = torch.frombuffer(tensor_bytes, dtype=torch.uint8)
else:
# mmap memoryview needs copy to create writable tensor
byte_tensor = torch.frombuffer(bytearray(tensor_bytes), dtype=torch.uint8)
if dtype_str in ["F8_E5M2", "F8_E4M3"]: if dtype_str in ["F8_E5M2", "F8_E4M3"]:
return self._convert_float8(byte_tensor, dtype_str, shape) return self._convert_float8(byte_tensor, dtype_str, shape)
return byte_tensor.view(dtype).reshape(shape) return byte_tensor.view(dtype).reshape(shape)
@staticmethod @staticmethod
def _get_torch_dtype(dtype_str): def _get_torch_dtype(dtype_str):
dtype_map = { dtype_map = {
@@ -322,7 +241,7 @@ class MemoryEfficientSafeOpen:
elif dtype_str == "F8_E4M3" and hasattr(torch, "float8_e4m3fn"): elif dtype_str == "F8_E4M3" and hasattr(torch, "float8_e4m3fn"):
return byte_tensor.view(torch.float8_e4m3fn).reshape(shape) return byte_tensor.view(torch.float8_e4m3fn).reshape(shape)
else: else:
raise ValueError(f"Unsupported float8 type: {dtype_str}. Your PyTorch version may be too old.") raise ValueError(f"Unsupported float8 type: {dtype_str}")
def _read_tensor_from_file( def _read_tensor_from_file(
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "ComfyUI-ModelUtils" name = "ComfyUI-ModelUtils"
description = "[WIP]Custom nodes for handling, inspecting, modifying and creating various model files." description = "[WIP]Custom nodes for handling, inspecting, modifying and creating various model files."
version = "0.2.7" version = "0.2.8"
license = { file = "LICENSE" } license = { file = "LICENSE" }
[project.urls] [project.urls]