Files
WildAi cfa57c8738 feat: streaming quant load, single-rounding dequant, release hygiene (v2.11.0)
Loading
- GGUF install is now two passes: a metadata pass that decides each
  tensor's disposition, then an install pass that assigns residents in
  place and streams dense tensors. The whole checkpoint is no longer
  buffered in a dict alongside the model being built.
- dequantize_reader_tensor takes a target dtype, so dequant-at-load
  writes straight into the destination parameter and the fp32
  intermediate is never allocated.
- A bundle whose heavy fields were released is rebuilt from its recorded
  source_path instead of failing the consumer.
- Host memory is released after install.

Numerics
- Q8_0 dequant computes in fp32 so the result is rounded once, at the
  final cast. The activation-dtype path was reverted: it rounded twice
  and moved stored weights.
- Removed a redundant weight-sized copy from the dequant kernel.
  Bitwise-identical, ~1.16x.
- Precision gates are bitwise rather than tolerance-based.

Docs and tests
- README condensed; changelog moved to CHANGELOG.md.
- Third-party project references removed from source comments.
- Tests no longer assert README prose; the e2e smoke contract follows
  the developer script to its new location and skips when absent.
- Version guard reads CHANGELOG.md.
2026-09-28 18:25:19 +03:00

540 lines
24 KiB
Python

