Files
diodiogod f285fa8bd2 Fix ComfyUI model wrapper compatibility with ComfyUI 0.18 memory management
Technical details:
- Add model_mmap_residency fallback to the shared ComfyUI wrapper
- Add pinned memory, dtype, patcher, and device compatibility methods
- Return full model memory for non-mmap-backed engines instead of a zero stub
- Add regression coverage for wrapper compatibility behavior

Fixes #276
Fixes #278
2026-03-27 09:25:38 -03:00

508 lines
22 KiB
Python

"""
Base ComfyUI model wrapper class
"""
import torch
import weakref
import gc
from typing import Optional, Any
from dataclasses import dataclass
from .cache_utils import invalidate_all_caches
# Import ComfyUI's model management if available
try:
import comfy.model_management as model_management
COMFYUI_AVAILABLE = True
except ImportError:
# Fallback if ComfyUI not available
COMFYUI_AVAILABLE = False
model_management = None
@dataclass
class ModelInfo:
"""Information about a model for memory management"""
model: Any
model_type: str # "tts", "vc", "audio_separation", "hubert", etc.
engine: str # "chatterbox", "f5tts", "higgs_audio", "rvc", etc. (may be masked for stateless wrappers)
device: str
memory_size: int # in bytes
load_device: str
original_engine: Optional[str] = None # Original engine name before masking (e.g., "higgs_audio" when engine="stateless_tts")
class SimpleModelWrapper:
"""
Simple wrapper to add .model attribute for ComfyUI compatibility.
ComfyUI expects .model.model for logging, so we wrap simple PyTorch models.
"""
def __init__(self, model):
self.model = model # This is what ComfyUI accesses for logging
def to(self, device):
"""Forward .to() calls to the wrapped model"""
if hasattr(self.model, 'to'):
self.model = self.model.to(device)
return self
def __getattr__(self, name):
"""Forward all other attributes to the wrapped model"""
return getattr(self.model, name)
class ComfyUIModelWrapper:
"""
Wrapper that makes TTS models compatible with ComfyUI's model management system.
This allows TTS models to be automatically unloaded when VRAM is low,
work with "Clear VRAM" buttons, and integrate properly with ComfyUI's ecosystem.
"""
def __init__(self, model: Any, model_info: ModelInfo, cache_key: str = None):
"""
Initialize the wrapper.
Args:
model: The actual model instance (ChatterBox, F5-TTS, etc.)
model_info: Metadata about the model
cache_key: Optional cache key for this specific model instance
"""
self.model = model
self.model_info = model_info
self.load_device = model_info.load_device
self._memory_size = model_info.memory_size
self._cache_key = cache_key # Store cache key for ensure_device calls
# ComfyUI compatibility attributes
# Convert device to torch.device object for ComfyUI compatibility
from utils.device import resolve_torch_device
device_name = resolve_torch_device(model_info.device)
# Track the resolved device string/object so ComfyUI comparisons are consistent.
self.current_device = device_name
# ComfyUI expects torch.device objects, not strings
if isinstance(device_name, str):
if device_name == "cuda":
self.device = torch.device("cuda", torch.cuda.current_device() if torch.cuda.is_available() else 0)
else:
self.device = torch.device(device_name)
else:
self.device = device_name
self.dtype = getattr(model, 'dtype', torch.float32)
self.offload_device = 'cpu' # TTS models offload to CPU
# ComfyUI compatibility attributes for diffusion models (TTS models don't need them)
self.parent = None # TTS models don't have parent models
# Additional ComfyUI LoadedModel compatibility attributes
# Use the same torch.device object for load_device
self.load_device = self.device
self.currently_used = True
self.model_finalizer = None # Will be set by LoadedModel
self._patcher_finalizer = None
# ComfyUI model patcher attributes (required for load_models_gpu)
self.model_options = {} # Model loading options
self.model_keys = set() # Model state dict keys
# Track if model is currently loaded on GPU
self._is_loaded_on_gpu = self.current_device not in ['cpu', 'offload']
# Track if model is valid for reuse (false if corrupted by CPU offloading)
self._is_valid_for_reuse = True
# Keep weak reference to avoid circular references
self._model_ref = weakref.ref(model) if model is not None else None
def loaded_size(self) -> int:
"""Return the memory size of the model in bytes"""
size = self._memory_size if self._is_loaded_on_gpu else 0
return size
def model_size(self) -> int:
"""Return the total model size in bytes"""
return self._memory_size
def model_memory(self) -> int:
"""Return the total model size in bytes (alias for model_size for ComfyUI compatibility)"""
return self._memory_size
def get_ram_usage(self) -> int:
"""Mirror ComfyUI's ModelPatcher RAM accounting contract."""
return self.model_size()
def model_offloaded_memory(self) -> int:
"""Return the amount of memory that would be freed if offloaded"""
return self.model_size() - self.loaded_size()
def model_mmap_residency(self, free: bool = False) -> tuple[int, int]:
"""
Report mmap-backed residency for ComfyUI 0.18+ memory management.
Most TTS/VC models are not mmap-backed, so the safe fallback is:
- resident mmap memory: 0
- total model memory: full model size
"""
wrapped = self._model_ref() if self._model_ref else self.model
if wrapped is not None:
method = getattr(wrapped, "model_mmap_residency", None)
if callable(method):
return method(free=free)
return 0, self.model_size()
def pinned_memory_size(self) -> int:
"""Return pinned host memory used by the model, if the wrapped model tracks it."""
wrapped = self._model_ref() if self._model_ref else self.model
if wrapped is not None:
method = getattr(wrapped, "pinned_memory_size", None)
if callable(method):
return method()
return 0
def lowvram_patch_counter(self) -> int:
"""TTS wrappers do not use ComfyUI low-VRAM patching, so default to zero."""
wrapped = self._model_ref() if self._model_ref else self.model
if wrapped is not None:
method = getattr(wrapped, "lowvram_patch_counter", None)
if callable(method):
return method()
return 0
def model_dtype(self):
"""Expose the active dtype through the same interface as ComfyUI's model patchers."""
return self.dtype
def model_patches_models(self):
"""TTS wrappers do not chain patcher models, so return an empty iterable."""
return ()
def model_patches_to(self, target) -> None:
"""
ComfyUI calls this on model patchers before loading.
TTS wrappers have no patch tensors, but tracking the requested device/dtype
keeps the wrapper contract aligned with newer ComfyUI versions.
"""
if isinstance(target, torch.dtype):
self.dtype = target
elif isinstance(target, torch.device):
self.device = target
self.load_device = target
self.current_device = target
def partially_unload_ram(self, ram_to_unload: int) -> int:
"""
ComfyUI compatibility: partially unload RAM/VRAM.
Newer ComfyUI versions call this on loaded models.
"""
try:
return self.partially_unload('cpu', ram_to_unload)
except Exception:
return 0
def current_loaded_device(self) -> torch.device:
"""Return the current device using ComfyUI's expected torch.device type."""
device = self.current_device
if isinstance(device, torch.device):
return device
from utils.device import resolve_torch_device
resolved = resolve_torch_device(device)
if resolved == "offload":
resolved = "cpu"
if resolved == "cuda":
return torch.device("cuda", torch.cuda.current_device() if torch.cuda.is_available() else 0)
return torch.device(resolved)
def partially_unload(self, device: str, memory_to_free: int) -> int:
"""
Partially unload the model to free memory.
Uses engine-specific handlers for specialized behavior.
Args:
device: Target device to move to (usually 'cpu')
memory_to_free: Amount of memory to free in bytes
Returns:
Amount of memory actually freed in bytes
"""
if not self._is_loaded_on_gpu:
print(f"⚠️ Skipping unload: model already marked as not on GPU (_is_loaded_on_gpu={self._is_loaded_on_gpu})")
return 0
# Import and use engine-specific handler
from .engine_handlers import get_engine_handler
handler = get_engine_handler(self.model_info.engine)
return handler.partially_unload(self, device, memory_to_free)
def model_unload(self, memory_to_free: Optional[int] = None, unpatch_weights: bool = True) -> bool:
"""
Fully unload the model from GPU memory.
Args:
memory_to_free: Amount of memory to free (ignored for full unload)
unpatch_weights: Whether to unpatch weights (TTS models don't use this)
Returns:
True if model was unloaded, False otherwise
"""
print(f"🔄 TTS Model unload requested: {self.model_info.engine} {self.model_info.model_type} (_is_loaded_on_gpu={self._is_loaded_on_gpu})")
# Import and use engine-specific handler
from .engine_handlers import get_engine_handler
handler = get_engine_handler(self.model_info.engine)
return handler.model_unload(self, memory_to_free, unpatch_weights)
def model_load(self, device: Optional[str] = None, _from_ensure_device: bool = False) -> None:
"""
Load the model back to GPU.
Args:
device: Device to load to (defaults to original load_device)
_from_ensure_device: Internal flag to prevent recursion when called from ensure_device()
"""
if self._is_loaded_on_gpu:
return
target_device = device or self.load_device
model = self._model_ref() if self._model_ref else None
if model is None:
return
# CRITICAL: Before trying to load, clear other TTS models using unified model manager
# This prevents OOM errors when ComfyUI calls partially_load() → model_load()
# But skip if we're already being called FROM ensure_device() to prevent infinite recursion
if str(target_device).startswith('cuda') and not _from_ensure_device:
try:
from utils.models.comfyui_model_wrapper.model_manager import tts_model_manager
# ensure_device() will clear other models and move this one - if it succeeds, we're done
# Pass cache_key so it knows which specific model to move (e.g., vibevoice-7B vs vibevoice-1.5B)
cache_key = getattr(self, '_cache_key', None)
if tts_model_manager.ensure_device(self.model_info.engine, str(target_device), model_cache_key=cache_key):
return # Successfully moved by ensure_device(), don't do it again
except RecursionError:
print(f"⚠️ Recursion detected in model_load, proceeding with direct load")
except Exception as e:
print(f"⚠️ Could not use ensure_device() for clearing, proceeding with direct load: {e}")
try:
# Check if engine handler has custom load logic
from .engine_handlers import get_engine_handler
handler = get_engine_handler(self.model_info.engine)
# Try engine-specific partial_load first (e.g., CosyVoice needs special handling)
if hasattr(handler, 'partially_load'):
success = handler.partially_load(self, str(target_device))
if success:
self.current_device = target_device
self._is_loaded_on_gpu = True
# Skip the generic movement code below
else:
print(f"⚠️ Engine handler partially_load failed, falling back to generic movement")
# Fall through to generic movement
else:
# Generic movement for engines without custom partially_load
# Move model back to GPU (comprehensive approach)
if hasattr(model, 'to'):
model.to(target_device)
print(f"🔄 Moved main {self.model_info.model_type} model ({self.model_info.engine}) to {target_device}")
# CRITICAL: Recursively move ALL nested components to ensure device consistency
self._move_all_components_to_device(model, target_device, depth=0)
self.current_device = target_device
self._is_loaded_on_gpu = True
print(f"✅ Fully moved {self.model_info.model_type} model components ({self.model_info.engine}) back to {target_device}")
# Re-register with ComfyUI after reload so "Clear VRAM" continues to work
try:
import comfy.model_management as model_management
# Check if we're in current_loaded_models list
if hasattr(model_management, 'current_loaded_models'):
# Find our LoadedModel wrapper
found = False
for loaded_model in model_management.current_loaded_models:
if hasattr(loaded_model, 'model') and loaded_model.model == self:
found = True
break
# If not found, re-register
if not found:
loaded_model = model_management.LoadedModel(self)
loaded_model.real_model = weakref.ref(model)
loaded_model._tts_wrapper_ref = self
# Set up finalizer that ComfyUI expects
if hasattr(model_management, 'cleanup_models'):
loaded_model.model_finalizer = weakref.finalize(model, model_management.cleanup_models)
else:
loaded_model.model_finalizer = weakref.finalize(model, lambda: None)
model_management.current_loaded_models.insert(0, loaded_model)
print(f"✅ Re-registered with ComfyUI model management after reload")
except Exception as e:
print(f"⚠️ Could not re-register with ComfyUI ({e}) - 'Clear VRAM' may not work")
except Exception as e:
print(f"⚠️ Error moving model to {target_device}: {e}")
def _move_all_components_to_device(self, obj, target_device: str, depth: int = 0, max_depth: int = 5):
"""
Recursively move all PyTorch components to target device.
This ensures no CPU/GPU device mismatches after model reload.
"""
if depth > max_depth:
return
if obj is None:
return
# Move PyTorch modules (any object with .to() method)
if hasattr(obj, 'to') and callable(getattr(obj, 'to')):
try:
obj.to(target_device)
if depth <= 1: # Log top-level and first-level components
print(f" {' ' * depth}📦 Moved {type(obj).__name__} to {target_device}")
except Exception as e:
if depth <= 1:
print(f" {' ' * depth}⚠️ Failed to move {type(obj).__name__}: {e}")
# Recurse through object attributes
if hasattr(obj, '__dict__'):
for attr_name, attr_value in obj.__dict__.items():
# Skip private attributes and known problematic ones
if attr_name.startswith('_'):
continue
if attr_value is None:
continue
# Skip internal PyTorch structures (already handled by .to())
if attr_name in ['_modules', '_parameters', '_buffers', 'training']:
continue
try:
self._move_all_components_to_device(attr_value, target_device, depth + 1, max_depth)
except Exception:
pass
def is_dynamic(self) -> bool:
"""
ComfyUI 0.12.0+ compatibility method.
Dynamic models can have their memory requirements change during inference
(e.g., models using dynamic batching or variable sequence lengths).
TTS models have fixed memory requirements, so we return False.
This prevents AttributeError when ComfyUI's model_management.py calls
is_dynamic() on loaded models in free_memory() and load_models_gpu_orig().
Returns:
False - TTS models are not dynamic
"""
return False
def is_clone(self, other) -> bool:
"""Check if this model is a clone of another model"""
if not isinstance(other, ComfyUIModelWrapper):
return False
return (self.model_info.model_type == other.model_info.model_type and
self.model_info.engine == other.model_info.engine)
def is_dead(self) -> bool:
"""Check if the model's weak reference is dead (for ComfyUI cleanup)"""
if self._model_ref is None:
return True
return self._model_ref() is None
def detach(self, unpatch_all: bool = False) -> None:
"""Detach the model - actually unload from GPU to CPU and invalidate cache"""
print(f"🔧 TTS Model detach called: {self.model_info.engine} {self.model_info.model_type} (unpatch_all={unpatch_all})")
# Actually unload the model from GPU
freed = self.partially_unload('cpu', self._memory_size)
if freed > 0:
print(f"✅ TTS Model detached: freed {freed // 1024 // 1024}MB VRAM")
else:
print(f"⚠️ TTS Model detach: no memory freed (model may already be on CPU)")
# CRITICAL: Mark model as invalid to prevent reuse of corrupted state
# Only needed for engines with CUDA graphs (Higgs Audio) that cannot be safely reused after CPU offloading
# Check both actual engine name and original engine name (for stateless wrappers)
original_engine = getattr(self.model_info, 'original_engine', self.model_info.engine)
is_higgs = self.model_info.engine == "higgs_audio" or original_engine == "higgs_audio"
if is_higgs:
self._is_valid_for_reuse = False
# Clear node-level engine caches to force fresh CUDA graph initialization
# CUDA graphs are device-specific and must be recreated after CPU migration
invalidate_all_caches()
else:
# Other engines (ChatterBox, F5-TTS, VibeVoice) can be safely reused after device movement
print(f"✅ {self.model_info.engine} model detached, marked valid for reuse")
def partially_load(self, device, extra_memory, force_patch_weights=False):
"""
Partially load model to device (ComfyUI compatibility method)
Args:
device: Target device
extra_memory: Extra memory available
force_patch_weights: Whether to force patch weights
Returns:
Amount of memory used
"""
print(f"🔄 TTS Model partially_load requested: {self.model_info.engine} {self.model_info.model_type} to {device}")
# For TTS models, we either fully load or fully unload
if str(device) != 'cpu' and not self._is_loaded_on_gpu:
self.model_load(device)
return self._memory_size
return 0 # No additional memory used
@staticmethod
def _estimate_model_memory(model) -> int:
"""Estimate memory usage of a PyTorch model"""
if not hasattr(model, 'parameters'):
return 0
total_size = 0
for param in model.parameters():
total_size += param.nelement() * param.element_size()
return total_size
@staticmethod
def calculate_model_memory(model: Any) -> int:
"""Calculate total memory usage of a model in bytes"""
if hasattr(model, 'parameters'):
# PyTorch model
return ComfyUIModelWrapper._estimate_model_memory(model)
elif hasattr(model, '__dict__'):
# Complex model with multiple components
total_size = 0
for attr_name, attr_value in model.__dict__.items():
if hasattr(attr_value, 'parameters'):
total_size += ComfyUIModelWrapper._estimate_model_memory(attr_value)
elif attr_name == 'f5tts_model' and hasattr(attr_value, '__dict__'):
# Special handling for ChatterBoxF5TTS wrapper
for sub_attr_name, sub_attr_value in attr_value.__dict__.items():
if hasattr(sub_attr_value, 'parameters'):
total_size += ComfyUIModelWrapper._estimate_model_memory(sub_attr_value)
return total_size if total_size > 0 else 1024 * 1024 * 1024 # 1GB default if nothing found
else:
# Estimate based on common model sizes
return 1024 * 1024 * 1024 # Default 1GB estimate
def __repr__(self):
return f"ComfyUIModelWrapper({self.model_info.model_type}:{self.model_info.engine}, {self._memory_size // 1024 // 1024}MB, device={self.current_device})"
def __getattr__(self, name):
"""Forward attribute access to wrapped model for backward compatibility"""
# Avoid infinite recursion - only forward if attribute not in wrapper
if name in ['model', 'model_info', 'current_device', '_memory_size']:
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
return getattr(self.model, name)