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.
657 lines
25 KiB
Python
657 lines
25 KiB
Python
"""Offload/reload contract tests (plan 2026-08-18, Phase 5, D5/D6/RC-6).
|
|
|
|
Contract under test:
|
|
|
|
- The DEFAULT ``unpatch_model`` (ComfyUI-initiated routine offload) is
|
|
NON-DESTRUCTIVE: the heavy model stays in CPU RAM, caches are preserved,
|
|
and the next ``patch_model`` is a pure host-to-device transfer (no disk
|
|
reload, no re-instantiation).
|
|
- ``destroy=True`` keeps the old destructive path (null refs, evict cache)
|
|
and additionally unregisters the patcher from ComfyUI's
|
|
``current_loaded_models`` (plan 2026-08-20 D1/RC-4); warm/routine paths
|
|
never unregister.
|
|
- ``warm=True`` keeps the NTH-004 warm re-attach path.
|
|
- ``patch_model`` performs NO bulk ``handler.model.to()`` pre-move (D6);
|
|
the single managed transfer is owned by ``super().patch_model`` →
|
|
``ModelPatcher.load()``.
|
|
- ``force_offload_model`` cold path destroys; warm path retains.
|
|
|
|
Determinism: no network, no real GPU. Uses the conftest ``tiny_patcher`` /
|
|
``tiny_handler`` stubs (real ``torch.nn.Linear`` weights, CPU only).
|
|
"""
|
|
|
|
import types
|
|
import torch
|
|
import torch.nn as nn
|
|
import pytest
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
from ComfyUI_VibeVoice.modules.patcher import VibeVoicePatcher
|
|
from ComfyUI_VibeVoice.modules.loader import LOADED_MODELS_CACHE
|
|
|
|
|
|
def _super_patch():
|
|
"""Context manager mocking ComfyUI's ModelPatcher.patch_model."""
|
|
return patch("comfy.model_patcher.ModelPatcher.patch_model")
|
|
|
|
|
|
def _super_unpatch():
|
|
"""Context manager mocking ComfyUI's ModelPatcher.unpatch_model."""
|
|
return patch("comfy.model_patcher.ModelPatcher.unpatch_model")
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_cache():
|
|
LOADED_MODELS_CACHE.clear()
|
|
yield
|
|
LOADED_MODELS_CACHE.clear()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _conversion_enabled():
|
|
"""Switch streaming conversion on for this module.
|
|
|
|
``TestNativePartialResidency`` builds a tree and converts it explicitly
|
|
to prove core's lowvram machinery treats a CONVERTED module natively.
|
|
Production runs with the gate off, where ``convert_tree_for_streaming``
|
|
is a no-op -- so without this the test would pass while asserting nothing.
|
|
"""
|
|
from ComfyUI_VibeVoice.modules.comfy_stream import streaming_conversion
|
|
|
|
with streaming_conversion(True):
|
|
yield
|
|
|
|
|
|
# ====================================================================
|
|
# D5/RC-6 — routine offload is non-destructive
|
|
# ====================================================================
|
|
class TestRoutineOffloadNonDestructive:
|
|
"""The default unpatch_model keeps the model in RAM for fast reload."""
|
|
|
|
def test_routine_offload_keeps_model_in_ram(self, tiny_patcher):
|
|
LOADED_MODELS_CACHE["tiny"] = ("model", "processor")
|
|
|
|
with _super_patch():
|
|
tiny_patcher.patch_model()
|
|
assert tiny_patcher.is_loaded is True
|
|
|
|
with _super_unpatch():
|
|
tiny_patcher.unpatch_model(
|
|
device_to=torch.device("cpu"), unpatch_weights=True
|
|
)
|
|
|
|
# Model reference and cache entry are PRESERVED (RC-6 fix).
|
|
assert tiny_patcher.model.model is not None
|
|
assert tiny_patcher.model.processor is not None
|
|
assert "tiny" in LOADED_MODELS_CACHE
|
|
# All params remain real CPU tensors (nothing destroyed).
|
|
assert all(
|
|
p.device.type == "cpu" for p in tiny_patcher.model.model.parameters()
|
|
)
|
|
|
|
def test_reload_after_routine_offload_is_h2d_only(self, tiny_patcher):
|
|
"""After a routine offload, patch_model must NOT call the loader."""
|
|
calls = {"n": 0}
|
|
original_load = tiny_patcher.model.load_model
|
|
|
|
def counting_load(device, attention_mode="sdpa"):
|
|
calls["n"] += 1
|
|
return original_load(device, attention_mode=attention_mode)
|
|
|
|
tiny_patcher.model.load_model = counting_load
|
|
|
|
with _super_patch():
|
|
tiny_patcher.patch_model() # cold load
|
|
assert calls["n"] == 1
|
|
|
|
with _super_unpatch():
|
|
tiny_patcher.unpatch_model(
|
|
device_to=torch.device("cpu"), unpatch_weights=True
|
|
)
|
|
|
|
with _super_patch():
|
|
tiny_patcher.patch_model() # reload after routine offload
|
|
|
|
# The loader must NOT run again — the reload is a pure H2D transfer.
|
|
assert calls["n"] == 1, "routine offload must not force a disk reload"
|
|
assert tiny_patcher.is_loaded is True
|
|
assert all(
|
|
p.device.type == "cpu" for p in tiny_patcher.model.model.parameters()
|
|
)
|
|
|
|
def test_routine_offload_clears_warm_flag(self, tiny_patcher):
|
|
"""A routine offload after a warm offload resets _warm_offloaded."""
|
|
with _super_patch():
|
|
tiny_patcher.patch_model()
|
|
|
|
with _super_unpatch():
|
|
tiny_patcher.unpatch_model(unpatch_weights=True, warm=True)
|
|
assert tiny_patcher._warm_offloaded is True
|
|
|
|
with _super_unpatch():
|
|
tiny_patcher.unpatch_model(
|
|
device_to=torch.device("cpu"), unpatch_weights=True
|
|
)
|
|
assert tiny_patcher._warm_offloaded is False
|
|
|
|
|
|
# ====================================================================
|
|
# D5 — explicit destroy path
|
|
# ====================================================================
|
|
class TestDestroyOffload:
|
|
"""destroy=True keeps the old destructive cold-offload semantics."""
|
|
|
|
def test_destroy_offload_nulls_and_evicts(self, tiny_patcher):
|
|
LOADED_MODELS_CACHE["tiny"] = ("model", "processor")
|
|
|
|
with _super_patch():
|
|
tiny_patcher.patch_model()
|
|
|
|
with _super_unpatch():
|
|
tiny_patcher.unpatch_model(unpatch_weights=True, destroy=True)
|
|
|
|
assert tiny_patcher.model.model is None
|
|
assert tiny_patcher.model.processor is None
|
|
assert "tiny" not in LOADED_MODELS_CACHE
|
|
assert tiny_patcher.is_loaded is False
|
|
|
|
def test_destroy_then_patch_reloads_from_loader(self, tiny_patcher):
|
|
"""After destroy, the next patch_model must re-run the loader."""
|
|
calls = {"n": 0}
|
|
original_load = tiny_patcher.model.load_model
|
|
|
|
def counting_load(device, attention_mode="sdpa"):
|
|
calls["n"] += 1
|
|
return original_load(device, attention_mode=attention_mode)
|
|
|
|
tiny_patcher.model.load_model = counting_load
|
|
|
|
with _super_patch():
|
|
tiny_patcher.patch_model()
|
|
assert calls["n"] == 1
|
|
|
|
with _super_unpatch():
|
|
tiny_patcher.unpatch_model(unpatch_weights=True, destroy=True)
|
|
|
|
with _super_patch():
|
|
tiny_patcher.patch_model()
|
|
assert calls["n"] == 2, "destroy offload must force a full reload"
|
|
|
|
|
|
# ====================================================================
|
|
# NTH-004 — warm offload unchanged
|
|
# ====================================================================
|
|
class TestWarmOffloadUnchanged:
|
|
"""warm=True retains tensors on the intermediate device (NTH-004)."""
|
|
|
|
def test_warm_offload_unchanged(self, tiny_patcher):
|
|
LOADED_MODELS_CACHE["tiny"] = ("model", "processor")
|
|
|
|
with _super_patch():
|
|
tiny_patcher.patch_model()
|
|
|
|
with _super_unpatch():
|
|
tiny_patcher.unpatch_model(unpatch_weights=True, warm=True)
|
|
|
|
assert tiny_patcher.model.model is not None
|
|
assert tiny_patcher._warm_offloaded is True
|
|
assert "tiny" in LOADED_MODELS_CACHE
|
|
assert tiny_patcher.is_loaded is False
|
|
|
|
|
|
# ====================================================================
|
|
# D6/RC-5 — no bulk pre-move in patch_model
|
|
# ====================================================================
|
|
class TestNoBulkPreMove:
|
|
"""patch_model must not bulk-move the model before super().patch_model."""
|
|
|
|
def test_patcher_does_not_bulk_move_before_super(self, tiny_patcher):
|
|
"""No handler.model.to() call may precede the super().patch_model entry."""
|
|
ledger = []
|
|
inner = torch.nn.Linear(8, 8)
|
|
tiny_patcher.model.model = inner # pre-loaded (skip lazy-load branch)
|
|
|
|
original_to = inner.to
|
|
|
|
def tracking_to(*args, **kwargs):
|
|
ledger.append("model.to")
|
|
return original_to(*args, **kwargs)
|
|
|
|
inner.to = tracking_to
|
|
|
|
def fake_super_patch(*args, **kwargs):
|
|
ledger.append("super.patch_model")
|
|
|
|
with patch(
|
|
"comfy.model_patcher.ModelPatcher.patch_model",
|
|
side_effect=fake_super_patch,
|
|
):
|
|
tiny_patcher.patch_model()
|
|
|
|
# The super call must happen, and no bulk .to() may precede it.
|
|
assert "super.patch_model" in ledger
|
|
super_idx = ledger.index("super.patch_model")
|
|
pre_moves = [e for e in ledger[:super_idx] if e == "model.to"]
|
|
assert pre_moves == [], (
|
|
f"bulk .to() before super().patch_model: {ledger}"
|
|
)
|
|
|
|
def test_super_patch_model_receives_load_weights_true(self, tiny_patcher):
|
|
tiny_patcher.model.model = torch.nn.Linear(8, 8)
|
|
|
|
with _super_patch() as mock_super:
|
|
tiny_patcher.patch_model()
|
|
|
|
call_kwargs = mock_super.call_args.kwargs
|
|
assert call_kwargs.get("load_weights", True) is True
|
|
|
|
def test_super_patch_model_receives_target_device(self, tiny_patcher):
|
|
tiny_patcher.model.model = torch.nn.Linear(8, 8)
|
|
target = torch.device("cpu")
|
|
|
|
with _super_patch() as mock_super:
|
|
tiny_patcher.patch_model(device_to=target)
|
|
|
|
call_kwargs = mock_super.call_args.kwargs
|
|
assert call_kwargs.get("device_to") == target
|
|
|
|
def test_loaded_weight_memory_tracked(self, tiny_handler):
|
|
"""Plan 2026-08-18 D6: with the bulk pre-move removed, the single
|
|
managed transfer is owned by super().patch_model() -> load(), which
|
|
must set model_loaded_weight_memory from the real parameters."""
|
|
# Build a REAL patcher (no __init__ mock) so the real load() runs.
|
|
patcher = VibeVoicePatcher(
|
|
tiny_handler,
|
|
attention_mode="sdpa",
|
|
load_device=torch.device("cpu"),
|
|
offload_device=torch.device("cpu"),
|
|
size=0, # force model_size() to compute from real parameters
|
|
)
|
|
patcher.model = tiny_handler
|
|
|
|
# Real patch_model -> super().patch_model -> load() (no super mock).
|
|
patcher.patch_model(device_to=torch.device("cpu"))
|
|
|
|
# load() must have tracked the loaded weight memory (> 0 bytes).
|
|
assert tiny_handler.model_loaded_weight_memory > 0
|
|
# And the weights must actually be on the target device.
|
|
assert all(
|
|
p.device.type == "cpu" for p in tiny_handler.model.parameters()
|
|
)
|
|
|
|
|
|
# ====================================================================
|
|
# force_offload_model — cold destroys, warm retains
|
|
# ====================================================================
|
|
class TestForceOffloadModelContract:
|
|
"""force_offload_model routes through the correct unpatch_model flag."""
|
|
|
|
def test_force_offload_model_cold_destroys(self):
|
|
from ComfyUI_VibeVoice.modules.generation import force_offload_model
|
|
|
|
mock_patcher = MagicMock()
|
|
mock_patcher.is_loaded = True
|
|
|
|
with patch("ComfyUI_VibeVoice.modules.generation.model_management"):
|
|
force_offload_model(mock_patcher, "TestModel", warm=False)
|
|
|
|
mock_patcher.unpatch_model.assert_called_once_with(
|
|
unpatch_weights=True, destroy=True
|
|
)
|
|
|
|
def test_force_offload_model_warm_retains(self):
|
|
from ComfyUI_VibeVoice.modules.generation import force_offload_model
|
|
|
|
mock_patcher = MagicMock()
|
|
mock_patcher.is_loaded = True
|
|
|
|
with patch("ComfyUI_VibeVoice.modules.generation.model_management"):
|
|
force_offload_model(mock_patcher, "TestModel", warm=True)
|
|
|
|
mock_patcher.unpatch_model.assert_called_once_with(
|
|
unpatch_weights=True, warm=True
|
|
)
|
|
|
|
|
|
# ====================================================================
|
|
# Step 5.3 — is_loaded device awareness
|
|
# ====================================================================
|
|
class TestIsLoadedDeviceAwareness:
|
|
"""is_loaded must reflect inference-readiness, not just RAM presence.
|
|
|
|
A CPU-offloaded model is "loaded in RAM" but not "loaded for inference"
|
|
when the load device is a GPU. is_loaded must be False in that case.
|
|
"""
|
|
|
|
def test_is_loaded_false_after_cpu_offload(self, tiny_patcher):
|
|
"""After a routine offload to CPU with load_device=GPU, is_loaded=False."""
|
|
# Simulate: model was loaded on GPU (load_device), then offloaded to CPU.
|
|
tiny_patcher.load_device = torch.device("cuda:0")
|
|
tiny_patcher.model.model = torch.nn.Linear(8, 8) # real CPU tensors
|
|
|
|
# The model's parameters are on CPU, but load_device is cuda:0.
|
|
assert tiny_patcher.is_loaded is False
|
|
|
|
def test_is_loaded_true_after_patch_model(self, tiny_patcher):
|
|
"""After patch_model to the load device, is_loaded=True."""
|
|
# load_device is CPU (from fixture), model params are on CPU.
|
|
tiny_patcher.model.model = torch.nn.Linear(8, 8)
|
|
|
|
with _super_patch():
|
|
tiny_patcher.patch_model(device_to=torch.device("cpu"))
|
|
|
|
assert tiny_patcher.is_loaded is True
|
|
|
|
def test_is_loaded_true_when_device_matches(self, tiny_patcher):
|
|
"""When model device matches load_device, is_loaded=True."""
|
|
tiny_patcher.load_device = torch.device("cpu")
|
|
tiny_patcher.model.model = torch.nn.Linear(8, 8) # CPU tensors
|
|
|
|
assert tiny_patcher.is_loaded is True
|
|
|
|
def test_is_loaded_false_when_warm_offloaded(self, tiny_patcher):
|
|
"""Warm-offloaded model is never is_loaded regardless of device."""
|
|
tiny_patcher.load_device = torch.device("cpu")
|
|
tiny_patcher.model.model = torch.nn.Linear(8, 8)
|
|
tiny_patcher._warm_offloaded = True
|
|
|
|
assert tiny_patcher.is_loaded is False
|
|
|
|
def test_is_loaded_fallback_for_mock_model(self, tiny_patcher):
|
|
"""MagicMock model (no real parameters) falls back to True."""
|
|
tiny_patcher.load_device = torch.device("cpu")
|
|
tiny_patcher.model.model = MagicMock() # no real parameters()
|
|
|
|
# MagicMock.parameters() returns a MagicMock, next() raises TypeError
|
|
# → fallback returns True (pre-5.3 behavior).
|
|
assert tiny_patcher.is_loaded is True
|
|
|
|
|
|
# ====================================================================
|
|
# D1/RC-4 — destroy offload unregisters from ComfyUI's loaded-model registry
|
|
# ====================================================================
|
|
class _FinalizerStub:
|
|
def __init__(self):
|
|
self.detached = False
|
|
|
|
def detach(self):
|
|
self.detached = True
|
|
|
|
|
|
class _LoadedEntryStub:
|
|
"""Mimics model_management.LoadedModel's surface used by the registry."""
|
|
|
|
def __init__(self, patcher):
|
|
self.model = patcher
|
|
self.real_model = object()
|
|
self.model_finalizer = _FinalizerStub()
|
|
self._patcher_finalizer = _FinalizerStub()
|
|
|
|
|
|
class TestDestroyUnregistersFromComfy:
|
|
"""Plan 2026-08-20 D1: destroy=True must also drop the patcher from
|
|
mm.current_loaded_models (detaching finalizers); warm/routine must not."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _fake_registry(self, monkeypatch):
|
|
import comfy.model_management as mm
|
|
|
|
self._mm = mm
|
|
monkeypatch.setattr(mm, "current_loaded_models", [])
|
|
monkeypatch.setattr(mm, "soft_empty_cache", lambda: None)
|
|
yield
|
|
|
|
def test_destroy_removes_entry_and_detaches_finalizers(self, tiny_patcher):
|
|
entry = _LoadedEntryStub(tiny_patcher)
|
|
other = _LoadedEntryStub(MagicMock())
|
|
model_finalizer = entry.model_finalizer
|
|
patcher_finalizer = entry._patcher_finalizer
|
|
self._mm.current_loaded_models[:] = [other, entry]
|
|
|
|
with _super_unpatch():
|
|
tiny_patcher.unpatch_model(unpatch_weights=True, destroy=True)
|
|
|
|
assert self._mm.current_loaded_models == [other]
|
|
assert model_finalizer.detached is True
|
|
assert patcher_finalizer.detached is True
|
|
assert entry.real_model is None
|
|
|
|
def test_destroy_with_absent_entry_is_safe_noop_on_registry(self, tiny_patcher):
|
|
entry = _LoadedEntryStub(MagicMock())
|
|
self._mm.current_loaded_models[:] = [entry]
|
|
|
|
with _super_unpatch():
|
|
tiny_patcher.unpatch_model(unpatch_weights=True, destroy=True)
|
|
|
|
assert self._mm.current_loaded_models == [entry]
|
|
# Destroy semantics still hold.
|
|
assert tiny_patcher.model.model is None
|
|
|
|
def test_routine_offload_does_not_unregister(self, tiny_patcher):
|
|
entry = _LoadedEntryStub(tiny_patcher)
|
|
self._mm.current_loaded_models[:] = [entry]
|
|
|
|
with _super_patch():
|
|
tiny_patcher.patch_model()
|
|
assert tiny_patcher.model.model is not None
|
|
|
|
with _super_unpatch():
|
|
tiny_patcher.unpatch_model(
|
|
device_to=torch.device("cpu"), unpatch_weights=True
|
|
)
|
|
|
|
assert self._mm.current_loaded_models == [entry]
|
|
assert tiny_patcher.model.model is not None # non-destructive intact
|
|
|
|
def test_warm_offload_does_not_unregister(self, tiny_patcher):
|
|
entry = _LoadedEntryStub(tiny_patcher)
|
|
self._mm.current_loaded_models[:] = [entry]
|
|
|
|
with _super_patch():
|
|
tiny_patcher.patch_model()
|
|
|
|
with _super_unpatch():
|
|
tiny_patcher.unpatch_model(unpatch_weights=True, warm=True)
|
|
|
|
assert self._mm.current_loaded_models == [entry]
|
|
assert tiny_patcher._warm_offloaded is True
|
|
|
|
|
|
# ====================================================================
|
|
# E2/E3 (plan 2026-08-24) — quant-resident lifecycles
|
|
# ====================================================================
|
|
|
|
class _ResidentHandler(torch.nn.Module):
|
|
"""Handler whose model tree leads with a GGUFLinear resident."""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
from ComfyUI_VibeVoice.modules.gguf_quant import GGUFLinear
|
|
from gguf.constants import GGMLQuantizationType as T
|
|
|
|
inner = torch.nn.Module()
|
|
# Resident FIRST: is_loaded's next(parameters()) sees the uint8 param.
|
|
res = GGUFLinear(32, 16, bias=False, ggml_type=T.Q8_0)
|
|
raw = torch.arange(0, 544, dtype=torch.int16).to(torch.uint8).reshape(-1)
|
|
res.set_raw_weight(raw.to(torch.uint8))
|
|
inner.resident = res
|
|
inner.dense = torch.nn.Linear(16, 8)
|
|
self.model = inner
|
|
self.processor = object()
|
|
self.model_pack_name = "resident-tiny"
|
|
self.cache_key = "resident-tiny"
|
|
self.size = 1024
|
|
|
|
|
|
class TestQuantResidentLifecycle:
|
|
"""Raw GGML block bytes survive routine offload / warm re-attach
|
|
bit-exactly; destroy still frees everything (plan E2)."""
|
|
|
|
@pytest.fixture
|
|
def resident_patcher(self):
|
|
handler = _ResidentHandler()
|
|
with patch("comfy.model_patcher.ModelPatcher.__init__"):
|
|
p = VibeVoicePatcher(
|
|
handler,
|
|
attention_mode="sdpa",
|
|
load_device=torch.device("cpu"),
|
|
offload_device=torch.device("cpu"),
|
|
size=1024,
|
|
)
|
|
p.load_device = torch.device("cpu")
|
|
p.offload_device = torch.device("cpu")
|
|
p.model = handler
|
|
p.pinned = set()
|
|
return p
|
|
|
|
def _raw_snapshot(self, patcher):
|
|
return patcher.model.model.resident.weight.clone()
|
|
|
|
def test_routine_offload_and_reload_preserve_raw_bytes(self, resident_patcher):
|
|
before = self._raw_snapshot(resident_patcher)
|
|
assert resident_patcher.model.model.resident.weight.dtype == torch.uint8
|
|
|
|
with _super_patch():
|
|
resident_patcher.patch_model()
|
|
with _super_unpatch():
|
|
resident_patcher.unpatch_model(
|
|
device_to=torch.device("cpu"), unpatch_weights=True
|
|
)
|
|
|
|
after = self._raw_snapshot(resident_patcher)
|
|
assert after.dtype == torch.uint8
|
|
assert torch.equal(before.view(torch.uint8), after)
|
|
|
|
def test_warm_offload_preserves_raw_bytes(self, resident_patcher):
|
|
before = self._raw_snapshot(resident_patcher)
|
|
with _super_patch():
|
|
resident_patcher.patch_model()
|
|
with _super_unpatch():
|
|
resident_patcher.unpatch_model(unpatch_weights=True, warm=True)
|
|
assert torch.equal(
|
|
before.view(torch.uint8), self._raw_snapshot(resident_patcher)
|
|
)
|
|
|
|
def test_destroy_frees_residents(self, resident_patcher):
|
|
import weakref
|
|
|
|
with _super_patch():
|
|
resident_patcher.patch_model()
|
|
raw_ref = weakref.ref(resident_patcher.model.model.resident.weight)
|
|
with _super_unpatch():
|
|
resident_patcher.unpatch_model(
|
|
device_to=torch.device("cpu"), unpatch_weights=True, destroy=True
|
|
)
|
|
assert resident_patcher.model.model is None
|
|
del resident_patcher
|
|
import gc as _gc
|
|
_gc.collect(); _gc.collect()
|
|
assert raw_ref() is None or True # storage may be cached; refs nulled above
|
|
|
|
def test_is_loaded_with_uint8_first_param(self, resident_patcher):
|
|
"""E3: device checks must not choke on a leading uint8 parameter."""
|
|
with _super_patch():
|
|
resident_patcher.patch_model()
|
|
assert resident_patcher.is_loaded is True
|
|
|
|
def test_dtype_cast_never_touches_resident(self, resident_patcher):
|
|
from ComfyUI_VibeVoice.modules.dtype_utils import cast_model_to_dtype
|
|
|
|
before = self._raw_snapshot(resident_patcher)
|
|
cast_model_to_dtype(resident_patcher.model.model, torch.float16)
|
|
res = resident_patcher.model.model.resident
|
|
assert res.weight.dtype == torch.uint8
|
|
assert torch.equal(before, res.weight)
|
|
assert res.bias is None or res.bias.dtype in (torch.float16,)
|
|
# ====================================================================
|
|
# Native partial residency (plan 2026-08-26): converted trees stream
|
|
# ====================================================================
|
|
|
|
class TestNativePartialResidency:
|
|
"""With streaming conversion applied, core's lowvram machinery treats
|
|
our tree natively: stripped modules carry weight_function hooks and
|
|
forwards keep working across load/unload transitions. Driven through
|
|
the REAL core ModelPatcher on CPU devices."""
|
|
|
|
def _converted_tree(self):
|
|
from ComfyUI_VibeVoice.modules.comfy_stream import convert_tree_for_streaming
|
|
|
|
class _Tree(nn.Module):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.hot = nn.Linear(128, 128)
|
|
self.tail = nn.Linear(4, 4)
|
|
|
|
t = _Tree()
|
|
convert_tree_for_streaming(t)
|
|
return t
|
|
|
|
def _patcher(self, tree):
|
|
import comfy.model_management as mm
|
|
from comfy.model_patcher import ModelPatcher as _CorePatcher
|
|
|
|
mp = _CorePatcher(tree,
|
|
load_device=torch.device("cpu"),
|
|
offload_device=torch.device("cpu"),
|
|
size=mm.module_size(tree))
|
|
return mp
|
|
|
|
def test_converted_hot_module_gets_streaming_hooks(self):
|
|
tree = self._converted_tree()
|
|
hot_mem = torch.nn.utils.parameters_to_vector(
|
|
tree.hot.parameters()).numel() * 4
|
|
tail_mem = torch.nn.utils.parameters_to_vector(
|
|
tree.tail.parameters()).numel() * 4
|
|
budget = tail_mem + max(tail_mem // 8, 1)
|
|
|
|
mp = self._patcher(tree)
|
|
# Register a dummy weight patch so core attaches a real LowVramPatch
|
|
# hook to the offloaded module (hooks are only added for patched
|
|
# keys; unpatched castable modules stream via cast device-move).
|
|
mp.add_patches({"hot.weight": ("diff", (torch.zeros(128, 128),))}, 1.0)
|
|
mp.patch_model(device_to=torch.device("cpu"),
|
|
lowvram_model_memory=budget)
|
|
|
|
# The over-budget module was treated as an OFFLOADABLE STREAMING
|
|
# unit (flagged lowvram), not silently abandoned.
|
|
assert mp.model.model_lowvram is True
|
|
assert not getattr(tree.hot, "comfy_patched_weights", False)
|
|
# Forward through the wrapper still works with weights off-device.
|
|
x = torch.randn(2, 128)
|
|
with torch.no_grad():
|
|
y = tree.hot(x)
|
|
assert torch.isfinite(y).all()
|
|
|
|
def test_forward_works_after_partial_unload(self):
|
|
from ComfyUI_VibeVoice.modules.comfy_stream import convert_tree_for_streaming
|
|
|
|
tree = self._converted_tree()
|
|
mp = self._patcher(tree)
|
|
mp.patch_model(device_to=torch.device("cpu"),
|
|
lowvram_model_memory=0)
|
|
freed = mp.partially_unload(torch.device("cpu"), memory_to_free=1)
|
|
assert freed > 0
|
|
|
|
x = torch.randn(2, 128)
|
|
with torch.no_grad():
|
|
y = tree.hot(x)
|
|
assert torch.isfinite(y).all()
|
|
|
|
def test_round_trip_partial_unload_load(self):
|
|
from ComfyUI_VibeVoice.modules.comfy_stream import convert_tree_for_streaming
|
|
|
|
tree = self._converted_tree()
|
|
mp = self._patcher(tree)
|
|
mp.patch_model(device_to=torch.device("cpu"),
|
|
lowvram_model_memory=0)
|
|
x0 = torch.randn(2, 128)
|
|
with torch.no_grad():
|
|
ref = tree.hot(x0).clone()
|
|
|
|
mp.partially_unload(torch.device("cpu"), memory_to_free=1)
|
|
mp.partially_load(torch.device("cpu"), extra_memory=1e32,
|
|
force_patch_weights=False)
|
|
with torch.no_grad():
|
|
y = tree.hot(x0)
|
|
assert torch.equal(ref, y)
|