Files
FearL0rd 9aba0997ac fix: multi-GPU device mismatch, OOM, and LoRA on split batches
Keep replica blocks out of the nn.Module tree so model.to(cuda:0) cannot steal cuda:1 weights. Reuse the live home GPU model, skip wrapping it so ComfyUI can apply LoRA, and bake adapters only onto independent clones. Preserve operations in clone config for the fast incremental path.
2026-08-13 13:44:06 -05:00

1992 lines
84 KiB
Python

import torch
import torch.nn as nn
import types
import copy
import gc
import weakref
import threading
from types import SimpleNamespace
from dataclasses import fields, is_dataclass
from concurrent.futures import ThreadPoolExecutor, as_completed
import comfy.model_management
# ----------------------------------------------------------------------------
# Thread-local state for pipeline mode
# ----------------------------------------------------------------------------
_pipeline_state = threading.local()
def get_pipeline_mode():
return getattr(_pipeline_state, 'active', False)
def set_pipeline_mode(active):
_pipeline_state.active = active
def as_torch_device(device):
if isinstance(device, torch.device):
return device
return torch.device(device)
def move_tree_to_device(x, device, non_blocking=False):
"""Recursively move tensors inside nested Python containers to `device`."""
device = as_torch_device(device)
if isinstance(x, torch.Tensor):
if x.device != device:
x = x.to(device, non_blocking=non_blocking)
if is_float8_dtype(x.dtype) and not device_supports_float8(device):
x = x.half()
return x
if isinstance(x, (list, tuple)):
return type(x)(move_tree_to_device(item, device, non_blocking) for item in x)
if isinstance(x, dict):
return {k: move_tree_to_device(v, device, non_blocking) for k, v in x.items()}
if is_dataclass(x) and not isinstance(x, type):
new_obj = copy.copy(x)
for field in fields(x):
val = getattr(x, field.name)
setattr(new_obj, field.name, move_tree_to_device(val, device, non_blocking))
return new_obj
if isinstance(x, SimpleNamespace):
return SimpleNamespace(**{
k: move_tree_to_device(v, device, non_blocking) for k, v in vars(x).items()
})
return x
BLOCK_LIST_NAMES = ('double_blocks', 'single_blocks', 'transformer_blocks', 'layers')
_PATCH_KEY_PREFIXES = (
"diffusion_model.",
"model.diffusion_model.",
"model.",
)
def resolve_home_device(model_wrapper, target_model, device_names):
"""Device ComfyUI will load the live model onto.
After unload_all_models() parameters look like they live on CPU, so we
must remember the real home GPU *before* using that as a clone target.
Cloning onto the home device puts two full copies on one card and OOMs
(e.g. Lumina2 11.7GB x2 on a 24GB GPU).
"""
candidates = []
if model_wrapper is not None:
candidates.append(getattr(model_wrapper, "load_device", None))
inner = getattr(model_wrapper, "model", None)
if inner is not None:
candidates.append(getattr(inner, "load_device", None))
try:
candidates.append(next(target_model.parameters()).device)
except (StopIteration, AttributeError):
pass
for candidate in candidates:
if candidate is None:
continue
name = str(candidate)
if name != "cpu":
return name
for name in device_names or ():
if name != "cpu":
return name
if device_names:
return device_names[0]
return "cpu"
def should_reuse_live_model(dev_name, home_device_str):
return str(dev_name) == str(home_device_str)
def find_model_patcher(model):
if model is None:
return None
if hasattr(model, "patches") and hasattr(model, "add_patches"):
return model
for attr in ("model_patcher", "patcher"):
inner = getattr(model, attr, None)
if inner is not None and hasattr(inner, "patches") and hasattr(inner, "add_patches"):
return inner
return None
def collect_weight_patches(model):
"""Return ModelPatcher.patches without baking them into the live model."""
patcher = find_model_patcher(model)
if patcher is None:
return {}
patches = getattr(patcher, "patches", None)
return patches if patches else {}
def snapshot_weight_patches(patches):
"""Copy the patch mapping so later disarm/clear cannot empty our bake list."""
if not patches:
return {}
return {key: list(value) for key, value in patches.items()}
def disarm_patcher_weight_patches(patcher):
"""Detach patches so ComfyUI dynamic-VRAM load does not apply LoRA a second time."""
if patcher is None or not hasattr(patcher, "patches"):
return None
saved = patcher.patches
if not saved:
return None
patcher.patches = {}
return saved
def restore_patcher_weight_patches(patcher, saved):
if patcher is None or saved is None:
return
patcher.patches = saved
def bake_lora_with_patcher(patcher, device="cpu"):
"""Apply LoRA once through ComfyUI's own loader.
Writing patched values into the live model with param.data.copy_ can
segfault on quantized / dynamic-VRAM parameters. patch_model() knows
how to handle those tensors.
"""
if patcher is None or not getattr(patcher, "patches", None):
return False
if not hasattr(patcher, "patch_model"):
return False
patcher.patch_model(device_to=torch.device(device), force_patch_weights=True)
return True
def resolve_module_param_key(patch_key, named_params):
if patch_key in named_params:
return patch_key
for prefix in _PATCH_KEY_PREFIXES:
if patch_key.startswith(prefix):
stripped = patch_key[len(prefix):]
if stripped in named_params:
return stripped
return None
def _patch_key_candidates(patch_key):
keys = [patch_key]
for prefix in _PATCH_KEY_PREFIXES:
if patch_key.startswith(prefix):
keys.append(patch_key[len(prefix):])
return keys
def _unwrap_parallel(obj):
while isinstance(obj, ParallelBlock):
obj = obj.local_block
return obj
def _lookup_module_tensor(module, dotted_key):
obj = module
try:
for part in dotted_key.split("."):
obj = _unwrap_parallel(getattr(_unwrap_parallel(obj), part))
except AttributeError:
return None
obj = _unwrap_parallel(obj)
if isinstance(obj, (nn.Parameter, torch.Tensor)):
return obj
return None
def _lookup_module(module, dotted_key):
if not dotted_key:
return _unwrap_parallel(module)
obj = module
try:
for part in dotted_key.split("."):
obj = _unwrap_parallel(getattr(_unwrap_parallel(obj), part))
except AttributeError:
return None
obj = _unwrap_parallel(obj)
return obj if isinstance(obj, nn.Module) else None
def _module_and_hook_attr(root, tensor_key):
for candidate in _patch_key_candidates(tensor_key):
if candidate.endswith(".weight"):
mod = _lookup_module(root, candidate[: -len(".weight")])
if mod is not None:
return mod, "weight_function"
if candidate.endswith(".bias"):
mod = _lookup_module(root, candidate[: -len(".bias")])
if mod is not None:
return mod, "bias_function"
return None, None
def copy_lora_cast_hooks(source, replica):
"""Copy ComfyUI lazy-LoRA hooks that state_dict cloning drops."""
if source is None or replica is None or source is replica:
return 0
src_map = dict(source.named_modules())
copied = 0
for name, dst in replica.named_modules():
src = src_map.get(name)
if src is None:
continue
for attr in ("weight_function", "bias_function"):
val = getattr(src, attr, None)
if val:
setattr(dst, attr, list(val))
copied += 1
for attr in ("comfy_cast_weights", "comfy_force_cast_weights"):
if hasattr(src, attr):
setattr(dst, attr, getattr(src, attr))
return copied
def _attach_lora_hook(module, hook_attr, patch_list, key):
def apply_lora(weight):
return _calculate_patched_weight(patch_list, weight, key)
apply_lora._parallel_lora_key = key
hooks = list(getattr(module, hook_attr, None) or [])
if any(getattr(fn, "_parallel_lora_key", None) == key for fn in hooks):
setattr(module, hook_attr, hooks)
module.comfy_cast_weights = True
return False
hooks.append(apply_lora)
setattr(module, hook_attr, hooks)
module.comfy_cast_weights = True
return True
def attach_lora_hooks_to_replica(replica, patches):
"""Install ComfyUI-style weight_function hooks. No matmuls — safe at setup and every step."""
if replica is None or not patches:
return {"attached": 0, "skipped": 0}
attached = 0
skipped = 0
for key, patch_list in patches.items():
mod, hook_attr = _module_and_hook_attr(replica, key)
if mod is None:
skipped += 1
continue
if _attach_lora_hook(mod, hook_attr, patch_list, key):
attached += 1
else:
attached += 1
return {"attached": attached, "skipped": skipped}
def apply_patches_to_replica(replica, patches, progress_label=None):
"""Bake ModelPatcher.patches into a cloned replica's param.data.
Must run after incremental clone. Dynamic VRAM often leaves LoRA only in
hooks on the live model; state_dict copies are the base checkpoint.
Always bake the replica (never the live model). Stay on the replica device.
"""
if replica is None or not patches:
return {"baked": 0, "attached": 0, "failed": 0, "skipped": 0}
baked = 0
attached = 0
failed = 0
skipped = 0
total = len(patches)
for index, (key, patch_list) in enumerate(patches.items(), start=1):
if progress_label and (index == 1 or index == total or index % 30 == 0):
print(f"[ParallelAnything] {progress_label} {index}/{total}", flush=True)
param = None
for candidate in _patch_key_candidates(key):
param = _lookup_module_tensor(replica, candidate)
if param is not None:
break
if param is None:
skipped += 1
continue
try:
with torch.no_grad():
work = param.detach()
if work.dtype != torch.float32:
work = work.float()
else:
work = work.clone()
patched = _calculate_patched_weight(patch_list, work, key)
if not isinstance(patched, torch.Tensor) or patched.shape != param.shape:
raise RuntimeError(
f"bad LoRA result for {key}: {type(patched)} {getattr(patched, 'shape', None)}"
)
param.data.copy_(patched.to(device=param.device, dtype=param.dtype))
baked += 1
except Exception as e:
mod, hook_attr = _module_and_hook_attr(replica, key)
if mod is not None and (
hasattr(mod, "comfy_cast_weights") or hasattr(mod, "weight_function")
):
try:
_attach_lora_hook(mod, hook_attr, patch_list, key)
attached += 1
continue
except Exception:
pass
failed += 1
print(f"[ParallelAnything] Warning: could not apply LoRA {key}: {e}")
return {"baked": baked, "attached": attached, "failed": failed, "skipped": skipped}
def sync_lora_to_replica(source, replica, patches, weights_already_baked=False):
"""Compatibility wrapper: always bake patches onto the replica."""
del source, weights_already_baked
return apply_patches_to_replica(replica, patches)
def _fallback_calculate_weight(patch_list, weight, key):
"""Subset of comfy.lora.calculate_weight for tests and simple diff patches."""
out = weight
for patch in patch_list:
if not isinstance(patch, (tuple, list)) or len(patch) < 2:
continue
strength = patch[0]
value = patch[1]
strength_model = patch[2] if len(patch) > 2 else 1.0
if not isinstance(strength, (int, float)):
continue
if strength_model != 1.0:
out = out * strength_model
if hasattr(value, "calculate_weight"):
computed = value.calculate_weight(
out, key, strength, strength_model, None, lambda a: a, torch.float32, None
)
if computed is None:
raise RuntimeError(f"adapter calculate_weight returned None for {key}")
out = computed
continue
if isinstance(value, torch.Tensor):
if value.shape == out.shape:
out = out + strength * value.to(device=out.device, dtype=out.dtype)
continue
if not isinstance(value, (tuple, list)) or len(value) == 0:
continue
patch_type = "diff"
data = value
if len(value) == 2 and isinstance(value[0], str):
patch_type = value[0]
data = value[1]
payload = data[0] if isinstance(data, (tuple, list)) else data
if not isinstance(payload, torch.Tensor):
continue
if patch_type == "set":
out = payload.to(device=out.device, dtype=out.dtype)
elif patch_type == "diff" and payload.shape == out.shape:
out = out + strength * payload.to(device=out.device, dtype=out.dtype)
return out
def _comfy_calculate_weight():
try:
import comfy.lora as comfy_lora
candidate = getattr(comfy_lora, "calculate_weight", None)
if callable(candidate) and getattr(candidate, "__module__", "") == "comfy.lora":
return candidate
except ImportError:
pass
return None
def _calculate_patched_weight(patch_list, weight, key):
calc = _comfy_calculate_weight()
if calc is not None:
return calc(patch_list, weight, key)
return _fallback_calculate_weight(patch_list, weight, key)
def bake_patches_into_module(module, patches, progress_label=None):
"""Bake ModelPatcher weight patches onto a module.
Looks up tensors by key path so we never call named_parameters() on a
dynamically staged model (that pages the full 10GB+ checkpoint onto GPU
and looks like a hang).
"""
if module is None or not patches:
return 0
applied = 0
total = len(patches)
for index, (key, patch_list) in enumerate(patches.items(), start=1):
if progress_label and (index == 1 or index == total or index % 20 == 0):
print(f"[ParallelAnything] {progress_label} {index}/{total}", flush=True)
param = None
for candidate in _patch_key_candidates(key):
param = _lookup_module_tensor(module, candidate)
if param is not None:
break
if param is None:
continue
try:
with torch.no_grad():
work_dtype = torch.float32 if param.dtype != torch.float32 else param.dtype
base = param.detach()
if base.device.type != "cpu" or base.dtype != work_dtype:
base = base.to(device="cpu", dtype=work_dtype)
else:
base = base.clone()
patched = _calculate_patched_weight(patch_list, base, key)
if not isinstance(patched, torch.Tensor):
raise RuntimeError(f"calculate_weight returned {type(patched).__name__}")
if patched.shape != param.data.shape:
print(
f"[ParallelAnything] Warning: LoRA shape mismatch for {key}: "
f"{tuple(patched.shape)} != {tuple(param.data.shape)}"
)
continue
param.data.copy_(patched.to(device=param.device, dtype=param.dtype))
applied += 1
except Exception as e:
print(f"[ParallelAnything] Warning: failed to bake LoRA patch {key}: {e}")
if applied:
print(f"[ParallelAnything] Baked {applied} LoRA/weight patches into replica", flush=True)
elif patches:
print(f"[ParallelAnything] Warning: {len(patches)} patches found but none matched replica keys", flush=True)
return applied
def unwrap_parallel_blocks(module):
"""Replace ParallelBlock wrappers with the original lead-model blocks.
Re-running setup without this nests wrappers and leaves stale peer
references that point at deleted replicas.
"""
if module is None or isinstance(module, ParallelBlock):
return
for list_name in BLOCK_LIST_NAMES:
block_list = getattr(module, list_name, None)
if not isinstance(block_list, nn.ModuleList):
continue
for idx, block in enumerate(block_list):
current = block
while isinstance(current, ParallelBlock):
current = current.local_block
if current is not block:
block_list[idx] = current
for child in module.children():
unwrap_parallel_blocks(child)
class ParallelBlock(nn.Module):
"""
A wrapper that handles dual-mode execution:
1. Data Parallel (Batch > 1): Runs the local block immediately.
2. Pipeline Parallel (Batch = 1): Moves data to assigned device/replica.
"""
def __init__(self, local_block, block_idx, owner_device, peers, is_last_block, lead_device):
super().__init__()
self.local_block = local_block
self.block_idx = block_idx
self.owner_device = as_torch_device(owner_device)
self.is_last_block = is_last_block
self.lead_device = as_torch_device(lead_device)
# Peer replica modules must NOT be registered as children.
# ComfyUI later does model.to(lead_device); if owner_block is a
# submodule, that silently moves cuda:1 weights onto cuda:0 and
# produces wrapper_CUDA_addmm device mismatches.
owner_key = str(self.owner_device)
if owner_key in peers:
owner_block = peers[owner_key]
elif self.owner_device == self.lead_device:
owner_block = local_block
else:
raise RuntimeError(
f"[ParallelAnything] ParallelBlock {block_idx}: no peer for {owner_key}; "
f"have {list(peers)}"
)
object.__setattr__(self, "peers", peers)
object.__setattr__(self, "_owner_block_holder", [owner_block])
@property
def owner_block(self):
return self._owner_block_holder[0]
def _move_tensor(self, x, device):
return move_tree_to_device(x, device, non_blocking=True)
def _move_args(self, args, device):
return tuple(self._move_tensor(a, device) for a in args)
def _move_kwargs(self, kwargs, device):
return {k: self._move_tensor(v, device) for k, v in kwargs.items()}
def forward(self, *args, **kwargs):
if not get_pipeline_mode():
# Standard execution for Data Parallel (Batch > 1)
return self.local_block(*args, **kwargs)
target_device = self.owner_device
# --- CRITICAL FIX ---
# Do NOT check args[0].device to skip moving.
# Main input might be on target_device (from prev block),
# but auxiliary inputs (timesteps, context) might still be on Lead Device.
# We must ensure ALL inputs are on target_device.
args_moved = self._move_args(args, target_device)
kwargs_moved = self._move_kwargs(kwargs, target_device)
# Execute on target replica
out = self.owner_block(*args_moved, **kwargs_moved)
if self.is_last_block:
# Return data to Lead Device for final layers/output processing
out = self._move_tensor(out, self.lead_device)
return out
# ----------------------------------------------------------------------------
# Utilities
# ----------------------------------------------------------------------------
def is_float8_dtype(dtype):
if dtype is None:
return False
dtype_name = str(dtype)
fp8_types = ['float8_e4m3fn', 'float8_e5m2', 'float8_e4m3fnuz', 'float8_e5m2fnuz', 'float8']
return any(t in dtype_name for t in fp8_types)
def check_sm80_support(device_name):
if not device_name.startswith("cuda"):
return True
try:
idx = int(device_name.split(":")[-1])
capability = torch.cuda.get_device_capability(idx)
major, _ = capability
return major >= 8
except Exception as e:
print(f"[ParallelAnything] Warning: Could not check compute capability for {device_name}: {e}")
return False
def device_supports_float8(device):
if isinstance(device, str):
if not device.startswith("cuda"):
return False
device = torch.device(device)
if device.type != 'cuda':
return False
try:
cap = torch.cuda.get_device_capability(device)
major, minor = cap
return (major, minor) >= (9, 0)
except:
return False
def disable_flash_xformers(model):
disable_configs = [
('set_use_memory_efficient_attention_xformers', False),
('set_use_flash_attention_2', False),
('disable_xformers_memory_efficient_attention', None),
('use_xformers', False),
('use_flash_attention', False),
('use_flash_attention_2', False),
('_use_memory_efficient_attention', False),
('_flash_attention_enabled', False),
]
for attr_or_method, value in disable_configs:
if hasattr(model, attr_or_method):
try:
method = getattr(model, attr_or_method)
if callable(method) and value is None:
method()
elif callable(method):
method(value)
else:
setattr(model, attr_or_method, value)
except Exception:
pass
for name, module in model.named_modules():
if any(x in name.lower() for x in ['attn', 'attention', 'transformer']):
for attr in ['use_xformers', 'use_flash_attention', 'use_flash_attention_2', '_use_memory_efficient_attention', 'enable_flash', 'enable_xformers']:
if hasattr(module, attr):
try:
setattr(module, attr, False)
except AttributeError:
pass
if hasattr(module, 'set_processor'):
try:
from diffusers.models.attention_processor import Attention
module.set_processor(Attention())
except Exception:
pass
def clear_flux_caches(model):
cache_attrs = [
'img_ids', 'txt_ids', '_img_ids', '_txt_ids', 'cached_img_ids', 'cached_txt_ids',
'pos_emb', '_pos_emb', 'pos_embed', '_pos_embed', 'cached_pos_emb',
'rope', '_rope', 'freqs_cis', '_freqs_cis', 'freqs', '_freqs',
'cache', '_cache', 'kv_cache', '_kv_cache', 'attn_bias', '_attn_bias',
'rope_cache', '_rope_cache', 'freqs_cis_cache', '_freqs_cis_cache',
'temporal_ids', 'frame_ids', 'video_ids', 'temp_pos_emb',
]
cleared_count = 0
def clear_attrs(obj, name_prefix=""):
nonlocal cleared_count
for attr in cache_attrs:
if hasattr(obj, attr):
try:
val = getattr(obj, attr)
if val is not None:
setattr(obj, attr, None)
cleared_count += 1
except (AttributeError, TypeError):
pass
clear_attrs(model)
for name, module in model.named_modules():
clear_attrs(module, name)
if cleared_count > 0:
print(f"[ParallelAnything] Cleared {cleared_count} cached tensors")
def aggressive_cleanup():
gc.collect()
if torch.cuda.is_available():
torch.cuda.synchronize()
torch.cuda.empty_cache()
for i in range(torch.cuda.device_count()):
try:
with torch.cuda.device(f'cuda:{i}'):
torch.cuda.synchronize()
torch.cuda.empty_cache()
except:
pass
comfy.model_management.soft_empty_cache()
def cleanup_parallel_model(model_ref):
model = model_ref() if isinstance(model_ref, weakref.ref) else model_ref
if model is None:
return
if not getattr(model, '_true_parallel_active', False):
return
print("[ParallelAnything] Cleaning up parallel model...")
should_purge_cache = getattr(model, '_parallel_purge_cache', True)
should_purge_models = getattr(model, '_parallel_purge_models', False)
saved_patches = getattr(model, '_parallel_saved_patches', None)
if saved_patches:
try:
patcher = saved_patches[0]
patches = saved_patches[1]
backup = saved_patches[2] if len(saved_patches) > 2 else None
if backup is not None and hasattr(patcher, "backup"):
patcher.backup = backup
if hasattr(patcher, "unpatch_model"):
try:
patcher.unpatch_model(unpatch_weights=True)
except Exception:
pass
restore_patcher_weight_patches(patcher, patches)
except Exception as e:
print(f"[ParallelAnything] Warning: Could not restore LoRA patches: {e}")
try:
delattr(model, '_parallel_saved_patches')
except Exception:
pass
# Restore original forward
if hasattr(model, '_original_forward'):
try:
model.forward = model._original_forward
delattr(model, '_original_forward')
except Exception:
pass
# Drop pipeline wrappers before replicas go away so a later setup
# does not nest ParallelBlocks around stale peer references.
try:
unwrap_parallel_blocks(model)
except Exception as e:
print(f"[ParallelAnything] Warning: Could not unwrap parallel blocks: {e}")
# Cleanup replicas
if hasattr(model, '_parallel_replicas'):
replicas = model._parallel_replicas
for dev_name, replica in list(replicas.items()):
try:
unwrap_parallel_blocks(replica)
if replica is not model and hasattr(replica, 'cpu'):
replica.cpu()
clear_flux_caches(replica)
if hasattr(replica, 'gradient_checkpointing'):
replica.gradient_checkpointing = False
if hasattr(replica, '_gradient_checkpointing_func'):
replica._gradient_checkpointing_func = None
except Exception as e:
print(f"[ParallelAnything] Warning: Error cleaning up replica on {dev_name}: {e}")
try:
delattr(model, '_parallel_replicas')
except Exception:
pass
# Clear other attributes
for attr in ['_true_parallel_active', '_parallel_devices', '_parallel_streams',
'_parallel_weights', '_auto_vram_balance', '_parallel_purge_cache',
'_parallel_purge_models']:
if hasattr(model, attr):
try:
delattr(model, attr)
except Exception:
pass
if should_purge_models:
try:
print("[ParallelAnything] Purging models from VRAM...")
comfy.model_management.unload_all_models()
except Exception as e:
print(f"[ParallelAnything] Warning: Could not unload models: {e}")
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
if should_purge_cache:
try:
comfy.model_management.soft_empty_cache()
except:
pass
if torch.cuda.is_available():
for i in range(torch.cuda.device_count()):
try:
with torch.cuda.device(f'cuda:{i}'):
torch.cuda.empty_cache()
except:
pass
def extract_model_config(model):
config = {}
possible_attrs = [
'in_channels', 'out_channels', 'vec_in_dim', 'context_in_dim', 'hidden_size',
'mlp_ratio', 'num_heads', 'depth', 'depth_single_blocks', 'depth_single',
'axes_dim', 'theta', 'patch_size', 'qkv_bias', 'guidance_embed',
'txt_ids_dim', 'img_ids_dim', 'num_res_blocks', 'attention_resolutions',
'dropout', 'channel_mult', 'num_classes', 'use_checkpoint', 'num_heads_upsample',
'use_scale_shift_norm', 'resblock_updown', 'use_new_attention_order',
'adm_in_channels', 'num_noises', 'context_dim', 'n_heads', 'd_head',
'transformer_depth', 'model_channels', 'max_depth', 'num_frames',
'temporal_compression', 'temporal_dim', 'video_length',
'operations', 'ops',
]
for attr in possible_attrs:
if hasattr(model, attr):
try:
val = getattr(model, attr)
if not callable(val) and not isinstance(val, (nn.Module, torch.nn.Parameter, torch.Tensor)):
config[attr] = val
except Exception:
pass
if hasattr(model, 'params'):
try:
params = model.params
if isinstance(params, dict):
config.update(params)
elif is_dataclass(params):
for field_info in fields(params):
try:
val = getattr(params, field_info.name)
if not isinstance(val, (torch.Tensor, nn.Module)):
config[field_info.name] = val
except Exception:
pass
else:
params_dict = vars(params)
if params_dict:
config.update({k: v for k, v in params_dict.items() if not isinstance(v, (torch.Tensor, nn.Module))})
except Exception:
pass
if hasattr(model, 'config'):
try:
cfg = model.config
if isinstance(cfg, dict):
config.update({k: v for k, v in cfg.items() if not isinstance(v, (torch.Tensor, nn.Module))})
else:
cfg_dict = {k: v for k, v in vars(cfg).items() if not k.startswith('_') and not callable(v) and not isinstance(v, (torch.Tensor, nn.Module))}
config.update(cfg_dict)
except Exception:
pass
if hasattr(model, 'unet_config') and isinstance(model.unet_config, dict):
config.update({k: v for k, v in model.unet_config.items() if not isinstance(v, (torch.Tensor, nn.Module))})
# ComfyUI models (Lumina2/Flux) need the ops factory. Dropping it makes
# model_class(**config) do None.Linear and fall back to the slow recursive clone.
for attr in ("operations", "ops"):
if hasattr(model, attr):
config[attr] = getattr(model, attr)
clean_config = {}
keep_refs = {"operations", "ops"}
for k, v in config.items():
if k in keep_refs:
clean_config[k] = v
continue
if v is not None and not isinstance(v, (torch.Tensor, nn.Module)):
try:
copy.deepcopy(v)
clean_config[k] = v
except Exception:
pass
return clean_config
def clone_dataclass_or_object(obj, target_device=None):
if is_dataclass(obj):
try:
field_values = {}
for field_info in fields(obj):
try:
val = getattr(obj, field_info.name)
if isinstance(val, torch.Tensor):
field_values[field_info.name] = val.clone().detach()
elif is_dataclass(val):
field_values[field_info.name] = clone_dataclass_or_object(val)
elif isinstance(val, (list, tuple)):
new_val = []
for v in val:
if is_dataclass(v):
new_val.append(clone_dataclass_or_object(v))
elif isinstance(v, torch.Tensor):
new_val.append(v.clone())
else:
new_val.append(copy.deepcopy(v))
field_values[field_info.name] = type(val)(new_val)
else:
field_values[field_info.name] = copy.deepcopy(val)
except Exception:
# Fallback to direct reference if deep copy fails
field_values[field_info.name] = getattr(obj, field_info.name)
return obj.__class__(**field_values)
except Exception:
pass
try:
return copy.deepcopy(obj)
except Exception:
return obj
def safe_getattr(obj, attr, default=None):
return getattr(obj, attr, default)
def clone_module_simple(module, target_device):
if module is None:
return None
module_class = module.__class__
if isinstance(module, (nn.Linear, nn.Conv2d, nn.Conv1d)):
try:
weight = safe_getattr(module, 'weight', None)
weight_dtype = weight.dtype if weight is not None else torch.float32
has_bias = safe_getattr(module, 'bias', None) is not None
supports_fp8 = device_supports_float8(target_device)
if not supports_fp8 and is_float8_dtype(weight_dtype):
weight_dtype = torch.float16
if isinstance(module, nn.Linear):
new_mod = nn.Linear(
safe_getattr(module, 'in_features'),
safe_getattr(module, 'out_features'),
bias=has_bias,
device=target_device,
dtype=weight_dtype
)
elif isinstance(module, nn.Conv2d):
new_mod = nn.Conv2d(
safe_getattr(module, 'in_channels'),
safe_getattr(module, 'out_channels'),
safe_getattr(module, 'kernel_size', 1),
stride=safe_getattr(module, 'stride', 1),
padding=safe_getattr(module, 'padding', 0),
dilation=safe_getattr(module, 'dilation', 1),
groups=safe_getattr(module, 'groups', 1),
bias=has_bias,
padding_mode=safe_getattr(module, 'padding_mode', 'zeros'),
device=target_device,
dtype=weight_dtype
)
elif isinstance(module, nn.Conv1d):
new_mod = nn.Conv1d(
safe_getattr(module, 'in_channels'),
safe_getattr(module, 'out_channels'),
safe_getattr(module, 'kernel_size', 1),
stride=safe_getattr(module, 'stride', 1),
padding=safe_getattr(module, 'padding', 0),
dilation=safe_getattr(module, 'dilation', 1),
groups=safe_getattr(module, 'groups', 1),
bias=has_bias,
padding_mode=safe_getattr(module, 'padding_mode', 'zeros'),
device=target_device,
dtype=weight_dtype
)
with torch.no_grad():
if weight is not None:
if not supports_fp8 and is_float8_dtype(weight.dtype):
weight = weight.half()
new_mod.weight.copy_(weight)
if has_bias and safe_getattr(module, 'bias') is not None:
new_mod.bias.copy_(module.bias)
return new_mod
except Exception as e:
print(f"[ParallelAnything] Warning: Failed to reconstruct {module_class}: {e}")
if isinstance(module, (nn.LayerNorm, nn.BatchNorm2d, nn.GroupNorm)):
try:
weight = safe_getattr(module, 'weight', None)
weight_dtype = weight.dtype if weight is not None else torch.float32
supports_fp8 = device_supports_float8(target_device)
if not supports_fp8 and is_float8_dtype(weight_dtype):
weight_dtype = torch.float16
if isinstance(module, nn.LayerNorm):
new_mod = nn.LayerNorm(
safe_getattr(module, 'normalized_shape'),
eps=safe_getattr(module, 'eps', 1e-5),
elementwise_affine=safe_getattr(module, 'elementwise_affine', True),
device=target_device,
dtype=weight_dtype
)
elif isinstance(module, nn.BatchNorm2d):
new_mod = nn.BatchNorm2d(
safe_getattr(module, 'num_features'),
eps=safe_getattr(module, 'eps', 1e-5),
momentum=safe_getattr(module, 'momentum', 0.1),
affine=safe_getattr(module, 'affine', True),
track_running_stats=safe_getattr(module, 'track_running_stats', True),
device=target_device,
dtype=weight_dtype
)
elif isinstance(module, nn.GroupNorm):
new_mod = nn.GroupNorm(
safe_getattr(module, 'num_groups'),
safe_getattr(module, 'num_channels'),
eps=safe_getattr(module, 'eps', 1e-5),
affine=safe_getattr(module, 'affine', True),
device=target_device,
dtype=weight_dtype
)
with torch.no_grad():
if weight is not None:
if not supports_fp8 and is_float8_dtype(weight.dtype):
weight = weight.half()
new_mod.weight.copy_(weight)
if safe_getattr(module, 'bias') is not None:
new_mod.bias.copy_(module.bias)
if isinstance(module, (nn.BatchNorm2d, nn.GroupNorm)):
if safe_getattr(module, 'running_mean') is not None:
new_mod.running_mean.copy_(module.running_mean)
if safe_getattr(module, 'running_var') is not None:
new_mod.running_var.copy_(module.running_var)
return new_mod
except Exception as e:
print(f"[ParallelAnything] Warning: Failed to reconstruct norm layer {module_class}: {e}")
try:
new_mod = module_class.__new__(module_class)
nn.Module.__init__(new_mod)
supports_fp8 = device_supports_float8(target_device)
# Copy parameters
for name, param in module.named_parameters(recurse=False):
if param is not None:
new_param_data = param.clone().detach()
if not supports_fp8 and is_float8_dtype(new_param_data.dtype):
new_param_data = new_param_data.half()
new_param = nn.Parameter(new_param_data.to(device=target_device), requires_grad=False)
new_mod.register_parameter(name, new_param)
else:
new_mod.register_parameter(name, None)
# Copy buffers
for name, buffer in module.named_buffers(recurse=False):
if buffer is not None:
new_buffer_data = buffer.clone().detach()
if not supports_fp8 and is_float8_dtype(new_buffer_data.dtype):
new_buffer_data = new_buffer_data.half()
new_buffer = new_buffer_data.to(device=target_device)
new_mod.register_buffer(name, new_buffer)
else:
new_mod.register_buffer(name, None)
# Recursively clone children
for name, child in module.named_children():
if child is not None:
cloned_child = clone_module_simple(child, target_device)
new_mod.add_module(name, cloned_child)
# Copy other attributes
excluded_attrs = {
'_parameters', '_buffers', '_modules', '_non_persistent_buffers_set',
'_backward_pre_hooks', '_backward_hooks', '_forward_pre_hooks',
'_forward_hooks', '_state_dict_hooks', '_load_state_dict_pre_hooks',
'_extra_state', '_modules_to_load'
}
cache_attrs = {
'img_ids', 'txt_ids', '_img_ids', '_txt_ids', 'cached_img_ids', 'cached_txt_ids',
'pos_emb', '_pos_emb', 'pos_embed', '_pos_embed', 'freqs_cis', '_freqs_cis',
'freqs', '_freqs', 'cache', '_cache', 'kv_cache', '_kv_cache', 'attn_bias', '_attn_bias'
}
for key, value in module.__dict__.items():
if key in excluded_attrs:
continue
try:
if is_dataclass(value):
setattr(new_mod, key, clone_dataclass_or_object(value))
elif isinstance(value, torch.Tensor):
if key in cache_attrs:
setattr(new_mod, key, None)
else:
tensor_val = value.clone().detach()
if not supports_fp8 and is_float8_dtype(tensor_val.dtype):
tensor_val = tensor_val.half()
setattr(new_mod, key, tensor_val.to(target_device))
elif isinstance(value, (list, tuple)) and len(value) > 0 and isinstance(value[0], torch.Tensor):
if key in cache_attrs:
setattr(new_mod, key, None)
else:
converted = []
for t in value:
t_conv = t.clone().detach()
if not supports_fp8 and is_float8_dtype(t_conv.dtype):
t_conv = t_conv.half()
converted.append(t_conv.to(target_device))
setattr(new_mod, key, type(value)(converted))
else:
setattr(new_mod, key, copy.deepcopy(value))
except Exception:
pass
return new_mod
except Exception as e:
raise RuntimeError(f"Failed to clone module {module_class}: {e}")
def safe_model_clone(source_model, target_device, disable_flash=False):
clear_flux_caches(source_model)
try:
src_device = next(source_model.parameters()).device
except StopIteration:
src_device = torch.device('cpu')
if str(src_device) == str(target_device):
print(f"[ParallelAnything] Model already on {target_device}, using reference...")
clear_flux_caches(source_model)
return source_model
# Move to CPU first if on GPU
if src_device.type == 'cuda':
model_cpu = source_model.cpu()
if torch.cuda.is_available():
torch.cuda.synchronize()
torch.cuda.empty_cache()
comfy.model_management.soft_empty_cache()
else:
model_cpu = source_model
model_class = model_cpu.__class__
replica = None
method_used = "unknown"
try:
print(f"[ParallelAnything] Attempting efficient clone to {target_device}...")
config = extract_model_config(model_cpu)
state_dict = model_cpu.state_dict()
if not config:
raise RuntimeError("Could not extract model config")
try:
replica = model_class(**config)
replica = replica.to(target_device, non_blocking=False)
except TypeError as e:
if "missing" in str(e) and "required" in str(e):
try:
replica = model_class(config)
replica = replica.to(target_device, non_blocking=False)
except Exception:
config_obj = SimpleNamespace(**config)
replica = model_class(config_obj)
replica = replica.to(target_device, non_blocking=False)
else:
raise
print(f"[ParallelAnything] Loading parameters incrementally...")
supports_fp8 = device_supports_float8(target_device)
with torch.no_grad():
for key in list(state_dict.keys()):
try:
param = state_dict[key]
if '.' in key:
parts = key.split('.')
module = replica
for part in parts[:-1]:
module = getattr(module, part)
target_param = getattr(module, parts[-1])
else:
target_param = getattr(replica, key)
if isinstance(target_param, (torch.nn.Parameter, torch.Tensor)):
param_data = param.to(target_device, non_blocking=False)
if is_float8_dtype(param_data.dtype) and not supports_fp8:
param_data = param_data.half()
target_param.data.copy_(param_data)
del state_dict[key]
del param
if len(state_dict) % 50 == 0 and torch.cuda.is_available():
torch.cuda.empty_cache()
except Exception as e:
print(f"[ParallelAnything] Warning: Could not load {key}: {e}")
del state_dict
if src_device.type != 'cuda':
del model_cpu
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
method_used = "incremental_gpu"
except Exception as recon_error:
print(f"[ParallelAnything] Config reconstruction failed: {recon_error}")
print(f"[ParallelAnything] Attempting manual recursive cloning...")
replica = clone_module_simple(model_cpu, target_device)
method_used = "manual_recursive_clone"
if src_device.type != 'cuda':
del model_cpu
gc.collect()
if replica is None:
raise RuntimeError("All clone methods failed")
clear_flux_caches(replica)
if not device_supports_float8(target_device):
print(f"[ParallelAnything] Converting FP8 weights to FP16 for {target_device}...")
def convert_fp8_recursive(m):
for name, param in m.named_parameters():
if is_float8_dtype(param.dtype):
param.data = param.data.half()
for name, buffer in m.named_buffers():
if is_float8_dtype(buffer.dtype):
buffer.data = buffer.data.half()
for child in m.children():
convert_fp8_recursive(child)
convert_fp8_recursive(replica)
if hasattr(replica, 'gradient_checkpointing'):
replica.gradient_checkpointing = False
if hasattr(replica, '_gradient_checkpointing_func'):
replica._gradient_checkpointing_func = None
if hasattr(replica, 'hooks'):
replica.hooks = []
if hasattr(replica, '_hf_hook'):
replica._hf_hook = None
replica.eval()
for param in replica.parameters():
param.requires_grad = False
for buffer in replica.buffers():
if buffer.device != target_device:
buffer.data = buffer.data.to(target_device)
if disable_flash:
disable_flash_xformers(replica)
print(f"[ParallelAnything] Cloned via {method_used} to {target_device}")
return replica
def get_free_vram(device_name):
try:
if device_name.startswith("cuda"):
idx = int(device_name.split(":")[-1])
torch.cuda.set_device(idx)
free_memory = (
torch.cuda.get_device_properties(idx).total_memory - torch.cuda.memory_allocated(idx)
)
return free_memory / (1024 ** 2)
except Exception:
pass
return 0
def auto_split_batch(batch_size, devices, weights):
if not any(d.startswith("cuda") for d in devices):
return [max(1, int(batch_size * w)) for w in weights]
vram_avail = []
for d in devices:
if d.startswith("cuda"):
vram_avail.append(get_free_vram(d))
else:
vram_avail.append(0)
total_vram = sum(vram_avail)
if total_vram == 0:
return [max(1, int(batch_size * w)) for w in weights]
adjusted_weights = []
for i, (w, vram) in enumerate(zip(weights, vram_avail)):
if vram > 0:
vram_weight = vram / total_vram
adjusted = 0.7 * w + 0.3 * vram_weight
else:
adjusted = w
adjusted_weights.append(adjusted)
total = sum(adjusted_weights)
adjusted_weights = [w/total for w in adjusted_weights]
split_sizes = [max(1, int(batch_size * w)) for w in adjusted_weights]
split_sizes[-1] = batch_size - sum(split_sizes[:-1])
return split_sizes
class ParallelDevice:
@classmethod
def get_available_devices(cls):
devices = ["cpu"]
if torch.cuda.is_available():
for i in range(torch.cuda.device_count()):
devices.append(f"cuda:{i}")
if hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
devices.append("mps")
if hasattr(torch, 'xpu') and torch.xpu.is_available():
for i in range(torch.xpu.device_count()):
devices.append(f"xpu:{i}")
try:
import torch_directml
for i in range(torch_directml.device_count()):
devices.append(f"privateuseone:{i}")
except ImportError:
pass
return devices
@classmethod
def INPUT_TYPES(s):
available = s.get_available_devices()
default = "cuda:0" if any(d.startswith("cuda:0") for d in available) else available[0]
return {
"required": {
"device_id": (available, {
"default": default,
"tooltip": "Select available compute device (CPU/CUDA/MPS/XPU)"
}),
"percentage": ("FLOAT", {
"default": 50.0,
"min": 1.0,
"max": 100.0,
"step": 1.0,
"tooltip": "Percentage of batch (or layers for batch=1) to process on this device"
}),
},
"optional": {
"previous_devices": ("DEVICE_CHAIN", {
"tooltip": "Connect from another ParallelDevice node to chain multiple GPUs"
}),
}
}
RETURN_TYPES = ("DEVICE_CHAIN",)
RETURN_NAMES = ("device_chain",)
FUNCTION = "add_device"
CATEGORY = "utils/hardware"
DESCRIPTION = "Add a GPU/CPU/MPS/XPU device to the parallel processing chain"
def add_device(self, device_id, percentage, previous_devices=None):
if previous_devices is None:
previous_devices = []
config = {
"device": device_id,
"percentage": float(percentage),
"weight": float(percentage) / 100.0
}
new_chain = previous_devices.copy()
new_chain.append(config)
return (new_chain,)
class ParallelDeviceList:
@classmethod
def get_available_devices(cls):
devices = ["cpu"]
if torch.cuda.is_available():
for i in range(torch.cuda.device_count()):
devices.append(f"cuda:{i}")
if hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
devices.append("mps")
if hasattr(torch, 'xpu') and torch.xpu.is_available():
for i in range(torch.xpu.device_count()):
devices.append(f"xpu:{i}")
return devices
@classmethod
def INPUT_TYPES(s):
devices = s.get_available_devices()
def_dev = "cuda:0" if "cuda:0" in devices else devices[0]
return {
"required": {
"device_1": (devices, {"default": def_dev}),
"pct_1": ("FLOAT", {"default": 50.0, "min": 1.0, "max": 100.0, "step": 1.0}),
"device_2": (devices, {"default": devices[1] if len(devices) > 1 else def_dev}),
"pct_2": ("FLOAT", {"default": 50.0, "min": 0.0, "max": 100.0, "step": 1.0}),
},
"optional": {
"device_3": (devices, {"default": devices[2] if len(devices) > 2 else "cpu"}),
"pct_3": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 100.0, "step": 1.0}),
"device_4": (devices, {"default": devices[3] if len(devices) > 3 else "cpu"}),
"pct_4": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 100.0, "step": 1.0}),
}
}
RETURN_TYPES = ("DEVICE_CHAIN",)
RETURN_NAMES = ("device_chain",)
FUNCTION = "create_list"
CATEGORY = "utils/hardware"
def create_list(self, device_1, pct_1, device_2, pct_2, device_3="cpu", pct_3=0, device_4="cpu", pct_4=0):
chain = []
devices = [(device_1, pct_1), (device_2, pct_2), (device_3, pct_3), (device_4, pct_4)]
for dev_str, pct in devices:
if pct > 0:
chain.append({
"device": dev_str,
"percentage": float(pct),
"weight": float(pct) / 100.0
})
return (chain,)
class ParallelAnything:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model": ("MODEL",),
"device_chain": ("DEVICE_CHAIN", {"tooltip": "Connect from ParallelDevice nodes"}),
},
"optional": {
"workload_split": ("BOOLEAN", {
"default": True,
"tooltip": "Enable multi-device processing"
}),
"auto_vram_balance": ("BOOLEAN", {
"default": True,
"tooltip": "Automatically adjust batch split based on available VRAM"
}),
"purge_cache": ("BOOLEAN", {
"default": True,
"tooltip": "Purge CUDA cache when cleaning up parallel resources"
}),
"purge_models": ("BOOLEAN", {
"default": False,
"tooltip": "Unload all models from VRAM when cleaning up (aggressive memory clearing)"
}),
}
}
RETURN_TYPES = ("MODEL",)
RETURN_NAMES = ("model",)
FUNCTION = "setup_parallel"
CATEGORY = "utils/hardware"
def setup_parallel(self, model, device_chain, workload_split=True, auto_vram_balance=False, purge_cache=True, purge_models=False):
if model is None or not device_chain:
return (model,)
# Extract the diffusion model from ComfyUI model wrapper
if hasattr(model, 'model') and hasattr(model.model, 'diffusion_model'):
target_model = model.model.diffusion_model
model_wrapper = model
elif hasattr(model, 'diffusion_model'):
target_model = model.diffusion_model
model_wrapper = None
else:
target_model = model
model_wrapper = None
# CRITICAL FIX: Check if model is stranded on CPU BEFORE determining original_device
try:
current_device = next(target_model.parameters()).device
if current_device.type == 'cpu':
expected_device = None
if hasattr(model, 'load_device'):
expected_device = model.load_device
elif hasattr(model, 'model') and hasattr(model.model, 'load_device'):
expected_device = model.model.load_device
if expected_device is not None and expected_device.type == 'cuda':
print(f"[ParallelAnything] Restoring stranded model from CPU to {expected_device}")
target_model = target_model.to(str(expected_device))
if torch.cuda.is_available():
torch.cuda.synchronize()
else:
mgmt_device = comfy.model_management.get_torch_device()
if mgmt_device.type == 'cuda':
print(f"[ParallelAnything] Restoring stranded model from CPU to {mgmt_device}")
target_model = target_model.to(mgmt_device)
if torch.cuda.is_available():
torch.cuda.synchronize()
except StopIteration:
pass
except Exception as e:
print(f"[ParallelAnything] Warning during CPU restoration: {e}")
try:
original_device = next(target_model.parameters()).device
original_device_str = str(original_device)
except StopIteration:
original_device = torch.device('cpu')
original_device_str = "cpu"
# Original LoRA path: patch_model, then clone. Incremental clone only
# copies state_dict, so we also sync weight_function hooks afterward.
patch_source = model_wrapper if model_wrapper is not None else model
weight_patches = snapshot_weight_patches(collect_weight_patches(patch_source))
has_lora_patches = bool(weight_patches)
lora_weights_baked = False
if has_lora_patches:
print(f"[ParallelAnything] Found {len(weight_patches)} LoRA/weight patches")
try:
print("[ParallelAnything] Applying LoRA/Custom patches before cloning...")
if hasattr(model_wrapper, 'patch_model'):
model_wrapper.patch_model(
device_to=comfy.model_management.get_torch_device(),
force_patch_weights=True,
)
print("[ParallelAnything] Patches applied successfully")
elif hasattr(model_wrapper, 'model_patcher') and hasattr(model_wrapper.model_patcher, 'patch_model'):
model_wrapper.model_patcher.patch_model(
device_to=comfy.model_management.get_torch_device(),
force_patch_weights=True,
)
print("[ParallelAnything] Patches applied via model_patcher")
patcher = find_model_patcher(patch_source)
lora_weights_baked = bool(getattr(patcher, "backup", None))
except Exception as e:
print(f"[ParallelAnything] Warning: Could not apply patches: {e}")
# Cleanup previous parallel setup if exists
if hasattr(target_model, "_true_parallel_active"):
print("[ParallelAnything] Cleaning up previous parallel setup...")
cleanup_parallel_model(target_model)
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
comfy.model_management.soft_empty_cache()
# Do not unload_all_models() here: that unbakes LoRA on current ComfyUI.
# Original principle is patch_model then clone the baked weights.
aggressive_cleanup()
total_pct = sum(item["percentage"] for item in device_chain)
if total_pct <= 0:
return (model,)
device_names = []
weights = []
for item in device_chain:
weights.append(item["percentage"] / total_pct)
device_names.append(item["device"])
print(f"[ParallelAnything] Setup: {list(zip(device_names, [f'{w*100:.1f}%' for w in weights]))}")
devices_needing_safe_attention = {}
for dev_name in device_names:
if not check_sm80_support(dev_name):
devices_needing_safe_attention[dev_name] = True
print(f"[ParallelAnything] {dev_name} < SM_80, disabling Flash/xFormers")
for dev in device_names:
try:
torch.device(dev)
except Exception:
print(f"[ParallelAnything] Invalid device: {dev}")
return (model,)
try:
original_device = next(target_model.parameters()).device
original_device_str = str(original_device)
except StopIteration:
original_device = torch.device('cpu')
original_device_str = "cpu"
replicas = {}
streams = {}
successful_devices = []
successful_weights = []
try:
print(f"[ParallelAnything] Cloning to {len(device_names)} devices...")
clear_flux_caches(target_model)
needs_cpu_transition = original_device.type == 'cuda' and original_device_str not in device_names
if needs_cpu_transition:
print(f"[ParallelAnything] Moving model to CPU for safe cloning...")
target_model = target_model.cpu()
aggressive_cleanup()
for i, dev_name in enumerate(device_names):
dev = torch.device(dev_name)
need_safe = dev_name in devices_needing_safe_attention
is_original_device = (dev_name == original_device_str and not needs_cpu_transition)
# Original rule: with LoRA, clone every device including home
# so each replica has independent baked weights.
if is_original_device and not has_lora_patches:
print(f"[ParallelAnything] Using original model for {dev_name} (skipping clone)")
replicas[dev_name] = target_model
if dev.type == 'cuda':
streams[dev_name] = torch.cuda.Stream(dev)
successful_devices.append(dev_name)
successful_weights.append(weights[i])
continue
try:
if is_original_device and has_lora_patches:
print(f"[ParallelAnything] Cloning to {dev_name} (preserving LoRA weights)...")
else:
print(f"[ParallelAnything] Cloning to {dev_name} ({i+1}/{len(device_names)})...")
if dev.type == 'cuda':
idx = dev.index if dev.index is not None else int(dev_name.split(":")[-1])
torch.cuda.synchronize(idx)
free_mem = torch.cuda.mem_get_info(idx)[0] / 1024**3
print(f"[ParallelAnything] VRAM available on {dev_name}: {free_mem:.2f}GB")
replica = safe_model_clone(target_model, dev, disable_flash=need_safe)
clear_flux_caches(replica)
replicas[dev_name] = replica
if dev.type == 'cuda':
streams[dev_name] = torch.cuda.Stream(dev)
status_msg = f"[ParallelAnything] ✓ {dev_name}"
if need_safe:
status_msg += " (Safe mode)"
if has_lora_patches:
status_msg += " (LoRA active)"
print(status_msg)
successful_devices.append(dev_name)
successful_weights.append(weights[i])
aggressive_cleanup()
except RuntimeError as e:
if "out of memory" in str(e).lower():
print(f"[ParallelAnything] OOM cloning to {dev_name}, skipping...")
aggressive_cleanup()
continue
else:
raise
if len(successful_devices) < len(device_names):
if len(successful_devices) == 0:
raise RuntimeError("No devices available for parallel processing - all OOM")
print(f"[ParallelAnything] Reducing to {len(successful_devices)} devices due to OOM")
device_names = successful_devices
total_weight = sum(successful_weights)
weights = [w/total_weight for w in successful_weights]
if needs_cpu_transition and original_device_str not in replicas:
try:
print(f"[ParallelAnything] Restoring original model to {original_device}")
target_model = target_model.to(original_device)
except Exception as e:
print(f"[ParallelAnything] Warning: Could not restore original model: {e}")
except Exception as e:
print(f"[ParallelAnything] Error during cloning: {e}")
import traceback
traceback.print_exc()
for dev_name, r in replicas.items():
try:
if r is not target_model:
r.cpu()
del r
except Exception:
pass
aggressive_cleanup()
return (model,)
print("[ParallelAnything] Configuring Model/Pipeline Parallelism for Batch=1...")
lead_device_name = device_names[0]
lead_replica = replicas[lead_device_name]
unwrap_parallel_blocks(lead_replica)
# Never wrap the live ComfyUI model. Wrapping hides Linear keys from
# ModelPatcher and our hooks fight dynamic-VRAM/sage (blurry "paint"
# images on the 70% home split).
wrap_lead = lead_replica is not target_model
if not wrap_lead:
print("[ParallelAnything] Skipping pipeline wrap on live home model (keeps ComfyUI LoRA/sage)")
block_lists = list(BLOCK_LIST_NAMES)
for list_name in block_lists:
if not wrap_lead:
break
if hasattr(lead_replica, list_name):
local_blocks = getattr(lead_replica, list_name)
if not isinstance(local_blocks, nn.ModuleList):
continue
num_blocks = len(local_blocks)
if num_blocks == 0:
continue
print(f"[ParallelAnything] Configuring {list_name} ({num_blocks} blocks) for pipeline execution...")
device_assignments = []
current_block = 0
for i, weight in enumerate(weights):
count = int(round(weight * num_blocks))
if i == len(weights) - 1:
count = num_blocks - current_block
target_dev = torch.device(device_names[i])
for _ in range(count):
if current_block < num_blocks:
device_assignments.append(target_dev)
current_block += 1
for idx in range(num_blocks):
assigned_dev = device_assignments[idx]
peers = {}
for d_name, r in replicas.items():
if hasattr(r, list_name):
peers[d_name] = getattr(r, list_name)[idx]
original_block = local_blocks[idx]
is_last = (idx == num_blocks - 1)
wrapper = ParallelBlock(
local_block=original_block,
block_idx=idx,
owner_device=assigned_dev,
peers=peers,
is_last_block=is_last,
lead_device=torch.device(lead_device_name)
)
local_blocks[idx] = wrapper
# Clones only: bake LoRA into param.data. Hooks do not run on many
# cloned Linears, which is why the 30% V100 split had no LoRA while
# the live 3090 (ComfyUI path) did. Never bake the live model.
if has_lora_patches:
seen = set()
for dev_name, replica in replicas.items():
if replica is target_model or id(replica) in seen:
if replica is target_model:
print(f"[ParallelAnything] {dev_name} is live model; ComfyUI applies LoRA")
continue
seen.add(id(replica))
print(f"[ParallelAnything] Baking LoRA into clone on {dev_name}...", flush=True)
baked = apply_patches_to_replica(
replica, weight_patches, progress_label=f"{dev_name} LoRA"
)
print(
f"[ParallelAnything] LoRA on {dev_name}: "
f"baked={baked['baked']} attached={baked['attached']} "
f"failed={baked['failed']} skipped={baked['skipped']}",
flush=True,
)
if baked["baked"] + baked["attached"] == 0:
raise RuntimeError(
f"LoRA patches were found ({len(weight_patches)}) but none "
f"applied on clone {dev_name}."
)
target_model._parallel_purge_cache = purge_cache
target_model._parallel_purge_models = purge_models
replicas_ref = replicas
devices_ref = tuple(device_names)
weights_ref = tuple(weights)
lead_device = torch.device(device_names[0])
streams_ref = streams
auto_balance_ref = auto_vram_balance
def get_batch_size(x):
if isinstance(x, torch.Tensor):
return x.shape[0]
elif isinstance(x, (list, tuple)) and len(x) > 0:
if isinstance(x[0], torch.Tensor):
batch_sizes = [t.shape[0] for t in x if isinstance(t, torch.Tensor)]
if batch_sizes:
return batch_sizes[0]
return len(x)
else:
return 1
def split_batch(x, split_sizes):
if isinstance(x, torch.Tensor):
return torch.split(x, split_sizes, dim=0)
elif isinstance(x, (list, tuple)):
split_lists = []
for t in x:
if isinstance(t, torch.Tensor):
split_lists.append(torch.split(t, split_sizes, dim=0))
else:
split_lists.append([t] * len(split_sizes))
result = []
for i in range(len(split_sizes)):
result.append(type(x)(sl[i] for sl in split_lists))
return result
else:
return [x] * len(split_sizes)
def move_to_device(x, device, non_blocking=False):
return move_tree_to_device(x, device, non_blocking=non_blocking)
def split_kwargs(kwargs, split_sizes, total_batch):
split_kwargs_list = [{} for _ in range(len(split_sizes))]
for key, value in kwargs.items():
if isinstance(value, torch.Tensor) and value.shape[0] == total_batch:
chunks = torch.split(value, split_sizes, dim=0)
for i, chunk in enumerate(chunks):
split_kwargs_list[i][key] = chunk
elif isinstance(value, (list, tuple)) and len(value) > 0 and isinstance(value[0], torch.Tensor):
if all(isinstance(t, torch.Tensor) and t.shape[0] == total_batch for t in value):
split_lists = [torch.split(t, split_sizes, dim=0) for t in value]
for i in range(len(split_sizes)):
split_kwargs_list[i][key] = type(value)(sl[i] for sl in split_lists)
else:
for i in range(len(split_sizes)):
split_kwargs_list[i][key] = value
return split_kwargs_list
def concatenate_results(results, dim=0):
if len(results) == 0:
return results
first = results[0]
if isinstance(first, torch.Tensor):
return torch.cat(results, dim=dim)
elif isinstance(first, (list, tuple)):
concatenated = []
for i in range(len(first)):
if isinstance(first[i], torch.Tensor):
to_concat = [r[i] for r in results]
concatenated.append(torch.cat(to_concat, dim=dim))
else:
concatenated.append(first[i])
return type(first)(concatenated)
else:
return results
def parallel_forward(self, x, timesteps, context=None, **kwargs):
try:
try:
batch_size = get_batch_size(x)
except ValueError as e:
print(f"[ParallelAnything] Error: {e}")
raise
# Pipeline mode for batch_size == 1
if batch_size == 1 and workload_split:
set_pipeline_mode(True)
try:
lead_replica = replicas_ref[devices_ref[0]]
if hasattr(lead_replica, '_original_forward'):
return lead_replica._original_forward(x, timesteps, context=context, **kwargs)
else:
return lead_replica(x, timesteps, context=context, **kwargs)
finally:
set_pipeline_mode(False)
# Single device fallback if batch too small or workload_split disabled
if batch_size < len(devices_ref) or not workload_split:
set_pipeline_mode(False)
with torch.no_grad():
first_replica = replicas_ref[devices_ref[0]]
if hasattr(first_replica, '_original_forward'):
return first_replica._original_forward(x, timesteps, context=context, **kwargs)
else:
return first_replica(x, timesteps, context=context, **kwargs)
# Data parallelism for batch_size > 1
if auto_balance_ref:
split_sizes = auto_split_batch(batch_size, list(devices_ref), list(weights_ref))
else:
split_sizes = [max(1, int(batch_size * w)) for w in weights_ref]
split_sizes[-1] = batch_size - sum(split_sizes[:-1])
active = []
for idx, (dev_name, size) in enumerate(zip(devices_ref, split_sizes)):
if size > 0:
active.append({
'idx': idx,
'dev_name': dev_name,
'device': torch.device(dev_name),
'replica': replicas_ref[dev_name],
'stream': streams_ref.get(dev_name),
'size': size,
})
if len(active) == 0:
raise RuntimeError("No active devices available")
if len(active) == 1:
set_pipeline_mode(False)
with torch.no_grad():
replica = active[0]['replica']
if hasattr(replica, '_original_forward'):
return replica._original_forward(x, timesteps, context=context, **kwargs)
else:
return replica(x, timesteps, context=context, **kwargs)
x_chunks = split_batch(x, [a['size'] for a in active])
t_chunks = split_batch(timesteps, [a['size'] for a in active])
if context is not None:
c_chunks = split_batch(context, [a['size'] for a in active])
else:
c_chunks = [None] * len(active)
kwargs_chunks = split_kwargs(kwargs, [a['size'] for a in active], batch_size)
results = [None] * len(active)
exceptions = []
def worker(task_idx):
set_pipeline_mode(False)
task = active[task_idx]
dev = task['device']
replica = task['replica']
stream = task['stream']
try:
if dev.type == 'cuda':
torch.cuda.set_device(dev)
x_in = move_to_device(x_chunks[task_idx], dev)
t_in = move_to_device(t_chunks[task_idx], dev)
c_in = move_to_device(c_chunks[task_idx], dev) if c_chunks[task_idx] is not None else None
k_in = {}
for k, v in kwargs_chunks[task_idx].items():
k_in[k] = move_to_device(v, dev, non_blocking=False)
if hasattr(replica, '_original_forward'):
forward_fn = replica._original_forward
else:
forward_fn = replica.forward
if dev.type == 'cuda' and stream is not None:
with torch.cuda.device(dev):
with torch.cuda.stream(stream):
torch.cuda.synchronize(dev)
with torch.no_grad():
out = forward_fn(x_in, t_in, context=c_in, **k_in)
torch.cuda.synchronize(dev)
elif dev.type == 'cuda':
with torch.cuda.device(dev):
torch.cuda.synchronize(dev)
with torch.no_grad():
out = forward_fn(x_in, t_in, context=c_in, **k_in)
torch.cuda.synchronize(dev)
elif dev.type == 'xpu':
with torch.xpu.device(dev):
torch.xpu.synchronize(dev)
with torch.no_grad():
out = forward_fn(x_in, t_in, context=c_in, **k_in)
torch.xpu.synchronize(dev)
else:
with torch.no_grad():
out = forward_fn(x_in, t_in, context=c_in, **k_in)
out = move_to_device(out, lead_device, non_blocking=False)
return task_idx, out
except Exception as e:
return task_idx, e
with ThreadPoolExecutor(max_workers=len(active)) as executor:
futures = [executor.submit(worker, i) for i in range(len(active))]
for future in as_completed(futures):
idx, result = future.result()
if isinstance(result, Exception):
exceptions.append((active[idx]['dev_name'], result))
results[idx] = None
else:
results[idx] = result
if exceptions:
for dev_name, exc in exceptions:
print(f"[ParallelAnything] Error on {dev_name}: {exc}")
raise exceptions[0][1]
if any(r is None for r in results):
missing = [active[i]['dev_name'] for i, r in enumerate(results) if r is None]
raise RuntimeError(f"Missing results from devices: {missing}")
return concatenate_results(results, dim=0)
except RuntimeError as e:
if "out of memory" in str(e).lower():
print("[ParallelAnything] OOM in parallel forward, attempting emergency cleanup...")
lead_name = devices_ref[0]
lead_replica = replicas_ref[lead_name]
for name, replica in list(replicas_ref.items()):
if replica is lead_replica:
continue
try:
if hasattr(replica, "cpu"):
replica.cpu()
except Exception:
pass
aggressive_cleanup()
print("[ParallelAnything] Falling back to single device processing...")
set_pipeline_mode(False)
with torch.no_grad():
if hasattr(lead_replica, '_original_forward'):
return lead_replica._original_forward(x, timesteps, context=context, **kwargs)
else:
return lead_replica(x, timesteps, context=context, **kwargs)
else:
raise
target_model._original_forward = target_model.forward
target_model.forward = types.MethodType(parallel_forward, target_model)
target_model._true_parallel_active = True
target_model._parallel_replicas = replicas
target_model._parallel_devices = device_names
target_model._parallel_streams = streams
target_model._parallel_weights = weights
target_model._auto_vram_balance = auto_vram_balance
weakref.finalize(model, cleanup_parallel_model, weakref.ref(target_model))
# Update ComfyUI's model device tracking to point to lead device
if hasattr(model, 'load_device'):
model.load_device = lead_device
elif hasattr(model, 'model') and hasattr(model.model, 'load_device'):
model.model.load_device = lead_device
print(f"[ParallelAnything] Parallel setup complete. Devices: {device_names}")
if has_lora_patches:
print("[ParallelAnything] LoRA weights synchronized across all devices")
return (model,)
NODE_CLASS_MAPPINGS = {
"ParallelAnything": ParallelAnything,
"ParallelDevice": ParallelDevice,
"ParallelDeviceList": ParallelDeviceList,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ParallelAnything": "Parallel Anything (True Multi-GPU)",
"ParallelDevice": "Parallel Device Config",
"ParallelDeviceList": "Parallel Device List (1-4x)",
}