Files
WildAi 567fb03e30 feat: align VibeVoice loading with core DynamicVRAM, fix RAM ghost (v2.12.0)
Loading filled host RAM 20->40GB while streaming from SSD and never released
it; inference then re-served the whole model per AR step. Teardown against
core's aimdo/DynamicVRAM machinery, measured on the real 9.47GB 7B fp8 and
5.16GB 1.5B bf16 checkpoints, found four loader defects plus one that was
never ours to fix.

Defects fixed (each with before/after numbers):

* Quant pass 1 pre-read each dequant-at-load layer's scale with
  safetensors.safe_open + get_tensor. On Windows one such call commits ~1x
  file size as private, untouched memory, pinned for the lifetime of the
  returned tensor: +9,050MB on the 7B fp8, invisible to both the working-set
  counter and the storage census. Now maps the file once through core's
  aimdo arm and clones only the tiny scales. Whole load: 1.51x -> 0.13x file
  size; retained after free 11.3GB -> 96MB.

* Quant families were excluded from core's dynamic patcher by an inherited
  "quant streams natively" stop condition. Because a legacy patcher has
  nowhere to page from, those routes cloned every tensor into host RAM and
  fully H2D'd it. select_patcher_class now follows core's availability rule
  for every family, with gguf_block still excluded for a measured reason
  (GGUFTensor.from_reader_tensor clones the reader's view).

* replace_linears_for_quant built its resident modules outside the meta
  context: 8.08GB of never-written host allocation per 7B fp8 load.

* Dormant weight_function/bias_function double application: core already
  applies them inside cast_bias_weight (ops.py:431-438).

Exonerated with numbers, not assumed: the dense read path is core's own
(13.5MB private for a 5.16GB file), and core's file->VRAM paging read is
cache-clean (+0.13GB per 2GB). Only host-side view reads reproduce the
reported 1:1 RAM at 0.55GB/s signature.

Inference: reproduced headlessly that when the tree does not fit in the
VRAM that is actually free, every forward re-reads every weight from the
checkpoint file (3072/3072 file reads, 21ms -> 125ms per step). Our wrappers
are protocol-correct; residency is stable at 100% with headroom. [vvpull]
now reports resident vs reread with bytes and free VRAM so one live line
settles it, behind VIBEVOICE_VBAR_OBSERVER=0.

Not changed after being tried and reverted: deriving fast_disk from the
checkpoint. It measured as a no-op on the dev host but converted RAM-speed
pinned re-reads into disk-speed reads live, which made loading dramatically
slower. Reverted in full; see the report for the probe-design lesson.

Adds host-RAM instrumentation ([vvrss] with machine-level start/end, [vvcensus]
storage census, tts-generate bracket), four standalone probes, and ~4000
lines of tests pinning the invariants above.
2026-09-30 18:24:52 +03:00

409 lines
16 KiB
Python

"""FIX 1 regression tests: eviction must release the node-output bundle.
The external loader node returns the model bundle as a node OUTPUT
(``nodes/external_loader_node.VibeVoiceModel.Output``), and ComfyUI's
execution cache holds that dict strongly (``execution.CacheEntry.outputs``)
until the loader node leaves the prompt or re-executes. So popping our own
patcher-cache entry frees nothing on its own: the cached bundle still pins the
live ``nn.Module`` and its tensors. These tests pin the keyed bundle registry
that makes eviction NEUTRALIZE the bundle.
Each eviction test deliberately keeps the bundle alive in a stand-in holder
for the whole test (standing in for ComfyUI's output cache). The holder is
NOT what frees the model — the release step is.
"""
import gc
import os
import weakref
import pytest
import torch
from unittest.mock import MagicMock, patch
import comfy.model_management as comfy_mm
from ComfyUI_VibeVoice.modules import model_registry
from ComfyUI_VibeVoice.modules.model_registry import (
FAMILY_ASR,
FAMILY_TTS,
clear_active_keys,
evict_if_changed,
identity_for_external,
register_model_bundle,
)
from ComfyUI_VibeVoice.modules.utils import VIBEVOICE_ASR_PATCHER_CACHE, VIBEVOICE_PATCHER_CACHE
from ComfyUI_VibeVoice.modules.generation import load_vibevoice_from_external
from ComfyUI_VibeVoice.modules.asr_generation import load_asr_from_external
MR = "ComfyUI_VibeVoice.modules.model_registry"
GEN = "ComfyUI_VibeVoice.modules.generation"
ASRGEN = "ComfyUI_VibeVoice.modules.asr_generation"
@pytest.fixture(autouse=True)
def _isolated_state(monkeypatch):
"""Isolate global registries and neuter accelerator cache flushes."""
def _reset():
VIBEVOICE_PATCHER_CACHE.clear()
VIBEVOICE_ASR_PATCHER_CACHE.clear()
clear_active_keys()
model_registry.clear_bundle_registry()
_reset()
monkeypatch.setattr(comfy_mm, "soft_empty_cache", lambda: None)
monkeypatch.setattr(comfy_mm, "current_loaded_models", [])
yield
_reset()
# ====================================================================
# Helpers
# ====================================================================
def _write_weights(tmp_path, tag, ext="gguf"):
f = tmp_path / f"weights_{tag}.{ext}"
f.write_bytes(b"fake-weights-" + tag.encode())
return f
def _make_bundle(tmp_path, tag="a", name="VibeVoice-1.5B", attn="sdpa",
q4=False, dtype="auto", is_asr=False):
"""A bundle shaped exactly like the external loader node's output."""
f = _write_weights(tmp_path, tag)
return {
"model": torch.nn.Linear(8, 8),
"processor": object(),
"model_name": name,
"source_path": str(f),
"attention_mode": attn,
"use_llm_4bit": bool(q4),
"dtype_str": dtype,
"is_asr": bool(is_asr),
}
def _cache_key_for(bundle, is_asr=False):
"""The consumer's REAL cache key, so the test tracks production."""
return identity_for_external(
bundle["source_path"],
bundle["model_name"],
bundle["attention_mode"],
use_llm_4bit=bundle["use_llm_4bit"],
dtype_str=bundle["dtype_str"],
prefix="asr_external" if is_asr else "external",
)
# ====================================================================
# TTS: eviction frees the weights the output cache is pinning
# ====================================================================
class TestTTSBundleReleasedOnEviction:
def test_tts_bundle_model_freed_on_eviction(self, tmp_path):
bundle = _make_bundle(tmp_path, tag="tts")
# ComfyUI's output cache: the bundle stays strongly held for the whole
# test. This holder is deliberately NOT what frees the model.
comfy_output_cache = {"node_1": bundle}
del bundle
key = _cache_key_for(comfy_output_cache["node_1"])
register_model_bundle(key, comfy_output_cache["node_1"])
VIBEVOICE_PATCHER_CACHE[key] = MagicMock()
model_registry.set_active(FAMILY_TTS, key)
wr = weakref.ref(comfy_output_cache["node_1"]["model"])
assert wr() is not None
evicted = evict_if_changed(FAMILY_TTS, "some_other_key", (VIBEVOICE_PATCHER_CACHE,))
gc.collect()
assert key in evicted
assert wr() is None, (
"eviction must neutralize the output-cached bundle, otherwise the "
"external->default/sharded transition leaks the whole model"
)
# The cached dict survives (debuggable), only the heavy fields are nulled.
cached = comfy_output_cache["node_1"]
assert cached["model"] is None
assert cached["processor"] is None
assert cached["model_name"] == "VibeVoice-1.5B"
assert cached["source_path"]
assert cached["attention_mode"] == "sdpa"
assert cached["use_llm_4bit"] is False
assert cached["dtype_str"] == "auto"
assert cached["is_asr"] is False
# ====================================================================
# ASR: the registry-level twin of the TTS release test
# ====================================================================
class TestASRBundleReleasedOnEviction:
def test_asr_bundle_model_freed_on_eviction(self, tmp_path):
bundle = _make_bundle(tmp_path, tag="asr", name="VibeVoice-ASR", is_asr=True)
comfy_output_cache = {"node_2": bundle}
del bundle
key = _cache_key_for(comfy_output_cache["node_2"], is_asr=True)
assert key.startswith("asr_external_"), "ASR key namespace must be preserved"
register_model_bundle(key, comfy_output_cache["node_2"])
VIBEVOICE_ASR_PATCHER_CACHE[key] = MagicMock()
model_registry.set_active(FAMILY_ASR, key)
wr = weakref.ref(comfy_output_cache["node_2"]["model"])
assert wr() is not None
evicted = evict_if_changed(FAMILY_ASR, "some_other_key", (VIBEVOICE_ASR_PATCHER_CACHE,))
gc.collect()
assert key in evicted
assert wr() is None, "ASR eviction must neutralize the output-cached bundle"
cached = comfy_output_cache["node_2"]
assert cached["model"] is None
assert cached["processor"] is None
assert cached["is_asr"] is True
assert cached["model_name"] == "VibeVoice-ASR"
# ====================================================================
# ASR end-to-end: the REAL consumer does the registering
# ====================================================================
class TestASRConsumerWiring:
"""The ASR counterpart of ``test_nulled_bundle_fails_loudly_not_silently``.
Registry-level tests can pass while the ASR consumer never calls
``register_model_bundle`` at all (the TTS equivalent is the
``register_model_bundle(cache_key, model_bundle)`` line in
``modules/generation.py``). These drive the real
``load_asr_from_external`` so a regression in the ASR wiring is caught
here rather than on a user's external ASR swap.
"""
def _run_asr_consumer(self, bundle):
"""Drive load_asr_from_external with GPU loading stubbed out.
The load stub patches ``load_models_gpu``, not ``load_model_gpu``:
core's ``load_model_gpu(model)`` is literally ``load_models_gpu([model])``
(comfy/model_management.py:1041-1042), resolved as a module global at
call time. One patch therefore intercepts both the legacy
``load_to_device`` branch and the dynamic one, which calls
``load_models_gpu([patcher], memory_required=...)``, without the test
having to know which class the selector picked.
"""
with patch("comfy.model_patcher.ModelPatcher.patch_model"), \
patch(f"{ASRGEN}.model_management.load_models_gpu",
side_effect=lambda models: models[0].patch_model()), \
patch(f"{MR}.gc.collect"):
return load_asr_from_external(
bundle, device="cpu", dtype="fp32", attention_mode="sdpa"
)
def test_asr_consumer_registers_bundle_under_its_patcher_key(self, tmp_path):
bundle = _make_bundle(tmp_path, tag="e2e", name="VibeVoice-ASR",
is_asr=True)
# ComfyUI's output cache holds the node output for the whole test.
comfy_output_cache = {"node_asr": bundle}
del bundle
patcher, _, _ = self._run_asr_consumer(comfy_output_cache["node_asr"])
key = patcher.cache_key
assert key in VIBEVOICE_ASR_PATCHER_CACHE
# The consumer must have registered THE SAME dict under THE SAME key
# the patcher is cached under — that is the only thing eviction can
# act on.
assert model_registry.release_model_bundles(key) == 1
def test_asr_consumer_bundle_freed_by_eviction(self, tmp_path):
bundle = _make_bundle(tmp_path, tag="e2e2", name="VibeVoice-ASR",
is_asr=True)
comfy_output_cache = {"node_asr2": bundle}
del bundle
patcher, _, _ = self._run_asr_consumer(comfy_output_cache["node_asr2"])
key = patcher.cache_key
model_registry.set_active(FAMILY_ASR, key)
wr = weakref.ref(comfy_output_cache["node_asr2"]["model"])
assert wr() is not None
evicted = evict_if_changed(
FAMILY_ASR, "a_different_key", (VIBEVOICE_ASR_PATCHER_CACHE,)
)
gc.collect()
assert key in evicted
assert wr() is None, (
"the real ASR consumer must register a bundle that eviction can "
"neutralize, or external ASR -> anything leaks the whole model"
)
assert comfy_output_cache["node_asr2"]["model"] is None
def test_nulled_asr_bundle_fails_loudly_not_silently(self, tmp_path):
bundle = _make_bundle(tmp_path, tag="loudasr", name="VibeVoice-ASR",
is_asr=True)
key = _cache_key_for(bundle, is_asr=True)
register_model_bundle(key, bundle)
comfy_output_cache = {"node_asr3": bundle} # output-cache holder
del bundle
model_registry.release_model_bundles(key)
with patch(f"{ASRGEN}.evict_if_changed"):
with pytest.raises(ValueError) as exc:
load_asr_from_external(
comfy_output_cache["node_asr3"], device="cpu",
dtype="fp32", attention_mode="sdpa",
)
assert "missing required key 'model'" in str(exc.value)
# ====================================================================
# Scoping: a live sibling key is never collateral damage
# ====================================================================
class TestReleaseScoping:
def test_release_is_scoped_to_the_evicted_key(self, tmp_path):
"""K1 is the superseded model, K2 the still-connected one: only K1's
weights may be released."""
bundle_k1 = _make_bundle(tmp_path, tag="k1")
bundle_k2 = _make_bundle(tmp_path, tag="k2")
key1 = _cache_key_for(bundle_k1)
key2 = _cache_key_for(bundle_k2)
assert key1 != key2
register_model_bundle(key1, bundle_k1)
register_model_bundle(key2, bundle_k2)
VIBEVOICE_PATCHER_CACHE[key1] = MagicMock()
VIBEVOICE_PATCHER_CACHE[key2] = MagicMock()
model_registry.set_active(FAMILY_TTS, key1)
wr1 = weakref.ref(bundle_k1["model"])
wr2 = weakref.ref(bundle_k2["model"])
del bundle_k1, bundle_k2
# K2 becomes the new active model -> only K1 is swept.
evicted = evict_if_changed(FAMILY_TTS, key2, (VIBEVOICE_PATCHER_CACHE,))
gc.collect()
assert evicted == [key1]
assert wr1() is None, "the evicted key's weights must be freed"
assert wr2() is not None, "the still-active key's weights must survive"
assert key2 in VIBEVOICE_PATCHER_CACHE
def test_release_returns_1_for_known_key_and_0_otherwise(self, tmp_path):
bundle = _make_bundle(tmp_path, tag="ret")
key = _cache_key_for(bundle)
register_model_bundle(key, bundle)
assert model_registry.release_model_bundles(key) == 1
assert model_registry.release_model_bundles(key) == 0
assert model_registry.release_model_bundles("never_registered") == 0
def test_reregistering_same_bundle_does_not_neutralize_it(self, tmp_path):
"""The reused-patcher path re-registers the SAME dict."""
bundle = _make_bundle(tmp_path, tag="reuse")
key = _cache_key_for(bundle)
model = bundle["model"]
register_model_bundle(key, bundle)
register_model_bundle(key, bundle) # same object, no-op replace
assert bundle["model"] is model, (
"re-registering the live bundle must never null it out from under "
"its own consumer"
)
assert model_registry.release_model_bundles(key) == 1
assert bundle["model"] is None
# ====================================================================
# A still-connected consumer of a nulled bundle fails LOUDLY
# ====================================================================
class TestNulledBundleFailsLoudly:
def test_nulled_bundle_rebuilds_from_source_path(self, tmp_path):
"""A released bundle is rebuilt, not rejected.
The loader node's output is cached by ComfyUI and can outlive the
registry entry that backed it, so a consumer can legitimately be
handed a bundle whose heavy fields were already released.
"""
bundle = _make_bundle(tmp_path, tag="rebuild")
key = _cache_key_for(bundle)
src = bundle["source_path"]
register_model_bundle(key, bundle)
comfy_output_cache = {"node_3": bundle} # output-cache holder
del bundle
model_registry.release_model_bundles(key)
assert comfy_output_cache["node_3"]["model"] is None
rebuilt = {"model": torch.nn.Linear(8, 8), "processor": object(),
"model_name": "VibeVoice-1.5B"}
with patch(f"{GEN}.evict_if_changed"):
with patch(
"ComfyUI_VibeVoice.modules.external_loader."
"load_external_vibevoice_model", return_value=rebuilt
) as reload_:
patcher, model, processor = load_vibevoice_from_external(
comfy_output_cache["node_3"], device="cpu",
dtype="fp32", attention_mode="sdpa",
)
assert model is rebuilt["model"] and processor is rebuilt["processor"]
assert reload_.call_args[0][0] == src
def test_nulled_bundle_without_source_still_raises(self, tmp_path):
"""With no source to rebuild from, failing loudly is correct."""
bundle = _make_bundle(tmp_path, tag="nosrc")
bundle["source_path"] = ""
bundle["model"] = None
comfy_output_cache = {"node_3": bundle}
with patch(f"{GEN}.evict_if_changed"):
with pytest.raises(ValueError) as exc:
load_vibevoice_from_external(
comfy_output_cache["node_3"], device="cpu",
dtype="fp32", attention_mode="sdpa",
)
assert "missing required key 'model'" in str(exc.value)
# ====================================================================
# Key format stability
# ====================================================================
class TestIdentityKeyFormat:
def test_identity_for_external_key_format_is_stable(self, tmp_path):
f = _write_weights(tmp_path, "fmt")
st = os.stat(f)
key = identity_for_external(
str(f), "VibeVoice-1.5B", "sdpa",
use_llm_4bit=False, dtype_str="auto",
)
assert key == (
f"external_VibeVoice-1.5B@{f.name}@{st.st_mtime_ns}@{st.st_size}"
"_attn_sdpa_q4_0_dtype_auto"
)
asr_key = identity_for_external(
str(f), "VibeVoice-ASR", "sage",
use_llm_4bit=True, dtype_str="bf16", prefix="asr_external",
)
assert asr_key == (
f"asr_external_VibeVoice-ASR@{f.name}@{st.st_mtime_ns}@{st.st_size}"
"_attn_sage_q4_1_dtype_bf16"
)
def test_missing_path_degrades_to_placeholders(self):
key = identity_for_external(
r"C:\nope\missing.gguf", "VibeVoice-1.5B", "sdpa",
)
assert key == (
"external_VibeVoice-1.5B@missing.gguf@0@0_attn_sdpa_q4_0_dtype_auto"
)