Files
WildAi 4d8686f9ad test: clean the suite to match the current architecture, and de-localise it
Suite goes from 1894 passed / 66 failed to 1876 passed / 0 failed.

The 66 failures were not 66 problems. Two mechanisms caused all of them:

  * comfy_stream._STREAMING_CONVERSION_ENABLED is False and nothing in
    production sets it, so convert_tree_for_streaming is a no-op. Every
    test asserting "this module was converted" was asserting on a sweep
    that never ran.
  * select_patcher_class ignores both its arguments and returns
    legacy_cls. Every test parametrized over a "dynamic" arm was
    exercising a configuration production cannot produce.

Deleted (assert wiring that no longer exists): test_dynamic_patcher_
selection.py, test_dynamic_vram_mechanism.py, test_ram_residual_
attribution.py, test_loader_streaming_integration.py (three of its five
tests were passing VACUOUSLY once conversion went), and
TestCensusOnRealDynamicRoute.

Corrected to the real contract, keeping the coverage: the conversion
tests now enable the gate explicitly and test the capability, which is
what they were written for; the TestP1 load tests now assert that the
LOADER places each tensor (it took a target_device) instead of asserting
the superseded CPU staging; the both-patcher-classes tests were
de-parametrized onto the selector's real answer.

Two production error messages had lost the hints their tests pinned, and
the tests were right: UnmappedKeyError no longer told a user with a
non-standard converter to place a sidecar config, and the GGUF block-size
error no longer said the layout could not be recovered.

Also fixes a latent bug: test_comfy_stream.py imported `modules.comfy_stream`
while everything else imported `ComfyUI_VibeVoice.modules.comfy_stream`.
Under pytest those are two distinct module objects with independent
globals, so a gate set through one was invisible to the other. All bare
`modules.*` imports in collected tests now use the package name.

Portability, verified by simulation rather than inspection:

  * conftest no longer falls back to a hardcoded C:\_Dev ComfyUI path. It
    used to put a nonexistent directory on sys.path off-box and surface
    as an opaque import error across 14 test files; now it names
    COMFYUI_ROOT and stops.
  * The 5.4 GB and 3.2 GB checkpoint fixtures are opt-in via env vars with
    no machine-specific default (VIBEVOICE_TEST_DENSE_CHECKPOINT,
    VIBEVOICE_TEST_GGUF).
  * comfy_kitchen guards added. It is genuinely optional; a driver
    script that blocks the import went from 9 failed to 37 passed /
    11 skipped / 0 errors.
  * comfy_aimdo import guarded, though it is in practice a hard
    dependency of the ComfyUI fork (core imports it unconditionally).
  * test_audio_backend writes to tmp_path instead of into the repo tree.
  * test_asr_loader no longer registers the real folder_paths.models_dir
    in an autouse fixture without restoring it.

The large-checkpoint and .dev-symlink tests skip cleanly off-box; that is
the intended shape for files that cannot ship with the repo.
2026-10-01 15:49:15 +03:00

