Files
wildminder-ComfyUI-VibeVoice/tests/test_streaming_cache_compat.py
T
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

795 lines
32 KiB
Python

"""Regression tests for the realtime voice-prompt KV cache compatibility layer.
The released ``.pt`` voice prompts store their cache in the pre-4.57
``key_cache`` / ``value_cache`` lists. transformers 5.x expects a list of cache
*layer objects* and reads ``layer.keys`` / ``layer.values`` plus
``get_seq_length()`` to size the attention mask.
A wrapper that only implemented the 4.x names did not raise — the voice
prefill was simply invisible to the model, so it conditioned on nothing and
produced garbled syllables (or an immediate end-of-speech). These tests pin the
5.x interface to the same tensors as the legacy ones.
"""
import importlib
import sys
from pathlib import Path
import pytest
import torch
REPO_ROOT = Path(__file__).parent.parent
@pytest.fixture(scope="module")
def mod():
"""Import the genuine vendored streaming module.
``conftest.py`` replaces the whole ``src.vibevoice`` tree with MagicMocks to
keep the default suite lightweight, so the mocked entries are removed for
this module and restored on teardown.
"""
mocked_prefixes = ("src.vibevoice", "ComfyUI_VibeVoice.src.vibevoice")
saved = {
name: module
for name, module in sys.modules.items()
if name.startswith(mocked_prefixes)
}
for name in saved:
del sys.modules[name]
root = str(REPO_ROOT)
if root not in sys.path:
sys.path.insert(0, root)
try:
yield importlib.import_module(
"ComfyUI_VibeVoice.src.vibevoice.modular."
"modeling_vibevoice_streaming_inference"
)
finally:
for name in [
n
for n in list(sys.modules)
if n.startswith("src.vibevoice") or n.startswith("ComfyUI_VibeVoice.src")
]:
del sys.modules[name]
sys.modules.update(saved)
class _LegacyCache:
"""Stands in for a DynamicCache unpickled from a released .pt voice prompt."""
def __init__(self, key_cache, value_cache):
self.key_cache = key_cache
self.value_cache = value_cache
def _cache(n_layers=2, seq=8, heads=2, dim=4, batch=1):
keys = [torch.randn(batch, heads, seq, dim) for _ in range(n_layers)]
values = [torch.randn(batch, heads, seq, dim) for _ in range(n_layers)]
return _LegacyCache(keys, values)
def _real_dynamic_cache(n_layers=3, seq=8, heads=2, dim=4, batch=1):
"""A genuine 5.x ``DynamicCache`` carrying the *legacy* pickle shape.
The released prompts are a ``DynamicCache`` pickled by an older
transformers: the object is of that class, but its state lives in
``key_cache`` / ``value_cache`` and it has no ``layers`` at all. Building
one here means the container-level assertions run against the installed
``DynamicCache`` methods rather than against a stand-in.
"""
from transformers.cache_utils import DynamicCache
cache = DynamicCache()
for layer_idx in range(n_layers):
cache.update(
torch.randn(batch, heads, seq, dim),
torch.randn(batch, heads, seq, dim),
layer_idx,
)
cache.key_cache = [layer.keys for layer in cache.layers]
cache.value_cache = [layer.values for layer in cache.layers]
del cache.layers
return cache
class TestCacheLayerSurfaceMatchesTheInstalledMixin:
"""S2.1 anti-regression: the wrapper must cover the *installed* mixin API.
The comparison is made against ``transformers.cache_utils.CacheLayerMixin``
at test time, so a transformers bump that adds (or renames) an accessor
fails here loudly instead of degrading silently into "the prefill is
invisible" - the failure mode that produced F2.
"""
@staticmethod
def _required_surface():
from transformers.cache_utils import CacheLayerMixin
return {name for name in dir(CacheLayerMixin) if not name.startswith("_")}
@staticmethod
def _public(obj):
return {name for name in dir(obj) if not name.startswith("_")}
def _missing(self, implemented):
return self._required_surface() - implemented
def test_required_surface_is_not_empty(self):
# Guards the test itself: an empty requirement would make the check
# below vacuously pass on a broken import.
assert {"offload", "prefetch", "reorder_cache", "update"} <= self._required_surface()
def test_mock_layer_implements_every_mixin_accessor(self, mod):
missing = self._missing(self._public(mod.MockCacheLayer))
assert not missing, f"MockCacheLayer is missing {sorted(missing)}"
def test_the_check_bites_when_an_accessor_is_deleted(self, mod):
"""Mutation guard: drop one accessor and the surface check must fail.
A contract test that cannot fail proves nothing, so the same predicate
is re-run against (a) the real surface minus one member and (b) a class
that lost the whole mixin contract by no longer inheriting it.
"""
complete = self._public(mod.MockCacheLayer)
assert not self._missing(complete)
assert self._missing(complete - {"prefetch"}) == {"prefetch"}
class _NotACacheLayer:
"""Everything deleted - the degenerate case of the same bug."""
assert self._missing(self._public(_NotACacheLayer)) == self._required_surface()
def test_the_5x_only_accessors_are_present(self, mod):
# Named explicitly so the intent survives a mixin reshuffle: these are
# the three that were missing and that ``Cache`` calls on the offload,
# prefetch and beam-search paths.
for name in ("offload", "prefetch", "reorder_cache"):
assert callable(getattr(mod.MockCacheLayer, name, None)), name
class TestCacheLayerExposesModernApi:
def test_layers_are_built_from_the_legacy_lists(self, mod):
cache = mod._ensure_cache_has_layers(_cache(n_layers=3))
assert len(cache.layers) == 3
def test_5x_and_legacy_names_alias_the_same_tensor(self, mod):
cache = mod._ensure_cache_has_layers(_cache())
layer = cache.layers[0]
assert layer.keys is layer.key_cache
assert layer.values is layer.value_cache
def test_get_seq_length_reports_the_prefill_length(self, mod):
cache = mod._ensure_cache_has_layers(_cache(seq=108))
# This is what the 5.x attention path calls to build the attention mask.
assert cache.layers[0].get_seq_length() == 108
def test_get_mask_sizes_includes_the_query_length(self, mod):
cache = mod._ensure_cache_has_layers(_cache(seq=316))
kv_len, offset = cache.layers[0].get_mask_sizes(torch.arange(5))
assert kv_len == 316 + 5
assert offset == 0
def test_is_initialized_reflects_presence(self, mod):
populated = mod._ensure_cache_has_layers(_cache(seq=8))
empty = mod._ensure_cache_has_layers(_cache(n_layers=1, seq=0))
assert populated.layers[0].is_initialized is True
assert empty.layers[0].is_initialized is False
def test_empty_cache_reports_zero_length(self, mod):
empty = mod._ensure_cache_has_layers(_cache(n_layers=1, seq=0))
assert empty.layers[0].get_seq_length() == 0
def test_sliding_and_compileable_flags_exist(self, mod):
layer = mod._ensure_cache_has_layers(_cache()).layers[0]
assert layer.is_sliding is False
assert hasattr(layer, "is_compileable")
def test_lazy_initialization_is_a_noop(self, mod):
layer = mod._ensure_cache_has_layers(_cache()).layers[0]
before = layer.get_seq_length()
layer.lazy_initialization(torch.randn(1, 2, 1, 4), torch.randn(1, 2, 1, 4))
assert layer.get_seq_length() == before
class TestCacheLayerUpdate:
def test_update_appends_to_the_parent_cache(self, mod):
cache = mod._ensure_cache_has_layers(_cache(seq=8))
layer = cache.layers[0]
new_k = torch.randn(1, 2, 1, 4)
new_v = torch.randn(1, 2, 1, 4)
keys, values = layer.update(new_k, new_v)
assert keys.shape[-2] == 9
assert values.shape[-2] == 9
# The parent list is the single source of truth for both APIs.
assert cache.key_cache[0].shape[-2] == 9
assert layer.get_seq_length() == 9
def test_update_on_a_detached_layer_returns_its_own_tensors(self, mod):
keys = torch.randn(1, 2, 3, 4)
values = torch.randn(1, 2, 3, 4)
layer = mod.MockCacheLayer(keys, values)
out_k, out_v = layer.update(torch.randn(1, 2, 1, 4), torch.randn(1, 2, 1, 4))
assert out_k.shape[-2] == 3
assert out_v.shape[-2] == 3
def test_legacy_attribute_writes_stay_visible_to_5x_readers(self, mod):
layer = mod._ensure_cache_has_layers(_cache(seq=4)).layers[0]
replacement = torch.randn(1, 2, 11, 4)
layer.key_cache = replacement
assert layer.keys is replacement
assert layer.get_seq_length() == 11
def test_update_concatenates_on_the_cache_axis(self, mod):
cache = mod._ensure_cache_has_layers(_cache(seq=3, heads=2, dim=5))
layer = cache.layers[0]
before = layer.keys.clone()
out_k, out_v = layer.update(torch.randn(1, 2, 2, 5), torch.randn(1, 2, 2, 5))
# dim=-2 is the sequence axis for [batch, heads, seq, head_dim]; a
# concatenation on any other axis would silently corrupt RoPE alignment.
assert out_k.shape == (1, 2, 5, 5)
assert out_v.shape == (1, 2, 5, 5)
assert torch.equal(out_k[..., :3, :], before)
class TestCacheLayerOffloadPrefetchReorder:
"""S2.1 behaviour for the three accessors the wrapper was missing."""
def test_device_and_dtype_come_from_the_key_tensor(self, mod):
layer = mod.MockCacheLayer(
torch.randn(1, 2, 4, 5, dtype=torch.float16), torch.randn(1, 2, 4, 5)
)
assert layer.device == torch.device("cpu")
assert layer.dtype == torch.float16
def test_offload_moves_both_tensors_to_cpu_and_keeps_the_parent_in_sync(self, mod):
cache = mod._ensure_cache_has_layers(_cache(seq=6))
layer = cache.layers[0]
original_keys = layer.keys.clone()
layer.offload()
assert layer.keys.device.type == "cpu"
assert layer.values.device.type == "cpu"
# No data loss, and the legacy spelling sees the same object.
assert torch.equal(layer.keys, original_keys)
assert cache.key_cache[0] is layer.keys
assert cache.value_cache[0] is layer.values
def test_prefetch_is_a_noop_when_already_on_the_layer_device(self, mod):
cache = mod._ensure_cache_has_layers(_cache(seq=6))
layer = cache.layers[0]
before = layer.keys.clone()
layer.prefetch()
assert layer.keys.device == layer.device
assert torch.equal(layer.keys, before)
def test_prefetch_brings_an_offloaded_layer_back_to_its_device(self, mod):
if not torch.cuda.is_available():
pytest.skip("a real offload/prefetch move needs a second device")
layer = mod.MockCacheLayer(torch.randn(1, 2, 4, 5), torch.randn(1, 2, 4, 5))
layer.device = torch.device("cuda")
before = layer.keys.clone()
layer.offload()
assert layer.keys.device.type == "cpu"
layer.prefetch()
assert layer.keys.device.type == "cuda"
assert torch.equal(layer.keys.cpu(), before)
def test_offload_then_prefetch_round_trips_without_data_loss(self, mod):
cache = mod._ensure_cache_has_layers(_cache(seq=6))
layer = cache.layers[0]
before_k, before_v = layer.keys.clone(), layer.values.clone()
layer.offload()
layer.prefetch()
assert torch.equal(layer.keys, before_k)
assert torch.equal(layer.values, before_v)
def test_reorder_cache_selects_the_new_batch_order(self, mod):
cache = mod._ensure_cache_has_layers(_cache(seq=6, batch=3))
layer = cache.layers[0]
rows = layer.keys[:, :, 0, 0].clone()
layer.reorder_cache(torch.tensor([2, 0, 1]))
assert torch.equal(layer.keys[:, :, 0, 0], rows[[2, 0, 1]])
assert cache.key_cache[0] is layer.keys
def test_reorder_cache_on_an_empty_layer_is_a_noop(self, mod):
layer = mod.MockCacheLayer(None, None)
layer.reorder_cache(torch.tensor([0]))
assert layer.keys is None
def test_crop_and_batch_helpers_keep_the_parent_in_sync(self, mod):
cache = mod._ensure_cache_has_layers(_cache(seq=6, batch=2))
layer = cache.layers[0]
layer.crop(4)
assert layer.get_seq_length() == 4
assert cache.key_cache[0].shape[-2] == 4
layer.batch_repeat_interleave(2)
assert layer.keys.shape[0] == 4
assert cache.key_cache[0].shape[0] == 4
layer.batch_select_indices(torch.tensor([2, 3]))
assert layer.keys.shape[0] == 2
assert cache.key_cache[0].shape[0] == 2
class TestContainerParityOnARealDynamicCache:
"""S2.2: the *container* must behave like a 5.x cache, not just its layers.
Every assertion below runs on an object whose class is the installed
``transformers.cache_utils.DynamicCache``, so it is the real container
implementation being exercised - ``_ensure_cache_has_layers`` only adapts
it (populates ``layers`` and the stateful offload bookkeeping) rather than
reimplementing it.
"""
def test_layers_are_populated_before_any_forward(self, mod):
cache = _real_dynamic_cache(n_layers=3, seq=316)
assert getattr(cache, "layers", None) in (None, [])
wrapped = mod._ensure_cache_has_layers(cache)
# The ordering is the whole point: ``Cache.get_mask_sizes`` answers
# ``(query_length, 0)`` while ``layers`` is short, which makes the voice
# prefill invisible with no exception raised.
assert len(wrapped.layers) == 3
assert wrapped.layers[0].get_seq_length() == 316
def test_container_get_seq_length_returns_the_prefill(self, mod):
wrapped = mod._ensure_cache_has_layers(_real_dynamic_cache(seq=316))
assert wrapped.get_seq_length() == 316
def test_container_get_mask_sizes_returns_prefill_plus_query(self, mod):
wrapped = mod._ensure_cache_has_layers(_real_dynamic_cache(seq=316))
kv_length, kv_offset = wrapped.get_mask_sizes(torch.arange(316, 321), 0)
assert kv_length == 316 + 5
assert kv_offset == 0
def test_container_get_max_cache_shape_matches_the_layer(self, mod):
wrapped = mod._ensure_cache_has_layers(_real_dynamic_cache(seq=8))
assert wrapped.get_max_cache_shape() == -1
def test_container_offload_then_prefetch_round_trips(self, mod):
wrapped = mod._ensure_cache_has_layers(_real_dynamic_cache(seq=6))
before_k = wrapped.layers[0].keys.clone()
before_v = wrapped.layers[0].values.clone()
wrapped.offload(0)
assert wrapped.layers[0].keys.device.type == "cpu"
assert wrapped.key_cache[0].device.type == "cpu"
wrapped.prefetch(0)
assert torch.equal(wrapped.layers[0].keys, before_k)
assert torch.equal(wrapped.layers[0].values, before_v)
assert wrapped.get_seq_length() == 6
def test_container_crop_shortens_the_prefill(self, mod):
wrapped = mod._ensure_cache_has_layers(_real_dynamic_cache(seq=10))
wrapped.crop(4)
assert wrapped.get_seq_length() == 4
assert wrapped.key_cache[0].shape[-2] == 4
assert wrapped.get_mask_sizes(torch.arange(4, 9), 0)[0] == 9
def test_container_batch_repeat_interleave_and_select_indices(self, mod):
wrapped = mod._ensure_cache_has_layers(
_real_dynamic_cache(seq=5, batch=2)
)
wrapped.batch_repeat_interleave(3)
assert wrapped.layers[0].keys.shape[0] == 6
assert wrapped.key_cache[0].shape[0] == 6
wrapped.batch_select_indices(torch.tensor([4, 5]))
assert wrapped.layers[0].keys.shape[0] == 2
assert wrapped.key_cache[0].shape[0] == 2
def test_container_reorder_cache_follows_the_beam_index(self, mod):
wrapped = mod._ensure_cache_has_layers(_real_dynamic_cache(seq=5, batch=3))
rows = wrapped.layers[0].keys[:, :, 0, 0].clone()
wrapped.reorder_cache(torch.tensor([1, 2, 0]))
assert torch.equal(wrapped.layers[0].keys[:, :, 0, 0], rows[[1, 2, 0]])
def test_container_is_sliding_and_is_compileable_agree_with_the_layers(self, mod):
wrapped = mod._ensure_cache_has_layers(_real_dynamic_cache(n_layers=2))
assert wrapped.is_sliding == [False, False]
assert wrapped.is_compileable is False
assert wrapped.is_initialized is True
def test_legacy_pickle_carrying_offloading_true_is_corrected(self, mod):
"""A stale ``offloading=True`` is a live AttributeError on the offload path.
``Cache.__init__`` creates ``prefetch_stream`` only when ``offloading``
is truthy, and ``Cache.update`` reads that stream on every step. A
pickle that carries the flag without the stream therefore fails inside
the model's first forward, so the adapter has to drop the flag.
"""
cache = _real_dynamic_cache(seq=4)
cache.offloading = True
assert not hasattr(cache, "prefetch_stream")
wrapped = mod._ensure_cache_has_layers(cache)
assert wrapped.offloading is False
# One real Cache.update must not raise.
keys, values = wrapped.update(
torch.randn(1, 2, 1, 4), torch.randn(1, 2, 1, 4), 0
)
assert keys.shape[-2] == 5
assert values.shape[-2] == 5
def test_offloading_with_a_real_stream_is_left_alone(self, mod):
cache = _real_dynamic_cache(seq=4)
cache.offloading = True
cache.prefetch_stream = torch.Stream()
wrapped = mod._ensure_cache_has_layers(cache)
assert wrapped.offloading is True
assert wrapped.prefetch_stream is cache.prefetch_stream
def test_adapted_cache_exposes_a_prefetch_stream(self, mod):
# ``Cache.offload``/``Cache.prefetch`` open ``prefetch_stream``
# unconditionally, so it has to exist even with offloading off.
wrapped = mod._ensure_cache_has_layers(_real_dynamic_cache(seq=4))
assert hasattr(wrapped, "prefetch_stream")
def test_adapted_cache_stays_deep_copyable(self, mod):
# A cached voice prompt is deep-copied once per generation. A real
# torch.Stream cannot be copied ("cannot pickle 'torch.Stream' object"),
# so the stream the adapter provides must not make the cache uncopyable.
import copy as _copy
wrapped = mod._ensure_cache_has_layers(_real_dynamic_cache(seq=4))
clone = _copy.deepcopy(wrapped)
assert clone.get_seq_length() == 4
assert len(clone.layers) == len(wrapped.layers)
assert clone.layers[0].keys is clone.key_cache[0]
def test_adaptation_preserves_a_preconfigured_offloading_flag(self, mod):
cache = _real_dynamic_cache(seq=4)
cache.offloading = False
wrapped = mod._ensure_cache_has_layers(cache)
assert wrapped.offloading is False
class TestMaskConstructionConsumesThePrefill:
"""S2.3 tier C: the mask 5.3 actually hands to attention must be 321 wide.
No weights are needed: a two-layer Qwen2 is built from a tiny config on CPU,
exactly as the vendored model builds its LMs (``AutoModel.from_config`` on a
Qwen2 config), and the mask is captured with a forward pre-hook registered
with ``with_kwargs=True`` because ``modeling_qwen2`` passes it by keyword.
The arithmetic that matters: ``cache_position`` is ``arange(316, 321)`` and
``masking_utils`` uses those *real* positions as the query indices, so every
prefill key is visible by construction. What has to be checked is that the
kv length is ``316 + 5`` and not the ``cache_position`` length - an
unpopulated ``layers`` list makes ``Cache.get_mask_sizes`` answer
``(query_length, 0)`` silently.
"""
PREFILL = 316
QUERY = 5
LAYERS = 2
HEADS = 4
KV_HEADS = 2
HEAD_DIM = 16
def _tiny_qwen2(self):
from transformers import AutoModel
from transformers.models.qwen2.configuration_qwen2 import Qwen2Config
config = Qwen2Config(
vocab_size=256,
hidden_size=self.HEADS * self.HEAD_DIM,
intermediate_size=64,
num_hidden_layers=self.LAYERS,
num_attention_heads=self.HEADS,
num_key_value_heads=self.KV_HEADS,
max_position_embeddings=1024,
)
return AutoModel.from_config(config).eval()
def _prefilled_cache(self, mod, n_layers=None):
return mod._ensure_cache_has_layers(
_real_dynamic_cache(
n_layers=n_layers or self.LAYERS,
seq=self.PREFILL,
heads=self.KV_HEADS,
dim=self.HEAD_DIM,
)
)
def _run(self, model, cache, cache_position):
"""One forward, returning every mask handed to self-attention."""
seen = []
def record(module, args, kwargs):
seen.append(kwargs.get("attention_mask"))
handles = [
layer.self_attn.register_forward_pre_hook(record, with_kwargs=True)
for layer in model.layers
]
try:
with torch.no_grad():
model(
input_ids=torch.randint(0, 256, (1, cache_position.shape[0])),
attention_mask=torch.ones(
1, self.PREFILL + cache_position.shape[0], dtype=torch.long
),
past_key_values=cache,
cache_position=cache_position,
use_cache=True,
)
finally:
for handle in handles:
handle.remove()
return seen
def test_container_reports_prefill_plus_query_before_the_forward(self, mod):
cache = self._prefilled_cache(mod)
cache_position = torch.arange(self.PREFILL, self.PREFILL + self.QUERY)
assert len(cache.layers) == self.LAYERS
assert cache.get_seq_length() == self.PREFILL
assert cache.get_mask_sizes(cache_position, 0) == (self.PREFILL + self.QUERY, 0)
def test_an_unadapted_cache_would_silently_lose_the_prefill(self, mod):
"""The contrast case: this is what F2 looks like from the mask side.
With ``layers`` empty, ``Cache.get_mask_sizes`` falls back to the query
length. No exception, no warning - just a model that conditions on
nothing. Pinning this makes the "no bypass" claim above falsifiable.
"""
cache = _real_dynamic_cache(n_layers=self.LAYERS, seq=self.PREFILL, dim=self.HEAD_DIM)
cache.layers = []
cache_position = torch.arange(self.PREFILL, self.PREFILL + self.QUERY)
assert cache.get_mask_sizes(cache_position, 0) == (self.QUERY, 0)
def test_attention_receives_a_321_wide_mask(self, mod):
model = self._tiny_qwen2()
cache = self._prefilled_cache(mod)
cache_position = torch.arange(self.PREFILL, self.PREFILL + self.QUERY)
masks = self._run(model, cache, cache_position)
assert len(masks) == self.LAYERS
for mask in masks:
assert mask is not None, "a 5-token window must not take the is_causal_skip path"
assert tuple(mask.shape) == (1, 1, self.QUERY, self.PREFILL + self.QUERY)
def test_the_mask_is_causal_against_the_real_cache_positions(self, mod):
model = self._tiny_qwen2()
cache = self._prefilled_cache(mod)
cache_position = torch.arange(self.PREFILL, self.PREFILL + self.QUERY)
mask = self._run(model, cache, cache_position)[0][0, 0].bool()
for row, query_position in enumerate(cache_position.tolist()):
visible = mask[row]
# A query may attend to itself and to everything before it, and to
# nothing after it.
expected = torch.arange(self.PREFILL + self.QUERY) <= query_position
assert torch.equal(visible, expected), f"row {row} (cache_position {query_position})"
def test_no_prefill_position_is_masked_out(self, mod):
model = self._tiny_qwen2()
cache = self._prefilled_cache(mod)
cache_position = torch.arange(self.PREFILL, self.PREFILL + self.QUERY)
mask = self._run(model, cache, cache_position)[0][0, 0].bool()
# Every one of the 316 cached voice-prompt positions stays visible to
# every query row: that is the whole claim of F3.
assert mask[:, : self.PREFILL].all()
class TestReleasedVoicePromptPickle:
"""S2.2: the real .pt preset, not a hand-built stand-in.
Skipped when the local checkpoint/preset is absent, because the default
suite must stay runnable without a model download.
"""
@staticmethod
def _preset_path():
"""Resolve a real preset the way the node does, or ``None``.
Registration is the production path (``folder_paths`` only learns about
the TTS roots once ``extra_model_paths.yaml`` has been read and the
node has registered them), so anything less would skip the test on a
machine that in fact has the voices on disk.
"""
import os
import folder_paths
from ComfyUI_VibeVoice.modules.folder_registration import (
register_voice_preset_folder,
register_vibevoice_folders,
)
from ComfyUI_VibeVoice.modules.voice_presets import resolve_voice_preset_path
name = os.environ.get("VIBEVOICE_REALTIME_VOICE_PRESET", "en-Carter_man")
try:
config = Path(os.environ.get("COMFYUI_ROOT", "")) / "extra_model_paths.yaml"
if config.is_file():
import utils.extra_config as extra_config
extra_config.load_extra_path_config(str(config))
roots = register_vibevoice_folders(folder_paths)
register_voice_preset_folder(folder_paths, roots[0], roots[1:])
return resolve_voice_preset_path(name)
except Exception: # noqa: BLE001 - no local voices is a skip, not a failure
return None
def test_real_preset_reports_sane_cache_state(self, mod):
path = self._preset_path()
if path is None:
pytest.skip("no local voice preset available")
from ComfyUI_VibeVoice.modules.voice_presets import load_voice_preset
preset = load_voice_preset(path, torch.device("cpu"))
cache = mod._ensure_cache_has_layers(preset["tts_lm"].past_key_values)
import transformers.cache_utils as cache_utils
# The unpickled object IS the installed DynamicCache - the adapter
# only populates state, it does not reimplement the container.
assert isinstance(preset["tts_lm"].past_key_values, cache_utils.DynamicCache)
assert len(cache.layers) == len(cache.key_cache) > 0
cache_position = torch.arange(cache.get_seq_length(), cache.get_seq_length() + 5)
assert cache.get_seq_length() > 0
assert cache.get_mask_sizes(cache_position, 0) == (cache.get_seq_length() + 5, 0)
assert cache.is_sliding == [False] * len(cache.layers)
assert cache.is_initialized is True
assert cache.offloading is False
assert cache.layers[0].keys is cache.key_cache[0]
class TestEnsureCacheHasLayers:
def test_none_passes_through(self, mod):
assert mod._ensure_cache_has_layers(None) is None
def test_is_idempotent(self, mod):
cache = _cache(n_layers=2)
first = mod._ensure_cache_has_layers(cache)
second = mod._ensure_cache_has_layers(cache)
assert first is second
assert len(second.layers) == 2
def test_a_cache_with_no_legacy_lists_gets_an_empty_layer_list(self, mod):
class Bare:
pass
cache = mod._ensure_cache_has_layers(Bare())
assert cache.layers == []
def test_an_object_that_rejects_attributes_is_left_alone(self, mod):
# ``object()`` has no __dict__, so every setattr raises and the
# defensive branches swallow it rather than breaking the caller.
cache = mod._ensure_cache_has_layers(object())
assert not hasattr(cache, "layers")
class TestCachePositionContract:
"""``cache_position`` must name the tokens about to be fed, and only those.
This is the contract transformers 4.57.6 implemented and 5.3.0 changed.
4.57.6 returned ``cache_position[-1:] + num_new_tokens`` — a tensor of
exactly ``num_new_tokens`` entries. 5.3.0 returns
``torch.cat((cache_position, next_cache_position))``
(``transformers/generation/utils.py:938``), i.e. the whole history with the
new positions appended, so it grows by one entry per step.
The vendored ``prepare_inputs_for_generation`` slices its inputs with
``input_ids[:, -cache_position.shape[0]:]``, so the widened tensor silently
re-feeds every already-cached token: measured on VibeVoice-Realtime-0.5B the
KV cache grew 316 -> 321 -> 327 -> 334 -> 342 -> 351 -> 361 -> 372 instead
of by one per speech token. The mask is built correctly *for the wrong
query*, so nothing raises — the model just never reaches its own
end-of-speech and every clip runs to the length budget.
"""
@staticmethod
def _update(mod, cache_position, num_new_tokens=1):
"""Drive the vendored override with a stand-in for the base class.
The override delegates to ``super()._update_model_kwargs_for_generation``
and then trims. Faking the super() call is what makes this a pure unit
test: the assertion is on what the override hands back, not on which
transformers is installed.
"""
import transformers
owner = mod.VibeVoiceStreamingForConditionalGenerationInference
installed = transformers.generation.utils.GenerationMixin._update_model_kwargs_for_generation
def fake_super(self, outputs, model_kwargs, is_encoder_decoder=False, num_new_tokens=1):
# Exactly transformers 5.3.0's implementation, lines 932-939.
cache_position = model_kwargs.get("cache_position")
if cache_position is not None:
model_kwargs["cache_position"] = torch.cat(
(
cache_position,
torch.arange(num_new_tokens, dtype=torch.long) + cache_position[-1] + 1,
)
)
return model_kwargs
try:
transformers.generation.utils.GenerationMixin._update_model_kwargs_for_generation = fake_super
instance = owner.__new__(owner)
return owner._update_model_kwargs_for_generation(
instance, None, {"cache_position": cache_position}, num_new_tokens=num_new_tokens
)
finally:
transformers.generation.utils.GenerationMixin._update_model_kwargs_for_generation = installed
def test_a_single_new_token_yields_a_single_position(self, mod):
out = self._update(mod, torch.tensor([318, 319, 320]))
assert out["cache_position"].tolist() == [321]
def test_a_text_window_yields_exactly_that_many_positions(self, mod):
out = self._update(mod, torch.tensor([318, 319, 320]), num_new_tokens=5)
assert out["cache_position"].tolist() == [321, 322, 323, 324, 325]
def test_the_history_is_never_carried_forward(self, mod):
"""The whole point: the tensor must not start at position 0.
5.3.0 hands back ``[0, 1, ..., 320, 321]``. Feeding that to
``prepare_inputs_for_generation`` makes the query window 322 tokens
wide, which is the defect this pins.
"""
history = torch.arange(0, 321)
out = self._update(mod, history)
assert out["cache_position"].numel() == 1
assert int(out["cache_position"][0]) > int(history[-1])
def test_it_stays_correct_over_many_consecutive_steps(self, mod):
"""Feed the result back in, as the generation loop does.
The 4.x contract is a fixed point: each step advances the position by
exactly one, whatever history preceded it.
"""
cache_position = torch.tensor([318, 319, 320])
for expected in range(321, 360):
cache_position = self._update(mod, cache_position)["cache_position"]
assert cache_position.tolist() == [expected]
def test_model_kwargs_without_a_cache_position_are_left_alone(self, mod):
out = self._update(mod, None)
assert out["cache_position"] is None
def test_zero_new_tokens_does_not_slice_the_whole_history(self, mod):
"""``tensor[-0:]`` is the entire tensor, not an empty tail.
The generation loop never asks for zero new tokens, but a guard that
silently inverted on that input would be a trap for the next caller.
"""
history = torch.arange(0, 321)
out = self._update(mod, history, num_new_tokens=0)
assert out["cache_position"].tolist() == history.tolist()