[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:
1 parent
c85f1a44fb
commit
1ea0ee3b38
28 files changed
+4009
-223
No files matched your search
@@ -16,6 +16,15 @@ steps:
|
||||
- "pytest -sv tests/model_executor -m 'core_model and cpu'"
|
||||
mirror_hardwares: l4_1
|
||||
|
||||
- label: "Simple · CosyVoice3 Packed Flow Test"
|
||||
timeout_in_minutes: 15
|
||||
source_file_dependencies:
|
||||
- tests/model_executor/models/cosyvoice3/test_cosyvoice3_full_batch.py
|
||||
- vllm_omni/model_executor/models/cosyvoice3/
|
||||
commands:
|
||||
- "pytest -sv tests/model_executor/models/cosyvoice3/test_cosyvoice3_full_batch.py -k packed -m 'core_model and cuda' --run-level=core_model"
|
||||
mirror_hardwares: h100_1
|
||||
|
||||
- label: "Simple · Diffusion Test"
|
||||
timeout_in_minutes: 30
|
||||
commands:
|
||||
|
||||
@@ -167,7 +167,7 @@ def test_inventory_matches_reviewed_snapshot_counts():
|
||||
EnvironmentVariableCategory.PUBLIC_OMNI: 32,
|
||||
EnvironmentVariableCategory.INHERITED_VLLM: 20,
|
||||
EnvironmentVariableCategory.PLATFORM_EXTERNAL: 29,
|
||||
EnvironmentVariableCategory.MODEL_SPECIFIC: 66,
|
||||
EnvironmentVariableCategory.MODEL_SPECIFIC: 74,
|
||||
EnvironmentVariableCategory.BENCHMARK_TRANSITIONAL: 21,
|
||||
EnvironmentVariableCategory.INTERNAL: 6,
|
||||
}
|
||||
@@ -178,10 +178,10 @@ def test_inventory_matches_reviewed_snapshot_counts():
|
||||
if item.category is EnvironmentVariableCategory.MODEL_SPECIFIC
|
||||
)
|
||||
assert {disposition: disposition_counts[disposition] for disposition in ModelEnvironmentVariableDisposition} == {
|
||||
ModelEnvironmentVariableDisposition.PROMOTE: 38,
|
||||
ModelEnvironmentVariableDisposition.PROMOTE: 45,
|
||||
ModelEnvironmentVariableDisposition.REQUEST_SCOPE: 5,
|
||||
ModelEnvironmentVariableDisposition.EXTERNAL: 0,
|
||||
ModelEnvironmentVariableDisposition.INTERNALIZE: 16,
|
||||
ModelEnvironmentVariableDisposition.INTERNALIZE: 17,
|
||||
ModelEnvironmentVariableDisposition.DEPRECATE_REMOVE: 7,
|
||||
}
|
||||
|
||||
|
||||
@@ -106,3 +106,86 @@ def test_numeric_waveform_cache_limits(server, entries, budget):
|
||||
server._put_resolved_ref_audio("b", np.ones(2, dtype=np.float32), 24000, "artifact-b")
|
||||
assert list(server._ref_audio_resolve_cache) == ["b"]
|
||||
assert server._ref_audio_resolve_cache_bytes == 8
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cosyvoice_array_prompt_owns_buffer_without_list_roundtrip(mocker, monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.base import SpeechServingContext
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.cosyvoice3 import CosyVoice3Adapter
|
||||
|
||||
monkeypatch.setenv("COSYVOICE3_REFERENCE_PREFETCH", "0")
|
||||
waveform = np.linspace(-1, 1, 24000, dtype=np.float32)
|
||||
server = mocker.Mock()
|
||||
server._resolve_ref_audio_array = mocker.AsyncMock(return_value=(waveform, 24000, "key"))
|
||||
server._resolve_ref_audio = mocker.AsyncMock(side_effect=AssertionError("unnecessary list conversion"))
|
||||
adapter = CosyVoice3Adapter(SpeechServingContext(server=server))
|
||||
request = SimpleNamespace(input="Target text.", ref_audio="reference", ref_text="Reference text.", voice=None)
|
||||
prompt = await adapter._build_prompt(request)
|
||||
actual, rate = prompt["multi_modal_data"]["audio"]
|
||||
np.testing.assert_array_equal(actual, waveform)
|
||||
assert rate == 24000 and actual.dtype == np.float32
|
||||
assert not np.shares_memory(actual, waveform)
|
||||
actual[0] = 100
|
||||
assert waveform[0] == -1
|
||||
assert prompt["prompt"] == request.input
|
||||
assert prompt["mm_processor_kwargs"]["prompt_text"].endswith("<|endofprompt|>Reference text.")
|
||||
server._resolve_ref_audio.assert_not_awaited()
|
||||
server._resolve_ref_audio_array.assert_awaited_once_with("reference")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cosyvoice_reference_prefetch_resamples_and_warms_conditioning(mocker):
|
||||
from types import SimpleNamespace
|
||||
|
||||
import vllm_omni.model_executor.models.cosyvoice3.cosyvoice3 as cosyvoice3
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.base import SpeechServingContext
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.cosyvoice3 import CosyVoice3Adapter
|
||||
|
||||
waveform = np.linspace(-1, 1, 24000, dtype=np.float32)
|
||||
server = mocker.Mock()
|
||||
server._resolve_ref_audio_array = mocker.AsyncMock(return_value=(waveform, 24000, "key"))
|
||||
server.model_config.hf_config = SimpleNamespace(target_sr=16000)
|
||||
server.model_config.model = "model-dir"
|
||||
warm = mocker.patch.object(cosyvoice3, "prefetch_reference_conditioning")
|
||||
adapter = CosyVoice3Adapter(SpeechServingContext(server=server))
|
||||
request = SimpleNamespace(input="Target text.", ref_audio="reference", ref_text="Reference text.", voice=None)
|
||||
prompt = await adapter._build_prompt(request)
|
||||
audio, rate = prompt["multi_modal_data"]["audio"]
|
||||
# The processor receives samples at its own rate, so its resampling is a
|
||||
# no-op and its content-addressed cache lookup hits the warmed entry.
|
||||
assert rate == 16000 and prompt["mm_processor_kwargs"]["sample_rate"] == 16000
|
||||
assert audio.shape == (16000,) and audio.dtype == np.float32
|
||||
warm.assert_called_once()
|
||||
model_dir, config, (warmed, warmed_rate) = warm.call_args.args
|
||||
assert model_dir == "model-dir" and config is server.model_config.hf_config
|
||||
assert warmed is audio and warmed_rate == 16000
|
||||
assert waveform[0] == -1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["ras", "standard"])
|
||||
def test_cosyvoice_sampling_mode_sets_control_stops_without_mutating_defaults(mocker, mode):
|
||||
from vllm.sampling_params import SamplingParams
|
||||
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.base import SpeechServingContext
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.cosyvoice3 import CosyVoice3Adapter
|
||||
from vllm_omni.transformers_utils.configs.cosyvoice3 import CosyVoice3Config
|
||||
|
||||
config = CosyVoice3Config()
|
||||
config.cosyvoice3_sampling_mode = mode
|
||||
serving = mocker.Mock(spec=OmniOpenAIServingSpeech)
|
||||
serving.model_config = mocker.Mock(hf_config=config)
|
||||
adapter = CosyVoice3Adapter(SpeechServingContext(server=serving))
|
||||
adapter._tokenizer = mocker.Mock()
|
||||
mocker.patch("vllm_omni.model_executor.models.cosyvoice3.utils.extract_text_token", return_value=(None, 9))
|
||||
defaults = [SamplingParams(stop_token_ids=[6562])]
|
||||
request = OpenAICreateSpeechRequest(input="Example text.", max_new_tokens=2048)
|
||||
result = adapter.apply_sampling_overrides(defaults, request)
|
||||
assert defaults[0].stop_token_ids == [6562]
|
||||
assert result[0].min_tokens == 18
|
||||
assert result[0].max_tokens == 2048
|
||||
expected = set(range(6561, 6761)) if mode == "standard" else {6562}
|
||||
assert set(result[0].stop_token_ids) == expected
|
||||
assert expected <= result[0].all_stop_token_ids
|
||||
@@ -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]
|
||||
@@ -149,6 +149,13 @@ _MODEL_PROMOTE = (
|
||||
"COSMOS3_SOUND_TOKENIZER_PATH",
|
||||
"COSYVOICE3_BATCH_FLOW",
|
||||
"COSYVOICE3_ESTIMATOR_ONNX",
|
||||
"COSYVOICE3_FLOW_CUDAGRAPH",
|
||||
"COSYVOICE3_FLOW_GRAPH",
|
||||
"COSYVOICE3_FLOW_GRAPH_MAX_TOTAL",
|
||||
"COSYVOICE3_FULL_RESPONSE_OPTIMIZATIONS",
|
||||
"COSYVOICE3_PACKED_STREAMING",
|
||||
"COSYVOICE3_REFERENCE_PREFETCH",
|
||||
"COSYVOICE3_REFERENCE_WORKERS",
|
||||
"COSYVOICE3_TRT",
|
||||
"COSYVOICE3_TRT_CACHE",
|
||||
"FASTVIDEO_VSA_SM100A",
|
||||
@@ -200,6 +207,7 @@ _MODEL_EXTERNAL: tuple[str, ...] = ()
|
||||
_MODEL_INTERNALIZE = (
|
||||
"COSYVOICE3_BATCH_FLOW_DEBUG",
|
||||
"COSYVOICE3_F0_ON_CPU",
|
||||
"COSYVOICE3_REFERENCE_CACHE_DEBUG",
|
||||
"DZ_PHASE_TIMING",
|
||||
"MAGI2_ALLOW_UNSUPPORTED_TOPOLOGY",
|
||||
"MAGI2_ROUTER_BIAS_SOURCE",
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# Opt-in single-Hopper full-response throughput profile (not streaming TTFP).
|
||||
# Stage 1 selects the packed backend through its environment below.
|
||||
# The 4096-frame internal boundary exceeds this profile's 2048-token output cap.
|
||||
async_chunk: true
|
||||
connectors:
|
||||
connector_of_shared_memory:
|
||||
extra:
|
||||
codec_chunk_frames: 4096
|
||||
codec_left_context_frames: 25
|
||||
codec_streaming: true
|
||||
codec_vocab_size: 6561
|
||||
connector_get_max_wait: 300
|
||||
connector_get_max_wait_first_chunk: 3000
|
||||
connector_get_sleep_s: 0.01
|
||||
name: SharedMemoryConnector
|
||||
cuda_mps: true
|
||||
pipeline: cosyvoice3
|
||||
stages:
|
||||
- default_sampling_params:
|
||||
max_tokens: 2048
|
||||
repetition_penalty: 1.0001
|
||||
temperature: 1.0
|
||||
top_k: 25
|
||||
top_p: 0.8
|
||||
devices: '0'
|
||||
disable_hybrid_kv_cache_manager: true
|
||||
# Prompt embedding rearrangement requires the complete prefill block.
|
||||
enable_chunked_prefill: false
|
||||
enable_prefix_caching: false
|
||||
enforce_eager: false
|
||||
gpu_memory_utilization: 0.4
|
||||
max_num_batched_tokens: 32768
|
||||
max_num_seqs: 64
|
||||
mm_processor_cache_gb: 0
|
||||
# The talker runs on Model Runner V2 (both RAS and standard sampling);
|
||||
# set model_runner: v1 to fall back. The codec stage stays on V1.
|
||||
model_runner: v2
|
||||
output_connectors:
|
||||
to_stage_1: connector_of_shared_memory
|
||||
skip_mm_profiling: true
|
||||
stage_id: 0
|
||||
trust_remote_code: true
|
||||
- default_sampling_params:
|
||||
max_tokens: 2048
|
||||
devices: '0'
|
||||
disable_hybrid_kv_cache_manager: true
|
||||
enable_prefix_caching: false
|
||||
enforce_eager: true
|
||||
gpu_memory_utilization: 0.25
|
||||
input_connectors:
|
||||
from_stage_0: connector_of_shared_memory
|
||||
max_model_len: 32768
|
||||
max_num_batched_tokens: 32768
|
||||
max_num_seqs: 8
|
||||
skip_mm_profiling: true
|
||||
stage_id: 1
|
||||
env:
|
||||
COSYVOICE3_TRT: "0"
|
||||
COSYVOICE3_BATCH_FLOW: "1"
|
||||
COSYVOICE3_FULL_RESPONSE_OPTIMIZATIONS: "1"
|
||||
COSYVOICE3_PACKED_STREAMING: "0"
|
||||
trust_remote_code: true
|
||||
@@ -0,0 +1,19 @@
|
||||
# Opt-in single-Hopper C64 throughput profile: one AR and two codec replicas.
|
||||
# Inherit sampling, connector, and runtime settings from the full-response profile.
|
||||
# Each stage has aggregate capacity 64; this is not a streaming TTFP profile.
|
||||
# One Model Runner V2 talker serves all 64 requests: a decode step costs about
|
||||
# the same at 32 and 64 rows, so a second talker only adds processes. The
|
||||
# codec is the throughput limit; two replicas overlap its launch-bound steps.
|
||||
base_config: cosyvoice3_full_response.yaml
|
||||
|
||||
stages:
|
||||
- stage_id: 0
|
||||
devices: "0"
|
||||
num_replicas: 1
|
||||
gpu_memory_utilization: 0.35
|
||||
max_num_seqs: 64
|
||||
- stage_id: 1
|
||||
devices: "0,0"
|
||||
num_replicas: 2
|
||||
gpu_memory_utilization: 0.2
|
||||
max_num_seqs: 32
|
||||
@@ -0,0 +1,15 @@
|
||||
# Standard (non-RAS) sampling variant of the C64 throughput profile, used for
|
||||
# SGLang-parity validation. Launch with
|
||||
# --hf-overrides '{"cosyvoice3_sampling_mode": "standard"}'.
|
||||
# Standard sampling needs a real repetition penalty (RAS handles repetition
|
||||
# itself); these defaults match the SGLang reference.
|
||||
base_config: cosyvoice3_full_response_high_concurrency.yaml
|
||||
|
||||
stages:
|
||||
- stage_id: 0
|
||||
default_sampling_params:
|
||||
max_tokens: 2048
|
||||
repetition_penalty: 1.21
|
||||
temperature: 0.7
|
||||
top_k: 20
|
||||
top_p: 0.8
|
||||
@@ -0,0 +1,18 @@
|
||||
# Opt-in single-Hopper streaming profile: AR64 / codec16, Flow10.
|
||||
# Request stream=true to measure first audible audio; non-stream latency is not TTFP.
|
||||
# Keeps each request's HiFT mel/phase state on GPU and uses mainline GPU FP32 F0.
|
||||
# Shared serving settings come from the full-response profile; only chunk
|
||||
# boundaries, codec admission capacity, and stage environment differ.
|
||||
base_config: cosyvoice3_full_response.yaml
|
||||
connectors:
|
||||
connector_of_shared_memory:
|
||||
extra:
|
||||
codec_chunk_frames: 25
|
||||
stages:
|
||||
- stage_id: 1
|
||||
env:
|
||||
COSYVOICE3_TRT: "0"
|
||||
COSYVOICE3_BATCH_FLOW: "1"
|
||||
COSYVOICE3_FULL_RESPONSE_OPTIMIZATIONS: "0"
|
||||
COSYVOICE3_PACKED_STREAMING: "1"
|
||||
max_num_seqs: 16
|
||||
@@ -2,7 +2,9 @@
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""CosyVoice3 TTS serving adapter."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import numpy as np
|
||||
@@ -10,6 +12,7 @@ from vllm.logger import init_logger
|
||||
|
||||
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest
|
||||
from vllm_omni.model_executor.models.cosyvoice3.runtime import cosyvoice3_standard_sampling
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
||||
@@ -34,13 +37,49 @@ class CosyVoice3Adapter(ARTTSAdapter):
|
||||
# Rebuilding it would repeat snapshot resolution and add ~100 ms to the
|
||||
# TTFP-critical sampling override on every request.
|
||||
self._tokenizer = None
|
||||
# Cold references are resampled and conditioned here, in parallel
|
||||
# workers, instead of serially inside the input processor.
|
||||
self._reference_executor: ThreadPoolExecutor | None = None
|
||||
self._reference_resampler = None
|
||||
|
||||
async def _prefetch_reference(self, wav: np.ndarray, sr: int) -> tuple[np.ndarray, int]:
|
||||
"""Resample to the processor rate and warm its content-addressed cache.
|
||||
|
||||
Uses the processor's own resampler, so the processor receives exactly
|
||||
these samples (its resampling becomes a no-op) and its cache lookup for
|
||||
the same bytes hits instead of recomputing on the serving event loop.
|
||||
"""
|
||||
from vllm.multimodal.parse import MultiModalDataParser
|
||||
|
||||
from vllm_omni.model_executor.models.cosyvoice3.cosyvoice3 import prefetch_reference_conditioning
|
||||
|
||||
server = self.ctx.server
|
||||
hf_cfg = server.model_config.hf_config
|
||||
target_sr = getattr(hf_cfg, "target_sr", None)
|
||||
if target_sr is None:
|
||||
return wav, sr
|
||||
if self._reference_executor is None:
|
||||
workers = int(os.environ.get("COSYVOICE3_REFERENCE_WORKERS", "16"))
|
||||
self._reference_executor = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="cosyvoice3-ref")
|
||||
self._reference_resampler = MultiModalDataParser(target_sr=target_sr).audio_resampler
|
||||
model_dir = server.model_config.model
|
||||
resampler = self._reference_resampler
|
||||
assert resampler is not None
|
||||
|
||||
def prepare() -> np.ndarray:
|
||||
audio = resampler.resample(wav, orig_sr=sr)
|
||||
prefetch_reference_conditioning(model_dir, hf_cfg, (audio, target_sr))
|
||||
return audio
|
||||
|
||||
audio = await asyncio.get_running_loop().run_in_executor(self._reference_executor, prepare)
|
||||
return audio, int(target_sr)
|
||||
|
||||
async def _build_prompt(
|
||||
self, request: "OpenAICreateSpeechRequest", *, has_inline_ref_audio: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""Build the multimodal CosyVoice3 voice-cloning prompt."""
|
||||
server = self.ctx.server
|
||||
wav_samples, sr, _ = await server._resolve_ref_audio(request.ref_audio)
|
||||
wav_samples, sr, _ = await server._resolve_ref_audio_array(request.ref_audio)
|
||||
ref_text = request.ref_text or ""
|
||||
if _PROMPT_DELIMITER not in ref_text:
|
||||
ref_text = f"{_PROMPT_PREFIX}{ref_text}"
|
||||
@@ -50,9 +89,13 @@ class CosyVoice3Adapter(ARTTSAdapter):
|
||||
if voice_lower in server.uploaded_speakers and not has_inline_ref_audio:
|
||||
mm_kwargs["voice_name"] = voice_lower
|
||||
mm_kwargs["voice_created_at"] = server._voice_created_at(voice_lower)
|
||||
wav = np.array(wav_samples, dtype=np.float32, copy=True)
|
||||
if "voice_name" not in mm_kwargs and os.environ.get("COSYVOICE3_REFERENCE_PREFETCH", "1") != "0":
|
||||
wav, sr = await self._prefetch_reference(wav, sr)
|
||||
mm_kwargs["sample_rate"] = sr
|
||||
return {
|
||||
"prompt": request.input,
|
||||
"multi_modal_data": {"audio": (np.asarray(wav_samples, dtype=np.float32), sr)},
|
||||
"multi_modal_data": {"audio": (wav, sr)},
|
||||
"mm_processor_kwargs": mm_kwargs,
|
||||
}
|
||||
|
||||
@@ -110,6 +153,11 @@ class CosyVoice3Adapter(ARTTSAdapter):
|
||||
server = self.ctx.server
|
||||
sampling_params_list = copy.deepcopy(sampling_params_list)
|
||||
hf_cfg = server.model_config.hf_config
|
||||
if cosyvoice3_standard_sampling(hf_cfg):
|
||||
first_control = int(hf_cfg.llm["speech_token_size"])
|
||||
controls = list(range(first_control, first_control + 200))
|
||||
sampling_params_list[0].stop_token_ids = controls
|
||||
sampling_params_list[0].all_stop_token_ids.update(controls)
|
||||
# Build the Qwen tokenizer once per process (resolving the model dir via
|
||||
# snapshot_download at most once) and reuse it across requests.
|
||||
tokenizer = self._tokenizer
|
||||
|
||||
@@ -14,7 +14,9 @@ import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
from torch.nn import Conv1d, ConvTranspose1d
|
||||
from torch.nn.utils import remove_weight_norm
|
||||
from torch.nn.utils import parametrize
|
||||
from torch.nn.utils import remove_weight_norm as remove_legacy_weight_norm
|
||||
from torch.nn.utils.weight_norm import WeightNorm
|
||||
|
||||
try:
|
||||
from torch.nn.utils.parametrizations import weight_norm
|
||||
@@ -43,6 +45,44 @@ def get_padding(kernel_size, dilation=1):
|
||||
return int((kernel_size * dilation - dilation) / 2)
|
||||
|
||||
|
||||
def _normalize_folded_weight(module: nn.Module, name: str = "weight") -> None:
|
||||
"""Keep ordinary Parameters; replace plain or inference tensors.
|
||||
|
||||
In inference_mode, the legacy API can leave an inference Parameter,
|
||||
while the parametrization API can leave a plain inference tensor.
|
||||
Rebuild those outside inference_mode so they remain usable afterward.
|
||||
"""
|
||||
weight = getattr(module, name)
|
||||
if isinstance(weight, nn.Parameter) and not weight.is_inference():
|
||||
return
|
||||
with torch.inference_mode(False):
|
||||
frozen = nn.Parameter(weight.detach().clone(), requires_grad=False)
|
||||
delattr(module, name)
|
||||
module.register_parameter(name, frozen)
|
||||
|
||||
|
||||
def _fold_weight_norm(module: nn.Module) -> int:
|
||||
"""Fold one weight-normalized layer into a plain ``weight``, in place.
|
||||
|
||||
Supports both APIs: the modern ``torch.nn.utils.parametrizations`` API
|
||||
(what this module is built with when torch provides it) and the legacy
|
||||
``WeightNorm`` forward-pre-hook. Returns 1 when a weight norm was
|
||||
folded and 0 when there was nothing to fold (e.g. on a second call).
|
||||
"""
|
||||
if parametrize.is_parametrized(module, "weight"):
|
||||
# Removes every parametrization registered on "weight"; only
|
||||
# weight_norm is applied to the layers in this module.
|
||||
parametrize.remove_parametrizations(module, "weight", leave_parametrized=True)
|
||||
_normalize_folded_weight(module)
|
||||
return 1
|
||||
for hook in list(module._forward_pre_hooks.values()):
|
||||
if isinstance(hook, WeightNorm):
|
||||
remove_legacy_weight_norm(module, name=hook.name)
|
||||
_normalize_folded_weight(module, hook.name)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
class ResBlock(torch.nn.Module):
|
||||
"""Residual block module in HiFiGAN/BigVGAN."""
|
||||
|
||||
@@ -94,10 +134,13 @@ class ResBlock(torch.nn.Module):
|
||||
x = xt + x
|
||||
return x
|
||||
|
||||
def remove_weight_norm(self):
|
||||
def remove_weight_norm(self) -> int:
|
||||
"""Fold this block's conv weight norms; returns the folded count."""
|
||||
folded = 0
|
||||
for idx in range(len(self.convs1)):
|
||||
remove_weight_norm(self.convs1[idx])
|
||||
remove_weight_norm(self.convs2[idx])
|
||||
folded += _fold_weight_norm(self.convs1[idx])
|
||||
folded += _fold_weight_norm(self.convs2[idx])
|
||||
return folded
|
||||
|
||||
|
||||
def _carry_phase_at_boundary(
|
||||
@@ -577,18 +620,24 @@ class HiFTGenerator(nn.Module):
|
||||
)
|
||||
self.f0_predictor = f0_predictor
|
||||
|
||||
def remove_weight_norm(self):
|
||||
def remove_weight_norm(self) -> int:
|
||||
"""Fold the generator's frozen weight norms into plain weights.
|
||||
|
||||
Returns how many convolutions were folded. ``source_downs`` and
|
||||
``m_source`` carry no weight norm. ``f0_predictor`` is excluded:
|
||||
causal inference runs it on CPU for precision, and folding it is
|
||||
tracked separately in RFC #6870 (C5).
|
||||
"""
|
||||
folded = 0
|
||||
for layer in self.ups:
|
||||
remove_weight_norm(layer)
|
||||
folded += _fold_weight_norm(layer)
|
||||
for block in self.resblocks:
|
||||
block.remove_weight_norm()
|
||||
remove_weight_norm(self.conv_pre)
|
||||
remove_weight_norm(self.conv_post)
|
||||
self.m_source.remove_weight_norm()
|
||||
for layer in self.source_downs:
|
||||
remove_weight_norm(layer)
|
||||
folded += block.remove_weight_norm()
|
||||
folded += _fold_weight_norm(self.conv_pre)
|
||||
folded += _fold_weight_norm(self.conv_post)
|
||||
for block in self.source_resblocks:
|
||||
block.remove_weight_norm()
|
||||
folded += block.remove_weight_norm()
|
||||
return folded
|
||||
|
||||
def _stft(self, x):
|
||||
if x.device.type == "npu":
|
||||
@@ -1101,6 +1150,14 @@ class CausalConvRNNF0Predictor(nn.Module):
|
||||
if isinstance(layer, CausalConv1d) and layer.causal_type == "left"
|
||||
)
|
||||
|
||||
def remove_weight_norm(self) -> int:
|
||||
"""Fold the five F0 convolutions after loading on the execution device.
|
||||
|
||||
Repeated calls are no-ops. Load original checkpoints into a fresh
|
||||
predictor, before folding changes the state-dict keys.
|
||||
"""
|
||||
return sum(_fold_weight_norm(layer) for layer in self.condnet)
|
||||
|
||||
def forward(self, x: torch.Tensor, finalize: bool = True) -> torch.Tensor:
|
||||
if finalize is True:
|
||||
x = self.condnet[0](x)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# ruff: noqa: N803
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Grouped causal Conv1d + Mish directly on packed DiT rows.
|
||||
|
||||
The DiT position embedding is two grouped (16 groups), 31-tap left-padded
|
||||
convolutions. cuDNN runs them as one small kernel per group and needs a padded
|
||||
``(rows, width)`` scatter, transposes and a gather around every call. This
|
||||
implicit-GEMM kernel reads the packed ``[tokens, channels]`` sequence directly:
|
||||
taps before a row's first frame are masked, which is exactly the left zero
|
||||
padding, so rows never mix and no padded layout is materialized.
|
||||
"""
|
||||
|
||||
import torch
|
||||
from vllm.triton_utils import tl, triton
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _packed_causal_conv_mish_kernel(
|
||||
x_ptr,
|
||||
w_ptr,
|
||||
bias_ptr,
|
||||
pos_ptr,
|
||||
out_ptr,
|
||||
total,
|
||||
channels,
|
||||
TAPS: tl.constexpr,
|
||||
GROUP: tl.constexpr,
|
||||
BLOCK_M: tl.constexpr,
|
||||
):
|
||||
pid_m = tl.program_id(0)
|
||||
group = tl.program_id(1)
|
||||
rows = pid_m * BLOCK_M + tl.arange(0, BLOCK_M)
|
||||
row_mask = rows < total
|
||||
positions = tl.load(pos_ptr + rows, mask=row_mask, other=0)
|
||||
cin = tl.arange(0, GROUP)
|
||||
cout = tl.arange(0, GROUP)
|
||||
acc = tl.zeros((BLOCK_M, GROUP), dtype=tl.float32)
|
||||
for tap in range(TAPS):
|
||||
shift = TAPS - 1 - tap
|
||||
valid = row_mask & (positions >= shift)
|
||||
src = (rows - shift).to(tl.int64)
|
||||
a = tl.load(
|
||||
x_ptr + src[:, None] * channels + group * GROUP + cin[None, :],
|
||||
mask=valid[:, None],
|
||||
other=0.0,
|
||||
)
|
||||
b = tl.load(w_ptr + ((group * TAPS + tap) * GROUP + cin[:, None]) * GROUP + cout[None, :])
|
||||
acc = tl.dot(a, b, acc)
|
||||
acc += tl.load(bias_ptr + group * GROUP + cout).to(tl.float32)[None, :]
|
||||
# Match eager rounding: the convolution output is stored in the input
|
||||
# dtype before Mish computes in float32 and rounds again.
|
||||
conv = acc.to(out_ptr.dtype.element_ty).to(tl.float32)
|
||||
softplus = tl.where(conv > 20.0, conv, tl.log(1.0 + tl.exp(conv)))
|
||||
e2 = tl.exp(-2.0 * softplus)
|
||||
mish = conv * (1.0 - e2) / (1.0 + e2)
|
||||
tl.store(
|
||||
out_ptr + rows.to(tl.int64)[:, None] * channels + group * GROUP + cout[None, :],
|
||||
mish.to(out_ptr.dtype.element_ty),
|
||||
mask=row_mask[:, None],
|
||||
)
|
||||
|
||||
|
||||
def pack_conv_weight(conv: torch.nn.Conv1d) -> torch.Tensor:
|
||||
"""[Cout, Cin/G, K] grouped weight -> [G, K, Cin/G, Cout/G] contiguous."""
|
||||
cout, cin_g, taps = conv.weight.shape
|
||||
groups = conv.groups
|
||||
return conv.weight.detach().view(groups, cout // groups, cin_g, taps).permute(0, 3, 2, 1).contiguous()
|
||||
|
||||
|
||||
def packed_causal_conv_mish(
|
||||
x: torch.Tensor,
|
||||
weight: torch.Tensor,
|
||||
bias: torch.Tensor,
|
||||
positions: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
"""x: [T, C] packed rows; positions: [T] int frame index within each row."""
|
||||
total, channels = x.shape
|
||||
groups, taps, group, _ = weight.shape
|
||||
out = torch.empty_like(x)
|
||||
if total:
|
||||
block_m = 64
|
||||
_packed_causal_conv_mish_kernel[(triton.cdiv(total, block_m), groups)](
|
||||
x, weight, bias, positions, out, total, channels, TAPS=taps, GROUP=group, BLOCK_M=block_m
|
||||
)
|
||||
return out
|
||||
@@ -0,0 +1,832 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Opt-in packed Flow for full-response and streaming inference on Hopper GPUs.
|
||||
|
||||
Adapted from SGLang-Omni packed_dit.py, commit
|
||||
127f34b57446a3cb5588ec987da0771a3be675be (Apache-2.0):
|
||||
https://github.com/sgl-project/sglang-omni
|
||||
Full-context and chunk-causal attention use vLLM's FA3 binding.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
import torch
|
||||
import torch._dynamo as dynamo
|
||||
import torch.nn.functional as F
|
||||
from vllm.vllm_flash_attn import flash_attn_varlen_func
|
||||
|
||||
from .packed_conv import pack_conv_weight, packed_causal_conv_mish
|
||||
|
||||
FA3_PAGE_SIZE = 1
|
||||
|
||||
|
||||
class RaggedRowAttention:
|
||||
"""FA3 page metadata isolating each request and each causal query chunk."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rows: PackedRows,
|
||||
*,
|
||||
heads: int,
|
||||
head_dim: int,
|
||||
chunk_size: int | None = None,
|
||||
width: int | None = None,
|
||||
) -> None:
|
||||
self.heads, self.head_dim = heads, head_dim
|
||||
device = rows.row_ids.device
|
||||
segment_rows, segment_ends, offsets = [], [], [0]
|
||||
for row, length in enumerate(rows.lengths):
|
||||
if length == 0 and width is not None:
|
||||
# Bucketed layouts keep one (empty) segment per row slot so
|
||||
# metadata shapes are identical across batches in a bucket.
|
||||
segment_rows.append(row)
|
||||
segment_ends.append(0)
|
||||
offsets.append(offsets[-1])
|
||||
continue
|
||||
span = chunk_size or length
|
||||
for start in range(0, length, span):
|
||||
end = min(start + span, length)
|
||||
segment_rows.append(row)
|
||||
segment_ends.append(end)
|
||||
offsets.append(offsets[-1] + end - start)
|
||||
self.cache_seqlens = torch.tensor(segment_ends, dtype=torch.int32, device=device)
|
||||
self.cu_seqlens_q = torch.tensor(offsets, dtype=torch.int32, device=device)
|
||||
self.max_seqlen_q = max(b - a for a, b in zip(offsets, offsets[1:]))
|
||||
starts = rows.starts_host[segment_rows].to(device)
|
||||
page = torch.arange(width or max(segment_ends), dtype=torch.int32, device=device)
|
||||
self.page_table = torch.where(page[None] < self.cache_seqlens[:, None], starts[:, None] + page[None], 0)
|
||||
|
||||
|
||||
class PackedDiT:
|
||||
"""Adapt a loaded DiT to compiled, variable-length inference with FA3."""
|
||||
|
||||
def __init__(self, dit: torch.nn.Module) -> None:
|
||||
self.dit = dit
|
||||
self.compiled_full_forward = None
|
||||
self.graph_buckets = False
|
||||
self.graph_runner: FlowGraphRunner | None = None
|
||||
conv_pos = dit.input_embed.conv_pos_embed
|
||||
group = conv_pos.conv1[0].in_channels // conv_pos.conv1[0].groups
|
||||
# The packed kernel's tensor-core tiles need a power-of-two group of at
|
||||
# least 16 channels in half precision; other shapes keep cuDNN.
|
||||
self.conv_weights = (
|
||||
(pack_conv_weight(conv_pos.conv1[0]), pack_conv_weight(conv_pos.conv2[0]))
|
||||
if group >= 16
|
||||
and group & (group - 1) == 0
|
||||
and conv_pos.conv1[0].weight.dtype in (torch.float16, torch.bfloat16)
|
||||
else None
|
||||
)
|
||||
self._modulations: dict = {}
|
||||
|
||||
@torch.inference_mode()
|
||||
def step_modulations(self, time_span: torch.Tensor, key: tuple) -> tuple[list[torch.Tensor], list[torch.Tensor]]:
|
||||
"""Per-step adaLN modulations, identical for every request and batch.
|
||||
|
||||
They depend only on the Euler time, which follows a fixed schedule.
|
||||
Replay the solver's own time recurrence and compute each step's
|
||||
modulation exactly as the forward would (same M=1 shapes and autocast),
|
||||
once per schedule, instead of 23 GEMVs and their activations per call.
|
||||
"""
|
||||
cached = self._modulations.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
dit = self.dit
|
||||
blocks, finals = [], []
|
||||
flow_time = torch.zeros(1, device=time_span.device, dtype=time_span.dtype)
|
||||
t, dt = time_span[0], time_span[1] - time_span[0]
|
||||
with torch.autocast("cuda", dtype=time_span.dtype):
|
||||
for step in range(1, len(time_span)):
|
||||
flow_time[:] = t
|
||||
emb = dit.time_embed(flow_time)
|
||||
blocks.append(
|
||||
torch.cat([block.attn_norm.linear(block.attn_norm.silu(emb)) for block in dit.transformer_blocks])
|
||||
)
|
||||
finals.append(dit.norm_out.linear(dit.norm_out.silu(emb)))
|
||||
t = t + dt
|
||||
if step < len(time_span) - 1:
|
||||
dt = time_span[step + 1] - t
|
||||
cached = self._modulations[key] = (blocks, finals)
|
||||
return cached
|
||||
|
||||
def compile(self, dtype: torch.dtype) -> None:
|
||||
if dtype not in (torch.float16, torch.bfloat16):
|
||||
raise ValueError("Packed Flow requires FP16 or BF16")
|
||||
import os
|
||||
|
||||
options = {"emulate_precision_casts": True}
|
||||
if os.environ.get("COSYVOICE3_FLOW_CUDAGRAPH") == "1":
|
||||
# Inductor CUDA graphs replay one recording per concrete shape;
|
||||
# full-context batches are padded to recurring buckets.
|
||||
options["triton.cudagraphs"] = True
|
||||
self.graph_buckets = True
|
||||
self.compiled_full_forward = torch.compile(
|
||||
self.forward_full,
|
||||
backend="inductor",
|
||||
dynamic=True,
|
||||
fullgraph=True,
|
||||
options=options,
|
||||
)
|
||||
if (
|
||||
os.environ.get("COSYVOICE3_FLOW_GRAPH", "0") == "1"
|
||||
and not self.graph_buckets
|
||||
and self.conv_weights is not None
|
||||
):
|
||||
self.graph_runner = FlowGraphRunner(self)
|
||||
|
||||
def row_attention(self, rows: PackedRows, *, streaming: bool, width: int | None = None) -> RaggedRowAttention:
|
||||
attn = self.dit.transformer_blocks[0].attn
|
||||
result = RaggedRowAttention(
|
||||
rows,
|
||||
heads=attn.heads,
|
||||
head_dim=attn.inner_dim // attn.heads,
|
||||
chunk_size=self.dit.static_chunk_size if streaming else None,
|
||||
width=width,
|
||||
)
|
||||
mark_packed_compile_metadata(rows, result)
|
||||
return result
|
||||
|
||||
def forward_full(
|
||||
self,
|
||||
x: torch.Tensor,
|
||||
mu: torch.Tensor,
|
||||
spks: torch.Tensor,
|
||||
cond: torch.Tensor,
|
||||
t: torch.Tensor,
|
||||
rows: PackedRows,
|
||||
attention: RaggedRowAttention,
|
||||
block_modulation: torch.Tensor | None = None,
|
||||
final_modulation: torch.Tensor | None = None,
|
||||
rope: tuple[torch.Tensor, torch.Tensor] | None = None,
|
||||
) -> torch.Tensor:
|
||||
return forward_packed_tensor_geometry(
|
||||
self,
|
||||
x,
|
||||
mu,
|
||||
spks,
|
||||
cond,
|
||||
t,
|
||||
rows,
|
||||
attention,
|
||||
max_seqlen_q=attention.page_table.shape[1],
|
||||
block_modulation=block_modulation,
|
||||
final_modulation=final_modulation,
|
||||
rope=rope,
|
||||
)
|
||||
|
||||
|
||||
@torch.library.custom_op(
|
||||
"vllm_omni_cosyvoice3_perf::packed_fa3",
|
||||
mutates_args=(),
|
||||
device_types="cuda",
|
||||
)
|
||||
def packed_fa3(
|
||||
q: torch.Tensor,
|
||||
k_cache: torch.Tensor,
|
||||
v_cache: torch.Tensor,
|
||||
cache_seqlens: torch.Tensor,
|
||||
page_table: torch.Tensor,
|
||||
cu_seqlens_q: torch.Tensor,
|
||||
max_seqlen_q: int,
|
||||
) -> torch.Tensor:
|
||||
"""Alias-free FA3 boundary for the compiled PackedDiT path."""
|
||||
return flash_attn_varlen_func(
|
||||
q,
|
||||
k_cache,
|
||||
v_cache,
|
||||
max_seqlen_q=max_seqlen_q,
|
||||
cu_seqlens_q=cu_seqlens_q,
|
||||
max_seqlen_k=page_table.shape[1],
|
||||
seqused_k=cache_seqlens,
|
||||
block_table=page_table,
|
||||
causal=False,
|
||||
fa_version=3,
|
||||
)
|
||||
|
||||
|
||||
@packed_fa3.register_fake
|
||||
def fake_packed_fa3(
|
||||
q: torch.Tensor,
|
||||
k_cache: torch.Tensor,
|
||||
v_cache: torch.Tensor,
|
||||
cache_seqlens: torch.Tensor,
|
||||
page_table: torch.Tensor,
|
||||
cu_seqlens_q: torch.Tensor,
|
||||
max_seqlen_q: int,
|
||||
) -> torch.Tensor:
|
||||
return torch.empty_like(q)
|
||||
|
||||
|
||||
@torch.library.custom_op(
|
||||
"vllm_omni_cosyvoice3_perf::native_mish",
|
||||
mutates_args=(),
|
||||
device_types="cuda",
|
||||
)
|
||||
def native_mish(x: torch.Tensor) -> torch.Tensor:
|
||||
"""Preserve eager CUDA Mish arithmetic across the Inductor boundary."""
|
||||
return F.mish(x)
|
||||
|
||||
|
||||
@native_mish.register_fake
|
||||
def fake_native_mish(x: torch.Tensor) -> torch.Tensor:
|
||||
return torch.empty_like(x)
|
||||
|
||||
|
||||
@torch.library.custom_op(
|
||||
"vllm_omni_cosyvoice3_perf::packed_conv_mish",
|
||||
mutates_args=(),
|
||||
device_types="cuda",
|
||||
)
|
||||
def packed_conv_mish(
|
||||
x: torch.Tensor, weight: torch.Tensor, bias: torch.Tensor, positions: torch.Tensor
|
||||
) -> torch.Tensor:
|
||||
"""Grouped causal conv + Mish on packed rows (see packed_conv.py)."""
|
||||
return packed_causal_conv_mish(x, weight, bias, positions)
|
||||
|
||||
|
||||
@packed_conv_mish.register_fake
|
||||
def fake_packed_conv_mish(
|
||||
x: torch.Tensor, weight: torch.Tensor, bias: torch.Tensor, positions: torch.Tensor
|
||||
) -> torch.Tensor:
|
||||
return torch.empty_like(x)
|
||||
|
||||
|
||||
@torch.library.custom_op(
|
||||
"vllm_omni_cosyvoice3_perf::native_layer_norm",
|
||||
mutates_args=(),
|
||||
device_types="cuda",
|
||||
)
|
||||
def native_layer_norm(
|
||||
x: torch.Tensor,
|
||||
normalized_size: int,
|
||||
eps: float,
|
||||
) -> torch.Tensor:
|
||||
"""Keep CUDA autocast's eager FP32 LayerNorm contract."""
|
||||
return F.layer_norm(x.float(), (normalized_size,), None, None, eps)
|
||||
|
||||
|
||||
@native_layer_norm.register_fake
|
||||
def fake_native_layer_norm(
|
||||
x: torch.Tensor,
|
||||
normalized_size: int,
|
||||
eps: float,
|
||||
) -> torch.Tensor:
|
||||
return torch.empty_like(x, dtype=torch.float32)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackedRows:
|
||||
lengths: tuple[int, ...]
|
||||
starts_host: torch.Tensor
|
||||
row_ids: torch.Tensor
|
||||
positions: torch.Tensor
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return sum(self.lengths)
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
return max(self.lengths)
|
||||
|
||||
|
||||
def pack_rows(lengths: Sequence[int], device: torch.device) -> PackedRows:
|
||||
lengths = tuple(int(length) for length in lengths)
|
||||
starts_host = F.pad(torch.tensor(lengths, dtype=torch.int64).cumsum(0), (1, 0))
|
||||
starts = starts_host.to(device)
|
||||
total = int(starts_host[-1])
|
||||
row_ids = torch.repeat_interleave(
|
||||
torch.arange(len(lengths), device=device),
|
||||
torch.tensor(lengths, dtype=torch.int64, device=device),
|
||||
output_size=total,
|
||||
)
|
||||
positions = torch.arange(total, device=device) - starts[row_ids]
|
||||
return PackedRows(
|
||||
lengths=lengths,
|
||||
starts_host=starts_host.to(torch.int32),
|
||||
row_ids=row_ids,
|
||||
positions=positions,
|
||||
)
|
||||
|
||||
|
||||
def gather_rows(padded: torch.Tensor, rows: PackedRows) -> torch.Tensor:
|
||||
"""(rows, width, channels) -> (1, total, channels), each row's first
|
||||
length frames in row order."""
|
||||
width = padded.shape[1]
|
||||
flat = padded.reshape(padded.shape[0] * width, padded.shape[2])
|
||||
return flat[rows.row_ids * width + rows.positions].unsqueeze(0)
|
||||
|
||||
|
||||
def scatter_rows(packed: torch.Tensor, rows: PackedRows, width: int) -> torch.Tensor:
|
||||
"""(1, total, channels) -> (rows, width, channels), zero past each row's
|
||||
length."""
|
||||
channels = packed.shape[2]
|
||||
flat = packed.new_zeros(len(rows.lengths) * width, channels)
|
||||
flat[rows.row_ids * width + rows.positions] = packed[0]
|
||||
return flat.view(len(rows.lengths), width, channels)
|
||||
|
||||
|
||||
def mark_packed_compile_metadata(rows: PackedRows, attention: RaggedRowAttention) -> None:
|
||||
dynamo.mark_dynamic(attention.page_table, (0, 1))
|
||||
dynamo.mark_dynamic(attention.cu_seqlens_q, 0)
|
||||
dynamo.mark_dynamic(attention.cache_seqlens, 0)
|
||||
dynamo.mark_dynamic(rows.starts_host, 0)
|
||||
dynamo.mark_dynamic(rows.row_ids, 0)
|
||||
dynamo.mark_dynamic(rows.positions, 0)
|
||||
|
||||
|
||||
def gather_rows_tensor_geometry(padded: torch.Tensor, rows: PackedRows) -> torch.Tensor:
|
||||
width = padded.shape[1]
|
||||
flat = padded.reshape(padded.shape[0] * width, padded.shape[2])
|
||||
return flat[rows.row_ids * width + rows.positions].unsqueeze(0)
|
||||
|
||||
|
||||
def scatter_rows_tensor_geometry(packed: torch.Tensor, rows: PackedRows, row_count: int, width: int) -> torch.Tensor:
|
||||
channels = packed.shape[2]
|
||||
flat = packed.new_zeros(row_count * width, channels)
|
||||
flat[rows.row_ids * width + rows.positions] = packed[0]
|
||||
return flat.view(row_count, width, channels)
|
||||
|
||||
|
||||
def bucketed_width(width):
|
||||
"""Round a frame count up so convolution shapes recur across batches.
|
||||
|
||||
cuDNN and cuFFT build a plan for every new shape; live batches have unique
|
||||
lengths. Right padding is exact for the causal convolutions it feeds.
|
||||
"""
|
||||
step = 32 if width <= 512 else (64 if width <= 2048 else 128)
|
||||
return (width + step - 1) // step * step
|
||||
|
||||
|
||||
def conv_pos_embed_tensor_geometry(
|
||||
estimator: PackedDiT,
|
||||
h: torch.Tensor,
|
||||
rows: PackedRows,
|
||||
attention: RaggedRowAttention,
|
||||
) -> torch.Tensor:
|
||||
module = estimator.dit.input_embed.conv_pos_embed
|
||||
if estimator.conv_weights is not None:
|
||||
# Both causal convolutions run on the packed sequence: taps before a
|
||||
# row start are masked (the left zero padding), so no padded layout,
|
||||
# transposes or per-group cuDNN launches are needed.
|
||||
first, second = estimator.conv_weights
|
||||
embedded = packed_conv_mish(h[0], first, module.conv1[0].bias, rows.positions)
|
||||
embedded = packed_conv_mish(embedded, second, module.conv2[0].bias, rows.positions)
|
||||
return embedded.unsqueeze(0)
|
||||
row_count = rows.starts_host.shape[0] - 1
|
||||
# Branch-free rounding keeps this symbolic under dynamic compilation; a
|
||||
# size comparison here would add guards and recompile mid-serving.
|
||||
width = (attention.page_table.shape[1] + 63) // 64 * 64
|
||||
padded = scatter_rows_tensor_geometry(h, rows, row_count, width)
|
||||
embedded = padded.permute(0, 2, 1)
|
||||
embedded = F.pad(embedded, (module.kernel_size - 1, 0, 0, 0))
|
||||
embedded = module.conv1[0](embedded)
|
||||
embedded = native_mish(embedded)
|
||||
embedded = F.pad(embedded, (module.kernel_size - 1, 0, 0, 0))
|
||||
embedded = module.conv2[0](embedded)
|
||||
embedded = native_mish(embedded)
|
||||
embedded = embedded.permute(0, 2, 1)
|
||||
return gather_rows_tensor_geometry(embedded, rows)
|
||||
|
||||
|
||||
def rope_tensor_geometry(
|
||||
estimator: PackedDiT,
|
||||
rows: PackedRows,
|
||||
attention: RaggedRowAttention,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
width = attention.page_table.shape[1]
|
||||
freqs, scale = estimator.dit.rotary_embed.forward_from_seq_len(width)
|
||||
assert not isinstance(scale, torch.Tensor), "the DiT's RoPE has no xpos scale"
|
||||
freqs = freqs[:, rows.positions]
|
||||
return freqs.cos(), freqs.sin()
|
||||
|
||||
|
||||
def ragged_attention_tensor_geometry(
|
||||
query: torch.Tensor,
|
||||
key: torch.Tensor,
|
||||
value: torch.Tensor,
|
||||
attention: RaggedRowAttention,
|
||||
max_seqlen_q: int,
|
||||
) -> torch.Tensor:
|
||||
page_shape = (-1, FA3_PAGE_SIZE, attention.heads, attention.head_dim)
|
||||
output = packed_fa3(
|
||||
query[0].reshape(-1, attention.heads, attention.head_dim),
|
||||
key[0].reshape(page_shape),
|
||||
value[0].reshape(page_shape),
|
||||
attention.cache_seqlens,
|
||||
attention.page_table,
|
||||
attention.cu_seqlens_q,
|
||||
max_seqlen_q,
|
||||
)
|
||||
return output.reshape(1, -1, attention.heads * attention.head_dim)
|
||||
|
||||
|
||||
def attend_tensor_geometry(
|
||||
attn: torch.nn.Module,
|
||||
x: torch.Tensor,
|
||||
rope: tuple[torch.Tensor, torch.Tensor],
|
||||
attention: RaggedRowAttention,
|
||||
max_seqlen_q: int,
|
||||
) -> torch.Tensor:
|
||||
# note (ratish): under autocast to_q, to_k and to_v would each cast the
|
||||
# float32 norm output again.
|
||||
x = x.to(attn.to_q.weight.dtype)
|
||||
query = attn.to_q(x)
|
||||
key = attn.to_k(x)
|
||||
value = attn.to_v(x)
|
||||
rotate_in_place(query, *rope)
|
||||
rotate_in_place(key, *rope)
|
||||
output = ragged_attention_tensor_geometry(query, key, value, attention, max_seqlen_q).to(query.dtype)
|
||||
return attn.to_out[1](attn.to_out[0](output))
|
||||
|
||||
|
||||
_FUSED_LAYER_NORM = __import__("os").environ.get("COSYVOICE3_FUSED_LAYERNORM", "1") != "0"
|
||||
|
||||
|
||||
def layer_norm_preserving_eager(layer_norm: torch.nn.LayerNorm, x: torch.Tensor) -> torch.Tensor:
|
||||
normalized_size = int(layer_norm.normalized_shape[0])
|
||||
if _FUSED_LAYER_NORM:
|
||||
# Same FP32 LayerNorm contract as autocast, but visible to Inductor so
|
||||
# the upcast, normalization, modulation and downcast fuse into one
|
||||
# kernel instead of a LayerNorm, two copies and elementwise passes.
|
||||
return F.layer_norm(x.float(), (normalized_size,), None, None, float(layer_norm.eps))
|
||||
return native_layer_norm(x, normalized_size, float(layer_norm.eps))
|
||||
|
||||
|
||||
def attn_norm_preserving_eager(
|
||||
block: torch.nn.Module,
|
||||
h: torch.Tensor,
|
||||
time_embedding: torch.Tensor | None,
|
||||
modulation: torch.Tensor | None = None,
|
||||
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
|
||||
if modulation is None:
|
||||
modulation = block.attn_norm.linear(block.attn_norm.silu(time_embedding))
|
||||
shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = torch.chunk(modulation, 6, dim=1)
|
||||
normalized = layer_norm_preserving_eager(block.attn_norm.norm, h)
|
||||
normalized = normalized * (1 + scale_msa[:, None]) + shift_msa[:, None]
|
||||
return normalized, gate_msa, shift_mlp, scale_mlp, gate_mlp
|
||||
|
||||
|
||||
def final_norm_preserving_eager(
|
||||
norm_out: torch.nn.Module,
|
||||
h: torch.Tensor,
|
||||
time_embedding: torch.Tensor | None,
|
||||
modulation: torch.Tensor | None = None,
|
||||
) -> torch.Tensor:
|
||||
if modulation is None:
|
||||
modulation = norm_out.linear(norm_out.silu(time_embedding))
|
||||
scale, shift = torch.chunk(modulation, 2, dim=1)
|
||||
normalized = layer_norm_preserving_eager(norm_out.norm, h)
|
||||
return normalized * (1 + scale)[:, None, :] + shift[:, None, :]
|
||||
|
||||
|
||||
def forward_packed_tensor_geometry(
|
||||
estimator: PackedDiT,
|
||||
x: torch.Tensor,
|
||||
mu: torch.Tensor,
|
||||
spks: torch.Tensor,
|
||||
cond: torch.Tensor,
|
||||
t: torch.Tensor,
|
||||
rows: PackedRows,
|
||||
attention: RaggedRowAttention,
|
||||
*,
|
||||
max_seqlen_q: int,
|
||||
block_modulation: torch.Tensor | None = None,
|
||||
final_modulation: torch.Tensor | None = None,
|
||||
rope: tuple[torch.Tensor, torch.Tensor] | None = None,
|
||||
) -> torch.Tensor:
|
||||
dit = estimator.dit
|
||||
t = dit.time_embed(t) if block_modulation is None else None
|
||||
h = dit.input_embed.proj(torch.cat((x, cond, mu, spks), dim=-1))
|
||||
h = conv_pos_embed_tensor_geometry(estimator, h, rows, attention) + h
|
||||
if rope is None:
|
||||
rope = rope_tensor_geometry(estimator, rows, attention)
|
||||
residual = h
|
||||
for index, block in enumerate(dit.transformer_blocks):
|
||||
modulation = None if block_modulation is None else block_modulation[index : index + 1]
|
||||
norm, gate_msa, shift_mlp, scale_mlp, gate_mlp = attn_norm_preserving_eager(block, h, t, modulation)
|
||||
h = h + gate_msa.unsqueeze(1) * attend_tensor_geometry(block.attn, norm, rope, attention, max_seqlen_q)
|
||||
ff_norm = layer_norm_preserving_eager(block.ff_norm, h)
|
||||
ff_norm = ff_norm * (1 + scale_mlp[:, None]) + shift_mlp[:, None]
|
||||
h = h + gate_mlp.unsqueeze(1) * block.ff(ff_norm)
|
||||
if dit.long_skip_connection is not None:
|
||||
h = dit.long_skip_connection(torch.cat((h, residual), dim=-1))
|
||||
h = final_norm_preserving_eager(dit.norm_out, h, t, final_modulation)
|
||||
return dit.proj_out(h)
|
||||
|
||||
|
||||
def rotate_in_place(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> None:
|
||||
"""x: (1, total, heads * head_dim). Interleaved RoPE in float32 on the
|
||||
rotary dims, rounded back into x."""
|
||||
# note (ratish): the DiT rotates only the first rotary dims of the
|
||||
# flattened heads, so the rest of x is never copied.
|
||||
rotary = x[..., : cos.shape[-1]]
|
||||
half = torch.stack((-rotary[..., 1::2], rotary[..., ::2]), dim=-1).flatten(-2)
|
||||
rotary.copy_(rotary * cos + half * sin)
|
||||
|
||||
|
||||
def solve_flow_euler_packed(
|
||||
estimator: PackedDiT,
|
||||
noise: torch.Tensor,
|
||||
time_span: torch.Tensor,
|
||||
mu: torch.Tensor,
|
||||
spks: torch.Tensor,
|
||||
cond: torch.Tensor,
|
||||
rows: PackedRows,
|
||||
*,
|
||||
cfg_rate: float,
|
||||
streaming: bool,
|
||||
modulation_key: tuple | None = None,
|
||||
) -> torch.Tensor:
|
||||
"""Euler steps over a packed sequence with classifier free guidance: the
|
||||
conditional rows and their unconditional twins share one DiT call."""
|
||||
if estimator.graph_runner is not None and modulation_key is not None:
|
||||
solved = estimator.graph_runner.run(
|
||||
noise, time_span, mu, spks, cond, rows, cfg_rate=cfg_rate, streaming=streaming, key=modulation_key
|
||||
)
|
||||
if solved is not None:
|
||||
return solved
|
||||
real_total = noise.shape[1]
|
||||
width = None
|
||||
if estimator.graph_buckets and not streaming:
|
||||
rows, noise, mu, spks, cond, width = pad_to_graph_bucket(rows, noise, mu, spks, cond)
|
||||
twin_rows = pack_rows(rows.lengths * 2, noise.device)
|
||||
attention = estimator.row_attention(twin_rows, streaming=streaming, width=width)
|
||||
mu_cfg = torch.cat((mu, torch.zeros_like(mu)), dim=1)
|
||||
cond_cfg = torch.cat((cond, torch.zeros_like(cond)), dim=1)
|
||||
spks_cfg = torch.cat((spks, torch.zeros_like(spks)), dim=0)
|
||||
spks_cfg = spks_cfg[twin_rows.row_ids].unsqueeze(0)
|
||||
flow_time = torch.zeros(1, device=noise.device, dtype=spks.dtype)
|
||||
forward = estimator.compiled_full_forward
|
||||
if forward is None:
|
||||
raise ValueError("Packed Flow requires a compiled packed path")
|
||||
modulations = None if modulation_key is None else estimator.step_modulations(time_span, modulation_key)
|
||||
x = euler_steps(
|
||||
forward, noise, time_span, mu_cfg, spks_cfg, cond_cfg, flow_time, twin_rows, attention, modulations, cfg_rate
|
||||
)
|
||||
return x[:, :real_total].float()
|
||||
|
||||
|
||||
def euler_steps(
|
||||
forward,
|
||||
noise: torch.Tensor,
|
||||
time_span: torch.Tensor,
|
||||
mu_cfg: torch.Tensor,
|
||||
spks_cfg: torch.Tensor,
|
||||
cond_cfg: torch.Tensor,
|
||||
flow_time: torch.Tensor,
|
||||
twin_rows: PackedRows,
|
||||
attention: RaggedRowAttention,
|
||||
modulations: tuple[list[torch.Tensor], list[torch.Tensor]] | None,
|
||||
cfg_rate: float,
|
||||
) -> torch.Tensor:
|
||||
total = noise.shape[1]
|
||||
x = noise
|
||||
t, dt = time_span[0], time_span[1] - time_span[0]
|
||||
for step in range(1, len(time_span)):
|
||||
flow_time[:] = t
|
||||
vector_field = forward(
|
||||
torch.cat((x, x), dim=1),
|
||||
mu_cfg,
|
||||
spks_cfg,
|
||||
cond_cfg,
|
||||
flow_time,
|
||||
twin_rows,
|
||||
attention,
|
||||
None if modulations is None else modulations[0][step - 1],
|
||||
None if modulations is None else modulations[1][step - 1],
|
||||
)
|
||||
conditional = vector_field[:, :total]
|
||||
unconditional = vector_field[:, total:]
|
||||
x = x + dt * ((1.0 + cfg_rate) * conditional - cfg_rate * unconditional)
|
||||
t = t + dt
|
||||
if step < len(time_span) - 1:
|
||||
dt = time_span[step + 1] - t
|
||||
return x
|
||||
|
||||
|
||||
_GRAPH_ROW_BUCKETS = (1, 2, 4, 8, 16)
|
||||
_GRAPH_TOTAL_STEP = 512
|
||||
_GRAPH_WIDTH_STEP = 256
|
||||
|
||||
|
||||
def pad_to_graph_bucket(rows, noise, mu, spks, cond):
|
||||
"""Pad a full-context batch so CUDA-graph shapes recur across batches.
|
||||
|
||||
Rows are padded to a bucketed count with empty rows, and the packed length
|
||||
to a bucketed total with one trailing padding row; attention isolates every
|
||||
row, so padding never reaches real frames, which stay first in the packing.
|
||||
"""
|
||||
n = len(rows.lengths)
|
||||
slots = next((b for b in _GRAPH_ROW_BUCKETS if b >= n), n)
|
||||
total = rows.total
|
||||
pad = -total % _GRAPH_TOTAL_STEP
|
||||
width = max(max(rows.lengths), pad, 1)
|
||||
width = -(-width // _GRAPH_WIDTH_STEP) * _GRAPH_WIDTH_STEP
|
||||
lengths = tuple(rows.lengths) + (0,) * (slots - n) + (pad,)
|
||||
padded_rows = pack_rows(lengths, noise.device)
|
||||
extend = (0, 0, 0, pad)
|
||||
spks = torch.cat((spks, spks.new_zeros((slots - n + 1, spks.shape[1]))))
|
||||
return (
|
||||
padded_rows,
|
||||
F.pad(noise, extend),
|
||||
F.pad(mu, extend),
|
||||
spks,
|
||||
F.pad(cond, extend),
|
||||
width,
|
||||
)
|
||||
|
||||
|
||||
_FLOW_GRAPH_SLOTS = (1, 2, 4, 8)
|
||||
_FLOW_GRAPH_MAX_TOTAL = int(__import__("os").environ.get("COSYVOICE3_FLOW_GRAPH_MAX_TOTAL", "16384"))
|
||||
_FLOW_GRAPH_SEGMENT_STEP = 16
|
||||
|
||||
|
||||
def flow_graph_total(total: int) -> int:
|
||||
"""Packed-length bucket: at most ~12% padding, few recurring layouts."""
|
||||
step = 128 if total <= 1024 else (256 if total <= 4096 else 512)
|
||||
return -(-total // step) * step
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FlowGraph:
|
||||
graph: torch.cuda.CUDAGraph
|
||||
noise: torch.Tensor
|
||||
mu: torch.Tensor
|
||||
cond: torch.Tensor
|
||||
spks: torch.Tensor
|
||||
time_span: torch.Tensor
|
||||
table: torch.Tensor
|
||||
row_ids: torch.Tensor
|
||||
positions: torch.Tensor
|
||||
cache_seqlens: torch.Tensor
|
||||
cu_seqlens_q: torch.Tensor
|
||||
page_table: torch.Tensor
|
||||
output: torch.Tensor | None = None
|
||||
|
||||
|
||||
class FlowGraphRunner:
|
||||
"""Replay the whole CFG Euler solve as one CUDA graph per padded layout.
|
||||
|
||||
Small codec batches are launch bound: every step walks Dynamo guards, the
|
||||
Inductor wrapper and one FA3 wrapper per block, several hundred host
|
||||
microseconds per step before the GPU sees work. The layout (row slots,
|
||||
packed length, attention segments) is padded to recurring buckets exactly
|
||||
like the bucketed layouts above: padding rows are isolated by attention
|
||||
and by the positional masks of the packed convolution, so real frames only
|
||||
see their own row. Metadata lives in static buffers that are rewritten
|
||||
before each replay.
|
||||
"""
|
||||
|
||||
def __init__(self, estimator: PackedDiT) -> None:
|
||||
self.estimator = estimator
|
||||
self.graphs: dict[tuple, _FlowGraph] = {}
|
||||
self.pool = None
|
||||
attn = estimator.dit.transformer_blocks[0].attn
|
||||
self.heads, self.head_dim = attn.heads, attn.inner_dim // attn.heads
|
||||
|
||||
def _layout(self, lengths: tuple[int, ...], streaming: bool):
|
||||
n, total = len(lengths), sum(lengths)
|
||||
slots = next((b for b in _FLOW_GRAPH_SLOTS if b >= n), None)
|
||||
padded_total = flow_graph_total(total)
|
||||
if slots is None or padded_total > _FLOW_GRAPH_MAX_TOTAL:
|
||||
return None
|
||||
twin = (tuple(lengths) + (0,) * (slots - n) + (padded_total - total,)) * 2
|
||||
chunk = self.estimator.dit.static_chunk_size if streaming else None
|
||||
seg_rows, seg_ends, offsets = [], [], [0]
|
||||
for row, length in enumerate(twin):
|
||||
if length == 0:
|
||||
seg_rows.append(row)
|
||||
seg_ends.append(0)
|
||||
offsets.append(offsets[-1])
|
||||
continue
|
||||
span = chunk or length
|
||||
for start in range(0, length, span):
|
||||
end = min(start + span, length)
|
||||
seg_rows.append(row)
|
||||
seg_ends.append(end)
|
||||
offsets.append(offsets[-1] + end - start)
|
||||
segments = len(seg_ends)
|
||||
if streaming:
|
||||
segments = -(-segments // _FLOW_GRAPH_SEGMENT_STEP) * _FLOW_GRAPH_SEGMENT_STEP
|
||||
extra = segments - len(seg_ends)
|
||||
seg_rows += [0] * extra
|
||||
seg_ends += [0] * extra
|
||||
offsets += [offsets[-1]] * extra
|
||||
return (streaming, slots, padded_total, segments), twin, seg_rows, seg_ends, offsets
|
||||
|
||||
def _allocate(self, key, noise, spks, time_span) -> _FlowGraph:
|
||||
_, slots, padded_total, segments = key[:4]
|
||||
device, dtype = noise.device, noise.dtype
|
||||
channels = noise.shape[-1]
|
||||
rows = 2 * (slots + 1)
|
||||
return _FlowGraph(
|
||||
graph=torch.cuda.CUDAGraph(),
|
||||
noise=torch.zeros((1, padded_total, channels), device=device, dtype=dtype),
|
||||
mu=torch.zeros((1, padded_total, channels), device=device, dtype=dtype),
|
||||
cond=torch.zeros((1, padded_total, channels), device=device, dtype=dtype),
|
||||
spks=torch.zeros((slots + 1, spks.shape[-1]), device=device, dtype=spks.dtype),
|
||||
time_span=torch.zeros_like(time_span),
|
||||
table=torch.zeros(rows + 3 * segments + 1, device=device, dtype=torch.int32),
|
||||
row_ids=torch.zeros(2 * padded_total, device=device, dtype=torch.int64),
|
||||
positions=torch.zeros(2 * padded_total, device=device, dtype=torch.int64),
|
||||
cache_seqlens=torch.zeros(segments, device=device, dtype=torch.int32),
|
||||
cu_seqlens_q=torch.zeros(segments + 1, device=device, dtype=torch.int32),
|
||||
page_table=torch.zeros((segments, padded_total), device=device, dtype=torch.int32),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _load(state: _FlowGraph, twin, seg_rows, seg_ends, offsets, noise, mu, cond, spks, time_span) -> None:
|
||||
total = noise.shape[1]
|
||||
host = torch.tensor(list(twin) + seg_rows + seg_ends + offsets, dtype=torch.int32, pin_memory=True)
|
||||
state.table.copy_(host, non_blocking=True)
|
||||
rows, segments = len(twin), len(seg_ends)
|
||||
lengths = state.table[:rows].long()
|
||||
starts = F.pad(lengths.cumsum(0), (1, 0))[:-1]
|
||||
width = state.row_ids.shape[0]
|
||||
row_ids = torch.repeat_interleave(torch.arange(rows, device=noise.device), lengths, output_size=width)
|
||||
state.row_ids.copy_(row_ids)
|
||||
state.positions.copy_(torch.arange(width, device=noise.device) - starts[row_ids])
|
||||
seg_rows_t = state.table[rows : rows + segments]
|
||||
ends = state.table[rows + segments : rows + 2 * segments]
|
||||
state.cache_seqlens.copy_(ends)
|
||||
state.cu_seqlens_q.copy_(state.table[rows + 2 * segments :])
|
||||
page = torch.arange(state.page_table.shape[1], device=noise.device, dtype=torch.int32)
|
||||
state.page_table.copy_(
|
||||
torch.where(page[None] < ends[:, None], starts[seg_rows_t.long()].int()[:, None] + page[None], 0)
|
||||
)
|
||||
state.noise[:, :total].copy_(noise)
|
||||
state.mu[:, :total].copy_(mu)
|
||||
state.cond[:, :total].copy_(cond)
|
||||
state.spks.zero_()
|
||||
state.spks[: spks.shape[0]].copy_(spks)
|
||||
state.time_span.copy_(time_span)
|
||||
|
||||
def _solve(self, state: _FlowGraph, cfg_rate: float, key: tuple) -> torch.Tensor:
|
||||
estimator = self.estimator
|
||||
rows = PackedRows(lengths=(), starts_host=state.table, row_ids=state.row_ids, positions=state.positions)
|
||||
attention = object.__new__(RaggedRowAttention)
|
||||
attention.heads, attention.head_dim = self.heads, self.head_dim
|
||||
attention.cache_seqlens, attention.cu_seqlens_q = state.cache_seqlens, state.cu_seqlens_q
|
||||
attention.page_table = state.page_table
|
||||
attention.max_seqlen_q = state.page_table.shape[1]
|
||||
mark_packed_compile_metadata(rows, attention)
|
||||
mu_cfg = torch.cat((state.mu, torch.zeros_like(state.mu)), dim=1)
|
||||
cond_cfg = torch.cat((state.cond, torch.zeros_like(state.cond)), dim=1)
|
||||
spks_cfg = torch.cat((state.spks, torch.zeros_like(state.spks)), dim=0)[state.row_ids].unsqueeze(0)
|
||||
flow_time = torch.zeros(1, device=state.noise.device, dtype=state.spks.dtype)
|
||||
modulations = estimator.step_modulations(state.time_span, key[4])
|
||||
return euler_steps(
|
||||
estimator.compiled_full_forward,
|
||||
state.noise,
|
||||
state.time_span,
|
||||
mu_cfg,
|
||||
spks_cfg,
|
||||
cond_cfg,
|
||||
flow_time,
|
||||
rows,
|
||||
attention,
|
||||
modulations,
|
||||
cfg_rate,
|
||||
)
|
||||
|
||||
def run(
|
||||
self,
|
||||
noise: torch.Tensor,
|
||||
time_span: torch.Tensor,
|
||||
mu: torch.Tensor,
|
||||
spks: torch.Tensor,
|
||||
cond: torch.Tensor,
|
||||
rows: PackedRows,
|
||||
*,
|
||||
cfg_rate: float,
|
||||
streaming: bool,
|
||||
key: tuple,
|
||||
) -> torch.Tensor | None:
|
||||
layout = self._layout(rows.lengths, streaming)
|
||||
if layout is None or torch.cuda.is_current_stream_capturing():
|
||||
return None
|
||||
graph_key, twin, seg_rows, seg_ends, offsets = layout
|
||||
graph_key = graph_key + (key, float(cfg_rate))
|
||||
state = self.graphs.get(graph_key)
|
||||
fresh = state is None
|
||||
if fresh:
|
||||
state = self._allocate(graph_key, noise, spks, time_span)
|
||||
self._load(state, twin, seg_rows, seg_ends, offsets, noise, mu, cond, spks, time_span)
|
||||
if fresh:
|
||||
# Warm up (and compile new dynamic shapes) off the capture, then
|
||||
# record the solve into the shared pool.
|
||||
stream = torch.cuda.Stream()
|
||||
stream.wait_stream(torch.cuda.current_stream())
|
||||
with torch.cuda.stream(stream):
|
||||
self._solve(state, cfg_rate, graph_key)
|
||||
torch.cuda.current_stream().wait_stream(stream)
|
||||
with torch.cuda.graph(state.graph, pool=self.pool):
|
||||
state.output = self._solve(state, cfg_rate, graph_key)
|
||||
if self.pool is None:
|
||||
self.pool = state.graph.pool()
|
||||
self.graphs[graph_key] = state
|
||||
state.graph.replay()
|
||||
return state.output[:, : noise.shape[1]].float()
|
||||
@@ -1,11 +1,15 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
import hashlib
|
||||
import os
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from dataclasses import replace
|
||||
from functools import partial
|
||||
from math import gcd
|
||||
from threading import Lock
|
||||
from types import MethodType
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import onnxruntime
|
||||
@@ -39,9 +43,12 @@ from vllm.v1.sample.sampler import Sampler
|
||||
|
||||
from vllm_omni.data_entry_keys import EmbeddingsStruct, OmniPayloadStruct, to_dict, to_struct
|
||||
from vllm_omni.inputs.mm_processor import OmniMultiModalProcessor
|
||||
from vllm_omni.model_executor.models.cosyvoice3.ras_sampler import MAX_FUSED_TOP_K, fused_ras_sample
|
||||
from vllm_omni.model_executor.models.cosyvoice3.runtime import (
|
||||
cosyvoice3_batch_flow_debug,
|
||||
cosyvoice3_batch_flow_enabled,
|
||||
cosyvoice3_packed_inference_enabled,
|
||||
cosyvoice3_standard_sampling,
|
||||
)
|
||||
from vllm_omni.model_executor.models.cosyvoice3.tokenizer import get_qwen_tokenizer
|
||||
from vllm_omni.model_executor.models.cosyvoice3.utils import (
|
||||
@@ -92,6 +99,267 @@ def _campplus_onnx_providers() -> list[str]:
|
||||
return ["CPUExecutionProvider"]
|
||||
|
||||
|
||||
def _audio_conditioning(proc, audio, model_dir: str, config, mm_kwargs: Mapping[str, object]) -> dict:
|
||||
"""Reference-audio conditioning (speech tokens, mel, speaker embedding).
|
||||
|
||||
Reuses bounded, process-local artifacts keyed by voice name or by the
|
||||
waveform content, and returns independent tensors so downstream mutation
|
||||
cannot poison a cache hit.
|
||||
"""
|
||||
device = "cpu"
|
||||
voice_name = mm_kwargs.get("voice_name")
|
||||
cache_key = None
|
||||
if voice_name and isinstance(voice_name, str):
|
||||
cache_key = proc._speaker_cache.make_cache_key(
|
||||
voice_name,
|
||||
model_type="cosyvoice3",
|
||||
created_at=int(mm_kwargs.get("voice_created_at") or 0),
|
||||
)
|
||||
else:
|
||||
# Cache audio artifacts only: reference/target text is tokenized
|
||||
# separately. Keep dtype and sample rate in the identity to avoid
|
||||
# aliases between numerically distinct preprocessing inputs.
|
||||
waveform, sample_rate = audio
|
||||
waveform = np.ascontiguousarray(waveform)
|
||||
digest = hashlib.sha256()
|
||||
digest.update(str((int(sample_rate), waveform.shape, waveform.dtype.str)).encode())
|
||||
digest.update(memoryview(waveform).cast("B"))
|
||||
backend = "trt" if proc.campplus_trt is not None else "onnx"
|
||||
cache_key = proc._speaker_cache.make_cache_key(
|
||||
digest.hexdigest(), model_type=f"cosyvoice3-reference:{model_dir}:{backend}"
|
||||
)
|
||||
cached = proc._speaker_cache.get(cache_key)
|
||||
if os.environ.get("COSYVOICE3_REFERENCE_CACHE_DEBUG") == "1":
|
||||
_REFERENCE_CACHE_DEBUG[cached is not None] += 1
|
||||
if sum(_REFERENCE_CACHE_DEBUG.values()) % 128 == 0:
|
||||
logger.info(
|
||||
"CosyVoice3 reference cache lookups: hits=%d misses=%d model_dir=%s",
|
||||
_REFERENCE_CACHE_DEBUG[True],
|
||||
_REFERENCE_CACHE_DEBUG[False],
|
||||
model_dir,
|
||||
)
|
||||
if cached is None:
|
||||
# Speech-token extraction via the S3Tokenizer PyTorch model on GPU
|
||||
# (~30x faster than the bundled ``speech_tokenizer_v3.onnx`` CPU path).
|
||||
speech_token, speech_token_len = proc._extract_speech_token_via_s3(audio, device)
|
||||
speech_feat, speech_feat_len = extract_speech_feat(audio, proc.feat_extractor, device)
|
||||
if config.sample_rate == 24000:
|
||||
token_len = min(int(speech_feat.shape[1] / 2), speech_token.shape[1])
|
||||
speech_feat, speech_feat_len[:] = speech_feat[:, : 2 * token_len], 2 * token_len
|
||||
speech_token, speech_token_len[:] = speech_token[:, :token_len], token_len
|
||||
if proc.campplus_trt is not None:
|
||||
embedding = extract_spk_embedding_trt(audio, proc.campplus_trt, device)
|
||||
else:
|
||||
embedding = extract_spk_embedding(audio, proc.campplus_session, device)
|
||||
cached = {
|
||||
"speech_feat": speech_feat.detach().cpu().clone(),
|
||||
"speech_token": speech_token.detach().cpu().clone(),
|
||||
"speech_token_len": speech_token_len.detach().cpu().clone(),
|
||||
"embedding": embedding.detach().cpu().clone(),
|
||||
}
|
||||
proc._speaker_cache.put(cache_key, cached)
|
||||
return {key: value.clone() for key, value in cached.items()}
|
||||
|
||||
|
||||
_PROMPT_SCRATCH_TOKENS = 1 << 20
|
||||
_MRV2_CONDITIONING_ENTRIES = 4096
|
||||
|
||||
|
||||
def _generated_only_penalty_writes(state) -> None:
|
||||
"""``PenaltiesState.apply_staged_writes`` that leaves the prompt unpenalized."""
|
||||
if state._new_penalties_reqs:
|
||||
from vllm.utils.torch_utils import async_tensor_h2d
|
||||
from vllm.v1.worker.gpu.sample.penalties import bincount
|
||||
|
||||
scratch = getattr(state, "_prompt_scratch_mask", None)
|
||||
if scratch is None:
|
||||
columns = max(state.prompt_bin_mask.shape[1], _PROMPT_SCRATCH_TOKENS // 32)
|
||||
scratch = state._prompt_scratch_mask = torch.zeros(
|
||||
state.prompt_bin_mask.shape[0], columns, dtype=torch.int32, device=state.device
|
||||
)
|
||||
rows = async_tensor_h2d(state._new_penalties_reqs, dtype=torch.int32, device=state.device)
|
||||
prefill_lens = state.req_states.prefill_len.np[state._new_penalties_reqs]
|
||||
# Prompt bits land in the scratch mask; resumed output tokens (between
|
||||
# prompt_len and prefill_len) still seed the output counts.
|
||||
bincount(
|
||||
rows,
|
||||
state.req_states.all_token_ids.gpu,
|
||||
state.req_states.prompt_len.gpu,
|
||||
state.req_states.prefill_len.gpu,
|
||||
scratch,
|
||||
state.output_bin_counts,
|
||||
int(prefill_lens.max()),
|
||||
)
|
||||
state.prompt_bin_mask.index_fill_(0, rows.long(), 0)
|
||||
state._new_penalties_reqs.clear()
|
||||
state.repetition_penalty.copy_to_uva()
|
||||
state.frequency_penalty.copy_to_uva()
|
||||
state.presence_penalty.copy_to_uva()
|
||||
|
||||
|
||||
def _ras_mrv2_sample(
|
||||
sampler,
|
||||
logits: torch.Tensor,
|
||||
expanded_idx_mapping: torch.Tensor,
|
||||
idx_mapping: torch.Tensor,
|
||||
idx_mapping_np: np.ndarray,
|
||||
pos: torch.Tensor,
|
||||
input_ids: torch.Tensor,
|
||||
expanded_local_pos: torch.Tensor,
|
||||
return_logprobs: bool = False,
|
||||
*,
|
||||
default_top_p: float,
|
||||
default_top_k: int,
|
||||
win_size: int,
|
||||
tau_r: float,
|
||||
eps: float,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""``Sampler.sample`` for MRv2: CosyVoice3 repetition-aware sampling on the GPU.
|
||||
|
||||
Same distribution as ``CosyVoice3Model._ras_sample_batch`` (V1): top-p over
|
||||
the full distribution capped at top-k; a draw repeating within the last
|
||||
``win_size`` generated tokens is replaced by a draw from the full remaining
|
||||
distribution. History is read from the runner's token table and rejection
|
||||
is resolved per row on the device, so no step waits on the host. Penalties
|
||||
are not applied, as in V1 RAS. Draws use the request seed and position; the
|
||||
replacement draw uses a disjoint noise stream.
|
||||
"""
|
||||
from vllm.v1.worker.gpu.sample.gumbel import gumbel_sample
|
||||
|
||||
logits = torch.empty_like(logits, dtype=torch.float32).copy_(logits)
|
||||
sampler.logit_bias_state.apply_logit_bias(logits, expanded_idx_mapping, idx_mapping_np, pos)
|
||||
states = sampler.sampling_states
|
||||
temperature = states.temperature.gpu
|
||||
req_states = sampler.req_states
|
||||
top_k_np = states.top_k.np[idx_mapping_np]
|
||||
use_top_k = bool((top_k_np != states.vocab_size).any())
|
||||
if logits.is_cuda and not return_logprobs and (top_k_np.max() if use_top_k else default_top_k) <= MAX_FUSED_TOP_K:
|
||||
# One kernel per step instead of ~70 (see ras_sampler.py).
|
||||
use_top_p = bool((states.top_p.np[idx_mapping_np] != 1.0).any())
|
||||
sampled = fused_ras_sample(
|
||||
logits,
|
||||
expanded_idx_mapping,
|
||||
temperature,
|
||||
states.top_k.gpu if use_top_k else None,
|
||||
states.top_p.gpu if use_top_p else None,
|
||||
states.seeds.gpu,
|
||||
pos,
|
||||
req_states.all_token_ids.gpu,
|
||||
req_states.total_len.gpu,
|
||||
req_states.prompt_len.gpu,
|
||||
default_top_k=default_top_k,
|
||||
default_top_p=default_top_p,
|
||||
win_size=win_size,
|
||||
tau_r=tau_r,
|
||||
eps=eps,
|
||||
)
|
||||
return sampled, logits
|
||||
rows = expanded_idx_mapping.long()
|
||||
row_temperature = temperature[rows]
|
||||
scores = torch.log_softmax(logits / row_temperature.clamp_min(eps).unsqueeze(1), dim=1)
|
||||
num_rows, vocab = scores.shape
|
||||
top_k, top_p = states.get_top_k_top_p(expanded_idx_mapping, idx_mapping_np)
|
||||
if top_p is None:
|
||||
top_p = scores.new_full((num_rows,), default_top_p)
|
||||
if top_k is None:
|
||||
top_k = torch.full((num_rows,), default_top_k, dtype=torch.int32, device=scores.device)
|
||||
sorted_scores, sorted_ids = scores.sort(dim=1, descending=True, stable=True)
|
||||
sorted_probs = sorted_scores.exp()
|
||||
# Top-p is decided on the full distribution, before the top-k cap.
|
||||
keep = (sorted_probs.cumsum(dim=1) - sorted_probs) < top_p.unsqueeze(1)
|
||||
ranks = torch.arange(vocab, device=scores.device)
|
||||
keep &= (top_k.unsqueeze(1) <= 0) | (ranks.unsqueeze(0) < top_k.unsqueeze(1))
|
||||
nucleus = sorted_scores.masked_fill(~keep, float("-inf"))
|
||||
seeds = states.seeds.gpu
|
||||
draws = gumbel_sample(nucleus, expanded_idx_mapping, temperature, seeds, pos, False, False)
|
||||
sampled = sorted_ids.gather(1, draws.unsqueeze(1))
|
||||
if win_size <= 0:
|
||||
return sampled.squeeze(1), scores
|
||||
|
||||
total_len = req_states.total_len.gpu[rows].long()
|
||||
prompt_len = req_states.prompt_len.gpu[rows].long()
|
||||
offsets = total_len.unsqueeze(1) - 1 - torch.arange(win_size, device=scores.device).unsqueeze(0)
|
||||
in_output = offsets >= prompt_len.unsqueeze(1)
|
||||
history = req_states.all_token_ids.gpu[rows.unsqueeze(1), offsets.clamp_min(0)]
|
||||
repeats = ((history == sampled) & in_output).sum(dim=1)
|
||||
rejected = (repeats >= win_size * tau_r) & in_output.any(dim=1) & (row_temperature > 0)
|
||||
remaining = scores.scatter(1, sampled, float("-inf"))
|
||||
remaining = torch.where(torch.isfinite(remaining).any(dim=1, keepdim=True), remaining, scores)
|
||||
# Without top-k/top-p, from a noise stream independent of the first draw.
|
||||
replacement = gumbel_sample(remaining, expanded_idx_mapping, temperature, seeds, pos, False, True)
|
||||
return torch.where(rejected, replacement, sampled.squeeze(1)), scores
|
||||
|
||||
|
||||
class _SpeechTokenBatcher:
|
||||
"""Run concurrent reference speech-token extractions as one S3 batch.
|
||||
|
||||
Cold references are conditioned by several worker threads. One S3 call per
|
||||
reference runs batch-1 FP32 GEMMs, which cost ~3x more GPU time per
|
||||
reference than a batch of 8 and compete with the talker and codec stages.
|
||||
A single thread drains every request already queued into one padded
|
||||
``quantize`` call; it never waits for more, so an idle server adds no
|
||||
latency. Padding changes ~0.1% of prompt tokens versus batch-1 extraction.
|
||||
"""
|
||||
|
||||
def __init__(self, model, s3, device, max_batch: int = 16) -> None:
|
||||
import queue
|
||||
import threading
|
||||
|
||||
self._model, self._s3, self._device, self._max_batch = model, s3, device, max_batch
|
||||
self._queue: queue.SimpleQueue = queue.SimpleQueue()
|
||||
self._empty = queue.Empty
|
||||
threading.Thread(target=self._run, name="cosyvoice3-s3-batch", daemon=True).start()
|
||||
|
||||
def tokens(self, mel: torch.Tensor) -> torch.Tensor:
|
||||
from concurrent.futures import Future
|
||||
|
||||
future: Future = Future()
|
||||
self._queue.put((mel, future))
|
||||
return future.result()
|
||||
|
||||
def _run(self) -> None:
|
||||
while True:
|
||||
items = [self._queue.get()]
|
||||
while len(items) < self._max_batch:
|
||||
try:
|
||||
items.append(self._queue.get_nowait())
|
||||
except self._empty:
|
||||
break
|
||||
try:
|
||||
mels, lens = self._s3.padding([mel for mel, _ in items])
|
||||
with torch.inference_mode():
|
||||
codes, codes_lens = self._model.quantize(mels.to(self._device), lens.to(self._device))
|
||||
codes = codes.cpu()
|
||||
for (_, future), row, n in zip(items, codes, codes_lens.tolist()):
|
||||
future.set_result(row[:n].clone())
|
||||
except BaseException as exc: # noqa: BLE001 - surfaced to every waiting caller
|
||||
for _, future in items:
|
||||
if not future.done():
|
||||
future.set_exception(exc)
|
||||
|
||||
|
||||
_REFERENCE_CONDITIONERS: dict[str, "CosyVoice3MultiModalProcessor"] = {}
|
||||
_REFERENCE_CACHE_DEBUG = {True: 0, False: 0}
|
||||
_REFERENCE_CONDITIONERS_LOCK = Lock()
|
||||
|
||||
|
||||
def prefetch_reference_conditioning(model_dir: str, config, audio) -> None:
|
||||
"""Warm the reference cache for ``audio`` (already at ``target_sr``).
|
||||
|
||||
Runs in a worker thread before the request enters the synchronous input
|
||||
processor, so concurrent cold references are prepared in parallel (the
|
||||
ONNX/TensorRT/torch work releases the GIL) and the processor hits the
|
||||
cache instead of blocking the serving event loop.
|
||||
"""
|
||||
with _REFERENCE_CONDITIONERS_LOCK:
|
||||
conditioner = _REFERENCE_CONDITIONERS.get(model_dir)
|
||||
if conditioner is None:
|
||||
conditioner = object.__new__(CosyVoice3MultiModalProcessor)
|
||||
conditioner._ensure_cached_runtime_components(model_dir, config)
|
||||
_REFERENCE_CONDITIONERS[model_dir] = conditioner
|
||||
_audio_conditioning(conditioner, audio, model_dir, config, {})
|
||||
|
||||
|
||||
class CosyVoice3MultiModalProcessingInfo(BaseProcessingInfo):
|
||||
def get_hf_config(self):
|
||||
"""If the config is not already present pass it
|
||||
@@ -244,7 +512,13 @@ class CosyVoice3MultiModalProcessor(OmniMultiModalProcessor[CosyVoice3MultiModal
|
||||
def _ensure_s3_model(cls):
|
||||
if cls._s3_model is not None:
|
||||
return cls._s3_model
|
||||
with _REFERENCE_CONDITIONERS_LOCK:
|
||||
if cls._s3_model is None:
|
||||
cls._s3_model = cls._load_s3_model()
|
||||
return cls._s3_model
|
||||
|
||||
@classmethod
|
||||
def _load_s3_model(cls):
|
||||
# s3tokenizer is imported lazily (kept off the module top level) so
|
||||
# callers that don't use the CosyVoice3 talker need not install it.
|
||||
try:
|
||||
@@ -258,8 +532,18 @@ class CosyVoice3MultiModalProcessor(OmniMultiModalProcessor[CosyVoice3MultiModal
|
||||
model = _s3.load_model("speech_tokenizer_v3_25hz")
|
||||
device = current_omni_platform.get_torch_device()
|
||||
model = model.to(device).eval()
|
||||
cls._s3_model = (model, _s3, device)
|
||||
return cls._s3_model
|
||||
return (model, _s3, device)
|
||||
|
||||
_s3_batcher: "_SpeechTokenBatcher | None" = None
|
||||
|
||||
@classmethod
|
||||
def _ensure_s3_batcher(cls) -> "_SpeechTokenBatcher":
|
||||
if cls._s3_batcher is None:
|
||||
model, s3, device = cls._ensure_s3_model()
|
||||
with _REFERENCE_CONDITIONERS_LOCK:
|
||||
if cls._s3_batcher is None:
|
||||
cls._s3_batcher = _SpeechTokenBatcher(model, s3, device)
|
||||
return cls._s3_batcher
|
||||
|
||||
def _extract_speech_token_via_s3(self, audio, return_device):
|
||||
"""Drop-in replacement for ``extract_speech_token`` that uses the
|
||||
@@ -280,12 +564,15 @@ class CosyVoice3MultiModalProcessor(OmniMultiModalProcessor[CosyVoice3MultiModal
|
||||
audio_t = torch.from_numpy(wav)
|
||||
|
||||
mel = _s3.log_mel_spectrogram(audio_t)
|
||||
mels_p, mels_lens = _s3.padding([mel])
|
||||
with torch.inference_mode():
|
||||
codes, codes_lens = model.quantize(mels_p.to(dev), mels_lens.to(dev))
|
||||
n = int(codes_lens[0].item())
|
||||
speech_token = codes[:1, :n].to(dtype=torch.int32, device=return_device)
|
||||
speech_token_len = torch.tensor([n], dtype=torch.int32, device=return_device)
|
||||
if torch.device(dev).type == "cuda":
|
||||
codes = self._ensure_s3_batcher().tokens(mel)
|
||||
else:
|
||||
mels_p, mels_lens = _s3.padding([mel])
|
||||
with torch.inference_mode():
|
||||
codes, codes_lens = model.quantize(mels_p.to(dev), mels_lens.to(dev))
|
||||
codes = codes[0, : int(codes_lens[0].item())]
|
||||
speech_token = codes.reshape(1, -1).to(dtype=torch.int32, device=return_device)
|
||||
speech_token_len = torch.tensor([speech_token.shape[1]], dtype=torch.int32, device=return_device)
|
||||
return speech_token, speech_token_len
|
||||
|
||||
def _call_hf_processor(
|
||||
@@ -342,71 +629,17 @@ class CosyVoice3MultiModalProcessor(OmniMultiModalProcessor[CosyVoice3MultiModal
|
||||
int(text_token_len),
|
||||
int(input_len),
|
||||
)
|
||||
device = "cpu"
|
||||
|
||||
# Speaker cache: skip 3 ONNX sessions on cache hit
|
||||
voice_name = mm_kwargs.get("voice_name")
|
||||
cache_key = None
|
||||
if voice_name and isinstance(voice_name, str):
|
||||
cache_key = self._speaker_cache.make_cache_key(
|
||||
voice_name,
|
||||
model_type="cosyvoice3",
|
||||
created_at=int(mm_kwargs.get("voice_created_at") or 0),
|
||||
)
|
||||
cached = self._speaker_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
ft = BatchFeature(
|
||||
{
|
||||
"input_ids": input_ids,
|
||||
"speech_feat": cached["speech_feat"].clone(),
|
||||
"speech_token": cached["speech_token"].clone(),
|
||||
"speech_token_len": [cached["speech_token_len"].clone()],
|
||||
"embedding": cached["embedding"].clone(),
|
||||
}
|
||||
)
|
||||
return ft
|
||||
|
||||
# Speech-token extraction via the S3Tokenizer PyTorch model on GPU
|
||||
# (~30x faster than the bundled ``speech_tokenizer_v3.onnx`` CPU ONNX
|
||||
# path in this venv).
|
||||
speech_token, speech_token_len = self._extract_speech_token_via_s3(audio, device)
|
||||
|
||||
speech_feat, speech_feat_len = extract_speech_feat(audio, self.feat_extractor, device)
|
||||
|
||||
if config.sample_rate == 24000:
|
||||
token_len = min(int(speech_feat.shape[1] / 2), speech_token.shape[1])
|
||||
speech_feat, speech_feat_len[:] = speech_feat[:, : 2 * token_len], 2 * token_len
|
||||
speech_token, speech_token_len[:] = speech_token[:, :token_len], token_len
|
||||
|
||||
if self.campplus_trt is not None:
|
||||
embedding = extract_spk_embedding_trt(audio, self.campplus_trt, device)
|
||||
else:
|
||||
embedding = extract_spk_embedding(audio, self.campplus_session, device)
|
||||
|
||||
# Cache the extracted artifacts for named speakers
|
||||
if cache_key is not None:
|
||||
self._speaker_cache.put(
|
||||
cache_key,
|
||||
{
|
||||
"speech_feat": speech_feat.detach().cpu(),
|
||||
"speech_token": speech_token.detach().cpu(),
|
||||
"speech_token_len": speech_token_len.detach().cpu(),
|
||||
"embedding": embedding.detach().cpu(),
|
||||
},
|
||||
)
|
||||
|
||||
ft = BatchFeature(
|
||||
conditioning = _audio_conditioning(self, audio, model_dir, config, mm_kwargs)
|
||||
return BatchFeature(
|
||||
{
|
||||
"input_ids": input_ids,
|
||||
"speech_feat": speech_feat,
|
||||
"speech_token": speech_token,
|
||||
"speech_token_len": [speech_token_len],
|
||||
"embedding": embedding,
|
||||
"speech_feat": conditioning["speech_feat"],
|
||||
"speech_token": conditioning["speech_token"],
|
||||
"speech_token_len": [conditioning["speech_token_len"]],
|
||||
"embedding": conditioning["embedding"],
|
||||
}
|
||||
)
|
||||
|
||||
return ft
|
||||
|
||||
def _get_mm_fields_config(
|
||||
self,
|
||||
hf_inputs: "BatchFeature",
|
||||
@@ -487,13 +720,31 @@ class CosyVoice3Model(
|
||||
supports_multimodal_raw_input_only = True
|
||||
supports_multimodal = True
|
||||
requires_raw_input_tokens = True
|
||||
supports_embed_input_ids_query_start_loc = True
|
||||
prefer_model_sampler = True
|
||||
_sampling_eps = 1e-5
|
||||
|
||||
@property
|
||||
def logits_vocab_size(self) -> int | None:
|
||||
"""Width of the logits ``compute_logits`` returns (the speech head).
|
||||
|
||||
The runners drop stop ids beyond it from min-tokens masking: vLLM folds
|
||||
the text tokenizer's EOS into every request's stop set, which this
|
||||
head can never emit (and MRv2's unchecked mask kernel would write past
|
||||
the logits row).
|
||||
"""
|
||||
if self.model_stage != "cosyvoice3_talker":
|
||||
return None
|
||||
return int(self.config.llm["speech_token_size"]) + 200
|
||||
|
||||
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
|
||||
super().__init__()
|
||||
self.config = vllm_config.model_config.hf_config
|
||||
standard_sampling = cosyvoice3_standard_sampling(self.config)
|
||||
logger.info("CosyVoice3 sampling policy: %s", "standard" if standard_sampling else "ras")
|
||||
self.have_multimodal_outputs = True
|
||||
# Full-response codec consumes tokens and conditioning, not LLM hidden states.
|
||||
self.omni_pooler_payload_include_hidden = not cosyvoice3_packed_inference_enabled()
|
||||
self.model_stage = vllm_config.model_config.model_stage
|
||||
model_dir = vllm_config.model_config.model
|
||||
if not os.path.isdir(model_dir):
|
||||
@@ -552,6 +803,13 @@ class CosyVoice3Model(
|
||||
# Use parent's cache config - critical for PagedAttention to work correctly
|
||||
return parent_config.with_hf_config(qwen_hf_config, architectures=["Qwen2Model"])
|
||||
|
||||
def on_requests_finished(self, request_ids: set[str]) -> None:
|
||||
"""Release per-request vocoder state on completion, cancellation or error."""
|
||||
if hasattr(self, "_stream_vocoder_cache_by_req"):
|
||||
with self._stream_audio_cache_lock:
|
||||
for request_id in request_ids:
|
||||
self._stream_vocoder_cache_by_req.pop(request_id, None)
|
||||
|
||||
def _stitch_stream_audio(self, req_id: str | None, audio: torch.Tensor, stream_finished: bool) -> torch.Tensor:
|
||||
"""Pass-through stitching for async_chunk.
|
||||
|
||||
@@ -692,6 +950,74 @@ class CosyVoice3Model(
|
||||
return False
|
||||
return True
|
||||
|
||||
def _ras_sample_batch(
|
||||
self,
|
||||
logits: torch.Tensor,
|
||||
sampling_metadata: SamplingMetadata,
|
||||
*,
|
||||
default_top_p: float,
|
||||
default_top_k: int,
|
||||
win_size: int,
|
||||
tau_r: float,
|
||||
) -> torch.Tensor:
|
||||
"""Batch random RAS without per-row scalar parameter/token transfers.
|
||||
|
||||
Rejection flags cross to the host once per batch so only requests that
|
||||
actually reject a token consume a second RNG draw. Per-request seeded
|
||||
generators retain their ownership when rejection compacts the batch.
|
||||
"""
|
||||
batch_size = logits.shape[0]
|
||||
|
||||
def parameter(value: torch.Tensor | None, default: float | int) -> torch.Tensor:
|
||||
if value is None or value.numel() == 0:
|
||||
return torch.full((batch_size,), default, device=logits.device)
|
||||
value = value.reshape(-1).to(device=logits.device)
|
||||
if value.numel() < batch_size:
|
||||
value = torch.cat((value, value[-1:].expand(batch_size - value.numel())))
|
||||
return value[:batch_size]
|
||||
|
||||
temperature = parameter(sampling_metadata.temperature, 1.0)
|
||||
top_p = parameter(sampling_metadata.top_p, default_top_p)
|
||||
top_k = parameter(sampling_metadata.top_k, default_top_k)
|
||||
weighted_scores = torch.log_softmax(logits / temperature.clamp_min(self._sampling_eps).unsqueeze(1), dim=1)
|
||||
sorted_probs, sorted_ids = weighted_scores.softmax(dim=1).sort(dim=1, descending=True, stable=True)
|
||||
# Keep top-p based on the full distribution, before top-k masking.
|
||||
keep = (sorted_probs.cumsum(dim=1) - sorted_probs) < top_p.unsqueeze(1)
|
||||
ranks = torch.arange(logits.shape[1], device=logits.device)
|
||||
keep &= (top_k.unsqueeze(1) <= 0) | (ranks.unsqueeze(0) < top_k.unsqueeze(1))
|
||||
weights = sorted_probs * keep.to(sorted_probs.dtype)
|
||||
generators = {i: g for i, g in sampling_metadata.generators.items() if i < batch_size}
|
||||
draws = random_sample(weights, generators).reshape(-1, 1).long()
|
||||
sampled = sorted_ids.gather(1, draws).squeeze(1)
|
||||
|
||||
histories = sampling_metadata.output_token_ids
|
||||
if win_size > 0 and any(histories[:batch_size]):
|
||||
recent = []
|
||||
for i in range(batch_size):
|
||||
row = list(histories[i][-win_size:]) if i < len(histories) else []
|
||||
recent.append([-1] * (win_size - len(row)) + row)
|
||||
history = torch.tensor(recent, dtype=torch.long, device=logits.device)
|
||||
repeated = ((history == sampled.unsqueeze(1)).sum(dim=1) >= win_size * tau_r) & (history >= 0).any(dim=1)
|
||||
rejected_rows = [i for i, reject in enumerate(repeated.tolist()) if reject]
|
||||
if rejected_rows:
|
||||
rows = torch.tensor(rejected_rows, device=logits.device, dtype=torch.long)
|
||||
scores = weighted_scores.index_select(0, rows)
|
||||
rejected_ids = sampled.index_select(0, rows).unsqueeze(1)
|
||||
original = scores.gather(1, rejected_ids)
|
||||
scores.scatter_(1, rejected_ids, float("-inf"))
|
||||
replacement = torch.where(
|
||||
torch.isfinite(scores).any(dim=1, keepdim=True),
|
||||
torch.full_like(original, float("-inf")),
|
||||
original,
|
||||
)
|
||||
scores.scatter_(1, rejected_ids, replacement)
|
||||
compact_generators = {i: generators[row] for i, row in enumerate(rejected_rows) if row in generators}
|
||||
# RAS rejection samples the complete remaining distribution,
|
||||
# without applying top-k/top-p a second time.
|
||||
replacement_ids = random_sample(scores.softmax(dim=1), compact_generators).reshape(-1).long()
|
||||
sampled.index_copy_(0, rows, replacement_ids)
|
||||
return sampled.to(torch.int32)
|
||||
|
||||
def sample(
|
||||
self,
|
||||
logits: torch.Tensor,
|
||||
@@ -702,14 +1028,31 @@ class CosyVoice3Model(
|
||||
if self.model_stage != "cosyvoice3_talker":
|
||||
return None
|
||||
|
||||
if cosyvoice3_standard_sampling(self.config):
|
||||
sampler = getattr(self, "_talker_sampler", None)
|
||||
if sampler is None:
|
||||
sampler = Sampler()
|
||||
self._talker_sampler = sampler
|
||||
# Penalize generated tokens only, never text or reference
|
||||
# speech in the multimodal prompt. Padding is ignored by vLLM.
|
||||
if sampling_metadata.prompt_token_ids is not None:
|
||||
sampling_metadata = replace(
|
||||
sampling_metadata,
|
||||
prompt_token_ids=torch.full_like(sampling_metadata.prompt_token_ids, logits.shape[-1]),
|
||||
)
|
||||
return sampler(logits=logits, sampling_metadata=sampling_metadata)
|
||||
|
||||
if not self._cosyvoice3_ras_enabled(sampling_metadata):
|
||||
sampler = getattr(self, "_talker_sampler", None)
|
||||
if sampler is None:
|
||||
sampler = Sampler()
|
||||
self._talker_sampler = sampler
|
||||
return sampler(logits=logits, sampling_metadata=sampling_metadata)
|
||||
return sampler(logits=self._full_vocab_logits(logits), sampling_metadata=sampling_metadata)
|
||||
|
||||
logits = logits.to(torch.float32)
|
||||
mask = sampling_metadata.allowed_token_ids_mask
|
||||
if mask is not None and mask.shape[-1] != logits.shape[-1]:
|
||||
sampling_metadata = replace(sampling_metadata, allowed_token_ids_mask=mask[..., : logits.shape[-1]])
|
||||
# Apply logits processors directly — RAS handles its own repetition
|
||||
# logic. We avoid instantiating Sampler() here because its import
|
||||
# chain pulls in flashinfer / GPU deps that fail in CPU-only tests.
|
||||
@@ -717,6 +1060,14 @@ class CosyVoice3Model(
|
||||
logits.masked_fill_(sampling_metadata.allowed_token_ids_mask, float("-inf"))
|
||||
for processor in sampling_metadata.logitsprocs.non_argmax_invariant:
|
||||
logits = processor.apply(logits)
|
||||
# The text tokenizer vocabulary is much larger than the speech head.
|
||||
# compute_logits pads its output for the generic sampler/processors;
|
||||
# RAS only needs speech codes and the merged stop token. Keep the
|
||||
# original token indices, and trim only after full-vocabulary masks
|
||||
# and processors have run. The generic sampler fallback above retains
|
||||
# its full-vocabulary contract (including logprobs and bad words).
|
||||
speech_head_size = int(self.config.llm["speech_token_size"]) + 200
|
||||
logits = logits[..., :speech_head_size]
|
||||
finite_logits = torch.isfinite(logits)
|
||||
if not finite_logits.any(dim=-1).all().item():
|
||||
raise ValueError("CosyVoice3 sampling received a row with no finite logits")
|
||||
@@ -728,6 +1079,17 @@ class CosyVoice3Model(
|
||||
win_size = int(sampling_cfg.get("win_size", 10))
|
||||
tau_r = float(sampling_cfg.get("tau_r", 0.1))
|
||||
|
||||
if sampling_metadata.all_random:
|
||||
sampled = self._ras_sample_batch(
|
||||
logits,
|
||||
sampling_metadata,
|
||||
default_top_p=default_top_p,
|
||||
default_top_k=default_top_k,
|
||||
win_size=win_size,
|
||||
tau_r=tau_r,
|
||||
)
|
||||
return SamplerOutput(sampled_token_ids=sampled.unsqueeze(-1), logprobs_tensors=None)
|
||||
|
||||
sampled_ids: list[int] = []
|
||||
for req_idx in range(int(logits.shape[0])):
|
||||
row_logits = logits[req_idx]
|
||||
@@ -759,6 +1121,48 @@ class CosyVoice3Model(
|
||||
sampled = torch.tensor(sampled_ids, device=logits.device, dtype=torch.int32)
|
||||
return SamplerOutput(sampled_token_ids=sampled.unsqueeze(-1), logprobs_tensors=None)
|
||||
|
||||
def mrv2_custom_sampler(self, sampler: Any) -> None:
|
||||
"""Model Runner V2: the V1 sampling policies on the MRv2 sampler.
|
||||
|
||||
RAS replaces the sampling step (``_ras_mrv2_sample``). Standard
|
||||
sampling penalizes generated speech tokens only: the V1 path hides the
|
||||
prompt from penalties (SGLang parity: text and reference speech are
|
||||
never penalized), while MRv2 keeps penalty statistics on the GPU and
|
||||
bins every prompt token of a new request, whose text ids also lie
|
||||
outside the narrow speech head. Bin the prompt into a scratch mask
|
||||
instead, keeping resumed output tokens counted.
|
||||
"""
|
||||
if self.model_stage != "cosyvoice3_talker":
|
||||
return None
|
||||
if not cosyvoice3_standard_sampling(self.config):
|
||||
sampling_cfg = dict(self.config.llm.get("sampling", {}))
|
||||
ras = partial(
|
||||
_ras_mrv2_sample,
|
||||
default_top_p=float(sampling_cfg.get("top_p", 0.8)),
|
||||
default_top_k=int(sampling_cfg.get("top_k", 25)),
|
||||
win_size=int(sampling_cfg.get("win_size", 10)),
|
||||
tau_r=float(sampling_cfg.get("tau_r", 0.1)),
|
||||
eps=self._sampling_eps,
|
||||
)
|
||||
sampler.sample = MethodType(ras, sampler)
|
||||
state = getattr(sampler, "penalties_state", None)
|
||||
if state is not None:
|
||||
state.apply_staged_writes = MethodType(_generated_only_penalty_writes, state)
|
||||
# MRv2 captures tensor outputs and constructs OmniOutput afterwards.
|
||||
# This contract also applies when only the codec stage enables packed Flow.
|
||||
self._mrv2_tensor_output = True
|
||||
# MRv2 hands make_omni_output no multimodal kwargs; carry each prompt's
|
||||
# conditioning from the encoder call to its batch row instead.
|
||||
self._mrv2_encoded_conditioning = OrderedDict()
|
||||
return None
|
||||
|
||||
def _full_vocab_logits(self, logits: torch.Tensor) -> torch.Tensor:
|
||||
"""Pad the speech head to the text vocabulary for the generic sampler contract."""
|
||||
pad_size = int(self.config.vocab_size) - logits.size(-1)
|
||||
if pad_size <= 0:
|
||||
return logits
|
||||
return torch.cat([logits, logits.new_full(logits.shape[:-1] + (pad_size,), float("-inf"))], dim=-1)
|
||||
|
||||
def compute_logits(self, hidden_states: torch.Tensor | OmniOutput) -> torch.Tensor | None:
|
||||
if isinstance(hidden_states, OmniOutput):
|
||||
hidden_states = hidden_states.text_hidden_states
|
||||
@@ -771,17 +1175,15 @@ class CosyVoice3Model(
|
||||
# fires with the correct aggregate stop probability.
|
||||
speech_token_size = self.config.llm["speech_token_size"]
|
||||
eos_idx = self.config.llm["eos_token_id"]
|
||||
stop_logits = logits[..., speech_token_size:] # last 200
|
||||
merged_stop = torch.logsumexp(stop_logits, dim=-1, keepdim=True)
|
||||
logits[..., speech_token_size:] = float("-inf") # mask all
|
||||
logits[..., eos_idx] = merged_stop.squeeze(-1) # restore merged
|
||||
# Pad to full vocab_size for vLLM token handling.
|
||||
vocab_size = self.config.vocab_size
|
||||
pad_size = vocab_size - logits.size(-1)
|
||||
if pad_size > 0:
|
||||
pad_shape = logits.shape[:-1] + (pad_size,)
|
||||
pad = logits.new_full(pad_shape, float("-inf"))
|
||||
logits = torch.cat([logits, pad], dim=-1)
|
||||
if not cosyvoice3_standard_sampling(self.config):
|
||||
stop_logits = logits[..., speech_token_size:] # last 200
|
||||
merged_stop = torch.logsumexp(stop_logits, dim=-1, keepdim=True)
|
||||
logits[..., speech_token_size:] = float("-inf") # mask all
|
||||
logits[..., eos_idx] = merged_stop.squeeze(-1) # restore merged
|
||||
# The model owns sampling and the runner accepts a narrow codec
|
||||
# head, so keep only the speech head: padding every decode step to
|
||||
# the ~152k text vocabulary multiplied sampler and penalty work.
|
||||
# The generic-sampler fallback pads on demand (_full_vocab_logits).
|
||||
return logits
|
||||
else:
|
||||
raise RuntimeError(f"compute_logits is only valid for {self.model_stage}.")
|
||||
@@ -799,8 +1201,25 @@ class CosyVoice3Model(
|
||||
# [1, T, emb] tensor (the caller's extend() iterates dim 0).
|
||||
if isinstance(speech_token, (list, tuple)):
|
||||
emb_dim = self.model.speech_embedding.weight.shape[1]
|
||||
return [self.model.speech_embedding(t).reshape(-1, emb_dim) for t in speech_token]
|
||||
return self.model.speech_embedding(speech_token)
|
||||
embeddings = [self.model.speech_embedding(t).reshape(-1, emb_dim) for t in speech_token]
|
||||
else:
|
||||
embeddings = self.model.speech_embedding(speech_token)
|
||||
encoded = getattr(self, "_mrv2_encoded_conditioning", None)
|
||||
if encoded is not None:
|
||||
conditioning = self._split_prompt_conditioning(
|
||||
speech_token, kwargs.get("speech_feat"), kwargs.get("embedding"), kwargs.get("speech_token_len")
|
||||
)
|
||||
for item, embedding in enumerate(embeddings):
|
||||
# The encoder cache hands this item's tensor back (whole) to
|
||||
# embed_input_ids, where its storage identifies the prompt.
|
||||
# Identical references share one cached tensor, so entries
|
||||
# stay until a newer encoding reuses the storage.
|
||||
key = embedding.data_ptr()
|
||||
encoded[key] = tuple(None if values is None else values[item] for values in conditioning)
|
||||
encoded.move_to_end(key)
|
||||
while len(encoded) > _MRV2_CONDITIONING_ENTRIES:
|
||||
encoded.popitem(last=False)
|
||||
return embeddings
|
||||
else:
|
||||
raise RuntimeError(f"embed_multimodal is only valid for {self.model_stage}.")
|
||||
|
||||
@@ -809,86 +1228,80 @@ class CosyVoice3Model(
|
||||
input_ids: torch.Tensor,
|
||||
multimodal_embeddings=None,
|
||||
is_multimodal=None,
|
||||
query_start_loc: Sequence[int] | None = None,
|
||||
) -> torch.Tensor:
|
||||
if self.model_stage == "cosyvoice3_talker":
|
||||
if is_multimodal is not None and any(is_multimodal):
|
||||
# Per-request rearrange for the talker prompt layout
|
||||
# [SOS_ph, TASK_ID_ph, AUDIO_ph * pstoken_len, ...text_tokens...]
|
||||
# → [SOS_emb, ...text_embs..., TASK_ID_emb, AUDIO_embs].
|
||||
#
|
||||
# In batched prefill ``input_ids`` is the flat concatenation of
|
||||
# N sequences and ``multimodal_embeddings`` is a list with one
|
||||
# tensor per multimodal request. Each request's audio
|
||||
# placeholders form one contiguous ``True`` group in
|
||||
# ``is_multimodal``; the 2 positions immediately before each
|
||||
# group are the SOS / TASK_ID placeholders. Walking the groups
|
||||
# lets us locate every request's segment without needing a
|
||||
# ``seq_token_counts`` kwarg (which only the generation runner
|
||||
# injects, not the AR runner used here).
|
||||
embed_tokens = self.model.llm.model.embed_tokens(input_ids)
|
||||
sos = self.model.speech_embedding.weight[self.model.sos].reshape(1, -1)
|
||||
task_id = self.model.speech_embedding.weight[self.model.task_id].reshape(1, -1)
|
||||
# Decode-only steps carry no speech embeddings; checking the
|
||||
# (device) placeholder mask would synchronize every step.
|
||||
if not multimodal_embeddings or is_multimodal is None or not torch.any(is_multimodal):
|
||||
return self.model.speech_embedding.weight[input_ids]
|
||||
|
||||
is_mm = is_multimodal.to(torch.bool).reshape(-1)
|
||||
# vLLM v1's multimodal placeholder range covers the FULL
|
||||
# ``[SOS_ph, TASK_ID_ph, AUDIO_ph * pstoken_len]`` block
|
||||
# (length ``2 + pstoken_len``, see ``_get_prompt_updates``'s
|
||||
# ``insertion_end`` which inserts ``[1] * (1 + 1 + token_len)``
|
||||
# and ``mm_position.length`` matches that). So each contiguous
|
||||
# ``True`` group in ``is_mm`` starts at a request's SOS
|
||||
# placeholder, NOT at its first audio placeholder.
|
||||
prev_false = torch.cat([torch.ones(1, dtype=torch.bool, device=is_mm.device), ~is_mm[:-1]])
|
||||
group_starts = (is_mm & prev_false).nonzero(as_tuple=True)[0].tolist()
|
||||
if len(group_starts) != len(multimodal_embeddings):
|
||||
raise RuntimeError(
|
||||
f"cosyvoice3 talker: found {len(group_starts)} placeholder "
|
||||
f"blocks in is_multimodal but {len(multimodal_embeddings)} "
|
||||
f"multimodal_embeddings tensors — these must match 1:1."
|
||||
)
|
||||
# Requests can interleave new prefills and ongoing decodes after
|
||||
# scheduler slot reuse. A placeholder group identifies the start
|
||||
# of a prompt, but cannot identify its end: use runner boundaries.
|
||||
total_tokens = input_ids.numel()
|
||||
boundaries = list(query_start_loc) if query_start_loc is not None else [0, total_tokens]
|
||||
if not boundaries or boundaries[0] != 0 or boundaries[-1] != total_tokens:
|
||||
raise ValueError("CosyVoice3 input embedding boundaries must cover all scheduled tokens")
|
||||
if any(end <= start for start, end in zip(boundaries, boundaries[1:])):
|
||||
raise ValueError("CosyVoice3 input embedding boundaries must be strictly increasing")
|
||||
mm_mask = is_multimodal.reshape(-1).tolist()
|
||||
# Conditioning contains prefill rows only, whereas the downstream
|
||||
# payload splitter indexes the full (prefill + decode) request batch.
|
||||
self._conditioning_request_rows = [
|
||||
i for i, (start, end) in enumerate(zip(boundaries, boundaries[1:])) if any(mm_mask[start:end])
|
||||
]
|
||||
self._conditioning_request_count = len(boundaries) - 1
|
||||
self._mrv2_step_conditioning = []
|
||||
|
||||
total_tokens = int(embed_tokens.shape[0])
|
||||
# vLLM v1 packs cached (decode) requests first, then new
|
||||
# (prefill) requests. The decode tokens at positions
|
||||
# ``[0:group_starts[0]]`` get a speech_embedding lookup (the
|
||||
# talker is generating codec tokens here, so input ids are
|
||||
# codec ids, not text ids). Positions starting at
|
||||
# ``group_starts[0]`` are the first new prefill request's
|
||||
# SOS placeholder.
|
||||
segments: list[torch.Tensor] = []
|
||||
first_prefill = group_starts[0]
|
||||
if first_prefill > 0:
|
||||
decode_ids = input_ids[:first_prefill].to(dtype=torch.long)
|
||||
segments.append(self.model.speech_embedding.weight[decode_ids])
|
||||
|
||||
for i, req_start in enumerate(group_starts):
|
||||
pstoken_len_i = int(multimodal_embeddings[i].shape[0])
|
||||
# Real text starts after the 2 + pstoken_len_i leading
|
||||
# placeholders of this request's prompt.
|
||||
text_start = req_start + 2 + pstoken_len_i
|
||||
# Next request's segment starts at its own SOS placeholder,
|
||||
# which is exactly the next True group's first index.
|
||||
if i + 1 < len(group_starts):
|
||||
req_end = group_starts[i + 1]
|
||||
else:
|
||||
req_end = total_tokens
|
||||
segments.append(sos)
|
||||
segments.append(embed_tokens[text_start:req_end])
|
||||
segments.append(task_id)
|
||||
segments.append(multimodal_embeddings[i])
|
||||
embed_tokens = torch.cat(segments, dim=0)
|
||||
else:
|
||||
embed_tokens = self.model.speech_embedding.weight[input_ids]
|
||||
return embed_tokens
|
||||
text_embeds = self.model.llm.model.embed_tokens(input_ids)
|
||||
sos = self.model.speech_embedding.weight[self.model.sos].reshape(1, -1)
|
||||
task_id = self.model.speech_embedding.weight[self.model.task_id].reshape(1, -1)
|
||||
segments = []
|
||||
mm_index = 0
|
||||
for start, end in zip(boundaries, boundaries[1:]):
|
||||
request_mask = mm_mask[start:end]
|
||||
if not any(request_mask):
|
||||
segments.append(self.model.speech_embedding.weight[input_ids[start:end]])
|
||||
continue
|
||||
if multimodal_embeddings is None or mm_index >= len(multimodal_embeddings):
|
||||
raise ValueError("CosyVoice3 prefill is missing its speech embedding")
|
||||
speech = multimodal_embeddings[mm_index]
|
||||
mm_index += 1
|
||||
encoded = getattr(self, "_mrv2_encoded_conditioning", None)
|
||||
if encoded is not None:
|
||||
key = speech.data_ptr()
|
||||
conditioning = encoded.get(key)
|
||||
if conditioning is None:
|
||||
raise ValueError("CosyVoice3 MRv2 prefill lost its prompt conditioning")
|
||||
encoded.move_to_end(key)
|
||||
self._mrv2_step_conditioning.append(conditioning)
|
||||
prefix_len = 2 + speech.shape[0]
|
||||
# The processor marks SOS, TASK_ID and all speech placeholders.
|
||||
# Rearrangement needs the complete prompt block in this call.
|
||||
if request_mask != [True] * prefix_len + [False] * (end - start - prefix_len):
|
||||
raise ValueError("CosyVoice3 requires a complete prefill block within each request boundary")
|
||||
segments.extend((sos, text_embeds[start + prefix_len : end], task_id, speech))
|
||||
if mm_index != len(multimodal_embeddings):
|
||||
raise ValueError("CosyVoice3 speech embeddings must match prefill requests one to one")
|
||||
return torch.cat(segments, dim=0)
|
||||
elif self.model_stage == "cosyvoice3_code2wav":
|
||||
assert input_ids.dim() == 1
|
||||
hidden = int(self.config.hidden_size)
|
||||
return torch.zeros(
|
||||
(input_ids.shape[0], hidden),
|
||||
device=input_ids.device,
|
||||
)
|
||||
return torch.zeros((input_ids.shape[0], int(self.config.hidden_size)), device=input_ids.device)
|
||||
else:
|
||||
raise RuntimeError(f"embed_input_ids is not valid for {self.model_stage}.")
|
||||
|
||||
def _align_prompt_conditioning(self, values):
|
||||
rows = getattr(self, "_conditioning_request_rows", None)
|
||||
if values is None or rows is None:
|
||||
return values
|
||||
if len(rows) != len(values):
|
||||
raise ValueError("CosyVoice3 conditioning must match the prefill request rows")
|
||||
aligned = [None] * self._conditioning_request_count
|
||||
for row, value in zip(rows, values):
|
||||
aligned[row] = value
|
||||
return aligned
|
||||
|
||||
@staticmethod
|
||||
def _split_prompt_conditioning(speech_token, speech_feat, embedding, speech_token_len):
|
||||
"""Split collated prompt conditioning into per-request lists.
|
||||
@@ -980,6 +1393,9 @@ class CosyVoice3Model(
|
||||
return
|
||||
self._code2wav_trt_done = True
|
||||
|
||||
# Packed Flow owns the torch estimator; speaker TRT remains independent.
|
||||
if cosyvoice3_packed_inference_enabled():
|
||||
return
|
||||
if not (_cosyvoice3_trt_enabled() and torch.cuda.is_available()):
|
||||
return
|
||||
onnx_path = self._resolve_flow_estimator_onnx()
|
||||
@@ -1005,6 +1421,70 @@ class CosyVoice3Model(
|
||||
exc,
|
||||
)
|
||||
|
||||
def make_omni_output(self, hidden_states: torch.Tensor, **kwargs) -> OmniOutput:
|
||||
"""Attach live prefill conditioning outside the full-response CUDA graph.
|
||||
|
||||
Graph replay reuses capture-time output containers. Returning conditioning
|
||||
from the captured forward would replay dummy prompt tensors on every
|
||||
decode step. The runner calls this adapter with the current step's kwargs.
|
||||
"""
|
||||
multimodal_outputs = {}
|
||||
|
||||
step_conditioning = getattr(self, "_mrv2_step_conditioning", None)
|
||||
if "speech_token" not in kwargs and step_conditioning:
|
||||
self._mrv2_step_conditioning = None
|
||||
speech_token, speech_feat, embedding, speech_token_len = (
|
||||
list(values) for values in zip(*step_conditioning)
|
||||
)
|
||||
multimodal_outputs = to_dict(
|
||||
OmniPayloadStruct(
|
||||
embed=EmbeddingsStruct(
|
||||
speech_token=self._align_prompt_conditioning(speech_token),
|
||||
speech_feat=self._align_prompt_conditioning(speech_feat),
|
||||
speech_token_len=self._align_prompt_conditioning(speech_token_len),
|
||||
embedding=self._align_prompt_conditioning(embedding),
|
||||
),
|
||||
)
|
||||
)
|
||||
elif "speech_token" in kwargs:
|
||||
# Prompt conditioning tensors for code2wav: live under
|
||||
# ``embed.*`` per OmniPayloadStruct schema.
|
||||
#
|
||||
# vLLM hands these mm fields to forward() as collated, padded
|
||||
# batch tensors (speech_token [B, maxT], speech_feat
|
||||
# [B, 2*maxT, F], embedding [B, D]). Emitting them raw makes the
|
||||
# downstream per-request payload split fragile at batch>1: it
|
||||
# intermittently de-batches speech_token to 1-D and leaks the
|
||||
# whole [B, D] embedding to every request, which corrupts voice
|
||||
# conditioning and crashes code2wav (`prompt_token.shape[1]`).
|
||||
# Instead, split into an explicit per-request list of unpadded,
|
||||
# correctly-ranked tensors so ``to_payload_element`` splits them
|
||||
# deterministically by request index (list[idx]).
|
||||
speech_token_list, speech_feat_list, embedding_list, speech_token_len_list = (
|
||||
self._split_prompt_conditioning(
|
||||
kwargs.get("speech_token"),
|
||||
kwargs.get("speech_feat"),
|
||||
kwargs.get("embedding"),
|
||||
kwargs.get("speech_token_len"),
|
||||
)
|
||||
)
|
||||
speech_token_list = self._align_prompt_conditioning(speech_token_list)
|
||||
speech_feat_list = self._align_prompt_conditioning(speech_feat_list)
|
||||
embedding_list = self._align_prompt_conditioning(embedding_list)
|
||||
speech_token_len_list = self._align_prompt_conditioning(speech_token_len_list)
|
||||
multimodal_outputs = to_dict(
|
||||
OmniPayloadStruct(
|
||||
embed=EmbeddingsStruct(
|
||||
speech_token=speech_token_list,
|
||||
speech_feat=speech_feat_list,
|
||||
speech_token_len=speech_token_len_list,
|
||||
embedding=embedding_list,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return OmniOutput(text_hidden_states=hidden_states, multimodal_outputs=multimodal_outputs)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
input_ids: torch.Tensor,
|
||||
@@ -1013,7 +1493,7 @@ class CosyVoice3Model(
|
||||
inputs_embeds: torch.Tensor | None = None,
|
||||
additional_information: dict[str, object] | None = None,
|
||||
**kwargs: object,
|
||||
) -> OmniOutput:
|
||||
) -> torch.Tensor | OmniOutput:
|
||||
if self.model_stage == "cosyvoice3_talker":
|
||||
if inputs_embeds is None:
|
||||
inputs_embeds = self.embed_input_ids(input_ids)
|
||||
@@ -1021,42 +1501,9 @@ class CosyVoice3Model(
|
||||
# [total_tokens, hidden]
|
||||
hidden_states = self.model.llm(inputs_embeds, positions)
|
||||
|
||||
multimodal_outputs = {}
|
||||
|
||||
if "speech_token" in kwargs:
|
||||
# Prompt conditioning tensors for code2wav: live under
|
||||
# ``embed.*`` per OmniPayloadStruct schema.
|
||||
#
|
||||
# vLLM hands these mm fields to forward() as collated, padded
|
||||
# batch tensors (speech_token [B, maxT], speech_feat
|
||||
# [B, 2*maxT, F], embedding [B, D]). Emitting them raw makes the
|
||||
# downstream per-request payload split fragile at batch>1: it
|
||||
# intermittently de-batches speech_token to 1-D and leaks the
|
||||
# whole [B, D] embedding to every request, which corrupts voice
|
||||
# conditioning and crashes code2wav (`prompt_token.shape[1]`).
|
||||
# Instead, split into an explicit per-request list of unpadded,
|
||||
# correctly-ranked tensors so ``to_payload_element`` splits them
|
||||
# deterministically by request index (list[idx]).
|
||||
speech_token_list, speech_feat_list, embedding_list, speech_token_len_list = (
|
||||
self._split_prompt_conditioning(
|
||||
kwargs.get("speech_token"),
|
||||
kwargs.get("speech_feat"),
|
||||
kwargs.get("embedding"),
|
||||
kwargs.get("speech_token_len"),
|
||||
)
|
||||
)
|
||||
multimodal_outputs = to_dict(
|
||||
OmniPayloadStruct(
|
||||
embed=EmbeddingsStruct(
|
||||
speech_token=speech_token_list,
|
||||
speech_feat=speech_feat_list,
|
||||
speech_token_len=speech_token_len_list,
|
||||
embedding=embedding_list,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return OmniOutput(text_hidden_states=hidden_states, multimodal_outputs=multimodal_outputs)
|
||||
if getattr(self, "_mrv2_tensor_output", False) or cosyvoice3_packed_inference_enabled():
|
||||
return hidden_states
|
||||
return self.make_omni_output(hidden_states, **kwargs)
|
||||
elif self.model_stage == "cosyvoice3_code2wav":
|
||||
# Lazily swap the flow-decoder estimator to a TensorRT engine on the
|
||||
# first code2wav step (after weights are loaded), gated by the same
|
||||
@@ -1202,7 +1649,7 @@ class CosyVoice3Model(
|
||||
item_shapes,
|
||||
)
|
||||
if (
|
||||
len(streaming_flow_items) > 1
|
||||
(len(streaming_flow_items) > 1 or cosyvoice3_packed_inference_enabled())
|
||||
and cosyvoice3_batch_flow_enabled()
|
||||
and hasattr(self.code2wav, "forward_streaming_batch")
|
||||
):
|
||||
|
||||
@@ -9,9 +9,10 @@ This module contains the code2wav (token-to-waveform) stage which uses:
|
||||
3. HiFiGAN vocoder for waveform synthesis
|
||||
"""
|
||||
|
||||
import os
|
||||
from collections import Counter
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, TypedDict, cast
|
||||
from typing import Any, Literal, TypedDict, cast, overload
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
@@ -34,6 +35,9 @@ from vllm_omni.model_executor.models.cosyvoice3.code2wav_core.layers import PreL
|
||||
from vllm_omni.model_executor.models.cosyvoice3.runtime import (
|
||||
cosyvoice3_batch_flow_debug,
|
||||
cosyvoice3_batch_flow_profile,
|
||||
cosyvoice3_full_response_enabled,
|
||||
cosyvoice3_packed_inference_enabled,
|
||||
cosyvoice3_packed_streaming_enabled,
|
||||
)
|
||||
from vllm_omni.transformers_utils.configs.cosyvoice3 import CosyVoice3Config
|
||||
|
||||
@@ -57,6 +61,14 @@ def _build_dit_estimator(estimator_config: Mapping[str, Any]) -> DiT:
|
||||
return DiT(**estimator_config)
|
||||
|
||||
|
||||
class StreamingHiFTState(TypedDict, total=False):
|
||||
"""Request-owned mel history, its absolute offset, and oscillator phase."""
|
||||
|
||||
mel: torch.Tensor
|
||||
mel_offset: int
|
||||
phase_acc: torch.Tensor | None
|
||||
|
||||
|
||||
class StreamingFlowItem(TypedDict, total=False):
|
||||
"""One entry of the batched-streaming item list passed to forward_streaming_batch."""
|
||||
|
||||
@@ -67,7 +79,7 @@ class StreamingFlowItem(TypedDict, total=False):
|
||||
prompt_token: torch.Tensor
|
||||
prompt_feat: torch.Tensor
|
||||
embedding: torch.Tensor
|
||||
cache_state: dict[str, torch.Tensor] | None
|
||||
cache_state: StreamingHiFTState | None
|
||||
token_offset_tokens: int
|
||||
finalize: bool
|
||||
|
||||
@@ -114,7 +126,6 @@ class CosyVoice3Code2Wav(nn.Module):
|
||||
pre_lookahead_layer=pre_lookahead_layer,
|
||||
decoder=decoder,
|
||||
)
|
||||
|
||||
# Build HiFiGAN vocoder
|
||||
f0_predictor = CausalConvRNNF0Predictor(
|
||||
num_class=config.hift["f0_predictor"]["num_class"],
|
||||
@@ -256,9 +267,9 @@ class CosyVoice3Code2Wav(nn.Module):
|
||||
self,
|
||||
feat: torch.Tensor,
|
||||
*,
|
||||
cache_state: dict[str, torch.Tensor] | None = None,
|
||||
cache_state: StreamingHiFTState | None = None,
|
||||
finalize: bool = False,
|
||||
) -> tuple[torch.Tensor, dict[str, torch.Tensor] | None]:
|
||||
) -> tuple[torch.Tensor, StreamingHiFTState | None]:
|
||||
hift_weight = self.hift.m_source.l_linear.weight
|
||||
chunk_mel = feat.to(device=hift_weight.device, dtype=hift_weight.dtype)
|
||||
|
||||
@@ -325,10 +336,24 @@ class CosyVoice3Code2Wav(nn.Module):
|
||||
if finalize:
|
||||
return emitted_speech.reshape(emitted_speech.shape[0], 1, -1), None
|
||||
|
||||
# Clone GPU views so each request owns its cache and does not retain
|
||||
# the full batch allocation or alias a later inference output.
|
||||
new_state = {
|
||||
"mel": f0_input_mel.detach().cpu().contiguous(),
|
||||
"mel": (
|
||||
f0_input_mel.detach().clone()
|
||||
if cosyvoice3_packed_streaming_enabled()
|
||||
else f0_input_mel.detach().cpu().contiguous()
|
||||
),
|
||||
"mel_offset": window_offset - f0_margin,
|
||||
"phase_acc": new_phase_acc.detach().cpu().contiguous() if new_phase_acc is not None else None,
|
||||
"phase_acc": (
|
||||
(
|
||||
new_phase_acc.detach().clone()
|
||||
if cosyvoice3_packed_streaming_enabled()
|
||||
else new_phase_acc.detach().cpu().contiguous()
|
||||
)
|
||||
if new_phase_acc is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
return emitted_speech.reshape(emitted_speech.shape[0], 1, -1), new_state
|
||||
|
||||
@@ -338,14 +363,34 @@ class CosyVoice3Code2Wav(nn.Module):
|
||||
items: list[StreamingFlowItem],
|
||||
*,
|
||||
n_timesteps: int = 10,
|
||||
) -> list[tuple[torch.Tensor, dict[str, torch.Tensor] | None]]:
|
||||
) -> list[tuple[torch.Tensor, StreamingHiFTState | None]]:
|
||||
"""Batch the flow-matching mel path, then run HiFT per request.
|
||||
|
||||
Items are grouped by prompt condition shape and finalization state.
|
||||
Codec tokens may have different lengths; those are padded within the
|
||||
group and passed to the flow as per-row token lengths.
|
||||
"""
|
||||
results: list[tuple[torch.Tensor, dict[str, torch.Tensor] | None] | None] = [None] * len(items)
|
||||
if items and cosyvoice3_packed_streaming_enabled():
|
||||
results = [None] * len(items)
|
||||
for finalize in (False, True):
|
||||
group = [(i, x) for i, x in enumerate(items) if bool(x.get("finalize", False)) == finalize]
|
||||
if group:
|
||||
values = self.forward_batch([x for _, x in group], n_timesteps=n_timesteps, stream_items=True)
|
||||
for (i, _), value in zip(group, values):
|
||||
results[i] = value
|
||||
return results
|
||||
if (
|
||||
items
|
||||
and cosyvoice3_packed_inference_enabled()
|
||||
and all(
|
||||
item.get("finalize", False)
|
||||
and item.get("cache_state") is None
|
||||
and int(item.get("token_offset_tokens", 0)) == 0
|
||||
for item in items
|
||||
)
|
||||
):
|
||||
return [(speech, None) for speech in self.forward_batch(items, n_timesteps=n_timesteps)]
|
||||
results: list[tuple[torch.Tensor, StreamingHiFTState | None] | None] = [None] * len(items)
|
||||
groups: dict[tuple[int, int, int, bool], list[tuple[int, StreamingFlowItem]]] = {}
|
||||
for index, item in enumerate(items):
|
||||
assert isinstance(item["token"], torch.Tensor)
|
||||
@@ -445,7 +490,7 @@ class CosyVoice3Code2Wav(nn.Module):
|
||||
)
|
||||
|
||||
assert all(result is not None for result in results), "every streaming item must produce exactly one result"
|
||||
return cast(list[tuple[torch.Tensor, dict[str, torch.Tensor] | None]], results)
|
||||
return cast(list[tuple[torch.Tensor, StreamingHiFTState | None]], results)
|
||||
|
||||
@torch.inference_mode()
|
||||
def forward_streaming(
|
||||
@@ -455,11 +500,11 @@ class CosyVoice3Code2Wav(nn.Module):
|
||||
prompt_feat: torch.Tensor,
|
||||
embedding: torch.Tensor,
|
||||
*,
|
||||
cache_state: dict[str, torch.Tensor] | None = None,
|
||||
cache_state: StreamingHiFTState | None = None,
|
||||
n_timesteps: int = 10,
|
||||
token_offset_tokens: int = 0,
|
||||
finalize: bool = False,
|
||||
) -> tuple[torch.Tensor, dict[str, torch.Tensor] | None]:
|
||||
) -> tuple[torch.Tensor, StreamingHiFTState | None]:
|
||||
"""Decode streaming audio using cumulative mel + emitted-speech offset.
|
||||
|
||||
This mirrors upstream CosyVoice3 streaming semantics more closely than
|
||||
@@ -480,6 +525,176 @@ class CosyVoice3Code2Wav(nn.Module):
|
||||
)
|
||||
return self._stream_hift_from_feat(feat, cache_state=cache_state, finalize=finalize)
|
||||
|
||||
@torch.inference_mode()
|
||||
def _stream_position_noise(self, width: int, like: torch.Tensor) -> torch.Tensor:
|
||||
"""Extend fixed-position noise without changing previously emitted prefixes.
|
||||
|
||||
Fixed-size CPU blocks preserve the initial experiment's seed-0 noise,
|
||||
isolate the global RNG, and make growth independent of request ordering.
|
||||
"""
|
||||
block_frames = 15000
|
||||
cached = getattr(self, "_position_noise", None)
|
||||
present = 0 if cached is None else cached.shape[-1]
|
||||
blocks = [] if cached is None else [cached.to(like)]
|
||||
for index in range(present // block_frames, (width + block_frames - 1) // block_frames):
|
||||
generator = torch.Generator(device="cpu").manual_seed(index)
|
||||
blocks.append(torch.randn((1, 80, block_frames), generator=generator).to(like))
|
||||
if not blocks:
|
||||
return like.new_empty((1, 80, 0))
|
||||
self._position_noise = torch.cat(blocks, dim=-1) if len(blocks) > 1 else blocks[0]
|
||||
return self._position_noise[..., :width]
|
||||
|
||||
@overload
|
||||
def forward_batch(
|
||||
self, items: list[StreamingFlowItem], *, n_timesteps: int = 10, stream_items: Literal[False] = False
|
||||
) -> list[torch.Tensor]: ...
|
||||
|
||||
@overload
|
||||
def forward_batch(
|
||||
self, items: list[StreamingFlowItem], *, n_timesteps: int = 10, stream_items: Literal[True]
|
||||
) -> list[tuple[torch.Tensor, StreamingHiFTState | None]]: ...
|
||||
|
||||
@torch.inference_mode()
|
||||
def forward_batch(
|
||||
self, items: list[StreamingFlowItem], *, n_timesteps: int = 10, stream_items: bool = False
|
||||
) -> list[torch.Tensor] | list[tuple[torch.Tensor, StreamingHiFTState | None]]:
|
||||
"""Batch Flow across different reference lengths and restore request order.
|
||||
|
||||
Build each prompt/continuation contiguously before right padding. Padding
|
||||
the prompt and continuation separately would insert a gap in conditioning.
|
||||
The default keeps exact-length CPU-F0 HiFT. The opt-in Hopper profile
|
||||
uses packed Flow, batched HiFT, and FP64 GPU F0.
|
||||
"""
|
||||
if not items:
|
||||
return []
|
||||
if stream_items:
|
||||
if not cosyvoice3_packed_streaming_enabled():
|
||||
raise ValueError("Packed streaming requires COSYVOICE3_PACKED_STREAMING on Hopper")
|
||||
if any(bool(item.get("finalize", False)) != bool(items[0].get("finalize", False)) for item in items):
|
||||
raise ValueError("Use forward_streaming_batch to group mixed finalization states")
|
||||
if len(items) > 8:
|
||||
ordered = sorted(
|
||||
enumerate(items), key=lambda pair: pair[1]["token"].shape[1] + pair[1]["prompt_token"].shape[1]
|
||||
)
|
||||
outputs: list[torch.Tensor | tuple[torch.Tensor, StreamingHiFTState | None] | None] = [None] * len(items)
|
||||
for start in range(0, len(ordered), 8):
|
||||
group = ordered[start : start + 8]
|
||||
for (index, _), speech in zip(
|
||||
group,
|
||||
self.forward_batch([item for _, item in group], n_timesteps=n_timesteps, stream_items=stream_items),
|
||||
):
|
||||
outputs[index] = speech
|
||||
return cast(list[torch.Tensor] | list[tuple[torch.Tensor, StreamingHiFTState | None]], outputs)
|
||||
flow = self.flow_model
|
||||
weight = next(flow.parameters())
|
||||
mus, conds, speakers, lengths, prompt_lengths = [], [], [], [], []
|
||||
for item in items:
|
||||
tokens = torch.cat(
|
||||
tuple(item[key].to(device=weight.device, dtype=torch.long) for key in ("prompt_token", "token")), dim=1
|
||||
)
|
||||
embedded = flow.input_embedding(tokens.clamp(min=0))
|
||||
if stream_items and not item.get("finalize", False) and int(flow.pre_lookahead_len) > 0:
|
||||
lookahead = int(flow.pre_lookahead_len)
|
||||
hidden = flow.pre_lookahead_layer(embedded[:, :-lookahead], context=embedded[:, -lookahead:])
|
||||
else:
|
||||
hidden = flow.pre_lookahead_layer(embedded)
|
||||
mu = hidden.repeat_interleave(self.token_mel_ratio, dim=1)
|
||||
prompt = item["prompt_feat"].to(device=weight.device, dtype=mu.dtype)
|
||||
cond = torch.zeros_like(mu)
|
||||
cond[:, : prompt.shape[1]] = prompt
|
||||
speaker = torch.nn.functional.normalize(item["embedding"].to(weight), dim=1)
|
||||
speakers.append(flow.spk_embed_affine_layer(speaker))
|
||||
mus.append(mu.transpose(1, 2))
|
||||
conds.append(cond.transpose(1, 2))
|
||||
lengths.append(mu.shape[1])
|
||||
prompt_lengths.append(prompt.shape[1])
|
||||
width = max(lengths)
|
||||
mu = torch.cat([torch.nn.functional.pad(x, (0, width - x.shape[-1])) for x in mus])
|
||||
cond = torch.cat([torch.nn.functional.pad(x, (0, width - x.shape[-1])) for x in conds])
|
||||
mask = (
|
||||
(torch.arange(width, device=weight.device)[None, :] < torch.tensor(lengths, device=weight.device)[:, None])
|
||||
.unsqueeze(1)
|
||||
.to(mu)
|
||||
)
|
||||
if cosyvoice3_packed_inference_enabled():
|
||||
from .code2wav_core.packed_dit import (
|
||||
PackedDiT,
|
||||
gather_rows,
|
||||
pack_rows,
|
||||
scatter_rows,
|
||||
solve_flow_euler_packed,
|
||||
)
|
||||
|
||||
if not hasattr(self, "_packed_estimator"):
|
||||
self._packed_estimator = PackedDiT(flow.decoder.estimator)
|
||||
self._packed_estimator.compile(mu.dtype)
|
||||
rows = pack_rows(lengths, mu.device)
|
||||
if stream_items:
|
||||
noise = self._stream_position_noise(width, mu).expand(len(items), -1, -1)
|
||||
else:
|
||||
noise = torch.randn(mu.shape, device=mu.device, dtype=mu.dtype)
|
||||
time_span = torch.linspace(0, 1, n_timesteps + 1, device=mu.device, dtype=mu.dtype)
|
||||
if flow.decoder.t_scheduler == "cosine":
|
||||
time_span = 1 - torch.cos(time_span * 0.5 * torch.pi)
|
||||
with torch.autocast("cuda", dtype=mu.dtype):
|
||||
packed = solve_flow_euler_packed(
|
||||
self._packed_estimator,
|
||||
gather_rows(noise.transpose(1, 2), rows),
|
||||
time_span,
|
||||
gather_rows(mu.transpose(1, 2), rows),
|
||||
torch.cat(speakers),
|
||||
gather_rows(cond.transpose(1, 2), rows),
|
||||
rows,
|
||||
cfg_rate=flow.decoder.inference_cfg_rate,
|
||||
streaming=stream_items and not items[0].get("finalize", False),
|
||||
modulation_key=(n_timesteps, flow.decoder.t_scheduler, mu.dtype, mu.device),
|
||||
)
|
||||
feat = scatter_rows(packed, rows, width).transpose(1, 2)
|
||||
|
||||
else:
|
||||
feat, _ = flow.decoder(
|
||||
mu=mu, mask=mask, spks=torch.cat(speakers), cond=cond, n_timesteps=n_timesteps, streaming=False
|
||||
)
|
||||
if stream_items:
|
||||
results = []
|
||||
for row, item in enumerate(items):
|
||||
start = prompt_lengths[row] + max(0, int(item.get("token_offset_tokens", 0))) * self.token_mel_ratio
|
||||
mel = feat[row : row + 1, :, start : lengths[row]].float()
|
||||
results.append(
|
||||
self._stream_hift_from_feat(
|
||||
mel, cache_state=item.get("cache_state"), finalize=bool(item.get("finalize", False))
|
||||
)
|
||||
)
|
||||
return results
|
||||
hift_weight = self.hift.m_source.l_linear.weight
|
||||
if cosyvoice3_full_response_enabled():
|
||||
mels = []
|
||||
for row, item in enumerate(items):
|
||||
start = prompt_lengths[row] + max(0, int(item.get("token_offset_tokens", 0))) * self.token_mel_ratio
|
||||
mels.append(feat[row : row + 1, :, start : lengths[row]].float().to(hift_weight))
|
||||
width = max(mel.shape[-1] for mel in mels)
|
||||
if width == 0:
|
||||
return [mel.new_zeros((1, 1, 0)) for mel in mels]
|
||||
from .code2wav_core.packed_dit import bucketed_width
|
||||
|
||||
# Recurring shapes let cuDNN/cuFFT reuse plans; rows are already
|
||||
# right padded to the batch maximum and trimmed below.
|
||||
width = bucketed_width(width)
|
||||
padded = torch.cat([torch.nn.functional.pad(mel, (0, width - mel.shape[-1])) for mel in mels])
|
||||
speech, _, _ = self.hift.inference(speech_feat=padded, finalize=True)
|
||||
stride = int(np.prod(self.hift.upsample_rates) * self.hift.istft_params["hop_len"])
|
||||
return [speech[row : row + 1, ..., : mel.shape[-1] * stride] for row, mel in enumerate(mels)]
|
||||
results = []
|
||||
for row, item in enumerate(items):
|
||||
start = prompt_lengths[row] + max(0, int(item.get("token_offset_tokens", 0))) * self.token_mel_ratio
|
||||
mel = feat[row : row + 1, :, start : lengths[row]].float().to(hift_weight)
|
||||
if mel.shape[-1] == 0:
|
||||
speech = mel.new_zeros((1, 1, 0))
|
||||
else:
|
||||
speech, _, _ = self.hift.inference(speech_feat=mel, finalize=True)
|
||||
results.append(speech)
|
||||
return results
|
||||
|
||||
@torch.inference_mode()
|
||||
def forward(
|
||||
self,
|
||||
@@ -524,7 +739,6 @@ class CosyVoice3Code2Wav(nn.Module):
|
||||
model_dir: Model directory containing flow.pt and hift.pt
|
||||
device: Device to load weights to
|
||||
"""
|
||||
import os
|
||||
|
||||
# Load flow weights
|
||||
flow_path = os.path.join(model_dir, "flow.pt")
|
||||
@@ -538,5 +752,21 @@ class CosyVoice3Code2Wav(nn.Module):
|
||||
k.replace("generator.", ""): v for k, v in torch.load(hift_path, map_location=device).items()
|
||||
}
|
||||
self.hift.load_state_dict(hift_state_dict, strict=True)
|
||||
self.hift.to(device).eval()
|
||||
self.hift.to(device)
|
||||
# Fold after loading and device placement to avoid recomputing weights
|
||||
# for every chunk. Folding changes state_dict keys, so reloading the
|
||||
# original checkpoint into this instance is unsupported.
|
||||
folded = self.hift.remove_weight_norm()
|
||||
logger.info("Folded %d weight-norm layers in HiFT generator", folded)
|
||||
if folded == 0:
|
||||
logger.warning("HiFT generator had no weight-norm layers to fold; check config drift")
|
||||
# Fold on the same device as mainline F0 inference. Folding on CPU
|
||||
# and moving to GPU later changes the normalized FP32 weights.
|
||||
f0_device = device if device.type == "cuda" and os.getenv("COSYVOICE3_F0_ON_CPU", "0") != "1" else "cpu"
|
||||
self.hift.f0_predictor.to(device=f0_device, dtype=torch.float32)
|
||||
f0_folded = self.hift.f0_predictor.remove_weight_norm()
|
||||
logger.info("Folded %d weight-norm layers in HiFT F0 predictor", f0_folded)
|
||||
if f0_folded == 0:
|
||||
logger.warning("HiFT F0 predictor had no weight-norm layers to fold; check config drift")
|
||||
self.hift.eval()
|
||||
logger.info(f"Loaded hift weights from {hift_path}")
|
||||
@@ -0,0 +1,155 @@
|
||||
# ruff: noqa: N803
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Fused CosyVoice3 repetition-aware sampling for Model Runner V2.
|
||||
|
||||
One program per row replaces the ~70 small kernels (softmax, full-vocabulary
|
||||
sort, cumulative sum, masks, two Gumbel draws, history gather, rejection) of
|
||||
the tensor implementation. The nucleus is the top-p prefix of the full
|
||||
distribution capped at top-k, found by repeated argmax instead of a sort; a
|
||||
draw that repeats within the last ``win_size`` generated tokens is replaced by
|
||||
a draw from the full remaining distribution, from a disjoint noise stream.
|
||||
"""
|
||||
|
||||
import torch
|
||||
from vllm.triton_utils import tl, triton
|
||||
|
||||
MAX_FUSED_TOP_K = 64
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _gumbel(seed, offset):
|
||||
u = tl.maximum(tl.rand(seed, offset), 4.6566127342e-10)
|
||||
return -tl.log(-tl.log(u))
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _ras_sample_kernel(
|
||||
out_ptr,
|
||||
logits_ptr,
|
||||
logits_stride,
|
||||
vocab,
|
||||
idx_mapping_ptr,
|
||||
temperature_ptr,
|
||||
top_k_ptr,
|
||||
top_p_ptr,
|
||||
seeds_ptr,
|
||||
pos_ptr,
|
||||
tokens_ptr,
|
||||
tokens_stride,
|
||||
total_len_ptr,
|
||||
prompt_len_ptr,
|
||||
default_top_k,
|
||||
default_top_p,
|
||||
eps,
|
||||
threshold,
|
||||
win_size,
|
||||
HAS_TOP_K: tl.constexpr,
|
||||
HAS_TOP_P: tl.constexpr,
|
||||
WIN: tl.constexpr,
|
||||
BLOCK: tl.constexpr,
|
||||
):
|
||||
row = tl.program_id(0)
|
||||
req = tl.load(idx_mapping_ptr + row).to(tl.int64)
|
||||
cols = tl.arange(0, BLOCK)
|
||||
mask = cols < vocab
|
||||
x = tl.load(logits_ptr + row * logits_stride + cols, mask=mask, other=float("-inf")).to(tl.float32)
|
||||
temperature = tl.load(temperature_ptr + req).to(tl.float32)
|
||||
if temperature == 0.0:
|
||||
tl.store(out_ptr + row, tl.argmax(x, axis=0).to(tl.int64))
|
||||
return
|
||||
x = x / tl.maximum(temperature, eps)
|
||||
peak = tl.max(x, axis=0)
|
||||
weights = tl.where(mask, tl.exp(x - peak), 0.0)
|
||||
total = tl.sum(weights, axis=0)
|
||||
log_probs = x - peak - tl.log(total)
|
||||
top_k = tl.load(top_k_ptr + req) if HAS_TOP_K else default_top_k
|
||||
top_p = tl.load(top_p_ptr + req).to(tl.float32) if HAS_TOP_P else default_top_p
|
||||
seed = tl.load(seeds_ptr + req)
|
||||
pos = tl.load(pos_ptr + row)
|
||||
nucleus_seed = tl.randint(seed, pos)
|
||||
|
||||
# Walk the distribution in descending order (ties: lowest id first, like a
|
||||
# stable descending sort) while the top-p prefix and the top-k cap allow.
|
||||
remaining = weights
|
||||
before = 0.0
|
||||
best = float("-inf")
|
||||
sampled = tl.argmax(x, axis=0).to(tl.int64)
|
||||
for _ in range(top_k):
|
||||
if before < top_p:
|
||||
value = tl.max(remaining, axis=0)
|
||||
token = tl.argmax(remaining, axis=0)
|
||||
prob = value / total
|
||||
score = tl.log(prob) + _gumbel(nucleus_seed, token)
|
||||
if score > best:
|
||||
best = score
|
||||
sampled = token.to(tl.int64)
|
||||
before += prob
|
||||
remaining = tl.where(cols == token, -1.0, remaining)
|
||||
|
||||
# Repetition-aware rejection over the last WIN generated tokens.
|
||||
total_len = tl.load(total_len_ptr + req).to(tl.int64)
|
||||
prompt_len = tl.load(prompt_len_ptr + req).to(tl.int64)
|
||||
offsets = total_len - 1 - tl.arange(0, WIN)
|
||||
in_output = (offsets >= prompt_len) & (tl.arange(0, WIN) < win_size)
|
||||
history = tl.load(tokens_ptr + req * tokens_stride + tl.maximum(offsets, 0), mask=in_output, other=-1)
|
||||
repeats = tl.sum((in_output & (history == sampled)).to(tl.float32), axis=0)
|
||||
if (repeats >= threshold) & (tl.sum(in_output.to(tl.int32), axis=0) > 0):
|
||||
# Offset keeps the replacement noise disjoint from the nucleus draw's.
|
||||
replacement_seed = tl.randint(seed, pos + 1073741824)
|
||||
scores = tl.where(mask & (cols != sampled), log_probs + _gumbel(replacement_seed, cols), float("-inf"))
|
||||
if tl.max(scores, axis=0) > float("-inf"):
|
||||
sampled = tl.argmax(scores, axis=0).to(tl.int64)
|
||||
tl.store(out_ptr + row, sampled)
|
||||
|
||||
|
||||
def fused_ras_sample(
|
||||
logits: torch.Tensor,
|
||||
idx_mapping: torch.Tensor,
|
||||
temperature: torch.Tensor,
|
||||
top_k: torch.Tensor | None,
|
||||
top_p: torch.Tensor | None,
|
||||
seeds: torch.Tensor,
|
||||
pos: torch.Tensor,
|
||||
all_token_ids: torch.Tensor,
|
||||
total_len: torch.Tensor,
|
||||
prompt_len: torch.Tensor,
|
||||
*,
|
||||
default_top_k: int,
|
||||
default_top_p: float,
|
||||
win_size: int,
|
||||
tau_r: float,
|
||||
eps: float,
|
||||
) -> torch.Tensor:
|
||||
"""Sample one token per logits row; per-request tensors are indexed by ``idx_mapping``."""
|
||||
rows, vocab = logits.shape
|
||||
out = torch.empty(rows, dtype=torch.int64, device=logits.device)
|
||||
if rows == 0:
|
||||
return out
|
||||
_ras_sample_kernel[(rows,)](
|
||||
out,
|
||||
logits,
|
||||
logits.stride(0),
|
||||
vocab,
|
||||
idx_mapping,
|
||||
temperature,
|
||||
temperature if top_k is None else top_k,
|
||||
temperature if top_p is None else top_p,
|
||||
seeds,
|
||||
pos,
|
||||
all_token_ids,
|
||||
all_token_ids.stride(0),
|
||||
total_len,
|
||||
prompt_len,
|
||||
int(default_top_k),
|
||||
float(default_top_p),
|
||||
float(eps),
|
||||
float(win_size * tau_r),
|
||||
int(win_size),
|
||||
HAS_TOP_K=top_k is not None,
|
||||
HAS_TOP_P=top_p is not None,
|
||||
WIN=triton.next_power_of_2(max(int(win_size), 1)),
|
||||
BLOCK=triton.next_power_of_2(vocab),
|
||||
num_warps=8,
|
||||
)
|
||||
return out
|
||||
@@ -29,3 +29,38 @@ def cosyvoice3_batch_flow_profile(name: str):
|
||||
if cosyvoice3_batch_flow_debug():
|
||||
return torch.profiler.record_function(name)
|
||||
return nullcontext()
|
||||
|
||||
|
||||
def cosyvoice3_full_response_enabled() -> bool:
|
||||
"""Opt-in Hopper full-response path; other devices retain the standard path."""
|
||||
return (
|
||||
_env_flag("COSYVOICE3_FULL_RESPONSE_OPTIMIZATIONS", "0")
|
||||
and torch.cuda.is_available()
|
||||
and torch.cuda.get_device_capability()[0] == 9
|
||||
)
|
||||
|
||||
|
||||
def cosyvoice3_packed_streaming_enabled() -> bool:
|
||||
"""Opt-in Hopper packed, chunk-causal Flow with request-owned GPU HiFT state."""
|
||||
return (
|
||||
_env_flag("COSYVOICE3_PACKED_STREAMING", "0")
|
||||
and torch.cuda.is_available()
|
||||
and torch.cuda.get_device_capability()[0] == 9
|
||||
)
|
||||
|
||||
|
||||
def cosyvoice3_packed_inference_enabled() -> bool:
|
||||
"""Either optimized profile needs live conditioning outside CUDA graphs."""
|
||||
return cosyvoice3_full_response_enabled() or cosyvoice3_packed_streaming_enabled()
|
||||
|
||||
|
||||
def cosyvoice3_standard_sampling(config: object) -> bool:
|
||||
"""Select ordinary sampling; RAS remains the default checkpoint behavior.
|
||||
|
||||
The optional HF override is shared by API stop configuration and the model
|
||||
sampler. Reject misspellings instead of silently selecting another policy.
|
||||
"""
|
||||
mode = getattr(config, "cosyvoice3_sampling_mode", "ras")
|
||||
if mode not in ("ras", "standard"):
|
||||
raise ValueError("cosyvoice3_sampling_mode must be 'ras' or 'standard'")
|
||||
return mode == "standard"
|
||||
@@ -633,3 +633,77 @@ def _patch_cumem_free_callback_cuda() -> None:
|
||||
|
||||
|
||||
_patch_cumem_free_callback_cuda()
|
||||
|
||||
|
||||
def _patch_batched_seeded_random_sample() -> None:
|
||||
"""Draw per-request seeded sampling noise in one launch.
|
||||
|
||||
vLLM's ``random_sample`` calls ``exponential_(generator=...)`` once per
|
||||
seeded request and step. The replacement reproduces those draws bit for
|
||||
bit (values and generator states) with one kernel; unsupported cases keep
|
||||
the original loop.
|
||||
"""
|
||||
try:
|
||||
import inspect
|
||||
|
||||
from vllm.v1.sample.ops import topk_topp_sampler as sampler_ops
|
||||
except ImportError:
|
||||
return
|
||||
original = getattr(sampler_ops, "random_sample", None)
|
||||
if original is None or getattr(original, "_omni_batched_seeded", False):
|
||||
return
|
||||
if list(inspect.signature(original).parameters) != ["probs", "generators", "use_fp64_gumbel"] or not all(
|
||||
hasattr(sampler_ops, name) for name in ("empty_exponential_noise_like", "sample_with_exponential_noise")
|
||||
):
|
||||
_PATCH_LOGGER.debug("[seeded-sampling] random_sample signature changed; batching disabled")
|
||||
return
|
||||
|
||||
def random_sample(
|
||||
probs: torch.Tensor,
|
||||
generators: dict[int, torch.Generator],
|
||||
use_fp64_gumbel: bool = False,
|
||||
) -> torch.Tensor:
|
||||
q = sampler_ops.empty_exponential_noise_like(probs, use_fp64_gumbel)
|
||||
if len(generators) != probs.shape[0]:
|
||||
q.exponential_()
|
||||
if generators:
|
||||
from vllm_omni.utils.seeded_exponential import (
|
||||
batched_seeded_exponential_supported,
|
||||
fill_exponential_rows,
|
||||
)
|
||||
|
||||
if batched_seeded_exponential_supported(q, generators):
|
||||
fill_exponential_rows(q, list(generators.values()), list(generators))
|
||||
else:
|
||||
for i, generator in generators.items():
|
||||
q[i].exponential_(generator=generator)
|
||||
return sampler_ops.sample_with_exponential_noise(probs, q)
|
||||
|
||||
random_sample._omni_batched_seeded = True # type: ignore[attr-defined]
|
||||
random_sample.__wrapped__ = original # type: ignore[attr-defined]
|
||||
sampler_ops.random_sample = random_sample
|
||||
|
||||
|
||||
_patch_batched_seeded_random_sample()
|
||||
|
||||
|
||||
def _patch_mrv2_stop_token_capacity() -> None:
|
||||
"""Let Model Runner V2 enforce ``min_tokens`` over large stop sets.
|
||||
|
||||
MRV2 keeps each request's stop token ids for ``min_tokens`` masking in a
|
||||
fixed-width GPU table capped at 128 ids and rejects larger sets. Speech
|
||||
LMs stop on every special id past the codebook (CosyVoice3: 200). The
|
||||
masking kernel sizes its block from the table widths, which other tables
|
||||
already raise to 1024, so widening this one only grows a small buffer.
|
||||
"""
|
||||
try:
|
||||
from vllm.v1.worker.gpu.sample import logit_bias
|
||||
except ImportError:
|
||||
return
|
||||
logit_bias.MAX_NUM_STOP_TOKEN_IDS = max(
|
||||
logit_bias.MAX_NUM_STOP_TOKEN_IDS,
|
||||
logit_bias.MAX_NUM_ALLOWED_TOKEN_IDS,
|
||||
)
|
||||
|
||||
|
||||
_patch_mrv2_stop_token_capacity()
|
||||
@@ -0,0 +1,121 @@
|
||||
# ruff: noqa: N803
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Batched per-request ``exponential_(generator=...)`` that is bit-identical to torch.
|
||||
|
||||
Seeded sampling draws exponential noise one request at a time, which costs a
|
||||
kernel launch and host overhead per request and step. This kernel reproduces
|
||||
torch's CUDA distribution kernel exactly for float32 (the grid-stride
|
||||
Philox4x32-10 ``curand_uniform4`` stream, the fast-math ``__logf`` transform
|
||||
and the per-call Philox offset increment), so a batch draws in one launch while
|
||||
every value and every generator state afterwards matches the per-request loop.
|
||||
"""
|
||||
|
||||
import torch
|
||||
from vllm.triton_utils import tl, triton
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _generator_exponential_kernel(
|
||||
out_ptr,
|
||||
rows_ptr,
|
||||
seed_ptr,
|
||||
offset_ptr,
|
||||
numel,
|
||||
threads,
|
||||
HAS_ROWS: tl.constexpr,
|
||||
BLOCK: tl.constexpr,
|
||||
):
|
||||
request = tl.program_id(0)
|
||||
elements = tl.program_id(1) * BLOCK + tl.arange(0, BLOCK)
|
||||
mask = elements < numel
|
||||
seed = tl.load(seed_ptr + request).to(tl.uint64)
|
||||
offset = tl.load(offset_ptr + request).to(tl.uint64)
|
||||
# torch's grid-stride kernel: thread t draws curand_uniform4 once per grid
|
||||
# stride and element j = t + threads * (4 * call + component).
|
||||
thread = (elements % threads).to(tl.uint64)
|
||||
stride = (elements // threads).to(tl.uint64)
|
||||
counter = offset // 4 + stride // 4
|
||||
component = stride % 4
|
||||
c0 = (counter & 0xFFFFFFFF).to(tl.uint32)
|
||||
c1 = (counter >> 32).to(tl.uint32)
|
||||
c2 = (thread & 0xFFFFFFFF).to(tl.uint32)
|
||||
c3 = (thread >> 32).to(tl.uint32)
|
||||
r0, r1, r2, r3 = tl.philox(seed, c0, c1, c2, c3, 10)
|
||||
bits = tl.where(component == 0, r0, tl.where(component == 1, r1, tl.where(component == 2, r2, r3)))
|
||||
# curand_uniform: (0, 1] from 32 bits.
|
||||
uniform = bits.to(tl.float32) * 2.3283064365386963e-10 + 1.1641532182693481e-10
|
||||
# at::exponential with lambda 1 under fast math (at::log is __logf).
|
||||
log = tl.where(
|
||||
uniform >= 1.0 - 5.960464477539063e-08,
|
||||
-5.960464477539063e-08,
|
||||
tl.extra.cuda.libdevice.fast_logf(uniform),
|
||||
)
|
||||
row = tl.load(rows_ptr + request).to(tl.int64) if HAS_ROWS else request.to(tl.int64)
|
||||
tl.store(out_ptr + row * numel + elements, -log, mask=mask)
|
||||
|
||||
|
||||
_POLICY_CACHE: dict[tuple[int, int], tuple[int, int]] = {}
|
||||
|
||||
|
||||
def torch_exponential_policy(numel: int, device: torch.device) -> tuple[int, int]:
|
||||
"""(grid threads, Philox offset increment) of torch's CUDA ``exponential_``."""
|
||||
key = (numel, device.index or 0)
|
||||
policy = _POLICY_CACHE.get(key)
|
||||
if policy is None:
|
||||
props = torch.cuda.get_device_properties(device)
|
||||
blocks = min(props.multi_processor_count * (props.max_threads_per_multi_processor // 256), -(-numel // 256))
|
||||
threads = blocks * 256
|
||||
policy = _POLICY_CACHE[key] = (threads, ((numel - 1) // (threads * 4) + 1) * 4)
|
||||
return policy
|
||||
|
||||
|
||||
def fill_exponential_rows(out: torch.Tensor, generators: list, rows: list[int] | None = None) -> torch.Tensor:
|
||||
"""Draw ``out[rows[i]].exponential_(generator=generators[i])`` for all ``i`` in one launch.
|
||||
|
||||
``out`` is a contiguous float32 CUDA matrix; ``rows`` defaults to
|
||||
``range(len(generators))``. ``None`` generators use the default CUDA
|
||||
generator in order. Each generator is advanced exactly as torch would.
|
||||
"""
|
||||
numel = int(out.shape[-1]) if rows is not None else int(out.numel()) // len(generators)
|
||||
device = out.device
|
||||
threads, increment = torch_exponential_policy(numel, device)
|
||||
default = None
|
||||
seeds, offsets = [], []
|
||||
for generator in generators:
|
||||
if generator is None:
|
||||
if default is None:
|
||||
default = torch.cuda.default_generators[device.index or 0]
|
||||
generator = default
|
||||
offset = generator.get_offset()
|
||||
generator.set_offset(offset + increment)
|
||||
seed = generator.initial_seed()
|
||||
seeds.append(seed - (1 << 64) if seed >= 1 << 63 else seed)
|
||||
offsets.append(offset)
|
||||
table = [seeds, offsets] if rows is None else [seeds, offsets, list(rows)]
|
||||
state = torch.tensor(table, dtype=torch.int64, pin_memory=True).to(device, non_blocking=True)
|
||||
block = 1024
|
||||
_generator_exponential_kernel[(len(generators), triton.cdiv(numel, block))](
|
||||
out,
|
||||
state[2] if rows is not None else state[0],
|
||||
state[0],
|
||||
state[1],
|
||||
numel,
|
||||
threads,
|
||||
HAS_ROWS=rows is not None,
|
||||
BLOCK=block,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def batched_seeded_exponential_supported(q: torch.Tensor, generators: dict) -> bool:
|
||||
"""Whether ``fill_exponential_rows`` reproduces the per-row loop for ``q``."""
|
||||
return (
|
||||
len(generators) > 1
|
||||
and q.is_cuda
|
||||
and q.dtype == torch.float32
|
||||
and q.dim() == 2
|
||||
and q.is_contiguous()
|
||||
# A generator shared by two rows makes the loop order observable.
|
||||
and len({id(g) for g in generators.values()}) == len(generators)
|
||||
)
|
||||
@@ -413,6 +413,32 @@ class OmniModelState(DefaultModelState):
|
||||
# Input preparation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def prepare_inputs_embeds(
|
||||
self,
|
||||
scheduled_encoder_inputs: dict[str, list[int]],
|
||||
input_batch: InputBatch,
|
||||
req_states: RequestState,
|
||||
) -> torch.Tensor:
|
||||
# Models that rearrange multimodal prompts per request (CosyVoice3)
|
||||
# need the request boundaries the V1 runner passes as query_start_loc.
|
||||
if (
|
||||
not self.supports_mm_inputs
|
||||
or self.mm_pruner is not None
|
||||
or not getattr(self.model, "supports_embed_input_ids_query_start_loc", False)
|
||||
):
|
||||
return super().prepare_inputs_embeds(scheduled_encoder_inputs, input_batch, req_states)
|
||||
self.execute_mm_encoder(scheduled_encoder_inputs)
|
||||
mm_embeds, is_mm_embed = self.gather_mm_embeddings(input_batch)
|
||||
kwargs: dict[str, Any] = {"multimodal_embeddings": mm_embeds, "is_multimodal": is_mm_embed}
|
||||
if mm_embeds:
|
||||
kwargs["query_start_loc"] = input_batch.query_start_loc_np.tolist()
|
||||
embeds = self.model.embed_input_ids(input_batch.input_ids[: input_batch.num_tokens], **kwargs)
|
||||
inputs_embeds = self.encoder_runner.inputs_embeds
|
||||
inputs_embeds[: embeds.shape[0]] = embeds
|
||||
if self.prompt_embeds_state is not None:
|
||||
self.prompt_embeds_state.apply(input_batch, req_states.num_computed_tokens.gpu, inputs_embeds)
|
||||
return inputs_embeds[: input_batch.num_tokens_after_padding]
|
||||
|
||||
def prepare_inputs(self, input_batch: InputBatch, req_states: RequestState) -> dict[str, Any]:
|
||||
# Forward-only stages have no preprocess hook. Honor their declared
|
||||
# GPU inputs before gathering the model's runtime view, so payload
|
||||
|
||||
Reference in new issue
Block a user