1021 lines
44 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Device-flow tests for the VibeVoice load path.
Plan: docs/plans/2026-08-15-vibevoice-load-device-flow-fix.md
Phases covered here:
- P0.1: device-ledger test double (records .to()/load_state_dict traffic
without moving real tensors — no CUDA hardware required).
- P0.2: reproduction of the CURRENT defective flow (DF-001/DF-002):
state dict loaded straight to CUDA, then GPU->CPU copy via
load_state_dict, then CPU->GPU move. These tests document the defect
and pass against the pre-fix code; they are replaced by the inverted
contract tests in Phase 1.
Determinism: no network, no real GPU. Device traffic is asserted via the
ledger double; ``torch.device("cuda")`` objects are used freely (they can
be constructed without CUDA hardware).
"""
import contextlib
import torch
import pytest
from unittest.mock import patch, MagicMock
from ComfyUI_VibeVoice.modules.loader import (
VibeVoiceLoader,
VibeVoiceModelHandler,
LOADED_MODELS_CACHE,
)
CUDA = torch.device("cuda")
CPU = torch.device("cpu")
# ====================================================================
# Device-ledger test double
# ====================================================================
def _parse_to_call(args, kwargs):
"""Parse ``nn.Module.to(*args, **kwargs)`` into ``(device, dtype)``.
Supports the call forms used by the production code:
``to(device)``, ``to(dtype)``, ``to(device, dtype)``,
``to(device=..., dtype=...)``. No CUDA hardware needed.
"""
device = kwargs.get("device", None)
dtype = kwargs.get("dtype", None)
for a in args:
if isinstance(a, torch.device):
device = a
elif isinstance(a, torch.dtype):
dtype = a
elif isinstance(a, str):
device = torch.device(a)
elif isinstance(a, (tuple, list)) and len(a) == 2:
device, dtype = a
if device is not None and not isinstance(device, torch.device):
device = torch.device(device)
return device, dtype
class _LedgerModel(torch.nn.Module):
"""Fake model that records device/dtype traffic instead of moving tensors.
Every ``.to()`` / ``load_state_dict()`` / ``eval()`` call is appended to
the shared ``ledger`` list as a tuple:
- ``("to", device_or_None, dtype_or_None)``
- ``("load_state_dict", strict, assign)``
- ``("eval", None, None)``
"""
def __init__(self, ledger):
super().__init__()
self._ledger = ledger
self._device = CPU
self._dtype = torch.float32
# Real parameter so the streaming assign (_stream_apply_dense) has a
# target for the ("w", ...) tensors the tests stream in.
self.w = torch.nn.Parameter(torch.zeros(1))
def to(self, *args, **kwargs):
device, dtype = _parse_to_call(args, kwargs)
self._ledger.append(("to", device, dtype))
if device is not None:
self._device = device
if dtype is not None:
self._dtype = dtype
return self
def load_state_dict(self, state_dict, strict=True, assign=False, **kwargs):
self._ledger.append(("load_state_dict", strict, assign))
return ([], [])
def eval(self):
self._ledger.append(("eval", None, None))
return self
@property
def device(self):
return self._device
@property
def dtype(self):
return self._dtype
@pytest.fixture
def ledger():
"""Fresh device ledger for one test."""
return []
@pytest.fixture
def ledger_model(ledger):
"""A fresh ledger-backed fake model (starts on CPU, fp32)."""
return _LedgerModel(ledger)
@pytest.fixture(autouse=True)
def clear_model_cache():
"""Isolate LOADED_MODELS_CACHE between tests."""
LOADED_MODELS_CACHE.clear()
yield
LOADED_MODELS_CACHE.clear()
# ====================================================================
# Loader seam patching helper
# ====================================================================
@contextlib.contextmanager
def _patched_loader(ledger_model, registry=None):
"""Patch every loader seam EXCEPT ``_load_state_dict_into_model``.
The state-dict loading path is the unit under test, so it stays real;
its own seams (``_resolve_checkpoint_path``, the streaming tensor
iterators ``iter_checkpoint_tensors`` / ``iter_sharded_tensors``) are
patched by the individual tests.
"""
class _FakeStreamingCfg: # isinstance() target must be a real class
pass
registry = registry or {
"TestModel": {"type": "official", "repo_id": "test/repo", "path": "x"}
}
with patch("ComfyUI_VibeVoice.modules.loader.AVAILABLE_VIBEVOICE_MODELS", registry), \
patch("ComfyUI_VibeVoice.modules.loader.VibeVoiceStreamingConfig", _FakeStreamingCfg), \
patch("ComfyUI_VibeVoice.modules.loader.VibeVoiceLoader._resolve_model_paths",
return_value=("mp", "cp", "pp", "td")), \
patch("ComfyUI_VibeVoice.modules.loader.VibeVoiceLoader._load_config",
return_value=MagicMock(spec=[])), \
patch("ComfyUI_VibeVoice.modules.loader.VibeVoiceLoader._load_tokenizer",
return_value=MagicMock()), \
patch("ComfyUI_VibeVoice.modules.loader.VibeVoiceLoader._load_processor",
return_value=MagicMock()), \
patch("ComfyUI_VibeVoice.modules.loader.VibeVoiceLoader._instantiate_model",
return_value=ledger_model):
yield
# ====================================================================
# P0.1 — Ledger harness self-tests
# ====================================================================
class TestP01LedgerHarness:
"""The device-ledger double must faithfully record traffic."""
def test_ledger_starts_empty(self, ledger, ledger_model):
assert ledger == []
assert ledger_model.device == CPU
def test_records_positional_device(self, ledger, ledger_model):
ledger_model.to(CUDA)
assert ledger == [("to", CUDA, None)]
assert ledger_model.device == CUDA
def test_records_string_device(self, ledger, ledger_model):
ledger_model.to("cuda")
assert ledger[0][0] == "to"
assert ledger[0][1] == CUDA
def test_records_dtype_only(self, ledger, ledger_model):
ledger_model.to(dtype=torch.bfloat16)
assert ledger == [("to", None, torch.bfloat16)]
assert ledger_model.device == CPU # device unchanged
assert ledger_model.dtype == torch.bfloat16
def test_records_device_and_dtype_kwargs(self, ledger, ledger_model):
ledger_model.to(device=CUDA, dtype=torch.float16)
assert ledger == [("to", CUDA, torch.float16)]
assert ledger_model.device == CUDA
assert ledger_model.dtype == torch.float16
def test_records_device_dtype_pair(self, ledger, ledger_model):
ledger_model.to((CUDA, torch.bfloat16))
assert ledger == [("to", CUDA, torch.bfloat16)]
def test_records_load_state_dict(self, ledger, ledger_model):
result = ledger_model.load_state_dict({"w": torch.zeros(1)}, strict=False)
assert result == ([], [])
assert ledger == [("load_state_dict", False, False)]
def test_records_eval(self, ledger, ledger_model):
assert ledger_model.eval() is ledger_model
assert ledger == [("eval", None, None)]
def test_to_returns_self_for_chaining(self, ledger, ledger_model):
assert ledger_model.to(CUDA).to(dtype=torch.float16) is ledger_model
assert len(ledger) == 2
# ====================================================================
# P1.1/P1.2 — State dict is ALWAYS loaded onto CPU (DF-001/DF-002 fix)
# ====================================================================
class TestP1StateDictLoadedToCpu:
"""Contract: the streaming iterator is device-agnostic; the LOADER places.
The iterator still has no device parameter -- that is unchanged. What
changed is what happens next: ``_stream_apply_dense`` takes a
``target_device`` and places every tensor itself (through core's
file->VRAM DMA when the storage is an aimdo view), instead of leaving a
CPU-resident model for the patcher to move in one bulk H2D later. So the
weight lands on the REQUESTED device, and the thing worth pinning is that
the loader -- not the iterator, and not the patcher -- decided.
"""
@pytest.mark.parametrize("model_type", ["official", "local_dir", "standalone"])
def test_state_dict_loaded_to_cpu_single_file(self, ledger, ledger_model, model_type):
registry = {"TestModel": {"type": model_type, "repo_id": "test/repo", "path": "x"}}
with _patched_loader(ledger_model, registry=registry), \
patch("ComfyUI_VibeVoice.modules.loader.VibeVoiceLoader._resolve_checkpoint_path",
return_value=("fake.safetensors", False)), \
patch("ComfyUI_VibeVoice.modules.base_loader.BaseVibeVoiceLoader.iter_checkpoint_tensors",
return_value=iter([("w", torch.zeros(1))])) as mock_iter:
VibeVoiceLoader.load_model(
"TestModel", CUDA, attention_mode="sdpa", dtype_str="bf16"
)
mock_iter.assert_called_once_with("fake.safetensors")
# The iterator yields CPU tensors; the loader places them on CUDA.
assert ledger_model.w.device.type == CUDA.type
def test_state_dict_loaded_to_cpu_sharded(self, ledger, ledger_model):
"""Sharded path: the streaming shard iterator feeds the assign loop."""
captured = {}
def fake_iter(model_dir):
captured["model_dir"] = model_dir
yield "w", torch.zeros(1)
with _patched_loader(ledger_model), \
patch("ComfyUI_VibeVoice.modules.loader.VibeVoiceLoader._resolve_checkpoint_path",
return_value=("model.safetensors.index.json", True)), \
patch("ComfyUI_VibeVoice.modules.base_loader.BaseVibeVoiceLoader.iter_sharded_tensors",
side_effect=fake_iter) as mock_iter:
VibeVoiceLoader.load_model(
"TestModel", CUDA, attention_mode="sdpa", dtype_str="bf16"
)
mock_iter.assert_called_once()
assert captured["model_dir"] == "mp"
# Shards stream in on CPU and are placed onto the target by the loader.
assert ledger_model.w.device.type == CUDA.type
def test_load_state_dict_into_model_places_on_the_requested_device(
self, ledger, ledger_model
):
"""``device`` is the placement target, not a reserved argument.
A CPU target must still be honoured -- placement follows the request
rather than hardcoding the accelerator.
"""
with patch("ComfyUI_VibeVoice.modules.loader.VibeVoiceLoader._resolve_checkpoint_path",
return_value=("fake.safetensors", False)), \
patch("ComfyUI_VibeVoice.modules.base_loader.BaseVibeVoiceLoader.iter_checkpoint_tensors",
return_value=iter([("w", torch.zeros(1))])):
VibeVoiceLoader._load_state_dict_into_model(
model=ledger_model,
model_path="mp",
model_type="official",
model_info={"type": "official"},
device=CUDA,
)
assert ledger_model.w.device.type == CUDA.type
with patch("ComfyUI_VibeVoice.modules.loader.VibeVoiceLoader._resolve_checkpoint_path",
return_value=("fake.safetensors", False)), \
patch("ComfyUI_VibeVoice.modules.base_loader.BaseVibeVoiceLoader.iter_checkpoint_tensors",
return_value=iter([("w", torch.zeros(1))])):
VibeVoiceLoader._load_state_dict_into_model(
model=ledger_model,
model_path="mp",
model_type="official",
model_info={"type": "official"},
device=torch.device("cpu"),
)
assert ledger_model.w.device.type == "cpu"
# ====================================================================
# P1.3 — Loader returns a CPU model in the final dtype (DF-002/DF-003 fix)
# ====================================================================
class TestP1LoaderReturnsCpuModel:
"""Contract: the loader performs ZERO GPU operations."""
def test_loader_returns_cpu_model(self, ledger, ledger_model):
with _patched_loader(ledger_model), \
patch("ComfyUI_VibeVoice.modules.loader.VibeVoiceLoader._resolve_checkpoint_path",
return_value=("fake.safetensors", False)), \
patch("ComfyUI_VibeVoice.modules.base_loader.BaseVibeVoiceLoader.iter_checkpoint_tensors",
return_value=iter([("w", torch.zeros(1))])):
model, _ = VibeVoiceLoader.load_model(
"TestModel", CUDA, attention_mode="sdpa", dtype_str="bf16"
)
assert model is ledger_model
# No CUDA traffic anywhere in the loader.
cuda_moves = [
e for e in ledger
if e[0] == "to" and e[1] is not None and e[1].type == "cuda"
]
assert cuda_moves == [], f"loader must not touch CUDA, ledger={ledger}"
assert ledger_model.device == CPU
def test_loader_applies_dtype_on_cpu(self, ledger, ledger_model):
"""Plan 2026-08-18 D4/RC-3: final dtype applied via the conditional
cast helper (not an unconditional ``.to()``), and never on CUDA."""
cast_calls = []
def fake_cast(model, dtype):
cast_calls.append(dtype)
model._dtype = dtype # simulate the in-place cast on the double
with _patched_loader(ledger_model), \
patch("ComfyUI_VibeVoice.modules.loader.VibeVoiceLoader._resolve_checkpoint_path",
return_value=("fake.safetensors", False)), \
patch("ComfyUI_VibeVoice.modules.base_loader.BaseVibeVoiceLoader.iter_checkpoint_tensors",
return_value=iter([("w", torch.zeros(1))])), \
patch("ComfyUI_VibeVoice.modules.loader.cast_model_to_dtype_if_needed",
side_effect=fake_cast):
VibeVoiceLoader.load_model(
"TestModel", CUDA, attention_mode="sdpa", dtype_str="fp16"
)
assert cast_calls == [torch.float16], (
"loader must apply the final dtype via the conditional cast helper")
# The dtype cast must not be combined with a CUDA device move.
cuda_moves = [
e for e in ledger
if e[0] == "to" and e[1] is not None and e[1].type == "cuda"
]
assert cuda_moves == [], f"dtype cast must happen on CPU, got {ledger}"
assert ledger_model.dtype == torch.float16
def test_4bit_quantization_runs_on_cpu(self, ledger, ledger_model):
"""bnb replacement must run while the model is still CPU-resident."""
try:
import transformers.integrations.bitsandbytes # noqa: F401
_bnb_target = "transformers.integrations.bitsandbytes.replace_with_bnb_linear"
except ImportError:
_bnb_target = "transformers.utils.bitsandbytes.replace_with_bnb_linear"
def fake_replace(model, quantization_config=None, modules_to_not_convert=None):
# At quantization time the model must still be on CPU.
assert model.device == CPU, "4-bit quantization must run on CPU"
assert not any(
e[0] == "to" and e[1] is not None and e[1].type == "cuda"
for e in ledger
), "no CUDA traffic before quantization"
with _patched_loader(ledger_model), \
patch("ComfyUI_VibeVoice.modules.loader.VibeVoiceLoader._resolve_checkpoint_path",
return_value=("fake.safetensors", False)), \
patch("ComfyUI_VibeVoice.modules.base_loader.BaseVibeVoiceLoader.iter_checkpoint_tensors",
return_value=iter([("w", torch.zeros(1))])), \
patch("ComfyUI_VibeVoice.modules.loader.BitsAndBytesConfig"), \
patch(_bnb_target, side_effect=fake_replace) as mock_replace:
VibeVoiceLoader.load_model(
"TestModel", CUDA, attention_mode="sdpa",
use_llm_4bit=True, dtype_str="bf16",
)
mock_replace.assert_called_once()
# ====================================================================
# P1.4 — Handler performs NO device move (DF-003, part 1)
# ====================================================================
class TestP1HandlerNoDeviceMove:
"""Contract: VibeVoiceModelHandler.load_model never moves the model."""
def test_handler_load_model_does_not_move_device(self, ledger, ledger_model):
handler = VibeVoiceModelHandler("VibeVoice-1.5B", attention_mode="sdpa")
with patch("ComfyUI_VibeVoice.modules.loader.VibeVoiceLoader.load_model",
return_value=(ledger_model, MagicMock())):
handler.load_model(CUDA, attention_mode="sdpa")
assert handler.model is ledger_model
assert ledger == [], f"handler must not move the model, ledger={ledger}"
assert ledger_model.device == CPU
# ====================================================================
# P1.5 — Patcher owns the SINGLE host-to-device transfer (DF-003, part 2)
# ====================================================================
class _LedgerHandler(torch.nn.Module):
"""Handler double mirroring the FIXED VibeVoiceModelHandler.
``load_model`` simulates the CPU-only loader: it (optionally) records a
``load_state_dict`` on the ledger model and assigns it — with NO device
move, exactly like the production handler after the DF-003 fix.
"""
def __init__(self, ledger, model=None, simulate_loader=True):
super().__init__()
self._ledger = ledger
self._pending_model = model
self._simulate_loader = simulate_loader
self.model = None
self.processor = object()
self.model_pack_name = "ledger"
self.cache_key = "ledger_test"
self.size = 1024
self.load_model_calls = []
def load_model(self, device, attention_mode="sdpa"):
self.load_model_calls.append(device)
if self._pending_model is not None:
if self._simulate_loader:
self._pending_model.load_state_dict({"w": torch.zeros(1)}, strict=False)
self.model = self._pending_model
def _make_ledger_patcher(handler, target_dtype=None):
"""Build a VibeVoicePatcher with ModelPatcher.__init__/patch_model mocked."""
from ComfyUI_VibeVoice.modules.patcher import VibeVoicePatcher
with patch("comfy.model_patcher.ModelPatcher.__init__"):
patcher = VibeVoicePatcher(
handler,
attention_mode="sdpa",
dtype=target_dtype,
load_device=CUDA,
offload_device=CPU,
size=1024,
)
patcher.load_device = CUDA
patcher.offload_device = CPU
patcher.model = handler
patcher.size = getattr(handler, "size", 1024)
# Attributes ModelPatcher.__init__ would normally set (avoids __del__ noise).
patcher.pinned = {}
patcher.is_injected = False
patcher.model_options = {"transformer_options": {}}
patcher.callbacks = {}
return patcher
class TestP1PatcherSingleH2D:
"""Contract (plan 2026-08-18, D6/RC-5): the patcher performs NO device
move of its own. The single host-to-device transfer is owned by
``super().patch_model()`` → ``ModelPatcher.load()``. Because super is
mocked in these tests, the ledger must record ZERO cuda moves from the
patcher's own code; the transfer is asserted via the super() call args.
"""
def test_patcher_single_h2d_transfer_cold_load(self, ledger, ledger_model):
handler = _LedgerHandler(ledger, model=ledger_model)
patcher = _make_ledger_patcher(handler)
with patch("comfy.model_patcher.ModelPatcher.patch_model") as mock_super:
patcher.patch_model(device_to=CUDA)
mock_super.assert_called_once()
# Loader ran once (cold load), model assigned on CPU.
assert len(handler.load_model_calls) == 1
kinds = [e[0] for e in ledger]
assert "load_state_dict" in kinds
# D6: the patcher itself performs NO cuda move — the H2D is owned by
# super().patch_model() → load() (mocked here).
cuda_moves = [
e for e in ledger
if e[0] == "to" and e[1] is not None and e[1].type == "cuda"
]
assert len(cuda_moves) == 0, f"patcher must not bulk-move, ledger={ledger}"
# The transfer target is delegated to super with the right device.
assert mock_super.call_args.kwargs["device_to"] == CUDA
def test_patcher_warm_reattach_single_h2d(self, ledger, ledger_model):
handler = _LedgerHandler(ledger, model=None, simulate_loader=False)
handler.model = ledger_model # already loaded (warm state)
patcher = _make_ledger_patcher(handler)
patcher._warm_offloaded = True # warm-offloaded state
with patch("comfy.model_patcher.ModelPatcher.patch_model") as mock_super:
patcher.patch_model(device_to=CUDA)
# No re-load from disk.
assert handler.load_model_calls == []
# D6: no cuda move from the patcher's own code (delegated to super).
cuda_moves = [
e for e in ledger
if e[0] == "to" and e[1] is not None and e[1].type == "cuda"
]
assert len(cuda_moves) == 0, f"ledger={ledger}"
assert patcher._warm_offloaded is False
assert mock_super.call_args.kwargs["device_to"] == CUDA
def test_no_gpu_cpu_gpu_round_trip(self, ledger, ledger_model):
"""Full cycle: cold load -> warm offload -> re-attach.
D6: within the load cycle the patcher records NO device move at all
(the H2D is delegated to super). The warm-offload CPU move belongs to
the offload cycle, not the load, so no GPU->CPU->GPU round-trip can
appear in the load cycle.
"""
handler = _LedgerHandler(ledger, model=ledger_model)
patcher = _make_ledger_patcher(handler)
with patch("comfy.model_patcher.ModelPatcher.patch_model"), \
patch("comfy.model_patcher.ModelPatcher.unpatch_model"):
# Cycle 1: cold load
patcher.patch_model(device_to=CUDA)
load_cycle_1 = list(ledger)
# Warm offload (tensors retained on intermediate/CPU device)
patcher.unpatch_model(device_to=CPU, unpatch_weights=True, warm=True)
devices = [
e[1].type for e in load_cycle_1
if e[0] == "to" and e[1] is not None
]
# D6: the load cycle contains NO patcher-owned device move.
assert "cuda" not in devices, (
f"patcher must not bulk-move during load: {load_cycle_1}"
)
# The warm offload itself moved the model off-GPU exactly once.
offload_moves = [
e for e in ledger[len(load_cycle_1):]
if e[0] == "to" and e[1] is not None and e[1].type == "cpu"
]
assert len(offload_moves) == 1
def test_patcher_uses_load_device_when_device_to_none(self, ledger, ledger_model):
handler = _LedgerHandler(ledger, model=None, simulate_loader=False)
handler.model = ledger_model
patcher = _make_ledger_patcher(handler)
with patch("comfy.model_patcher.ModelPatcher.patch_model") as mock_super:
patcher.patch_model(device_to=None)
# D6: no cuda move from the patcher's own code.
cuda_moves = [
e for e in ledger
if e[0] == "to" and e[1] is not None and e[1].type == "cuda"
]
assert len(cuda_moves) == 0
# device_to=None falls back to load_device and is delegated to super.
assert mock_super.call_args.kwargs["device_to"] == CUDA
# ====================================================================
# P2.1 — User dtype is threaded into the handler (DF-004 / AUD-008)
# ====================================================================
class TestP2DtypeThreading:
"""Contract: node dtype -> handler.dtype_str -> loader dtype_str."""
def test_handler_init_stores_dtype_str(self):
handler = VibeVoiceModelHandler("VibeVoice-1.5B", dtype_str="fp16")
assert handler.dtype_str == "fp16"
def test_handler_init_defaults_dtype_str_auto(self):
handler = VibeVoiceModelHandler("VibeVoice-1.5B")
assert handler.dtype_str == "auto"
def test_generation_passes_dtype_str_to_handler(self):
"""load_vibevoice_model must forward the node's dtype to the handler."""
from ComfyUI_VibeVoice.modules import generation as gen
captured = {}
class _HandlerSpy:
def __init__(self, model_name, attention_mode="eager",
use_llm_4bit=False, dtype_str="auto"):
captured["dtype_str"] = dtype_str
captured["model_name"] = model_name
self.model = MagicMock()
self.processor = MagicMock()
self.size = 1024
self.cache_key = "spy"
with patch.object(gen, "VIBEVOICE_PATCHER_CACHE", {}), \
patch.object(gen, "VibeVoiceModelHandler", _HandlerSpy), \
patch.object(gen, "VibeVoicePatcher") as mock_patcher_cls, \
patch.object(gen.model_management, "load_model_gpu"):
mock_patcher = MagicMock()
mock_patcher.model.model = MagicMock()
mock_patcher.model.processor = MagicMock()
mock_patcher_cls.return_value = mock_patcher
gen.load_vibevoice_model(
"VibeVoice-1.5B", device="cuda", dtype="fp16",
attention_mode="sdpa",
)
assert captured["dtype_str"] == "fp16"
# The patcher still receives the resolved torch dtype (unchanged).
assert mock_patcher_cls.call_args.kwargs["dtype"] == torch.float16
def test_handler_forwards_dtype_str_to_loader(self):
"""P2.2: handler.load_model passes dtype_str into VibeVoiceLoader."""
handler = VibeVoiceModelHandler("VibeVoice-1.5B", dtype_str="bf16")
with patch("ComfyUI_VibeVoice.modules.loader.VibeVoiceLoader.load_model",
return_value=(MagicMock(), MagicMock())) as mock_load:
handler.load_model(CUDA, attention_mode="sdpa")
mock_load.assert_called_once()
assert mock_load.call_args.kwargs.get("dtype_str") == "bf16"
# ====================================================================
# P2.3 — Patcher dtype cast is a mismatch-only guard (DF-004)
# ====================================================================
class TestP2PatcherCastGuard:
"""Contract: cast only when the model dtype differs from target."""
def test_patcher_skips_cast_when_dtype_matches(self, ledger, ledger_model):
ledger_model._dtype = torch.float16
# The cast guard walks REAL parameter dtypes (representative_dtype),
# so the ledger's parameter must match the target dtype too.
ledger_model.w = torch.nn.Parameter(torch.zeros(1, dtype=torch.float16))
handler = _LedgerHandler(ledger, model=None, simulate_loader=False)
handler.model = ledger_model
patcher = _make_ledger_patcher(handler, target_dtype=torch.float16)
with patch("comfy.model_patcher.ModelPatcher.patch_model"), \
patch("ComfyUI_VibeVoice.modules.patcher.cast_model_to_dtype") as mock_cast:
patcher.patch_model(device_to=CUDA)
mock_cast.assert_not_called()
def test_patcher_casts_when_dtype_differs(self, ledger, ledger_model):
ledger_model._dtype = torch.float32
handler = _LedgerHandler(ledger, model=None, simulate_loader=False)
handler.model = ledger_model
patcher = _make_ledger_patcher(handler, target_dtype=torch.float16)
with patch("comfy.model_patcher.ModelPatcher.patch_model"), \
patch("ComfyUI_VibeVoice.modules.patcher.cast_model_to_dtype") as mock_cast:
patcher.patch_model(device_to=CUDA)
mock_cast.assert_called_once()
assert mock_cast.call_args.args[1] == torch.float16
def test_patcher_no_cast_when_target_dtype_none(self, ledger, ledger_model):
handler = _LedgerHandler(ledger, model=None, simulate_loader=False)
handler.model = ledger_model
patcher = _make_ledger_patcher(handler, target_dtype=None)
with patch("comfy.model_patcher.ModelPatcher.patch_model"), \
patch("ComfyUI_VibeVoice.modules.patcher.cast_model_to_dtype") as mock_cast:
patcher.patch_model(device_to=CUDA)
mock_cast.assert_not_called()
# ====================================================================
# P3 — assign=True decision lock (DF-005: DEFERRED)
# ====================================================================
class _TiedTinyModel(torch.nn.Module):
"""Minimal model with a tied lm_head (mirrors VibeVoice-1.5B).
VibeVoice-1.5B config has ``tie_word_embeddings: true`` and
``_tied_weights_keys = ["lm_head.weight"]``; the checkpoint does NOT
contain ``lm_head.weight``.
"""
def __init__(self, vocab=4, dim=4):
super().__init__()
self.embed = torch.nn.Embedding(vocab, dim)
self.lm_head = torch.nn.Linear(dim, vocab, bias=False)
self.lm_head.weight = self.embed.weight # tie
@property
def dtype(self):
return self.embed.weight.dtype
class TestP3AssignWithRetie:
"""DF-005 un-deferred (plan 2026-08-18, D2): assign=True + re-tie.
With ``tie_word_embeddings=true`` the checkpoint omits ``lm_head.weight``.
``assign=True`` replaces the embedding's parameter object and breaks the
tie — so the loader re-invokes ``tie_weights()`` after loading (the same
mitigation transformers' ``from_pretrained`` applies). This eliminates the
copy pass and halves peak host RAM (RC-2).
"""
def test_copy_semantics_preserves_tying(self):
"""Documentation: copy semantics keeps tying (old behavior)."""
model = _TiedTinyModel()
sd = {"embed.weight": torch.ones(4, 4)}
model.load_state_dict(sd, strict=False) # copy semantics
assert model.lm_head.weight.data_ptr() == model.embed.weight.data_ptr()
assert torch.equal(model.lm_head.weight, torch.ones(4, 4))
def test_assign_breaks_tying_without_retie(self):
"""Documentation: the risk that assign=True alone would cause."""
model = _TiedTinyModel()
sd = {"embed.weight": torch.ones(4, 4)}
model.load_state_dict(sd, strict=False, assign=True)
# assign=True replaced embed.weight with the sd tensor object;
# lm_head.weight still references the OLD random parameter.
assert model.lm_head.weight.data_ptr() != model.embed.weight.data_ptr()
assert not torch.equal(model.lm_head.weight, torch.ones(4, 4))
def test_loader_uses_assign_semantics(self, ledger):
"""The production loader MUST assign per-tensor (D2, streaming form).
Plan 2026-08-28: the batch ``load_state_dict(assign=True)`` call was
replaced by ``_stream_apply_dense`` (per-tensor ``set_attr_param`` of
a private clone). The contract is unchanged: parameter objects are
REPLACED with the checkpoint values, never copied into.
"""
ledger_model = _LedgerModel(ledger)
with _patched_loader(ledger_model), \
patch("ComfyUI_VibeVoice.modules.loader.VibeVoiceLoader._resolve_checkpoint_path",
return_value=("fake.safetensors", False)), \
patch("ComfyUI_VibeVoice.modules.base_loader.BaseVibeVoiceLoader.iter_checkpoint_tensors",
return_value=iter([("w", torch.full((1,), 3.0))])):
VibeVoiceLoader.load_model(
"TestModel", CUDA, attention_mode="sdpa", dtype_str="bf16"
)
assert not any(e[0] == "load_state_dict" for e in ledger), (
"streaming assign must not route through load_state_dict"
)
# The parameter object now holds the checkpoint value (assign).
assert float(ledger_model.w.item()) == 3.0
def test_loader_reties_after_assign(self, ledger):
"""The loader must re-invoke tie_weights() after assign load (D2)."""
ledger_model = _LedgerModel(ledger)
ledger_model.tie_weights = MagicMock()
# Config gate: tied
ledger_model.config = MagicMock()
ledger_model.config.decoder_config.tie_word_embeddings = True
ledger_model.config.tie_word_embeddings = False
with _patched_loader(ledger_model), \
patch("ComfyUI_VibeVoice.modules.loader.VibeVoiceLoader._resolve_checkpoint_path",
return_value=("fake.safetensors", False)), \
patch("ComfyUI_VibeVoice.modules.base_loader.BaseVibeVoiceLoader.iter_checkpoint_tensors",
# bf16 = target dtype, so the conditional cast takes its
# fast path and the only tie comes from the assign fixups.
return_value=iter([("w", torch.zeros(1, dtype=torch.bfloat16))])):
VibeVoiceLoader.load_model(
"TestModel", CUDA, attention_mode="sdpa", dtype_str="bf16"
)
ledger_model.tie_weights.assert_called_once()
# tie_weights runs in the post-assign fixups, which execute only
# after the assign loop has delivered the weights.
assert hasattr(ledger_model, "w")
# ====================================================================
# P4 — VRAM arbitration correctness (DF-006)
# ====================================================================
class TestP4VramArbitration:
"""Contract: ComfyUI can arbitrate VRAM BEFORE any GPU traffic.
``load_models_gpu`` computes ``total_memory_required`` from
``patcher.model_size()`` (the handler's static ``size`` estimate) and
calls ``free_memory()`` BEFORE ``model_load`` → ``patch_model``. With
the loader now CPU-only, no untracked VRAM spike exists inside the
lazy load.
"""
def test_model_size_reported_before_load(self, ledger):
"""model_size() > 0 prior to any patch_model call (static estimate)."""
handler = _LedgerHandler(ledger, model=None, simulate_loader=False)
handler.size = int(3.0 * (1024**3))
patcher = _make_ledger_patcher(handler)
# No load has happened yet — the estimate must still be available
# so load_models_gpu can arbitrate VRAM up front.
assert handler.model is None
assert patcher.model_size() == handler.size
assert patcher.model_size() > 0
def test_loaded_size_tracking_after_load(self, ledger):
"""After a full load, ModelPatcher.load tracks loaded weight memory.
Reuses the AUD-002 weight-visibility pattern: once the handler's
inner model is assigned, its parameters are visible to
``_load_list()`` and ``ModelPatcher.load`` can set
``model_loaded_weight_memory``.
"""
import comfy.model_patcher
class _RealInner(torch.nn.Module):
def __init__(self):
super().__init__()
self.linear = torch.nn.Linear(16, 16)
class _RealHandler(torch.nn.Module):
def __init__(self):
super().__init__()
self.model = None
self.processor = object()
self.model_pack_name = "p4"
self.cache_key = "p4"
self.size = 1024
def load_model(self, device, attention_mode="sdpa"):
# FIXED contract: build on CPU, NO device move.
if self.model is None:
self.model = _RealInner()
handler = _RealHandler()
# REAL ModelPatcher.__init__ (CPU devices) so patcher.load() works.
patcher = VibeVoicePatcherForP4(handler)
# Simulate the ComfyUI sequence: lazy load (CPU-only) then load().
handler.load_model(CPU)
assert patcher.loaded_size() == 0
# ModelPatcher.load moves weights and sets model_loaded_weight_memory.
patcher.load(device_to=CPU, lowvram_model_memory=0,
force_patch_weights=False, full_load=True)
assert handler.model_loaded_weight_memory > 0
assert patcher.loaded_size() == handler.model_loaded_weight_memory
def test_no_untracked_vram_during_lazy_load(self, ledger, ledger_model):
"""The load must not perform CUDA operations the patcher cannot see.
DF-006 originally meant "the lazy load touched CUDA behind core's
back, so ``free_memory()`` arbitration ran against a stale number".
That is still the property worth pinning -- but the mechanism is
different now. The load no longer stages on CPU and leaves one bulk
H2D to the patcher; it places each tensor itself as it streams. The
weight therefore ends up on the target device during the load, and
the contract is that this happens through ``place_tensor_on_device``
(core's file->VRAM DMA, or a plain ``.to()``) rather than through a
whole-tree ``model.to(cuda)`` that would spike outside arbitration.
"""
handler = VibeVoiceModelHandler("VibeVoice-1.5B", attention_mode="sdpa")
registry = {"VibeVoice-1.5B": {"type": "official", "repo_id": "test/repo", "path": "x"}}
with _patched_loader(ledger_model, registry=registry), \
patch("ComfyUI_VibeVoice.modules.loader.VibeVoiceLoader._resolve_checkpoint_path",
return_value=("fake.safetensors", False)), \
patch("ComfyUI_VibeVoice.modules.base_loader.BaseVibeVoiceLoader.iter_checkpoint_tensors",
return_value=iter([("w", torch.zeros(1))])):
handler.load_model(CUDA, attention_mode="sdpa")
# Placed by the loader onto the requested device -- no CPU staging.
assert ledger_model.w.device.type == CUDA.type
# And no whole-tree device move snuck in behind core's arbitration:
# a bare ``.to()`` on the container would show up as a module-level
# "to" event with no tensor argument.
assert not any(
e[0] == "to" and e[1] is None
for e in ledger
), f"load moved the whole tree at once: {ledger}"
def VibeVoicePatcherForP4(handler):
"""Build a patcher with a REAL ModelPatcher.__init__ (CPU devices)."""
from ComfyUI_VibeVoice.modules.patcher import VibeVoicePatcher
return VibeVoicePatcher(
handler,
attention_mode="sdpa",
load_device=CPU,
offload_device=CPU,
size=1024,
)
# ====================================================================
# P4.2 — [GPU-OPTIONAL] real peak-VRAM measurement
# ====================================================================
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available")
class TestP42GpuPeakVram:
"""[GPU-OPTIONAL] Verify peak VRAM ≈ 1× model size during patch_model.
Runs the full patcher flow with a real (small) model on CUDA and
asserts the peak allocation is bounded by ~1× the model weight size —
NOT ~2× (which the pre-fix disk→VRAM→RAM→VRAM round-trip caused).
"""
def test_vram_peak_single_model_size(self, ledger):
class _RealInner(torch.nn.Module):
def __init__(self):
super().__init__()
# ~4 MB of weights
self.linear = torch.nn.Linear(1024, 1024)
class _RealHandler(torch.nn.Module):
def __init__(self):
super().__init__()
self.model = None
self.processor = object()
self.model_pack_name = "gpu"
self.cache_key = "gpu"
self.size = 1024 * 1024 * 4
def load_model(self, device, attention_mode="sdpa"):
if self.model is None:
self.model = _RealInner() # CPU-only, per the fix
handler = _RealHandler()
patcher = _make_ledger_patcher(handler)
patcher.load_device = CUDA
patcher.pinned = set()
# ``max_memory_allocated`` is a *process-wide* peak, and
# ``reset_peak_memory_stats`` only resets the counter to whatever is
# already resident — it does not free anything. A real checkpoint left
# on the device by an earlier test (the opt-in RUN_VIBEVOICE_E2E suite
# keeps one) therefore lands in the peak and this 4 MB model is measured
# as 2.7 GB. Subtract the resident baseline so the number is this
# model's own peak, which is what the assertion is about.
torch.cuda.synchronize()
resident_before = torch.cuda.memory_allocated()
torch.cuda.reset_peak_memory_stats()
with patch("comfy.model_patcher.ModelPatcher.patch_model"):
patcher.patch_model(device_to=CUDA)
torch.cuda.synchronize()
model_bytes = sum(
p.numel() * p.element_size() for p in handler.model.parameters()
)
peak = torch.cuda.max_memory_allocated() - resident_before
# Peak must be bounded by ~1.5× model size (single transfer + slack),
# never ~2× (state-dict + model simultaneously on GPU).
assert peak <= int(model_bytes * 1.5), (
f"peak VRAM {peak} exceeds 1.5x model size {model_bytes} "
f"(resident baseline {resident_before} subtracted)"
)
# Cleanup
handler.model.to(CPU)
torch.cuda.empty_cache()
# ====================================================================
# Honest resident-weight accounting
# ====================================================================
class TestResidentWeightAccounting:
"""Our loader puts every tensor on the load device before the patcher
exists, so core's own counter still reads zero. Core then plans a partial
load into whatever VRAM is left and reports a fully resident 9.33 GB model
as "5511.92 MB offloaded" on an idle 16 GB card. The patcher has to own up
to the bytes it already has.
"""
@staticmethod
def _handler(inner):
handler = VibeVoiceModelHandler("VibeVoice-1.5B")
handler.model = inner
return handler
def test_meta_model_leaves_the_counter_at_zero(self):
from ComfyUI_VibeVoice.modules.patcher import _adopt_resident_weights
with torch.device("meta"):
inner = torch.nn.Linear(8, 4)
patcher = MagicMock()
patcher.model = MagicMock()
patcher.model.model_loaded_weight_memory = 0
_adopt_resident_weights(patcher, self._handler(inner))
assert patcher.model.model_loaded_weight_memory == 0
def test_cpu_model_leaves_the_counter_at_zero(self):
from ComfyUI_VibeVoice.modules.patcher import _adopt_resident_weights
patcher = MagicMock()
patcher.model = MagicMock()
patcher.model.model_loaded_weight_memory = 0
_adopt_resident_weights(patcher, self._handler(torch.nn.Linear(8, 4)))
assert patcher.model.model_loaded_weight_memory == 0
def test_gpu_model_reports_every_resident_byte(self):
if not torch.cuda.is_available():
pytest.skip("no GPU in this environment")
from ComfyUI_VibeVoice.modules.patcher import _adopt_resident_weights
inner = torch.nn.Linear(8, 4).to("cuda")
expected = sum(
t.numel() * t.element_size()
for t in list(inner.parameters()) + list(inner.buffers())
)
patcher = MagicMock()
patcher.model = MagicMock()
patcher.model.model_loaded_weight_memory = 0
_adopt_resident_weights(patcher, self._handler(inner))
assert patcher.model.model_loaded_weight_memory == expected
assert expected > 0
def test_a_resident_counter_makes_core_skip_the_partial_load(self):
"""Core's own guard is the payoff, so assert the guard's condition."""
if not torch.cuda.is_available():
pytest.skip("no GPU in this environment")
from ComfyUI_VibeVoice.modules.patcher import _adopt_resident_weights
inner = torch.nn.Linear(8, 4).to("cuda")
patcher = MagicMock()
patcher.model = MagicMock()
patcher.model.model_loaded_weight_memory = 0
patcher.model.model_lowvram = False
_adopt_resident_weights(patcher, self._handler(inner))
# comfy/model_patcher.py partially_load(): this pair short-circuits
# the whole partial-load plan for an already-resident model.
assert patcher.model.model_lowvram is False
assert patcher.model.model_loaded_weight_memory > 0