"""Tests for the non-streaming VibeVoiceForConditionalGeneration.forward() and generate().
These tests exercise the model's inference path directly with a mocked inner
`self.model` (VibeVoiceModel) so we can verify behavior without importing the
full heavy vendored stack (which is blocked in the test env by a
diffusers/huggingface_hub version mismatch).
Key regression: forward() must NOT call semantic_connector with None during
generation (speech_semantic_tensors is None for inference).
To import the real module we stub the `diffusers` package (and submodules that
the vendored schedule code imports) so the import chain succeeds without the
real, version-incompatible diffusers install.
"""
import sys
import types
import importlib.util
from unittest.mock import MagicMock
import torch
import torch.nn as nn
def _stub_diffusers():
"""Stub the diffusers package and submodules the vendored code imports."""
if "diffusers" in sys.modules and not isinstance(sys.modules["diffusers"], MagicMock):
return # already a real diffusers
d = types.ModuleType("diffusers")
cu = types.ModuleType("diffusers.configuration_utils")
cu.ConfigMixin = object
cu.register_to_config = lambda *a, **k: None
du = types.ModuleType("diffusers.utils")
du.deprecate = lambda *a, **k: None
tu = types.ModuleType("diffusers.utils.torch_utils")
tu.randn_tensor = lambda *a, **k: None
su = types.ModuleType("diffusers.schedulers.scheduling_utils")
su.KarrasDiffusionSchedulers = object
su.SchedulerMixin = object
su.SchedulerOutput = object
d.configuration_utils = cu
d.utils = du
d.schedulers = su
sys.modules.setdefault("diffusers", d)
sys.modules.setdefault("diffusers.configuration_utils", cu)
sys.modules.setdefault("diffusers.utils", du)
sys.modules.setdefault("diffusers.utils.torch_utils", tu)
sys.modules.setdefault("diffusers.schedulers", su)
sys.modules.setdefault("diffusers.schedulers.scheduling_utils", su)
def _load_real_modeling_module():
"""Load the real modeling_vibevoice module, bypassing the conftest mock."""
_stub_diffusers()
# Remove any conftest-installed mocks for the vendored package so the real
# module is imported (not a MagicMock).
for mod in (
"src.vibevoice",
"src.vibevoice.modular",
"src.vibevoice.modular.modeling_vibevoice",
"ComfyUI_VibeVoice.src.vibevoice.modular.modeling_vibevoice",
):
sys.modules.pop(mod, None)
# Register the package hierarchy so relative imports (from .modular_...) work.
import os
root = os.path.join(os.getcwd(), "src", "vibevoice")
mod_pkg = types.ModuleType("src.vibevoice.modular")
mod_pkg.__path__ = [os.path.join(root, "modular")]
sys.modules.setdefault("src.vibevoice", types.ModuleType("src.vibevoice"))
sys.modules["src.vibevoice"].__path__ = [root]
sys.modules["src.vibevoice.modular"] = mod_pkg
# Stub configuration_vibevoice so AutoModel.register() works (it reads
# config_class.__name__). The conftest-mocked version is a bare MagicMock.
cfg_mod = types.ModuleType("src.vibevoice.modular.configuration_vibevoice")
class _FakeConfig:
__name__ = "VibeVoiceConfig"
model_type = "vibevoice"
cfg_mod.VibeVoiceConfig = _FakeConfig
# modeling_vibevoice imports the version-safe dtype helper from this
# module; provide the real implementation (never mocked).
from ComfyUI_VibeVoice.modules.dtype_utils import get_config_dtype, set_config_dtype
cfg_mod.get_config_dtype = get_config_dtype
cfg_mod.set_config_dtype = set_config_dtype
sys.modules["src.vibevoice.modular.configuration_vibevoice"] = cfg_mod
spec = importlib.util.spec_from_file_location(
"src.vibevoice.modular.modeling_vibevoice",
"src/vibevoice/modular/modeling_vibevoice.py",
)
module = importlib.util.module_from_spec(spec)
sys.modules["src.vibevoice.modular.modeling_vibevoice"] = module
spec.loader.exec_module(module)
return module
# Load the real module once at import time
_modeling = _load_real_modeling_module()
VibeVoiceForConditionalGeneration = _modeling.VibeVoiceForConditionalGeneration
VibeVoiceCausalLMOutputWithPast = _modeling.VibeVoiceCausalLMOutputWithPast
VibeVoiceGenerationOutput = _modeling.VibeVoiceGenerationOutput
class _FakeTokenizer:
"""Minimal stand-in for VibeVoiceTextTokenizer.
generate() resolves the four control-token ids off the tokenizer and
refuses to run without speech_start_id / speech_end_id /
speech_diffusion_id (modeling_vibevoice.py:802). The guard is correct —
the AR loop is token-constrained to exactly these ids — so the doubles
have to supply them. All four are distinct and inside the 100-row
embedding, so the constraint mask has somewhere to put them.
"""
speech_start_id = 90
speech_end_id = 91
speech_diffusion_id = 92
eos_id = 93
def __init__(self):
self.bos_token_id = 89
self.eos_token_id = 93
def _make_model(hidden_size: int = 64):
"""Build a VibeVoiceForConditionalGeneration with a fully mocked inner model.
The inner `self.model` (VibeVoiceModel) submodules are replaced with mocks
so forward()/generate() run without real weights.
"""
model = VibeVoiceForConditionalGeneration.__new__(VibeVoiceForConditionalGeneration)
# Properly initialize the nn.Module base so parameters()/device work and
# submodule assignment is allowed.
torch.nn.Module.__init__(model)
# generate() samples its next control token with torch.multinomial, so
# without a seed each run walks a different AR path. Seed here so a
# failure reproduces; the test that must actually REACH the diffusion
# branch pins the control stream outright rather than relying on this
# (see TestGenerateSemanticGuard.test_generate_reaches_the_diffusion_branch).
torch.manual_seed(1234)
# Minimal attributes used by forward()/generate()
model.config = MagicMock()
model.config.use_return_dict = True
model.config.diffusion_head_config = MagicMock()
model.config.diffusion_head_config.ddpm_num_inference_steps = 10
model.config.acoustic_tokenizer_config = MagicMock()
model.config.acoustic_tokenizer_config.vae_dim = 64
# generate() derives its AR step budget from max_position_embeddings
# (modeling_vibevoice.py:829). Left as a MagicMock auto-attribute,
# int(mock) is 1, so max_new_tokens = 1 - seq_len goes negative, clamps
# to 1 and max_steps collapses to 1 — the loop then draws exactly ONE
# control token and the whole diffusion branch (acoustic_tokenizer.decode
# -> semantic_tokenizer.encode -> both connectors) is never reached, so
# the doubles below would be inert and the seed below meaningless. Give
# the real width the model ships with.
model.config.decoder_config.max_position_embeddings = 2048
# sample_speech_tokens draws `torch.randn(N, config.acoustic_vae_dim)`
# (modeling_vibevoice.py:672); a MagicMock width raises TypeError there.
model.config.acoustic_vae_dim = 64
# generate() reads the control-token ids from the tokenizer; without it the
# guard at modeling_vibevoice.py:802 raises before anything else runs.
model.tokenizer = _FakeTokenizer()
# The AR loop builds a (1, vocab_size) -inf mask that admits only the four
# control ids, so it needs the real vocabulary width of the embedding
# below (100 rows), not the MagicMock config's auto-attribute.
model.vocab_size = 100
# Mock the inner VibeVoiceModel submodules
inner = MagicMock()
inner.semantic_connector = MagicMock()
inner.acoustic_connector = MagicMock()
inner.acoustic_tokenizer = MagicMock()
inner.speech_scaling_factor = torch.tensor(float('nan'))
inner.speech_bias_factor = torch.tensor(float('nan'))
inner.noise_scheduler = MagicMock()
inner.noise_scheduler.timesteps = [MagicMock()]
inner.noise_scheduler.set_timesteps = MagicMock()
inner.prediction_head = MagicMock()
inner.prediction_head.device = torch.device("cpu")
model.model = inner
# Mock get_input_embeddings() -> returns embeddings of shape (vocab, hidden)
emb = nn.Embedding(100, hidden_size)
model.get_input_embeddings = MagicMock(return_value=emb)
# Mock lm_head to produce logits. Register as a submodule so the model has
# at least one parameter (needed for next(self.parameters()).device).
model.add_module("lm_head", nn.Linear(hidden_size, 100, bias=False))
# Mock the inner language model forward used by forward()
def _inner_forward(**kwargs):
x = kwargs.get("inputs_embeds")
if x is None:
x = torch.randn(1, 4, hidden_size)
result = MagicMock()
result.last_hidden_state = x # real tensor so lm_head works
return result
inner.side_effect = _inner_forward
# The non-streaming generate() calls self.model.language_model(...) directly
# (one forward per autoregressive step), so provide a working inner LM that
# returns a real hidden-state tensor (mirrors the wrapper used by forward()).
def _lm_forward(inputs_embeds=None, **kwargs):
x = kwargs.get("inputs_embeds")
if not isinstance(x, torch.Tensor):
x = torch.randn(1, 4, hidden_size)
result = MagicMock()
result.last_hidden_state = x
result.past_key_values = None
return result
inner.language_model = MagicMock(side_effect=_lm_forward)
# Mock acoustic_tokenizer.decode to return a waveform tensor. The batch
# dimension must follow the latents it is handed: the AR loop fans several
# samples into one diffusion step, and a fixed-width mock made the encode /
# connector chain disagree with `sample_indices` on the runs where that
# happened.
inner.acoustic_tokenizer.decode = MagicMock(
side_effect=lambda latents, **kwargs: torch.randn(latents.shape[0], 24000)
)
# Mock prediction_head forward to return noise of correct shape
def _pred_head(noisy, timesteps, condition):
b = condition.shape[0]
return torch.randn(b, 64)
inner.prediction_head.side_effect = _pred_head
# Mock noise_scheduler.step to return prev_sample. The width must follow
# the `speech` tensor the diffusion loop hands it: sample_speech_tokens
# halves a 2N batch and returns `speech[:N]`, so a fixed 1-row prev_sample
# shrinks the latent to zero rows and the AR loop then indexes an empty
# audio chunk.
def _sched_step(eps, t, speech):
return types.SimpleNamespace(prev_sample=torch.randn_like(speech))
inner.noise_scheduler.step = MagicMock(side_effect=_sched_step)
# generate()'s diffusion step feeds each decoded audio chunk back through
# the semantic tokenizer and both connectors. Left as bare MagicMocks they
# return MagicMocks, which cannot be assigned into a real embedding tensor
# ("can't assign a MagicMock to a torch.FloatTensor"). The doubles only
# escaped this because the token-constrained sampling sometimes terminates
# before a diffusion step happens — so give the three real shapes, and the
# AR loop no longer depends on which token it happened to draw.
inner.semantic_tokenizer = MagicMock()
def _semantic_encode(audio, **kwargs):
n = audio.shape[0]
return types.SimpleNamespace(mean=torch.randn(n, 128, 4))
inner.semantic_tokenizer.encode = MagicMock(side_effect=_semantic_encode)
inner.acoustic_connector = MagicMock(
side_effect=lambda f: torch.randn(f.shape[0], hidden_size)
)
inner.semantic_connector = MagicMock(
side_effect=lambda f: torch.randn(f.shape[0], hidden_size)
)
return model
def _force_control_token(model, token_id: int):
"""Make the token-constrained AR loop always draw ``token_id``.
generate() builds a (1, vocab_size) -inf mask admitting only the speech
control ids, then argmaxes the masked logits (do_sample=False). Swapping in
a head that returns a constant logit vector with one entry above the rest
therefore pins the control stream without touching the code under test, so
a test can reach a specific AR branch deterministically instead of hoping
the RNG lands there.
A constant vector rather than a weight row: pointing a real Linear's row at
``token_id`` makes its logit ``sum(hidden)``, which goes negative whenever
the hidden state does — and then another valid control id wins the argmax
and the branch is skipped intermittently, which is the very flakiness this
exists to remove.
"""
class _FixedHead(nn.Module):
def __init__(self, vocab_size, forced):
super().__init__()
self.vocab_size = vocab_size
self.forced = forced
def forward(self, hidden):
logits = torch.zeros(
hidden.shape[0], self.vocab_size, dtype=hidden.dtype, device=hidden.device
)
logits[:, self.forced] = 1.0
return logits
model.lm_head = _FixedHead(model.vocab_size, token_id)
def _ar_step_budget(model, seq_len: int = 4, max_length_times: int = 2) -> int:
"""The AR loop's own step budget, recomputed from generate()'s arithmetic.
``max_new_tokens`` is derived from max_position_embeddings and
``max_steps = min(max_new_tokens, max_length_times * seq_len)``
(modeling_vibevoice.py:829-837), with a floor of 1.
"""
max_new_tokens = int(model.config.decoder_config.max_position_embeddings) - seq_len
max_new_tokens = max(1, min(max_new_tokens, 8192))
return max(1, min(max_new_tokens, max_length_times * seq_len))
class TestForwardSemanticConnectorGuard:
"""Regression: forward() must not crash when speech_semantic_tensors is None."""
def test_forward_with_none_semantic_tensors_does_not_crash(self):
model = _make_model()
input_ids = torch.randint(0, 100, (1, 4))
out = model.forward(
input_ids=input_ids,
speeches_loss_input=None,
speech_semantic_tensors=None,
return_dict=True,
)
assert isinstance(out, VibeVoiceCausalLMOutputWithPast)
assert out.logits is not None
def test_semantic_connector_not_called_when_none(self):
model = _make_model()
input_ids = torch.randint(0, 100, (1, 4))
model.forward(
input_ids=input_ids,
speeches_loss_input=None,
speech_semantic_tensors=None,
return_dict=True,
)
model.model.semantic_connector.assert_not_called()
def test_semantic_connector_called_when_provided(self):
model = _make_model()
input_ids = torch.randint(0, 100, (1, 4))
sem = torch.randn(1, 4, 128)
model.forward(
input_ids=input_ids,
speeches_loss_input=torch.zeros(1, 4, dtype=torch.bool),
speech_semantic_tensors=sem,
speech_masks=torch.zeros(1, 4, dtype=torch.bool),
acoustic_input_mask=torch.zeros(1, 4, dtype=torch.bool),
return_dict=True,
)
model.model.semantic_connector.assert_called_once()
class TestGenerateSemanticGuard:
"""Regression: generate() must run with semantic_speech_tensors=None."""
def test_generate_with_none_semantic_tensors_no_crash(self):
model = _make_model()
input_ids = torch.randint(0, 100, (1, 4))
out = model.generate(
input_ids=input_ids,
acoustic_input_mask=torch.zeros(1, 4, dtype=torch.bool),
semantic_speech_tensors=None,
cfg_scale=1.3,
inference_steps=5,
return_speech=True,
)
assert isinstance(out, VibeVoiceGenerationOutput)
assert out.speech_outputs is not None
assert len(out.speech_outputs) == 1
def test_generate_semantic_connector_never_sees_none(self):
model = _make_model()
input_ids = torch.randint(0, 100, (1, 4))
model.generate(
input_ids=input_ids,
acoustic_input_mask=torch.zeros(1, 4, dtype=torch.bool),
semantic_speech_tensors=None,
return_speech=True,
)
# semantic_connector must never be invoked with None during generation
model.model.semantic_connector.assert_not_called()
def test_generate_sets_ddpm_steps(self):
model = _make_model()
input_ids = torch.randint(0, 100, (1, 4))
model.set_ddpm_inference_steps(num_steps=7)
assert model.ddpm_inference_steps == 7
model.generate(
input_ids=input_ids,
acoustic_input_mask=torch.zeros(1, 4, dtype=torch.bool),
semantic_speech_tensors=None,
inference_steps=7,
return_speech=True,
)
model.model.noise_scheduler.set_timesteps.assert_called_with(7)
def test_generate_reaches_the_diffusion_branch(self):
"""The generate() doubles must actually be exercised, not merely defined.
Every other test in this class can pass while the whole diffusion
branch is dead: the AR loop only samples a latent when it draws
``speech_diffusion_id``, and whether it does depends on the RNG state
at the first multinomial draw. ``_make_model`` seeds the RNG, but each
test then draws its own ``torch.randint`` inputs first, so the draw
order — and therefore whether the diffusion branch runs at all — is an
accident of how many random numbers the test consumed before calling
generate(). That is how this file flaked: the doubles were correct but
unverified, and the shape errors they were written to prevent only
surfaced on the runs that happened to reach them.
A lucky seed would only make that accident reproducible rather than
removed — the draw would still shift under any torch whose
multinomial consumes the stream differently. So this test removes the
sampling from the equation: it forces the control stream with a fixed
lm_head and turns sampling off, which makes the AR path a pure
function of the code under test.
"""
model = _make_model()
_force_control_token(model, model.tokenizer.speech_diffusion_id)
out = model.generate(
input_ids=torch.randint(0, 100, (1, 4)),
acoustic_input_mask=torch.zeros(1, 4, dtype=torch.bool),
semantic_speech_tensors=None,
cfg_scale=1.3,
inference_steps=5,
do_sample=False,
return_speech=True,
)
inner = model.model
# One diffusion step per AR step, and the loop runs to its budget.
assert inner.acoustic_tokenizer.decode.call_count == _ar_step_budget(model), (
"expected every AR step to take the forced speech_diffusion_id "
"branch; the forced control stream is not reaching the loop"
)
# The semantic feedback is the part that assigns into a real tensor:
# next_inputs_embeds[diffusion_idx] = acoustic + semantic
# (modeling_vibevoice.py:1019), which a bare MagicMock cannot satisfy.
assert inner.semantic_tokenizer.encode.call_count == inner.acoustic_tokenizer.decode.call_count
assert inner.acoustic_connector.call_count == inner.acoustic_tokenizer.decode.call_count
assert inner.semantic_connector.call_count == inner.acoustic_tokenizer.decode.call_count
# decode is batch-faithful, so the assembled waveform has one chunk
# per diffusion step and a non-degenerate length.
assert out.speech_outputs[0] is not None
assert out.speech_outputs[0].dim() == 1
assert out.speech_outputs[0].shape[0] == inner.acoustic_tokenizer.decode.call_count * 24000
# Minimal stand-in for VibeVoiceTokenizerEncoderOutput (dataclass with .mean
# and .sample()). The real one lives in modular_vibevoice_tokenizer, but we
# avoid importing the heavy module here.
class _FakeEncoderOutput:
def __init__(self, mean):
self.mean = mean
def sample(self, dist_type="fix"):
# Mirror the real .sample(): returns (x, std) tuple.
x = self.mean + 0.01 * torch.randn_like(self.mean)
return x, None
def _make_model_with_encoder_output(hidden_size: int = 64):
"""Model whose acoustic_tokenizer.encode returns a dataclass (not a tensor)."""
model = _make_model(hidden_size)
# encode() must return a VibeVoiceTokenizerEncoderOutput-like object.
def _encode(audio, **kwargs):
# audio shape: [B, 1, L] -> produce mean [B, T, vae_dim]
mean = torch.randn(audio.shape[0], 4, 64)
return _FakeEncoderOutput(mean)
model.model.acoustic_tokenizer.encode = MagicMock(side_effect=_encode)
model.model.acoustic_tokenizer.std_dist_type = "fix"
# acoustic_connector must accept both:
# - [B, T, vae_dim] (from forward_speech_features, the reference prefix) -> [B, T, hidden]
# - [B, vae_dim] (a single generated latent fed back in the AR loop) -> [B, hidden]
# matching the real SpeechConnector's (input_dim -> hidden_size) contract.
def _acoustic_connector(f):
if f.dim() == 3:
return torch.randn(f.shape[0], f.shape[1], hidden_size)
return torch.randn(f.shape[0], hidden_size)
model.model.acoustic_connector.side_effect = _acoustic_connector
return model
class TestForwardSpeechFeaturesAudioPath:
"""Regression: forward_speech_features 'audio' branch must not subscript
the VibeVoiceTokenizerEncoderOutput dataclass returned by encode()."""
def test_forward_speech_features_audio_no_subscript_error(self):
model = _make_model_with_encoder_output()
speech_tensors = torch.randn(1, 1000) # [B, L] waveform
audio_features, connect_features = model.forward_speech_features(
speech_tensors=speech_tensors,
speech_masks=torch.ones(1, 4, dtype=torch.bool),
speech_type="audio",
)
assert isinstance(audio_features, torch.Tensor)
assert isinstance(connect_features, torch.Tensor)
# encode() must have been called (not crashed on [0][0])
model.model.acoustic_tokenizer.encode.assert_called_once()
def test_forward_speech_features_sample_called_with_std_dist(self):
model = _make_model_with_encoder_output()
speech_tensors = torch.randn(1, 1000)
model.forward_speech_features(
speech_tensors=speech_tensors,
speech_masks=torch.ones(1, 4, dtype=torch.bool),
speech_type="audio",
)
# The dataclass .sample() should be invoked with the tokenizer's std_dist_type
# (verified indirectly: no subscript error means .sample was reached)
model.model.acoustic_connector.assert_called()
class TestGenerateWithSpeechTensors:
"""Regression: generate() with voice-cloning speech_tensors must complete
without the encoder-output subscript crash."""
def test_generate_with_speech_tensors_no_crash(self):
model = _make_model_with_encoder_output()
input_ids = torch.randint(0, 100, (1, 4))
# acoustic_input_mask and speech_masks must mark the same positions
# (as the processor produces them), otherwise the embedding scatter
# at forward() line 416 would be a shape mismatch.
speech_mask = torch.ones(1, 4, dtype=torch.bool)
out = model.generate(
input_ids=input_ids,
speech_tensors=torch.randn(1, 1000),
speech_masks=speech_mask,
acoustic_input_mask=speech_mask,
semantic_speech_tensors=None,
cfg_scale=1.3,
inference_steps=5,
return_speech=True,
)
assert isinstance(out, VibeVoiceGenerationOutput)
assert out.speech_outputs is not None
assert len(out.speech_outputs) == 1