Files
WildAi f71eef465a refactor(log): rename console prefix to [VibeVoice TTS]
Partial rename completed across 24 source files (73 sites). Tests pinned
the old literal; test_logging_idiom.py now derives from PREFIX.
2026-10-03 16:03:14 +03:00

498 lines
22 KiB
Python

"""VibeVoicePatcher: Custom ModelPatcher for VibeVoice models.
Manages VRAM by loading/offloading the VibeVoice model through ComfyUI's
ModelPatcher infrastructure. Handles lazy model loading, device placement,
and dtype casting.
"""
import torch
import gc
import logging
from itertools import chain
import comfy.model_patcher
import comfy.model_management as model_management
from .loader import VibeVoiceLoader, LOADED_MODELS_CACHE
from .asr_loader import LOADED_ASR_MODELS_CACHE
from .dtype_utils import cast_model_to_dtype, get_dtype_str, representative_dtype
def _init_legacy_attributes(patcher, model, attention_mode, dtype) -> None:
"""Install the attribute vocabulary every VibeVoice patcher shares.
Split out of :class:`VibeVoicePatcher.__init__` because the minted dynamic
subclass (``make_dynamic_patcher_class``) builds its MRO as
``(ModelPatcherDynamic, <legacy>)`` — core's dynamic protocol must come
first so ``patch_model``/``unpatch_model`` dispatch to core rather than to
the legacy overrides. With that MRO, ``super()`` from the dynamic class
resolves to ``ModelPatcherDynamic.__init__`` and the legacy ``__init__``
never runs, so these four attributes are applied here instead. Sharing one
definition is what keeps the two classes from drifting apart.
"""
patcher.attention_mode = attention_mode
patcher.cache_key = getattr(model, 'cache_key', 'VibeVoice_Unknown')
patcher.target_dtype = dtype
# NTH-004: tracks whether the model was warm-offloaded (tensors retained
# on the intermediate device) so `is_loaded` reflects offload state. The
# dynamic class never sets it (its warm path releases pins, it does not move
# tensors), and its `is_loaded` does not read it — see the design note in
# `make_dynamic_patcher_class`.
patcher._warm_offloaded = False
def _adopt_resident_weights(patcher, model) -> None:
"""Report to core the weight bytes this patcher already has in VRAM.
Our loader places every tensor on the load device *before* the patcher is
built, so core's own counter still reads zero. ``partially_load`` then
computes ``0 + extra_memory > model_size()`` as false, decides the load is
not full, and plans a partial load into whatever VRAM happens to be left.
That is how a fully resident 9.33 GB 7B model gets reported as
``loaded partially; 5511.92 MB offloaded`` on an otherwise idle 16 GB card:
the weights never move, only the bookkeeping is wrong — and it is wrong in
the direction that makes a second model look like there is 5.5 GB free.
Counting the bytes that are genuinely on an accelerator lets core's own
guard do the right thing: ``partially_load`` returns 0 and leaves the
resident model alone.
"""
inner = getattr(model, "model", None)
if inner is None:
return
resident = 0
on_accelerator = False
for tensor in chain(inner.parameters(), inner.buffers()):
if tensor.is_meta:
return # nothing is loaded yet; leave core's counter at zero
if tensor.device.type == "cuda":
resident += tensor.numel() * tensor.element_size()
on_accelerator = True
if on_accelerator and resident > 0:
patcher.model.model_loaded_weight_memory = resident
def _init_patch_state(patcher, model, attention_mode, dtype) -> None:
"""The one place a patcher finishes coming up."""
_init_legacy_attributes(patcher, model, attention_mode, dtype)
_adopt_resident_weights(patcher, model)
class VibeVoicePatcher(comfy.model_patcher.ModelPatcher):
"""Custom ModelPatcher for managing VibeVoice models in ComfyUI.
Handles moving the model to the correct device (GPU) for inference
and offloading it to free VRAM.
Subclasses ComfyUI's standard ModelPatcher, allowing models that fit into
VRAM to be fully loaded directly onto the GPU with native CUDA operations
and zero paging/streaming overhead.
"""
def __init__(self, model, *args, attention_mode: str = "eager", dtype=None, **kwargs):
# attention_mode/dtype are keyword-only: core's ModelPatcher.clone()
# reconstructs the class with POSITIONAL args
# (model, load_device, offload_device, size, ...) at
# comfy/model_patcher.py:446, so any positional parameter declared
# before *args silently binds load_device to attention_mode and then
# blows up in ModelPatcher.__init__ with "missing 1 required
# positional argument: 'offload_device'".
super().__init__(model, *args, **kwargs)
_init_patch_state(self, model, attention_mode, dtype)
@property
def _model_cache(self) -> dict:
"""The (model, processor) cache dict this patcher's cold offload clears.
AUD-012: TTS and ASR patchers own *separate* caches. The cold-offload
path must only evict from this patcher's own registry so an ASR offload
can never drop a TTS cache entry (and vice versa).
"""
return LOADED_MODELS_CACHE
@property
def is_loaded(self) -> bool:
"""Check if the model's core components are loaded AND on the load device.
Plan 2026-08-18, Step 5.3: a CPU-offloaded model is "loaded in RAM"
but not "loaded for inference". The device check ensures is_loaded
reflects actual inference-readiness.
"""
if not (
hasattr(self, 'model')
and self.model is not None
and hasattr(self.model, 'model')
and self.model.model is not None
):
return False
if getattr(self, '_warm_offloaded', False):
return False
# Device awareness: the heavy model must be on the load device.
try:
model_device = next(self.model.model.parameters()).device
if not isinstance(model_device, torch.device):
# Can't determine device (e.g., MagicMock in tests) —
# fall back to loaded if refs exist.
return True
return model_device == self.load_device
except (StopIteration, AttributeError, TypeError):
return True
def patch_model(self, device_to=None, lowvram_model_memory=0, load_weights=True, force_patch_weights=False, *args, **kwargs):
"""Called by ComfyUI's model manager to load the model onto the GPU.
Lazily loads the VibeVoice model if not already loaded, then delegates
to ComfyUI's standard patch_model() for device placement and weight
tracking. This ensures ComfyUI's model_management properly tracks
model_loaded_weight_memory and can manage VRAM offloading.
Args:
device_to: Target device for the model.
lowvram_model_memory: Memory limit for low-VRAM mode (0 = full load).
load_weights: Whether to load weights (always True for VibeVoice).
force_patch_weights: Force re-patching of weights.
"""
self._warm_offloaded = False
target_device = self.load_device if device_to is None else device_to
if self.model.model is None:
logging.info(
f"[VibeVoice TTS] Loading VibeVoice models for '{self.model.model_pack_name}' to {target_device}..."
)
mode_names = {
"eager": "Eager (Most Compatible)",
"sdpa": "SDPA (Balanced Speed/Compatibility)",
"flash_attention_2": "Flash Attention 2 (Fastest)",
"sage": "SageAttention (Quantized High-Performance)",
}
logging.debug(f"[VibeVoice TTS] Attention Mode: {mode_names.get(self.attention_mode, self.attention_mode)}")
self.model.load_model(target_device, attention_mode=self.attention_mode)
# Apply dtype casting ONLY if the model's dtype differs from the target.
if self.target_dtype is not None and self.model.model is not None:
current_dtype = representative_dtype(self.model.model)
if current_dtype != self.target_dtype:
logging.debug(
f"[VibeVoice TTS] Casting model to dtype: {self.target_dtype} "
f"(current: {current_dtype})"
)
cast_model_to_dtype(self.model.model, self.target_dtype)
# Delegate to ComfyUI's standard patch_model() with load_weights=True.
# When lowvram_model_memory is 0, full_load is True and the model moves
# completely to the target device (GPU VRAM).
result = super().patch_model(
device_to=target_device,
lowvram_model_memory=lowvram_model_memory,
load_weights=load_weights,
force_patch_weights=force_patch_weights,
*args, **kwargs
)
return result
def unpatch_model(self, device_to=None, unpatch_weights=True, warm: bool = False,
destroy: bool = False, *args, **kwargs):
"""Called by ComfyUI's model manager to offload the model.
Plan 2026-08-18, D5/RC-6 — the offload contract is non-destructive
by default, matching ComfyUI's own ``ModelPatcher.unpatch_model`` which
moves weights to the offload device and never destroys them:
| warm | destroy | behavior |
|-------|---------|----------|
| False | False | Default (ComfyUI-initiated): keep handler.model + caches; move to device_to (CPU). |
| True | False | Warm path: move to intermediate device, keep refs. |
| False | True | Destructive path: handler.model=None, processor=None, evict cache, gc.collect(). |
"""
# A cached GGUF dequant weight is real VRAM core does not know about.
# Offloading must not strand it, or a second load in the same session
# fails for a reason nothing in the accounting explains.
try:
from .gguf_quant import clear_dequant_cache
clear_dequant_cache()
except Exception:
pass
if unpatch_weights:
if warm and self.model is not None and self.model.model is not None:
# Warm offload: keep tensors on the intermediate device for fast re-attach.
try:
offload_target = model_management.intermediate_device()
except Exception:
offload_target = self.offload_device
self.model.model = self.model.model.to(offload_target)
self._warm_offloaded = True
logging.debug(
f"[VibeVoice TTS] Warm offloading VibeVoice models for '{self.model.model_pack_name}' "
f"({self.attention_mode}) to {offload_target} (tensors retained)..."
)
return super().unpatch_model(device_to, unpatch_weights=False, *args, **kwargs)
if destroy:
# Destructive offload (explicit): null references and clear cache.
logging.debug(
f"[VibeVoice TTS] Destroying VibeVoice models for '{self.model.model_pack_name}' "
f"({self.attention_mode}) (weights freed)..."
)
try:
from .model_registry import unregister_from_comfy
unregister_from_comfy(self)
except Exception as e:
logging.warning(f"[VibeVoice TTS] Could not unregister patcher from ComfyUI: {e}")
self.model.model = None
self.model.processor = None
cache = self._model_cache
if self.cache_key in cache:
del cache[self.cache_key]
logging.debug(f"[VibeVoice TTS] Cleared model cache for: {self.cache_key}")
gc.collect()
model_management.soft_empty_cache()
return super().unpatch_model(device_to, unpatch_weights, *args, **kwargs)
# Routine offload: keep the model in CPU RAM.
self._warm_offloaded = False
logging.debug(
f"[VibeVoice TTS] Offloading VibeVoice models for '{self.model.model_pack_name}' "
f"({self.attention_mode}) to {device_to} (weights kept in RAM)..."
)
return super().unpatch_model(device_to, unpatch_weights, *args, **kwargs)
class VibeVoiceASRPatcher(VibeVoicePatcher):
"""ASR-specific ModelPatcher.
Behaves identically to the TTS patcher but clears the dedicated
ASR model cache (``LOADED_ASR_MODELS_CACHE``) on unload instead of the
TTS cache. This brings the ASR path under the same ComfyUI memory
orchestration that the TTS path uses.
"""
@property
def _model_cache(self) -> dict:
"""ASR patchers evict from the ASR cache only (never the TTS cache)."""
return LOADED_ASR_MODELS_CACHE
# ====================================================================
# Dynamic-VRAM (aimdo / VBAR) compatibility layer
# ====================================================================
_DYNAMIC_PATCHER_CLASS_CACHE: dict = {}
def resolve_core_patcher_class() -> type:
"""Return the class ``comfy.model_patcher.CoreModelPatcher`` currently names."""
return getattr(
comfy.model_patcher, "CoreModelPatcher", comfy.model_patcher.ModelPatcher
)
_PROBE_SENTINEL = object()
def dynamic_vram_available(patcher_cls, load_device) -> bool:
"""True when ``patcher_cls`` is a real dynamic patcher AND the target is CUDA."""
try:
device = torch.device(load_device)
except (TypeError, ValueError, RuntimeError) as e:
logging.debug("[VibeVoice TTS] Dynamic VRAM probe: unusable load_device %r (%s)", load_device, e)
return False
if device.type != "cuda":
return False
is_dynamic = getattr(patcher_cls, "is_dynamic", None)
if not callable(is_dynamic):
return False
try:
return bool(is_dynamic(_PROBE_SENTINEL))
except Exception as e:
logging.debug("[VibeVoice TTS] Dynamic VRAM probe: is_dynamic() failed on %s (%s)", patcher_cls, e)
return False
def make_dynamic_patcher_class(legacy_cls: type = VibeVoicePatcher) -> type:
"""Build (and cache) the Dynamic-VRAM sibling of ``legacy_cls``.
Retained for compatibility with test suites and external hooks that
probe for dynamic patcher construction.
"""
base = resolve_core_patcher_class()
dynamic_base = getattr(comfy.model_patcher, "ModelPatcherDynamic", None)
if dynamic_base is None or not (isinstance(base, type) and issubclass(base, dynamic_base)):
return base
key = (base, legacy_cls)
cached = _DYNAMIC_PATCHER_CLASS_CACHE.get(key)
if cached is not None:
return cached
def rebuild_dynamic_patcher(spec, disable_dynamic=False):
model, args, kwargs = spec
target = legacy_cls if disable_dynamic else make_dynamic_patcher_class(legacy_cls)
return target(model, *args, **kwargs)
class VibeVoiceDynamicPatcher(base, legacy_cls):
"""Demand-paged (aimdo/VBAR) sibling of the legacy VibeVoice patcher."""
def __new__(cls, model=None, load_device=None, offload_device=None, size=0,
weight_inplace_update=False, fast_disk=False, **kwargs):
return base.__new__(
cls, model, load_device, offload_device, size,
weight_inplace_update, fast_disk,
)
def __init__(self, model, *args, attention_mode: str = "eager", dtype=None, **kwargs):
base.__init__(self, model, *args, **kwargs)
_init_legacy_attributes(self, model, attention_mode, dtype)
spec = (model, tuple(args), dict(kwargs, attention_mode=attention_mode, dtype=dtype))
self.cached_patcher_init = (rebuild_dynamic_patcher, (spec,))
@property
def is_loaded(self) -> bool:
if not (
hasattr(self, 'model')
and self.model is not None
and getattr(self.model, 'model', None) is not None
):
return False
try:
return bool(self.loaded_size())
except Exception as e:
logging.debug("[VibeVoice TTS] Dynamic patcher is_loaded probe failed: %s", e)
return False
def patch_model(self, device_to=None, lowvram_model_memory=0, load_weights=False,
force_patch_weights=False, *args, **kwargs):
if load_weights:
raise ValueError(
f"{type(self).__name__}.patch_model(load_weights=True) is not supported: "
"ModelPatcherDynamic.patch_model asserts `not load_weights`. "
"Use comfy.model_management.load_models_gpu([patcher]) instead."
)
target_device = self.load_device if device_to is None else device_to
if self.model.model is None:
logging.info(
f"[VibeVoice TTS] Loading VibeVoice models for '{self.model.model_pack_name}' to {target_device}..."
)
self.model.load_model(target_device, attention_mode=self.attention_mode)
if self.target_dtype is not None and self.model.model is not None:
current_dtype = representative_dtype(self.model.model)
if current_dtype != self.target_dtype:
logging.debug(
f"[VibeVoice TTS] Casting model to dtype: {self.target_dtype} (current: {current_dtype})"
)
cast_model_to_dtype(self.model.model, self.target_dtype)
return super().patch_model(
device_to=self.load_device,
lowvram_model_memory=lowvram_model_memory,
load_weights=False,
force_patch_weights=force_patch_weights,
)
def unpatch_model(self, device_to=None, unpatch_weights=True, warm: bool = False,
destroy: bool = False, *args, **kwargs):
if unpatch_weights and warm:
unpin_all = getattr(self, 'unpin_all_weights', None)
if callable(unpin_all):
logging.debug(
f"[VibeVoice TTS] Warm offloading VibeVoice models for '{self.model.model_pack_name}' "
f"({self.attention_mode}) (pins released)..."
)
unpin_all()
if unpatch_weights and destroy:
logging.debug(
f"[VibeVoice TTS] Destroying VibeVoice models for '{self.model.model_pack_name}' "
f"({self.attention_mode}) (weights freed)..."
)
try:
from .model_registry import unregister_from_comfy
unregister_from_comfy(self)
except Exception as e:
logging.warning(f"[VibeVoice TTS] Could not unregister patcher from ComfyUI: {e}")
self.model.model = None
self.model.processor = None
cache = self._model_cache
if self.cache_key in cache:
del cache[self.cache_key]
logging.debug(f"[VibeVoice TTS] Cleared model cache for: {self.cache_key}")
gc.collect()
model_management.soft_empty_cache()
return super().unpatch_model(device_to, unpatch_weights, *args, **kwargs)
class_name = "VibeVoiceDynamic" + legacy_cls.__name__.removeprefix("VibeVoice")
VibeVoiceDynamicPatcher.__name__ = class_name
VibeVoiceDynamicPatcher.__qualname__ = class_name
_DYNAMIC_PATCHER_CLASS_CACHE[key] = VibeVoiceDynamicPatcher
return VibeVoiceDynamicPatcher
def select_patcher_class(weight_family, load_device, legacy_cls: type = VibeVoicePatcher) -> type:
"""Pick the patcher class for model lifecycle management.
Returns the standard ModelPatcher (legacy_cls) so the model can be loaded
completely into VRAM without being trapped in CPU virtual memory (VBAR)
or forced into per-step streaming.
"""
return legacy_cls
def _ensure_device_index(patcher) -> None:
"""Give an accelerator load device the explicit index core needs."""
device = getattr(patcher, "load_device", None)
if device is None or getattr(device, "index", 0) is not None:
return
if getattr(device, "type", None) != "cuda" or not torch.cuda.is_available():
return
patcher.load_device = torch.device("cuda", torch.cuda.current_device())
def load_to_device(patcher, memory_required: int = 0):
"""Put ``patcher`` on its load device under standard ComfyUI memory orchestration.
When the model fits into free VRAM, it triggers a direct full load into GPU VRAM
(force_full_load=True), matching the swift, single-pass loading of native
ComfyUI models (Krea, SDXL).
"""
_ensure_device_index(patcher)
is_dynamic = getattr(patcher, 'is_dynamic', None)
dynamic = is_dynamic() is True if callable(is_dynamic) else False
if dynamic:
model_management.load_models_gpu([patcher], memory_required=memory_required)
else:
device = patcher.load_device
if device is not None and device.type == "cuda":
free_vram = model_management.get_free_memory(device)
model_size = patcher.model_size()
# If the model fits in VRAM with headroom for inference activations:
if model_size < (free_vram - int(1.5 * 1024 ** 3)):
# Ensure weights are loaded and placed completely on GPU VRAM
patcher.patch_model(device_to=device, lowvram_model_memory=0, load_weights=True)
model_management.load_models_gpu([patcher], force_full_load=True)
return patcher
model_management.load_model_gpu(patcher)
return patcher