[Model] Add opt-in packed Flow and batched HiFT inference for CosyVoice3 (#8224)

Signed-off-by: Sy03 <1370724210@qq.com>
Signed-off-by: zack <huixindaddy@yahoo.com>
Co-authored-by: zack <huixindaddy@yahoo.com>
This commit is contained in:
Sy03andzack authored and GitHub committed 2026-09-29 15:48:36 +00:00
1 parent c85f1a44fb
commit 1ea0ee3b38
28 files changed
+4009 -223

No files matched your search

@@ -0,0 +1,283 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""F0 folding and joint C4/C5 loading regressions with the existing CPU F0 policy."""
import pytest
import torch
from torch import nn
from torch.nn.utils import parametrize
from vllm_omni.model_executor.models.cosyvoice3.code2wav_core import hifigan
pytestmark = [
pytest.mark.core_model,
pytest.mark.filterwarnings("ignore:.*weight_norm.*:FutureWarning"),
]
@pytest.fixture(autouse=True)
def fixed_seed():
with torch.random.fork_rng(devices=[]):
torch.random.default_generator.manual_seed(5)
yield
@pytest.fixture(params=["parametrized", "legacy"])
def weight_norm_api(request, monkeypatch):
if request.param == "legacy":
monkeypatch.setattr(hifigan, "weight_norm", nn.utils.weight_norm)
return request.param
@pytest.fixture(
params=[torch.enable_grad, torch.no_grad, torch.inference_mode], ids=["grad", "no_grad", "inference_mode"]
)
def fold_context(request):
return request.param
@pytest.fixture(
params=[
pytest.param("cpu", marks=pytest.mark.cpu),
pytest.param("cuda", marks=pytest.mark.cuda),
]
)
def runtime(request):
"""Only the generator may run on CUDA; production F0 still runs on CPU."""
on_cuda = request.param == "cuda"
if on_cuda and not torch.cuda.is_available():
pytest.skip("CUDA is required for the generator/device-placement regressions")
with torch.random.fork_rng(devices=[0] if on_cuda else []):
torch.manual_seed(5)
with torch.backends.cudnn.flags(benchmark=False, deterministic=True, allow_tf32=False):
yield torch.device("cuda:0" if on_cuda else "cpu")
def _has_weight_norm(module):
return parametrize.is_parametrized(module, "weight") or (
hasattr(module, "weight_g") and hasattr(module, "weight_v")
)
def _f0_layers(f0):
return [layer for layer in f0.condnet if isinstance(layer, hifigan.CausalConv1d)]
def _make_hift():
# Source noise/phase tensors are not in state_dict. Give independent
# instances identical construction RNG without changing the caller's RNG.
with torch.random.fork_rng(devices=[]):
torch.random.default_generator.manual_seed(5)
return hifigan.CausalHiFTGenerator(
base_channels=32,
sampling_rate=24000,
upsample_rates=[8, 5, 3],
upsample_kernel_sizes=[16, 11, 7],
resblock_kernel_sizes=[3],
resblock_dilation_sizes=[[1, 3, 5]],
source_resblock_kernel_sizes=[3, 3, 3],
source_resblock_dilation_sizes=[[1, 3, 5]] * 3,
f0_predictor=hifigan.CausalConvRNNF0Predictor(cond_channels=16),
)
def _make_loader():
from vllm_omni.model_executor.models.cosyvoice3.cosyvoice3_code2wav import CosyVoice3Code2Wav
# Real load_weights and vocoder; only the unrelated Flow is reduced.
model = CosyVoice3Code2Wav.__new__(CosyVoice3Code2Wav)
nn.Module.__init__(model)
model.flow_model = nn.Linear(2, 2)
model.hift = _make_hift().train()
model._hift_window_len = 64
return model
def _write_checkpoint(model, directory, legacy_checkpoint=False):
state = {name: value.detach().clone() for name, value in model.hift.state_dict().items()}
# Change conv_pre and F0 gains to detect stale constructor weights without
# amplifying every generator layer and overflowing its magnitude exp().
for name, value in state.items():
if name.startswith(("conv_pre.", "f0_predictor.")) and name.endswith(
("weight_g", "parametrizations.weight.original0")
):
value.mul_(2)
if legacy_checkpoint:
state = {
name.replace("parametrizations.weight.original0", "weight_g").replace(
"parametrizations.weight.original1", "weight_v"
): value
for name, value in state.items()
}
torch.save(model.flow_model.state_dict(), directory / "flow.pt")
torch.save({"generator." + name: value for name, value in state.items()}, directory / "hift.pt")
return state
@pytest.mark.cpu
@pytest.mark.parametrize("finalize", [False, True])
def test_f0_fold_is_exact_and_idempotent(weight_norm_api, finalize):
f0 = hifigan.CausalConvRNNF0Predictor(cond_channels=16).eval()
layers = _f0_layers(f0)
assert len(layers) == 5 and all(_has_weight_norm(layer) for layer in layers)
assert hasattr(layers[0], "weight_g") == (weight_norm_api == "legacy")
classifier = {name: value.detach().clone() for name, value in f0.classifier.state_dict().items()}
context = f0.left_context_frames
mel = torch.randn(1, 80, 24)
with torch.no_grad():
before = f0(mel, finalize=finalize)
assert f0.remove_weight_norm() == 5
weights = [layer.weight for layer in layers]
assert f0.remove_weight_norm() == 0
assert all(layer.weight is weight for layer, weight in zip(layers, weights))
assert not any(_has_weight_norm(module) for module in f0.modules())
assert f0.left_context_frames == context
with torch.no_grad():
after = f0(mel, finalize=finalize)
assert before.numel() > 0 and torch.isfinite(before).all()
torch.testing.assert_close(after, before, rtol=0, atol=0)
for name, value in f0.classifier.state_dict().items():
torch.testing.assert_close(value, classifier[name], rtol=0, atol=0)
@pytest.mark.cpu
def test_f0_fold_preserves_autograd_usability(weight_norm_api, fold_context):
f0 = hifigan.CausalConvRNNF0Predictor(cond_channels=16).eval()
mel = torch.randn(1, 80, 24, requires_grad=True)
with torch.no_grad():
before = f0(mel)
with fold_context():
assert f0.remove_weight_norm() == 5
assert f0.remove_weight_norm() == 0
for layer in _f0_layers(f0):
assert isinstance(layer.weight, nn.Parameter)
assert not layer.weight.is_inference()
out = f0(mel)
torch.testing.assert_close(out, before, rtol=0, atol=0)
out.sum().backward()
assert mel.grad is not None and torch.isfinite(mel.grad).all()
@pytest.mark.cpu
def test_f0_fold_leaves_generator_norms_unchanged(weight_norm_api):
hift = _make_hift()
generator_norms = {
name
for name, module in hift.named_modules()
if not name.startswith("f0_predictor.") and _has_weight_norm(module)
}
assert len(generator_norms) == 41
assert sum(_has_weight_norm(module) for module in hift.f0_predictor.modules()) == 5
assert hift.f0_predictor.remove_weight_norm() == 5
assert {name for name, module in hift.named_modules() if _has_weight_norm(module)} == generator_norms
@pytest.mark.parametrize(
"weight_norm_api,legacy_checkpoint",
[("parametrized", False), ("parametrized", True), ("legacy", True)],
indirect=["weight_norm_api"],
)
def test_load_folds_loaded_generator_and_f0_weights(
tmp_path, weight_norm_api, legacy_checkpoint, runtime, fold_context
):
device = runtime
model = _make_loader()
assert model.hift.training and model.hift.f0_predictor.training and model.flow_model.training
assert hasattr(model.hift.f0_predictor.condnet[0], "weight_g") == (weight_norm_api == "legacy")
state = _write_checkpoint(model, tmp_path, legacy_checkpoint)
# Do not forward the loader instance before loading. A separate unfolded
# reference refreshes legacy cached weights from the changed checkpoint.
reference = _make_hift()
reference.load_state_dict(state, strict=True)
reference.to(device).eval()
reference.f0_predictor.to(device="cpu", dtype=torch.float32)
normalized = {name: module for name, module in reference.named_modules() if _has_weight_norm(module)}
assert len(normalized) == 46 # 41 reduced-fixture generator layers + 5 F0
mel = torch.randn(1, 80, 24, device=device)
expected = reference.inference(mel, finalize=True)[0]
with fold_context():
model.load_weights(str(tmp_path), device)
assert not model.hift.training and not model.hift.f0_predictor.training and not model.flow_model.training
assert not any(_has_weight_norm(module) for module in model.hift.modules())
for name, ref_layer in normalized.items():
layer = model.hift.get_submodule(name)
assert isinstance(layer.weight, nn.Parameter) and not layer.weight.is_inference()
assert layer.weight.device == device
assert layer.weight.dtype == torch.float32
torch.testing.assert_close(layer.weight, ref_layer.weight, rtol=0, atol=0)
assert {p.device for p in model.hift.f0_predictor.parameters()} == {device}
assert {p.dtype for p in model.hift.f0_predictor.parameters()} == {torch.float32}
assert model.hift.remove_weight_norm() == 0
assert model.hift.f0_predictor.remove_weight_norm() == 0
actual = model.hift.inference(mel, finalize=True)[0]
assert expected.numel() > 0 and torch.isfinite(expected).all()
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
@pytest.mark.cpu
def test_invalid_checkpoint_does_not_fold_either_component(tmp_path, weight_norm_api, monkeypatch):
model = _make_loader()
state = _write_checkpoint(model, tmp_path)
state.pop("f0_predictor.classifier.bias")
torch.save({"generator." + name: value for name, value in state.items()}, tmp_path / "hift.pt")
def unexpected_fold(*args, **kwargs):
pytest.fail("Folding must not run before strict checkpoint validation")
monkeypatch.setattr(model.hift, "remove_weight_norm", unexpected_fold)
monkeypatch.setattr(model.hift.f0_predictor, "remove_weight_norm", unexpected_fold)
with pytest.raises(RuntimeError, match="Missing key"):
model.load_weights(str(tmp_path), torch.device("cpu"))
assert sum(_has_weight_norm(module) for module in model.hift.modules()) == 46
@pytest.mark.parametrize("reference_fold_generator", [False, True], ids=["unfolded", "c4_only"])
def test_joint_fold_preserves_streaming_audio_and_caches(tmp_path, weight_norm_api, runtime, reference_fold_generator):
device = runtime
model = _make_loader()
state = _write_checkpoint(model, tmp_path)
reference = _make_loader()
reference.hift.load_state_dict(state, strict=True)
reference.hift.to(device).eval()
reference.hift.f0_predictor.to(device="cpu", dtype=torch.float32)
# Compare both against no folding and against C4 alone, isolating C5's
# incremental effect while exercising the real loader/streaming path.
if reference_fold_generator:
assert reference.hift.remove_weight_norm() == 41
model.load_weights(str(tmp_path), device)
assert not any(_has_weight_norm(module) for module in model.hift.modules())
expected_norms = 5 if reference_fold_generator else 46
assert sum(_has_weight_norm(module) for module in reference.hift.modules()) == expected_norms
chunks = torch.randn(1, 80, 144, device=device).split(24, dim=-1)
cache = ref_cache = None
shifted = False
for i, chunk in enumerate(chunks):
finalize = i == len(chunks) - 1
expected, ref_cache = reference._stream_hift_from_feat(chunk, cache_state=ref_cache, finalize=finalize)
actual, cache = model._stream_hift_from_feat(chunk, cache_state=cache, finalize=finalize)
assert expected.numel() > 0 and torch.isfinite(expected).all()
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
if finalize:
assert cache is None and ref_cache is None
else:
assert cache.keys() == ref_cache.keys()
for key, value in ref_cache.items():
if isinstance(value, torch.Tensor):
torch.testing.assert_close(cache[key], value, rtol=0, atol=0)
else:
assert cache[key] == value
shifted |= cache["mel_offset"] > 0
assert shifted
@pytest.mark.cpu
def test_shipped_f0_config_folds_exactly_five_layers(weight_norm_api):
from vllm_omni.transformers_utils.configs.cosyvoice3 import CosyVoice3Config
f0 = hifigan.CausalConvRNNF0Predictor(**CosyVoice3Config().hift["f0_predictor"])
assert sum(_has_weight_norm(module) for module in f0.modules()) == 5
assert f0.remove_weight_norm() == 5
assert f0.remove_weight_norm() == 0
assert not any(_has_weight_norm(module) for module in f0.modules())
@@ -0,0 +1,65 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import pytest
import torch
from tests.helpers.mark import hardware_test
from tests.model_executor.models.cosyvoice3.test_cosyvoice3_full_batch import MelOutput, _items
from vllm_omni.platforms import current_omni_platform
pytestmark = [pytest.mark.core_model, pytest.mark.skipif(not current_omni_platform.is_cuda(), reason="requires CUDA")]
@hardware_test(res={"cuda": "H100"}, num_cards=1)
@pytest.mark.parametrize("streaming", [False, True])
@torch.inference_mode()
def test_flow_graph_replay_matches_eager_solve(monkeypatch, streaming):
if torch.cuda.get_device_capability()[0] != 9:
pytest.skip("the opt-in packed backend requires Hopper FA3")
import vllm_omni.model_executor.models.cosyvoice3.cosyvoice3_code2wav as module
from vllm_omni.transformers_utils.configs.cosyvoice3 import CosyVoice3Config
monkeypatch.setenv("DIFFUSION_ATTENTION_BACKEND", "TORCH_SDPA")
monkeypatch.setenv("COSYVOICE3_FLOW_GRAPH", "1")
monkeypatch.setenv("COSYVOICE3_FULL_RESPONSE_OPTIMIZATIONS", "0" if streaming else "1")
monkeypatch.setenv("COSYVOICE3_PACKED_STREAMING", "1" if streaming else "0")
monkeypatch.setattr(module, "CausalHiFTGenerator", MelOutput)
config = CosyVoice3Config()
config.flow["pre_lookahead_layer"]["channels"] = 32
# 256 channels in 16 conv groups keep the packed convolution (and graphs).
config.flow["decoder"]["estimator"].update(dim=256, depth=2, heads=4, dim_head=64)
model = module.CosyVoice3Code2Wav(config).cuda().bfloat16().eval()
if streaming:
monkeypatch.setattr(
model, "_stream_hift_from_feat", lambda mel, cache_state, finalize: (mel, None if finalize else cache_state)
)
else:
model.hift.upsample_rates = [1]
model.hift.istft_params = {"hop_len": 1}
def run(items, graph):
estimator = getattr(model, "_packed_estimator", None)
runner = None if estimator is None else estimator.graph_runner
if estimator is not None and not graph:
estimator.graph_runner = None
torch.manual_seed(3)
try:
if streaming:
return [audio for audio, _ in model.forward_streaming_batch(items, n_timesteps=3)]
return model.forward_batch(items, n_timesteps=3)
finally:
if estimator is not None:
estimator.graph_runner = runner
extra = dict(token_offset_tokens=2, finalize=False, cache_state=None) if streaming else {}
# The second batch reuses the first one's padded layout with new lengths.
batches = [_items([(7, 60), (13, 41), (3, 97)], **extra), _items([(5, 71), (9, 30), (11, 88)], **extra)]
run(batches[0], graph=False) # compile
for items in batches:
actual = run(items, graph=True)
expected = run(items, graph=False)
for output, reference in zip(actual, expected):
torch.testing.assert_close(output, reference, rtol=1e-2, atol=1e-2)
runner = model._packed_estimator.graph_runner
assert len(runner.graphs) == 1
@@ -0,0 +1,188 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from types import SimpleNamespace
import pytest
import torch
from torch import nn
from tests.helpers.mark import hardware_test
from vllm_omni.platforms import current_omni_platform
pytestmark = [pytest.mark.core_model, pytest.mark.skipif(not current_omni_platform.is_cuda(), reason="requires CUDA")]
class MelOutput(nn.Module):
def __init__(self, **kwargs):
super().__init__()
self.anchor = nn.Parameter(torch.zeros(1))
self.m_source = SimpleNamespace(l_linear=SimpleNamespace(weight=self.anchor))
def inference(self, speech_feat, finalize):
assert finalize
return speech_feat, None, None
def _items(shapes, **extra):
return [
dict(
token=torch.randint(0, 64, (1, length), device="cuda"),
prompt_token=torch.randint(0, 64, (1, prompt)),
prompt_feat=torch.randn(1, prompt * 2, 80, device="cuda"),
embedding=torch.randn(1, 192, device="cuda"),
**extra,
)
for prompt, length in shapes
]
def tiny_flow(monkeypatch):
import vllm_omni.model_executor.models.cosyvoice3.cosyvoice3_code2wav as module
from vllm_omni.transformers_utils.configs.cosyvoice3 import CosyVoice3Config
monkeypatch.setenv("DIFFUSION_ATTENTION_BACKEND", "TORCH_SDPA")
monkeypatch.setattr(module, "CausalHiFTGenerator", MelOutput)
config = CosyVoice3Config()
config.flow["pre_lookahead_layer"]["channels"] = 32
config.flow["decoder"]["estimator"].update(dim=32, depth=2, heads=4, dim_head=8)
return module.CosyVoice3Code2Wav(config).cuda().eval()
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("offset", [0, 3])
@pytest.mark.parametrize("count", [3, 11])
@torch.inference_mode()
def test_full_batch_ragged_prompt_matches_independent_flow(monkeypatch, offset, count):
model = tiny_flow(monkeypatch)
items = _items(([(7, 17), (13, 9), (3, 31)] * 4)[:count], token_offset_tokens=offset)
# Fix Flow noise so scheduling changes do not change the comparison input.
monkeypatch.setattr(torch, "randn", lambda shape, **kw: torch.zeros(shape, **kw))
expected = [model.forward(**item, n_timesteps=3) for item in items]
actual = model.forward_batch(items, n_timesteps=3)
for result, reference in zip(actual, expected):
torch.testing.assert_close(result, reference, rtol=3e-4, atol=3e-5)
# Reordering unrelated requests must not move their conditioning or crop points.
reverse = model.forward_batch(items[::-1], n_timesteps=3)
for result, reference in zip(reverse, expected[::-1]):
torch.testing.assert_close(result, reference, rtol=3e-4, atol=3e-5)
assert model.forward_batch([]) == []
@hardware_test(res={"cuda": "H100"}, num_cards=1)
@torch.inference_mode()
def test_packed_full_response_preserves_request_isolation(monkeypatch):
if torch.cuda.get_device_capability()[0] != 9:
pytest.skip("the opt-in packed backend requires Hopper FA3")
monkeypatch.setenv("COSYVOICE3_FULL_RESPONSE_OPTIMIZATIONS", "1")
model = tiny_flow(monkeypatch).bfloat16()
model.hift.upsample_rates = [1]
model.hift.istft_params = {"hop_len": 1}
items = _items([(7, 17), (13, 9), (3, 31)])
monkeypatch.setattr(torch, "randn", lambda shape, **kw: torch.zeros(shape, **kw))
expected = [model.forward(**item, n_timesteps=3) for item in items]
actual = model.forward_batch(items, n_timesteps=3)
for output, reference in zip(actual, expected):
torch.testing.assert_close(output, reference, rtol=0.04, atol=0.04)
# A different batch order must not leak the other requests' conditioning.
actual = model.forward_batch(items[::-1], n_timesteps=3)
for output, reference in zip(actual, expected[::-1]):
torch.testing.assert_close(output, reference, rtol=0.04, atol=0.04)
@hardware_test(res={"cuda": "H100"}, num_cards=1)
def test_packed_stream_noise_growth_preserves_prefix_and_global_rng():
from vllm_omni.model_executor.models.cosyvoice3.cosyvoice3_code2wav import CosyVoice3Code2Wav
model = CosyVoice3Code2Wav.__new__(CosyVoice3Code2Wav)
nn.Module.__init__(model)
like = torch.empty(1, 80, 1, dtype=torch.bfloat16)
rng = torch.random.get_rng_state().clone()
first = model._stream_position_noise(97, like).clone()
grown = model._stream_position_noise(30001, like).clone()
torch.testing.assert_close(grown[..., :97], first, rtol=0, atol=0)
torch.testing.assert_close(torch.random.get_rng_state(), rng, rtol=0, atol=0)
direct = CosyVoice3Code2Wav.__new__(CosyVoice3Code2Wav)
nn.Module.__init__(direct)
torch.testing.assert_close(direct._stream_position_noise(30001, like), grown, rtol=0, atol=0)
assert model._stream_position_noise(0, like).shape == (1, 80, 0)
@hardware_test(res={"cuda": "H100"}, num_cards=1)
@torch.inference_mode()
def test_packed_stream_attention_matches_chunk_causal_reference():
if torch.cuda.get_device_capability()[0] != 9:
pytest.skip("the opt-in packed backend requires Hopper FA3")
from vllm_omni.model_executor.models.cosyvoice3.code2wav_core.packed_dit import (
RaggedRowAttention,
pack_rows,
packed_fa3,
)
lengths = [67, 103]
rows = pack_rows(lengths, torch.device("cuda"))
attention = RaggedRowAttention(rows, heads=2, head_dim=64, chunk_size=50)
generator = torch.Generator(device="cuda").manual_seed(7)
q, k, v = [
torch.randn(sum(lengths), 2, 64, device="cuda", dtype=torch.bfloat16, generator=generator) for _ in range(3)
]
actual = packed_fa3(
q,
k[:, None],
v[:, None],
attention.cache_seqlens,
attention.page_table,
attention.cu_seqlens_q,
attention.max_seqlen_q,
)
start = 0
for length in lengths:
position = torch.arange(length, device="cuda")
mask = position[None, :] < ((position[:, None] // 50 + 1) * 50)
expected = torch.nn.functional.scaled_dot_product_attention(
q[start : start + length].transpose(0, 1).float(),
k[start : start + length].transpose(0, 1).float(),
v[start : start + length].transpose(0, 1).float(),
attn_mask=mask,
).transpose(0, 1)
torch.testing.assert_close(actual[start : start + length].float(), expected, rtol=0.03, atol=0.01)
start += length
@hardware_test(res={"cuda": "H100"}, num_cards=1)
@torch.inference_mode()
def test_packed_stream_mixed_finalization_and_ragged_requests_stay_aligned(monkeypatch):
if torch.cuda.get_device_capability()[0] != 9:
pytest.skip("the opt-in packed backend requires Hopper FA3")
monkeypatch.setenv("COSYVOICE3_FULL_RESPONSE_OPTIMIZATIONS", "0")
monkeypatch.setenv("COSYVOICE3_PACKED_STREAMING", "1")
model = tiny_flow(monkeypatch).bfloat16()
monkeypatch.setattr(
model, "_stream_hift_from_feat", lambda mel, cache_state, finalize: (mel, None if finalize else cache_state)
)
items = []
for index, (prompt, generated) in enumerate(([(7, 17), (13, 9), (3, 31)] * 6)[:17]):
items.append(
dict(
token=torch.randint(0, 64, (1, generated), device="cuda"),
prompt_token=torch.randint(0, 64, (1, prompt)),
prompt_feat=torch.randn(1, prompt * 2, 80, device="cuda"),
embedding=torch.randn(1, 192, device="cuda"),
token_offset_tokens=3,
finalize=index % 3 == 0,
cache_state={"request_marker": torch.tensor(index)},
)
)
expected = [model.forward_streaming_batch([item], n_timesteps=3)[0] for item in items]
for ordered, reference in [(items, expected), (items[::-1], expected[::-1])]:
actual = model.forward_streaming_batch(ordered, n_timesteps=3)
for (audio, state), (ref_audio, ref_state), item in zip(actual, reference, ordered):
torch.testing.assert_close(audio, ref_audio, rtol=0.04, atol=0.04)
assert state is ref_state
assert state is (None if item["finalize"] else item["cache_state"])
assert model.forward_streaming_batch([]) == []
with pytest.raises(ValueError, match="mixed finalization"):
model.forward_batch(items, n_timesteps=3, stream_items=True)
monkeypatch.setenv("COSYVOICE3_PACKED_STREAMING", "0")
with pytest.raises(ValueError, match="COSYVOICE3_PACKED_STREAMING"):
model.forward_batch(items[:1], n_timesteps=3, stream_items=True)
@@ -0,0 +1,264 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""CPU regressions for folding frozen HiFT generator weight normalization."""
import pytest
import torch
from torch import nn
from torch.nn.utils import parametrize
from vllm_omni.model_executor.models.cosyvoice3.code2wav_core import hifigan
pytestmark = [
pytest.mark.core_model,
pytest.mark.cpu,
# The legacy weight_norm API warns on construction; the "legacy" params
# below exercise it on purpose.
pytest.mark.filterwarnings("ignore:.*weight_norm.*:FutureWarning"),
]
@pytest.fixture(autouse=True)
def fixed_seed():
with torch.random.fork_rng(devices=[]):
torch.manual_seed(0)
yield
@pytest.fixture(params=["parametrized", "legacy"])
def weight_norm_api(request, monkeypatch):
if request.param == "legacy":
monkeypatch.setattr(hifigan, "weight_norm", nn.utils.weight_norm)
return request.param
def _has_weight_norm(module):
return parametrize.is_parametrized(module, "weight") or (
hasattr(module, "weight_g") and hasattr(module, "weight_v")
)
def _make_hift(causal=True, sampling_rate=24000):
# Note: pairing the non-causal generator with CausalConvRNNF0Predictor is
# not a shipped configuration; it exists only to exercise the shared
# ResBlock/conv fold code on both generator classes.
cls = hifigan.CausalHiFTGenerator if causal else hifigan.HiFTGenerator
return cls(
base_channels=32,
sampling_rate=sampling_rate,
# Keep the real CosyVoice3 upsample ratio and SineGen2 (24 kHz),
# while shrinking channels and residual blocks for CPU tests.
upsample_rates=[8, 5, 3],
upsample_kernel_sizes=[16, 11, 7],
resblock_kernel_sizes=[3],
resblock_dilation_sizes=[[1, 3, 5]],
source_resblock_kernel_sizes=[3, 3, 3],
source_resblock_dilation_sizes=[[1, 3, 5]] * 3,
f0_predictor=hifigan.CausalConvRNNF0Predictor(cond_channels=16),
).eval()
@pytest.mark.parametrize("causal", [False, True])
def test_resblock_fold_is_exact_and_idempotent(weight_norm_api, causal):
block = hifigan.ResBlock(channels=4, causal=causal).eval()
x = torch.randn(1, 4, 12)
conv = block.convs1[0]
# Guard: the fixture really selected the intended API, so the legacy
# cases cannot silently re-test the parametrizations path.
if weight_norm_api == "legacy":
assert hasattr(conv, "weight_g") and not parametrize.is_parametrized(conv, "weight")
else:
assert parametrize.is_parametrized(conv, "weight")
with torch.no_grad():
before = block(x)
# Fold in normal grad mode, like the production load path (vLLM's loader
# does not wrap load_weights in no_grad/inference_mode): the materialized
# weight must be a real Parameter.
first = block.remove_weight_norm()
assert isinstance(conv.weight, nn.Parameter)
second = block.remove_weight_norm()
with torch.no_grad():
once = block(x)
twice = block(x)
assert (first, second) == (6, 0) # 3 dilations x {convs1, convs2}; no-op after
assert not any(_has_weight_norm(module) for module in block.modules())
torch.testing.assert_close(once, before, rtol=0, atol=0)
torch.testing.assert_close(twice, before, rtol=0, atol=0)
@pytest.mark.parametrize(
"causal,sampling_rate,finalize",
[(False, 22050, True), (True, 22050, True), (True, 24000, True), (True, 24000, False)],
)
def test_hift_fold_preserves_audio_and_leaves_f0_unchanged(weight_norm_api, causal, sampling_rate, finalize):
hift = _make_hift(causal, sampling_rate)
mel = torch.randn(1, 80, 20)
if weight_norm_api == "legacy":
assert hasattr(hift.conv_pre, "weight_g") and not parametrize.is_parametrized(hift.conv_pre, "weight")
normalized = {name: module for name, module in hift.named_modules() if _has_weight_norm(module)}
f0_state = {name: value.clone() for name, value in hift.f0_predictor.state_dict().items()}
assert any(name.startswith("source_resblocks.") for name in normalized)
assert any(name.startswith("resblocks.") for name in normalized)
# The C5-scope layers must start out weight-normed, or the "f0 untouched"
# asserts below would pass vacuously.
assert any(name.startswith("f0_predictor.") for name in normalized)
# Non-causal HiFT draws fresh source noise on each invocation.
rng = torch.get_rng_state()
kwargs = {"finalize": finalize} if causal else {}
before = hift.inference(mel, **kwargs)[0]
# 3 ups + conv_pre + conv_post + (3 resblocks + 3 source resblocks) x 6 convs
assert hift.remove_weight_norm() == 41
assert hift.remove_weight_norm() == 0
torch.set_rng_state(rng)
after = hift.inference(mel, **kwargs)[0]
assert before.numel() > 0
assert torch.isfinite(before).all()
torch.testing.assert_close(after, before, rtol=0, atol=0)
for name, module in normalized.items():
# The generator entry point must leave F0 to its separate fold call.
assert _has_weight_norm(module) == name.startswith("f0_predictor.")
for name, value in hift.f0_predictor.state_dict().items():
torch.testing.assert_close(value, f0_state[name], rtol=0, atol=0)
@pytest.mark.parametrize(
"weight_norm_api,legacy_checkpoint",
[("parametrized", False), ("parametrized", True), ("legacy", True)],
indirect=["weight_norm_api"],
)
def test_load_weights_folds_loaded_not_initial_weights(tmp_path, weight_norm_api, legacy_checkpoint):
from vllm_omni.model_executor.models.cosyvoice3.cosyvoice3_code2wav import CosyVoice3Code2Wav
# Exercise the real loader with a small vocoder and synthetic checkpoints.
# __new__ skips CosyVoice3Code2Wav.__init__ (which would build the DiT and
# flow decoder); load_weights only touches .flow_model and .hift.
model = CosyVoice3Code2Wav.__new__(CosyVoice3Code2Wav)
nn.Module.__init__(model)
model.flow_model = nn.Linear(2, 2)
# Start in training mode to check the loader's transition to eval.
model.hift = _make_hift().train()
assert model.hift.training
conv = model.hift.conv_pre
assert hasattr(conv, "weight_g") == (weight_norm_api == "legacy")
state = {name: value.clone() for name, value in model.hift.state_dict().items()}
gain = "weight_g" if weight_norm_api == "legacy" else "parametrizations.weight.original0"
state[f"conv_pre.{gain}"].mul_(2)
expected_weight = conv.weight.detach().clone() * 2
assert not torch.equal(conv.weight, expected_weight)
# No forward before loading: a legacy fold must recompute from the loaded
# g/v parameters, not reuse the cached effective weight from construction.
flow_state = {name: torch.ones_like(value) for name, value in model.flow_model.state_dict().items()}
if legacy_checkpoint:
state = {
name.replace("parametrizations.weight.original0", "weight_g").replace(
"parametrizations.weight.original1", "weight_v"
): value
for name, value in state.items()
}
torch.save(flow_state, tmp_path / "flow.pt")
torch.save({"generator." + name: value for name, value in state.items()}, tmp_path / "hift.pt")
model.load_weights(str(tmp_path), torch.device("cpu"))
torch.testing.assert_close(model.hift.conv_pre.weight, expected_weight, rtol=0, atol=0)
# Production loads in normal grad mode, so the folded weight is a Parameter.
assert isinstance(model.hift.conv_pre.weight, nn.Parameter)
assert not model.hift.conv_pre.weight.is_inference()
assert not any(
_has_weight_norm(module) for name, module in model.hift.named_modules() if not name.startswith("f0_predictor.")
)
assert not any(_has_weight_norm(module) for module in model.hift.f0_predictor.modules())
assert not model.hift.training
assert not model.flow_model.training
for name, value in model.flow_model.state_dict().items():
torch.testing.assert_close(value, flow_state[name], rtol=0, atol=0)
@pytest.mark.parametrize(
"fold_context",
[torch.enable_grad, torch.no_grad, torch.inference_mode],
ids=["grad", "no_grad", "inference_mode"],
)
def test_fold_stays_usable_outside_grad_context(weight_norm_api, fold_context):
"""Both APIs must leave non-inference Parameters usable by autograd."""
block = hifigan.ResBlock(channels=4).eval()
x = torch.randn(1, 4, 12, requires_grad=True)
assert hasattr(block.convs1[0], "weight_g") == (weight_norm_api == "legacy")
with torch.no_grad():
before = block(x)
with fold_context():
assert block.remove_weight_norm() == 6
assert block.remove_weight_norm() == 0
for conv in (*block.convs1, *block.convs2):
assert isinstance(conv.weight, nn.Parameter)
assert not conv.weight.is_inference()
# no_grad would hide the failure when autograd tries to save a weight.
out = block(x)
torch.testing.assert_close(out, before, rtol=0, atol=0)
out.sum().backward()
assert x.grad is not None
assert torch.isfinite(x.grad).all()
@pytest.mark.parametrize("sampling_rate", [22050, 24000])
def test_fold_preserves_multichunk_audio_and_finalize(weight_norm_api, sampling_rate):
from tests.model_executor.models.cosyvoice3.test_cosyvoice3_incremental_hift import _make_model
hift = _make_hift(sampling_rate=sampling_rate)
model = _make_model(hift, window_len=64)
mel = torch.randn(1, 80, 192)
chunks = mel.split(24, dim=-1)
def stream():
cache = None
audio = []
window_shifted = False
for i, chunk in enumerate(chunks):
speech, cache = model._stream_hift_from_feat(chunk, cache_state=cache, finalize=i == len(chunks) - 1)
audio.append(speech)
if cache is not None:
window_shifted |= cache["mel_offset"] > 0
assert window_shifted
assert cache is None
return torch.cat(audio, dim=-1)
before = stream()
assert hift.remove_weight_norm() == 41
after = stream()
assert before.numel() > 0
assert torch.isfinite(before).all()
torch.testing.assert_close(after, before, rtol=0, atol=0)
def test_shipped_config_folds_77_generator_and_five_f0_layers(weight_norm_api):
"""Pin separate C4/C5 counts for both APIs on the shipped configuration.
Generator: conv_pre 1 + ups 3 + 9 resblocks x 6 + 3 source resblocks x 6
+ conv_post 1 = 77. F0 adds five condnet convolutions. Construction only
(no forward), so the full 512-channel model stays cheap on CPU.
"""
from vllm_omni.transformers_utils.configs.cosyvoice3 import CosyVoice3Config
hift_cfg = dict(CosyVoice3Config().hift)
f0_cfg = hift_cfg.pop("f0_predictor")
f0 = hifigan.CausalConvRNNF0Predictor(
num_class=f0_cfg["num_class"],
in_channels=f0_cfg["in_channels"],
cond_channels=f0_cfg["cond_channels"],
)
generator = hifigan.CausalHiFTGenerator(f0_predictor=f0, **hift_cfg)
assert sum(_has_weight_norm(module) for module in generator.modules()) == 82
assert generator.remove_weight_norm() == 77
assert generator.remove_weight_norm() == 0
remaining = [name for name, m in generator.named_modules() if _has_weight_norm(m)]
assert len(remaining) == 5
assert all(name.startswith("f0_predictor.") for name in remaining)
assert f0.remove_weight_norm() == 5
assert f0.remove_weight_norm() == 0
assert generator.remove_weight_norm() == 0
assert not any(_has_weight_norm(module) for module in generator.modules())
@@ -511,6 +511,37 @@ def test_sample_keeps_only_finite_token_after_ras_rejection():
assert out.sampled_token_ids.tolist() == [[1]]
def test_batched_ras_matches_serial_seeded_requests_and_rng_state():
model = _make_talker_model()
logits = torch.tensor([[2.0, 1.0, 0.0, -1.0], [0.0, 3.0, 1.0, -2.0], [1.0, 0.0, 2.0, -1.0]])
metadata = _make_sampling_metadata(output_token_ids=[[0] * 10, [], [2] * 10])
metadata.temperature = torch.tensor([0.5, 1.0, 1.5])
metadata.top_k = torch.tensor([1, 2, 4])
metadata.top_p = torch.tensor([0.7, 0.8, 0.9])
metadata.generators = {i: torch.Generator().manual_seed(400 + i) for i in range(3)}
reference_generators = {i: torch.Generator().manual_seed(400 + i) for i in range(3)}
for _ in range(6):
expected = []
for i in range(3):
scores = torch.log_softmax(logits[i] / metadata.temperature[i], dim=0)
expected.append(
model._ras_sample_one(
scores,
metadata.output_token_ids[i],
top_p=float(metadata.top_p[i]),
top_k=int(metadata.top_k[i]),
win_size=10,
tau_r=0.1,
generator=reference_generators[i],
)
)
actual = model._ras_sample_batch(logits, metadata, default_top_p=0.8, default_top_k=25, win_size=10, tau_r=0.1)
assert actual.tolist() == expected
for i, token in enumerate(expected):
assert torch.equal(metadata.generators[i].get_state(), reference_generators[i].get_state())
metadata.output_token_ids[i].append(token)
def test_gpu_ar_model_runner_prefers_model_sampler_when_opted_in():
metadata = _make_sampling_metadata(output_token_ids=[[1, 2, 3]])
expected = SamplerOutput(
@@ -637,3 +668,264 @@ def test_gpu_ar_model_runner_repairs_async_placeholders_for_model_sampler():
assert runner.input_batch.async_copy_ready_event.synced is True
assert runner.input_batch.update_async_called is True
assert seen_histories == [[[11, 29]]]
@pytest.mark.parametrize(
"order",
[("decode", "short", "decode", "long"), ("short", "decode", "long", "decode"), ("long", "short", "decode")],
)
def test_embed_input_ids_preserves_interleaved_request_boundaries(order):
model = _make_talker_model()
speech = nn.Embedding.from_pretrained(torch.arange(100 * 4, dtype=torch.float32).reshape(100, 4))
text = nn.Embedding.from_pretrained(torch.arange(100 * 4, dtype=torch.float32).reshape(100, 4) + 1000)
model.model = SimpleNamespace(
speech_embedding=speech, sos=0, task_id=1, llm=SimpleNamespace(model=SimpleNamespace(embed_tokens=text))
)
requests = {
"decode": (torch.tensor([7]), torch.tensor([False]), None, speech.weight[7:8]),
"short": (
torch.tensor([1, 1, 1, 1, 21]),
torch.tensor([True] * 4 + [False]),
speech.weight[2:4],
torch.cat((speech.weight[0:1], text.weight[21:22], speech.weight[1:2], speech.weight[2:4])),
),
"long": (
torch.tensor([1, 1, 1, 1, 1, 30, 31]),
torch.tensor([True] * 5 + [False] * 2),
speech.weight[4:7],
torch.cat((speech.weight[0:1], text.weight[30:32], speech.weight[1:2], speech.weight[4:7])),
),
}
rows = [requests[name] for name in order]
boundaries = [0]
for ids, *_ in rows:
boundaries.append(boundaries[-1] + ids.numel())
actual = model.embed_input_ids(
torch.cat([r[0] for r in rows]),
multimodal_embeddings=[r[2] for r in rows if r[2] is not None],
is_multimodal=torch.cat([r[1] for r in rows]),
query_start_loc=boundaries,
)
torch.testing.assert_close(actual, torch.cat([r[3] for r in rows]), rtol=0, atol=0)
conditioning = [torch.full((1, 192), i + 1.0) for i in range(sum(name != "decode" for name in order))]
aligned = model._align_prompt_conditioning(conditioning)
assert len(aligned) == len(order)
index = 0
for name, value in zip(order, aligned):
if name == "decode":
assert value is None
else:
assert value is conditioning[index]
index += 1
with pytest.raises(ValueError, match="conditioning must match"):
model._align_prompt_conditioning(conditioning[:-1])
def test_mrv2_conditioning_follows_encoded_prompts_to_batch_rows():
from collections import OrderedDict
model = _make_talker_model()
speech = nn.Embedding.from_pretrained(torch.arange(100 * 4, dtype=torch.float32).reshape(100, 4))
text = nn.Embedding.from_pretrained(torch.arange(100 * 4, dtype=torch.float32).reshape(100, 4) + 1000)
model.model = SimpleNamespace(
speech_embedding=speech, sos=0, task_id=1, llm=SimpleNamespace(model=SimpleNamespace(embed_tokens=text))
)
model._mrv2_encoded_conditioning = OrderedDict()
tokens = [torch.tensor([2, 3]), torch.tensor([4, 5, 6])]
feats = [torch.full((4, 2), 1.0), torch.full((6, 2), 2.0)]
speakers = [torch.full((1, 3), 1.0), torch.full((1, 3), 2.0)]
encoded = model.embed_multimodal(speech_token=tokens, speech_feat=feats, embedding=speakers)
# Batch: decode, short prompt, decode, long prompt, short again (same
# reference: the encoder cache hands back the same tensor).
ids = torch.tensor([7, 1, 1, 1, 1, 21, 8, 1, 1, 1, 1, 1, 30, 1, 1, 1, 1, 22])
is_mm = torch.tensor([False] + [True] * 4 + [False, False] + [True] * 5 + [False] + [True] * 4 + [False])
model.embed_input_ids(
ids,
multimodal_embeddings=[encoded[0], encoded[1], encoded[0]],
is_multimodal=is_mm,
query_start_loc=[0, 1, 6, 7, 13, 18],
)
output = model.make_omni_output(torch.zeros(18, 4), model_intermediate_buffer=[None] * 5)
embed = output.multimodal_outputs["embed"]
assert [value is None for value in embed["speech_token"]] == [True, False, True, False, False]
for row, item in ((1, 0), (3, 1), (4, 0)):
torch.testing.assert_close(embed["speech_token"][row], tokens[item][None])
torch.testing.assert_close(embed["speech_feat"][row], feats[item][None])
torch.testing.assert_close(embed["embedding"][row], speakers[item])
# A following decode step carries no conditioning.
model.embed_input_ids(
torch.tensor([9, 10]), multimodal_embeddings=[], is_multimodal=torch.zeros(2, dtype=torch.bool)
)
assert model.make_omni_output(torch.zeros(2, 4)).multimodal_outputs == {}
with pytest.raises(ValueError, match="lost its prompt conditioning"):
model.embed_input_ids(
torch.tensor([1, 1, 1, 1, 21]),
multimodal_embeddings=[speech.weight[2:4].clone()],
is_multimodal=torch.tensor([True] * 4 + [False]),
query_start_loc=[0, 5],
)
def test_talker_declares_its_narrow_logits_head():
model = _make_talker_model()
assert model.logits_vocab_size == 6561 + 200
model.model_stage = "cosyvoice3_code2wav"
assert model.logits_vocab_size is None
def test_embed_input_ids_rejects_cross_request_placeholder_block():
model = _make_talker_model()
speech = nn.Embedding(100, 4)
model.model = SimpleNamespace(
speech_embedding=speech,
sos=0,
task_id=1,
llm=SimpleNamespace(model=SimpleNamespace(embed_tokens=nn.Embedding(100, 4))),
)
with pytest.raises(ValueError, match="complete prefill block"):
model.embed_input_ids(
torch.tensor([7, 1, 1, 1, 1, 20]),
multimodal_embeddings=[speech.weight[2:4]],
is_multimodal=torch.tensor([False, True, True, True, True, False]),
)
def test_full_response_singleton_uses_the_optimized_batch_path(monkeypatch):
import vllm_omni.model_executor.models.cosyvoice3.cosyvoice3 as module
monkeypatch.setenv("COSYVOICE3_BATCH_FLOW", "1")
monkeypatch.setattr(module, "cosyvoice3_packed_inference_enabled", lambda: True)
model = _make_code2wav_model()
model.forward(
input_ids=torch.tensor([0, 1, 2]),
positions=torch.arange(3),
seq_token_counts=[3],
model_intermediate_buffer=[
{
"embed": {
"speech_token": torch.tensor([[1, 2, 3]]),
"speech_feat": torch.ones(1, 6, 2),
"embedding": torch.ones(1, 2),
},
"meta": {"req_id": ["single"], "stream_finished": torch.tensor(True), "left_context_size": 0},
}
],
)
assert len(model.code2wav.forward_streaming_batch_calls) == 1
assert model.code2wav.forward_streaming_batch_calls[0][0]["finalize"]
def test_full_response_adapter_uses_live_prefill_then_empty_decode(monkeypatch):
from vllm.v1.worker.gpu_model_runner import GPUModelRunner
from vllm_omni.worker.gpu_model_runner import OmniGPUModelRunner
model = _make_talker_model()
hidden = torch.ones(2, 4)
runner = object.__new__(OmniGPUModelRunner)
runner.model = model
runner._build_model_kwargs_extra = lambda: {}
# Replay the same graph tensor while the live conditioning changes.
monkeypatch.setattr(GPUModelRunner, "_model_forward", lambda *_args, **_kwargs: hidden)
model._conditioning_request_rows = [1]
model._conditioning_request_count = 2
prompt = torch.tensor([[7, 8]])
output = runner._model_forward(speech_token=prompt)
values = output.multimodal_outputs["embed"]["speech_token"]
assert values[0] is None
torch.testing.assert_close(values[1], prompt)
decode = runner._model_forward()
assert decode.text_hidden_states is hidden
assert decode.multimodal_outputs == {}
def test_packed_flow_preserves_torch_estimator_when_speaker_trt_is_enabled(monkeypatch):
import vllm_omni.model_executor.models.cosyvoice3.cosyvoice3 as module
model = _make_code2wav_model()
model._code2wav_trt_done = False
monkeypatch.setattr(module.torch.cuda, "is_available", lambda: True)
monkeypatch.setattr(module, "cosyvoice3_packed_inference_enabled", lambda: True)
monkeypatch.setattr(module, "_cosyvoice3_trt_enabled", lambda: True)
def unexpected_estimator_resolution():
raise AssertionError("Packed Flow must not resolve or replace its torch estimator")
monkeypatch.setattr(model, "_resolve_flow_estimator_onnx", unexpected_estimator_resolution)
model._maybe_enable_code2wav_trt()
assert model._code2wav_trt_done
def test_standard_sampling_preserves_parameters_and_excludes_prompt_penalties(monkeypatch):
model = _make_talker_model()
model.config.cosyvoice3_sampling_mode = "standard"
metadata = _make_sampling_metadata(output_token_ids=[[2, 3]], repetition_penalty=1.21)
metadata.prompt_token_ids = torch.tensor([[2, 4]], dtype=torch.long)
metadata.temperature = torch.tensor([0.7])
metadata.top_k = torch.tensor([20])
captured = {}
expected = SamplerOutput(sampled_token_ids=torch.tensor([[5]], dtype=torch.int32), logprobs_tensors=None)
def standard_sampler(*, logits, sampling_metadata):
captured["metadata"] = sampling_metadata
return expected
model._talker_sampler = standard_sampler
monkeypatch.setattr(model, "_ras_sample_batch", lambda *a, **k: pytest.fail("RAS must not run in standard mode"))
assert model.sample(torch.zeros(1, 8), metadata) is expected
actual = captured["metadata"]
assert actual is not metadata
assert actual.output_token_ids == [[2, 3]]
torch.testing.assert_close(actual.prompt_token_ids, torch.tensor([[8, 8]]))
torch.testing.assert_close(metadata.prompt_token_ids, torch.tensor([[2, 4]]))
assert actual.temperature is metadata.temperature
assert actual.top_k is metadata.top_k
assert actual.top_p is metadata.top_p
assert actual.repetition_penalties is metadata.repetition_penalties
@pytest.mark.parametrize("mode", ["ras", "standard"])
def test_sampling_mode_preserves_or_merges_control_logits(mode):
model = _make_talker_model()
model.config.cosyvoice3_sampling_mode = mode
model.config.vocab_size = 6770
model.model = nn.Module()
model.model.llm_decoder = lambda hidden: hidden.clone()
hidden = torch.linspace(-2, 2, 6761).reshape(1, -1)
logits = model.compute_logits(hidden)
torch.testing.assert_close(logits[:, :6561], hidden[:, :6561])
assert torch.isneginf(logits[:, 6761:]).all()
if mode == "standard":
torch.testing.assert_close(logits[:, 6561:6761], hidden[:, 6561:])
else:
torch.testing.assert_close(logits[:, 6562], torch.logsumexp(hidden[:, 6561:], dim=-1))
assert torch.isneginf(logits[:, 6561]).all()
assert torch.isneginf(logits[:, 6563:6761]).all()
def test_cancelled_stream_releases_only_its_vocoder_state():
model_cls, _ = _cosyvoice3_model_and_runner()
model = SimpleNamespace(
_stream_audio_cache_lock=Lock(),
_stream_vocoder_cache_by_req={"cancelled": {"mel": torch.ones(2)}, "live": {"mel": torch.zeros(2)}},
)
model_cls.on_requests_finished(model, {"cancelled", "unknown"})
model_cls.on_requests_finished(model, {"cancelled"})
assert set(model._stream_vocoder_cache_by_req) == {"live"}
@pytest.mark.parametrize("mrv2,packed", [(False, False), (False, True), (True, False)])
def test_talker_output_contract(monkeypatch, mrv2, packed):
import vllm_omni.model_executor.models.cosyvoice3.cosyvoice3 as mod
monkeypatch.setattr(mod, "cosyvoice3_packed_inference_enabled", lambda: packed)
model = _make_talker_model()
hidden = torch.zeros(2, 4)
model.model = SimpleNamespace(llm=lambda embeddings, positions: embeddings)
sentinel = object()
model.make_omni_output = lambda *args, **kwargs: sentinel
if mrv2:
model._sampling_eps = 1e-6
model.mrv2_custom_sampler(SimpleNamespace(penalties_state=None))
output = model.forward(torch.ones(2, dtype=torch.long), torch.arange(2), inputs_embeds=hidden)
assert output is (hidden if mrv2 or packed else sentinel)
@@ -0,0 +1,185 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Model Runner V2 penalties must count generated speech tokens only."""
from types import SimpleNamespace
import numpy as np
import pytest
import torch
from tests.helpers.mark import hardware_test
from vllm_omni.platforms import current_omni_platform
pytestmark = [pytest.mark.core_model, pytest.mark.skipif(not current_omni_platform.is_cuda(), reason="requires CUDA")]
class _Uva:
def copy_to_uva(self) -> None:
pass
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_generated_only_penalty_writes_skip_prompt_and_keep_resumed_outputs():
from vllm_omni.model_executor.models.cosyvoice3.cosyvoice3 import _generated_only_penalty_writes
vocab, rows, width = 6761, 3, 16
tokens = torch.zeros(rows, width, dtype=torch.int32, device="cuda")
# Row 0: fresh request whose prompt holds text ids beyond the speech head.
tokens[0, :4] = torch.tensor([151645, 12, 7, 99999])
# Row 1: resumed request, prompt [5, 6] then generated [7, 7, 9].
tokens[1, :5] = torch.tensor([5, 6, 7, 7, 9])
prompt_len = torch.tensor([4, 2, 0], dtype=torch.int32, device="cuda")
prefill_len = torch.tensor([4, 5, 0], dtype=torch.int32)
state = SimpleNamespace(
_new_penalties_reqs=[0, 1],
device=torch.device("cuda"),
prompt_bin_mask=torch.full((rows, (vocab + 31) // 32), -1, dtype=torch.int32, device="cuda"),
output_bin_counts=torch.full((rows, vocab), 3, dtype=torch.int32, device="cuda"),
req_states=SimpleNamespace(
all_token_ids=SimpleNamespace(gpu=tokens),
prompt_len=SimpleNamespace(gpu=prompt_len),
prefill_len=SimpleNamespace(gpu=prefill_len.cuda(), np=np.asarray(prefill_len)),
),
repetition_penalty=_Uva(),
frequency_penalty=_Uva(),
presence_penalty=_Uva(),
)
_generated_only_penalty_writes(state)
torch.accelerator.synchronize()
assert state._new_penalties_reqs == []
# No prompt token is penalized for the new requests.
assert int(state.prompt_bin_mask[:2].abs().sum()) == 0
counts = state.output_bin_counts
assert int(counts[0].sum()) == 0
assert counts[1, 7].item() == 2 and counts[1, 9].item() == 1 and int(counts[1].sum()) == 3
# Rows that were not added keep their state.
assert int(counts[2, 0]) == 3 and int(state.prompt_bin_mask[2, 0]) == -1
class _States:
def __init__(self, temperature, top_k, top_p, seeds, vocab):
self.vocab_size = vocab
self.temperature = SimpleNamespace(gpu=temperature)
self.seeds = SimpleNamespace(gpu=seeds)
self.top_k = SimpleNamespace(gpu=top_k, np=top_k.cpu().numpy())
self.top_p = SimpleNamespace(gpu=top_p, np=top_p.cpu().numpy())
def get_top_k_top_p(self, expanded_idx_mapping, idx_mapping_np):
rows = expanded_idx_mapping.long()
return self.top_k.gpu[rows], self.top_p.gpu[rows]
def _ras_sampler(tokens, prompt_len, total_len, temperature, top_k, top_p, seeds, vocab):
return SimpleNamespace(
logit_bias_state=SimpleNamespace(apply_logit_bias=lambda *args: None),
sampling_states=_States(temperature, top_k, top_p, seeds, vocab),
req_states=SimpleNamespace(
all_token_ids=SimpleNamespace(gpu=tokens),
prompt_len=SimpleNamespace(gpu=prompt_len),
total_len=SimpleNamespace(gpu=total_len),
),
)
def _sample(sampler, logits, pos, fused):
from vllm_omni.model_executor.models.cosyvoice3.cosyvoice3 import _ras_mrv2_sample
rows = logits.shape[0]
idx = torch.arange(rows, dtype=torch.int32, device=logits.device)
sampled, _ = _ras_mrv2_sample(
sampler,
logits,
idx,
idx,
np.arange(rows),
pos,
None,
None,
not fused, # logprobs keep the tensor path
default_top_p=0.8,
default_top_k=25,
win_size=10,
tau_r=0.1,
eps=1e-5,
)
return sampled
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("fused", [True, False])
def test_ras_mrv2_sample_rejects_recent_repeats_on_device(fused):
dev, vocab, rows = "cuda", 64, 4
logits = torch.full((rows, vocab), -4.0, device=dev)
logits[:, 7] = 8.0 # token 7 dominates every row
tokens = torch.zeros(rows, 32, dtype=torch.int32, device=dev)
# Row 0: token 7 generated recently -> rejected. Row 1: 7 only in the prompt.
# Row 2: no output yet. Row 3: greedy, never rejected.
tokens[0, :6] = torch.tensor([1, 2, 3, 7, 4, 5])
tokens[1, :3] = torch.tensor([7, 7, 9])
tokens[3, :4] = torch.tensor([1, 7, 7, 7])
prompt_len = torch.tensor([3, 2, 5, 1], dtype=torch.int32, device=dev)
total_len = torch.tensor([6, 3, 5, 4], dtype=torch.int32, device=dev)
sampler = _ras_sampler(
tokens,
prompt_len,
total_len,
torch.tensor([1.0, 1.0, 1.0, 0.0], device=dev),
torch.full((rows,), 1, dtype=torch.int32, device=dev),
torch.full((rows,), 0.8, device=dev),
torch.arange(rows, dtype=torch.int64, device=dev),
vocab,
)
sampled = _sample(sampler, logits, torch.full((rows,), 11, dtype=torch.int64, device=dev), fused).tolist()
assert sampled[0] != 7 and 0 <= sampled[0] < vocab
assert sampled[1:] == [7, 7, 7]
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("fused", [True, False])
def test_ras_mrv2_sample_draws_from_top_p_capped_by_top_k(fused):
dev, vocab, rows = "cuda", 32, 4096
probs = torch.tensor([0.4, 0.3, 0.2, 0.1] + [0.0] * (vocab - 4), device=dev) + 1e-9
logits = probs.log().expand(rows, vocab).contiguous()
no_history = torch.zeros(rows, dtype=torch.int32, device=dev)
# Row halves: top-p 0.6 keeps {0, 1}; top-p 0.95 with top-k 3 keeps {0, 1, 2}.
top_p = torch.where(torch.arange(rows, device=dev) < rows // 2, 0.6, 0.95).float()
sampler = _ras_sampler(
torch.zeros(rows, 4, dtype=torch.int32, device=dev),
no_history,
no_history,
torch.ones(rows, device=dev),
torch.full((rows,), 3, dtype=torch.int32, device=dev),
top_p,
torch.arange(rows, dtype=torch.int64, device=dev),
vocab,
)
sampled = _sample(sampler, logits, torch.zeros(rows, dtype=torch.int64, device=dev), fused)
low, high = sampled[: rows // 2], sampled[rows // 2 :]
assert set(low.tolist()) == {0, 1} and set(high.tolist()) == {0, 1, 2}
# Kept tokens are renormalized: 0.4 / 0.7 of the top-p 0.6 rows pick token 0.
assert abs((low == 0).float().mean().item() - 4 / 7) < 0.04
assert abs((high == 0).float().mean().item() - 4 / 9) < 0.04
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_fused_ras_replacement_follows_the_remaining_distribution():
dev, vocab, rows = "cuda", 16, 8192
probs = torch.tensor([0.5, 0.25, 0.125, 0.125] + [0.0] * (vocab - 4), device=dev) + 1e-12
logits = probs.log().expand(rows, vocab).contiguous()
tokens = torch.zeros(rows, 4, dtype=torch.int32, device=dev) # every row just generated token 0
sampler = _ras_sampler(
tokens,
torch.zeros(rows, dtype=torch.int32, device=dev),
torch.ones(rows, dtype=torch.int32, device=dev),
torch.ones(rows, device=dev),
torch.full((rows,), 1, dtype=torch.int32, device=dev), # nucleus is always token 0
torch.full((rows,), 0.8, device=dev),
torch.arange(rows, dtype=torch.int64, device=dev),
vocab,
)
sampled = _sample(sampler, logits, torch.zeros(rows, dtype=torch.int64, device=dev), True)
freq = torch.bincount(sampled, minlength=vocab).float() / rows
assert freq[0] == 0
for token, want in ((1, 0.5), (2, 0.25), (3, 0.25)):
assert abs(freq[token].item() - want) < 0.03
@@ -0,0 +1,41 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""The packed causal conv kernel must match the padded cuDNN position embedding."""
import pytest
import torch
import torch.nn.functional as F
from tests.helpers.mark import hardware_test
from vllm_omni.platforms import current_omni_platform
pytestmark = [pytest.mark.core_model, pytest.mark.skipif(not current_omni_platform.is_cuda(), reason="requires CUDA")]
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("lengths", [[1], [31, 30, 32], [500, 7, 129, 64]])
@torch.inference_mode()
def test_packed_causal_conv_mish_matches_padded_cudnn(lengths):
from vllm_omni.model_executor.models.cosyvoice3.code2wav_core.packed_conv import (
pack_conv_weight,
packed_causal_conv_mish,
)
from vllm_omni.model_executor.models.cosyvoice3.code2wav_core.packed_dit import (
gather_rows,
pack_rows,
scatter_rows,
)
torch.manual_seed(0)
channels, groups, taps = 256, 16, 31
conv = torch.nn.Conv1d(channels, channels, taps, groups=groups).cuda().to(torch.bfloat16)
rows = pack_rows(lengths, torch.device("cuda"))
x = torch.randn(rows.total, channels, device="cuda", dtype=torch.bfloat16)
padded = scatter_rows(x.unsqueeze(0), rows, rows.width).permute(0, 2, 1)
reference = F.mish(conv(F.pad(padded, (taps - 1, 0))))
reference = gather_rows(reference.permute(0, 2, 1), rows)[0]
output = packed_causal_conv_mish(x, pack_conv_weight(conv), conv.bias, rows.positions)
# Rows never mix and match the left-zero-padded convolution up to bf16 rounding.
torch.testing.assert_close(output.float(), reference.float(), atol=0.04, rtol=0.02)
@@ -0,0 +1,80 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from types import SimpleNamespace
import numpy as np
import pytest
import torch
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@pytest.fixture
def processor(monkeypatch):
from vllm_omni.model_executor.models.cosyvoice3 import cosyvoice3 as module
from vllm_omni.utils.speaker_cache import SpeakerEmbeddingCache
calls = []
config = SimpleNamespace(allowed_special=[], sample_rate=24000)
ctx = SimpleNamespace(get_hf_config=lambda: config, model_config=SimpleNamespace(model="model-a"))
obj = SimpleNamespace(
info=SimpleNamespace(ctx=ctx),
tokenizer=None,
feat_extractor=None,
campplus_trt=None,
campplus_session=None,
_speaker_cache=SpeakerEmbeddingCache(max_bytes=1024),
_ensure_cached_runtime_components=lambda *args: None,
)
def speech(audio, device):
calls.append(audio)
return torch.tensor([[1, 2]], dtype=torch.int32), torch.tensor([2], dtype=torch.int32)
obj._extract_speech_token_via_s3 = speech
monkeypatch.setattr(
module, "extract_text_token", lambda text, *args: (torch.tensor([[ord(c) for c in text]]), len(text))
)
monkeypatch.setattr(module, "extract_speech_feat", lambda *args: (torch.ones(1, 4, 2), torch.tensor([4])))
monkeypatch.setattr(module, "extract_spk_embedding", lambda *args: torch.ones(1, 2))
def run(audio, text="target", reference="reference"):
return module.CosyVoice3MultiModalProcessor._call_hf_processor(
obj, text, {"audio": audio}, {"prompt_text": reference}, {}
)
return obj, run, calls
def test_reference_cache_reuses_audio_but_not_text_and_owns_tensors(processor):
obj, run, calls = processor
audio = (np.arange(8, dtype=np.float32), 16000)
first = run(audio)
first["speech_feat"].zero_()
first["speech_token"].zero_()
second = run((audio[0].copy(), 16000), text="different", reference="changed")
assert len(calls) == 1
assert torch.all(second["speech_feat"] == 1)
assert second["speech_token"].tolist() == [[1, 2]]
assert not torch.equal(first["input_ids"], second["input_ids"])
second["embedding"].zero_()
assert torch.all(run(audio)["embedding"] == 1)
assert obj._speaker_cache.stats()["hits"] == 2
@pytest.mark.parametrize("change", ["samples", "rate", "dtype", "model"])
def test_reference_cache_distinguishes_inputs(processor, change):
obj, run, calls = processor
wave = np.arange(8, dtype=np.float32)
run((wave, 16000))
rate = 16000
if change == "samples":
wave[0] = 20
elif change == "rate":
rate = 24000
elif change == "dtype":
wave = wave.astype(np.float64)
else:
obj.info.ctx.model_config.model = "model-b"
run((wave, rate))
assert len(calls) == 2
@@ -0,0 +1,63 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Batched reference speech-token extraction returns each caller its own tokens."""
import threading
from types import SimpleNamespace
import pytest
import torch
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _s3(calls):
def padding(mels):
lens = torch.tensor([m.shape[-1] for m in mels])
out = torch.zeros(len(mels), 1, int(lens.max()))
for i, m in enumerate(mels):
out[i, :, : m.shape[-1]] = m
return out, lens
def quantize(mels, lens):
calls.append(int(mels.shape[0]))
if bool((mels < 0).any()):
raise RuntimeError("bad mel")
# Token t of a row is its first mel value plus t; padding shows up as garbage.
codes = mels[:, 0, :1].long() + torch.arange(mels.shape[-1]).unsqueeze(0)
return codes, lens
return SimpleNamespace(padding=padding), SimpleNamespace(quantize=quantize)
def test_speech_token_batcher_returns_each_rows_tokens_under_concurrency():
from vllm_omni.model_executor.models.cosyvoice3.cosyvoice3 import _SpeechTokenBatcher
calls: list[int] = []
s3, model = _s3(calls)
batcher = _SpeechTokenBatcher(model, s3, "cpu", max_batch=8)
results: dict[int, torch.Tensor] = {}
start = threading.Barrier(24)
def worker(i):
start.wait()
results[i] = batcher.tokens(torch.full((1, 3 + i % 5), float(100 * i)))
threads = [threading.Thread(target=worker, args=(i,)) for i in range(24)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
for i in range(24):
assert results[i].tolist() == [100 * i + t for t in range(3 + i % 5)]
assert sum(calls) == 24 and max(calls) <= 8
def test_speech_token_batcher_propagates_errors_and_keeps_serving():
from vllm_omni.model_executor.models.cosyvoice3.cosyvoice3 import _SpeechTokenBatcher
s3, model = _s3([])
batcher = _SpeechTokenBatcher(model, s3, "cpu")
with pytest.raises(RuntimeError, match="bad mel"):
batcher.tokens(torch.full((1, 4), -1.0))
assert batcher.tokens(torch.full((1, 2), 7.0)).tolist() == [7, 8]