Optimize CosyVoice3 Stage1 flow batching (#4876)

Signed-off-by: gerayking <399geray@gmail.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
This commit is contained in:
gerayandHongsheng Liu authored and GitHub committed 2026-09-12 20:31:49 +08:00
1 parent 28291df179
commit 9edc1b4667
10 files changed
+825 -198

No files matched your search

@@ -120,6 +120,15 @@ vllm serve FunAudioLLM/Fun-CosyVoice3-0.5B-2512 --omni --port 8091 --trust-remot
Streaming is on by default via `async_chunk: true` in `vllm_omni/deploy/cosyvoice3.yaml`. Pass `--no-async-chunk` (or `NO_ASYNC_CHUNK=1 ./cosyvoice3/run_server.sh`) for the legacy synchronous path.
Cross-request Stage-1 flow batching is opt-in. Enable it in the server environment when concurrent requests should share a flow-estimator call:
```bash
export COSYVOICE3_BATCH_FLOW=1
vllm serve FunAudioLLM/Fun-CosyVoice3-0.5B-2512 --omni --port 8091 --trust-remote-code
```
Batching preserves output lengths and streaming cache alignment, but the different GEMM shapes can produce small waveform differences compared with processing each request separately. Leave `COSYVOICE3_BATCH_FLOW` unset (or set it to `0`) when request-independent numerical behavior is required. Set `COSYVOICE3_BATCH_FLOW_DEBUG=1` to log the observed group-size distribution and enable detailed Stage-1 profiler scopes; diagnostics are disabled by default to avoid per-step profiling overhead.
### CLI client
The client defaults to the official upstream zero-shot prompt, so it runs without extra flags:
+3 -3
View File
@@ -167,7 +167,7 @@ def test_inventory_matches_reviewed_snapshot_counts():
EnvironmentVariableCategory.PUBLIC_OMNI: 24,
EnvironmentVariableCategory.INHERITED_VLLM: 20,
EnvironmentVariableCategory.PLATFORM_EXTERNAL: 27,
EnvironmentVariableCategory.MODEL_SPECIFIC: 61,
EnvironmentVariableCategory.MODEL_SPECIFIC: 63,
EnvironmentVariableCategory.BENCHMARK_TRANSITIONAL: 20,
EnvironmentVariableCategory.INTERNAL: 2,
}
@@ -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: 37,
ModelEnvironmentVariableDisposition.PROMOTE: 38,
ModelEnvironmentVariableDisposition.REQUEST_SCOPE: 6,
ModelEnvironmentVariableDisposition.EXTERNAL: 0,
ModelEnvironmentVariableDisposition.INTERNALIZE: 13,
ModelEnvironmentVariableDisposition.INTERNALIZE: 14,
ModelEnvironmentVariableDisposition.DEPRECATE_REMOVE: 5,
}
@@ -2,6 +2,7 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Unit tests for CosyVoice3 components."""
import types
from contextlib import contextmanager
from types import SimpleNamespace
@@ -9,6 +10,7 @@ import pytest
import torch
import torch.nn as nn
from tests.helpers.mark import hardware_test
from vllm_omni.diffusion.config import set_current_diffusion_config
from vllm_omni.diffusion.data import AttentionConfig
from vllm_omni.model_executor.models.cosyvoice3.code2wav_core.hifigan import (
@@ -29,6 +31,23 @@ def _force_torch_sdpa():
yield
def test_batch_flow_runtime_flags_are_opt_in(monkeypatch):
from vllm_omni.model_executor.models.cosyvoice3.runtime import (
cosyvoice3_batch_flow_debug,
cosyvoice3_batch_flow_enabled,
)
monkeypatch.delenv("COSYVOICE3_BATCH_FLOW", raising=False)
monkeypatch.delenv("COSYVOICE3_BATCH_FLOW_DEBUG", raising=False)
assert not cosyvoice3_batch_flow_enabled()
assert not cosyvoice3_batch_flow_debug()
monkeypatch.setenv("COSYVOICE3_BATCH_FLOW", "1")
monkeypatch.setenv("COSYVOICE3_BATCH_FLOW_DEBUG", "1")
assert cosyvoice3_batch_flow_enabled()
assert cosyvoice3_batch_flow_debug()
@pytest.fixture
def causal_hift():
return CausalHiFTGenerator(
@@ -139,6 +158,39 @@ class TestDiTAttention:
assert attention.to_k.out_features == 512
assert attention.to_v.out_features == 512
@pytest.mark.core_model
@pytest.mark.cpu
def test_forward_passes_2d_mask_without_casting_qkv(self):
"""The float32 fallback receives the per-row validity mask."""
from vllm_omni.diffusion.models.cosyvoice3_audio.cosyvoice3_dit import DiTAttention
class CaptureAttention(nn.Module):
def __init__(self):
super().__init__()
self.last_metadata = None
self.last_dtype = None
def forward(self, q, k, v, attn_metadata=None):
self.last_metadata = attn_metadata
self.last_dtype = q.dtype
return torch.zeros_like(q)
attention = DiTAttention(dim=16, heads=2, dim_head=8, dropout=0.0)
capture = CaptureAttention()
attention.attn = capture
x = torch.randn(2, 5, 16)
mask = torch.tensor([[True, True, True, False, False], [True, True, True, True, False]])
out = attention(x, mask=mask)
assert out.shape == x.shape
assert capture.last_metadata is not None
assert torch.equal(capture.last_metadata.attn_mask, mask)
assert capture.last_dtype == x.dtype
assert out.dtype == x.dtype
assert torch.allclose(out[~mask], torch.zeros_like(out[~mask]))
class TestDiTBlock:
"""Tests for DiTBlock."""
@@ -422,6 +474,52 @@ class TestCFM:
assert reused_context is context
assert reused_stream is stream
@pytest.mark.core_model
@pytest.mark.cpu
def test_causal_conditional_cfm_batches_cfg_estimator(self):
"""Batched flow calls should invoke the CFG estimator with 2B rows."""
from omegaconf import DictConfig
from vllm_omni.model_executor.models.cosyvoice3.code2wav_core.cfm import CausalConditionalCFM
class RecordingEstimator(nn.Module):
def __init__(self):
super().__init__()
self.shapes: list[tuple[int, ...]] = []
def forward(self, x, mask, mu, t, spks=None, cond=None):
self.shapes.append(tuple(x.shape))
return torch.zeros_like(x)
estimator = RecordingEstimator()
cfm_params = DictConfig(
{
"sigma_min": 1e-6,
"solver": "euler",
"t_scheduler": "cosine",
"training_cfg_rate": 0.2,
"inference_cfg_rate": 0.7,
}
)
cfm = CausalConditionalCFM(
in_channels=80,
cfm_params=cfm_params,
n_spks=1,
spk_emb_dim=80,
estimator=estimator,
)
batch, mel_dim, seq_len = 3, 80, 16
mu = torch.randn(batch, mel_dim, seq_len)
mask = torch.ones(batch, 1, seq_len)
spks = torch.randn(batch, 80)
cond = torch.randn(batch, mel_dim, seq_len)
out, _ = cfm(mu, mask, n_timesteps=2, spks=spks, cond=cond)
assert out.shape == mu.shape
assert estimator.shapes == [(2 * batch, mel_dim, seq_len)] * 2
class TestSDPAFallback:
"""Test SDPA fallback for float32 inputs."""
@@ -484,3 +582,157 @@ def test_code2wav_forward_finalizes_hift_tail():
assert out.shape == (1, 1, 8)
assert model.hift.finalize_calls == [True]
assert forward_mel_calls[0]["token_offset_tokens"] == 0
def test_code2wav_streaming_batch_pads_codec_tokens_and_preserves_lengths():
from vllm_omni.model_executor.models.cosyvoice3.cosyvoice3_code2wav import CosyVoice3Code2Wav
model = object.__new__(CosyVoice3Code2Wav)
model.flow_model = SimpleNamespace(pre_lookahead_len=1, token_mel_ratio=2)
forward_mel_calls = []
def fake_forward_mel(self, **kwargs):
forward_mel_calls.append(kwargs)
return torch.arange(2 * 80 * 8, dtype=torch.float32).reshape(2, 80, 8)
def fake_stream_hift(self, feat, *, cache_state=None, finalize=False):
return feat, None
model._forward_mel = types.MethodType(fake_forward_mel, model)
model._stream_hift_from_feat = types.MethodType(fake_stream_hift, model)
items = [
{
"token": torch.ones(1, 3, dtype=torch.int32),
"prompt_token": torch.ones(1, 4, dtype=torch.int32),
"prompt_feat": torch.ones(1, 8, 80),
"embedding": torch.ones(1, 192),
"finalize": False,
},
{
"token": torch.ones(1, 5, dtype=torch.int32),
"prompt_token": torch.ones(1, 4, dtype=torch.int32),
"prompt_feat": torch.ones(1, 8, 80),
"embedding": torch.ones(1, 192),
"finalize": False,
},
]
results = model.forward_streaming_batch(items)
assert len(forward_mel_calls) == 1
call = forward_mel_calls[0]
assert call["token"].shape == (2, 5)
assert torch.equal(call["token_lens"], torch.tensor([3, 5], dtype=torch.int32))
assert results[0][0].shape[-1] == 4
assert results[1][0].shape[-1] == 8
@pytest.mark.core_model
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_code2wav_streaming_batch_matches_ragged_flow_numerics(monkeypatch):
"""A padded flow batch must match individual flow calls on valid mels."""
from omegaconf import DictConfig
from vllm_omni.diffusion.models.cosyvoice3_audio.cosyvoice3_dit import DiT
from vllm_omni.model_executor.models.cosyvoice3.code2wav_core.cfm import (
CausalConditionalCFM,
CausalMaskedDiffWithDiT,
)
from vllm_omni.model_executor.models.cosyvoice3.code2wav_core.layers import PreLookaheadLayer
from vllm_omni.model_executor.models.cosyvoice3.cosyvoice3_code2wav import CosyVoice3Code2Wav
torch.manual_seed(0)
estimator = DiT(
dim=32,
depth=1,
heads=4,
dim_head=8,
dropout=0.0,
ff_mult=2,
mel_dim=80,
mu_dim=80,
spk_dim=80,
out_channels=80,
)
decoder = CausalConditionalCFM(
in_channels=80,
cfm_params=DictConfig(
{
"sigma_min": 1e-6,
"solver": "euler",
"t_scheduler": "cosine",
"training_cfg_rate": 0.2,
"inference_cfg_rate": 0.7,
}
),
n_spks=1,
spk_emb_dim=80,
estimator=estimator,
)
flow_model = (
CausalMaskedDiffWithDiT(
input_size=80,
output_size=80,
spk_embed_dim=192,
vocab_size=64,
input_frame_rate=25,
only_mask_loss=True,
token_mel_ratio=2,
pre_lookahead_len=1,
pre_lookahead_layer=PreLookaheadLayer(in_channels=80, channels=80, pre_lookahead_len=1),
decoder=decoder,
)
.to(device="cuda", dtype=torch.bfloat16)
.eval()
)
model = object.__new__(CosyVoice3Code2Wav)
nn.Module.__init__(model)
model.flow_model = flow_model
def return_mel(self, feat, *, cache_state=None, finalize=False):
return feat, None
model._stream_hift_from_feat = types.MethodType(return_mel, model)
original_randn = torch.randn
def length_consistent_randn(*size, **kwargs):
shape = tuple(size[0]) if len(size) == 1 and isinstance(size[0], (tuple, list)) else tuple(size)
if len(shape) == 3 and shape[1] == 80:
device = kwargs.get("device")
dtype = kwargs.get("dtype", torch.float32)
channels = torch.arange(shape[1], device=device, dtype=torch.float32).view(1, -1, 1)
positions = torch.arange(shape[2], device=device, dtype=torch.float32).view(1, 1, -1)
noise = torch.sin(channels * 0.17 + positions * 0.31)
return noise.expand(shape[0], -1, -1).to(dtype=dtype).clone()
return original_randn(*size, **kwargs)
monkeypatch.setattr(torch, "randn", length_consistent_randn)
common = {
"prompt_token": torch.tensor([[7, 8]], dtype=torch.int32),
"prompt_feat": torch.linspace(-0.5, 0.5, 4 * 80).reshape(1, 4, 80),
"embedding": torch.linspace(-1.0, 1.0, 192).reshape(1, 192),
"finalize": False,
}
items = [
{**common, "token": torch.tensor([[1, 2, 3]], dtype=torch.int32)},
{**common, "token": torch.tensor([[1, 2, 3, 4, 5]], dtype=torch.int32)},
]
batched = model.forward_streaming_batch(items, n_timesteps=2)
individual = [
model.forward_streaming(
token=item["token"],
prompt_token=item["prompt_token"],
prompt_feat=item["prompt_feat"],
embedding=item["embedding"],
n_timesteps=2,
)
for item in items
]
for (batched_mel, _), (individual_mel, _) in zip(batched, individual):
assert batched_mel.shape == individual_mel.shape
rel_mean = (batched_mel - individual_mel).abs().mean() / individual_mel.abs().mean().clamp_min(1e-6)
assert rel_mean.item() < 0.05
@@ -42,6 +42,7 @@ class _DummyCode2Wav:
self.outputs = list(outputs or [])
self.forward_calls: list[dict[str, object]] = []
self.forward_streaming_calls: list[dict[str, object]] = []
self.forward_streaming_batch_calls: list[list[dict[str, object]]] = []
def forward(self, **kwargs):
self.forward_calls.append(kwargs)
@@ -65,6 +66,22 @@ class _DummyCode2Wav:
}
return audio, new_state
def forward_streaming_batch(self, items, *, n_timesteps: int = 10):
self.forward_streaming_batch_calls.append(items)
return [
self.forward_streaming(
token=item["token"],
prompt_token=item["prompt_token"],
prompt_feat=item["prompt_feat"],
embedding=item["embedding"],
cache_state=item.get("cache_state"),
n_timesteps=n_timesteps,
token_offset_tokens=int(item.get("token_offset_tokens", 0)),
finalize=bool(item.get("finalize", False)),
)
for item in items
]
def _make_code2wav_model(
*,
@@ -373,6 +390,53 @@ def test_forward_clears_streaming_cache_on_terminal_chunk():
assert "rid-stream" not in model._stream_vocoder_cache_by_req
def test_forward_batches_streaming_flow_items(monkeypatch):
monkeypatch.setenv("COSYVOICE3_BATCH_FLOW", "1")
model = _make_code2wav_model()
runtime_info = [
{
"embed": {
"speech_token": torch.tensor([[1, 2, 3]], dtype=torch.long),
"speech_feat": torch.tensor([[[0.1, 0.2], [0.3, 0.4]]], dtype=torch.float32),
"embedding": torch.tensor([[0.5, 0.6]], dtype=torch.float32),
},
"meta": {
"req_id": ["rid-a"],
"stream_finished": torch.tensor(False),
"left_context_size": 0,
},
},
{
"embed": {
"speech_token": torch.tensor([[1, 2, 3]], dtype=torch.long),
"speech_feat": torch.tensor([[[0.1, 0.2], [0.3, 0.4]]], dtype=torch.float32),
"embedding": torch.tensor([[0.7, 0.8]], dtype=torch.float32),
},
"meta": {
"req_id": ["rid-b"],
"stream_finished": torch.tensor(False),
"left_context_size": 1,
},
},
]
out = model.forward(
input_ids=torch.tensor([0, 1, 2, 0, 1, 2], dtype=torch.long),
positions=torch.arange(6, dtype=torch.long),
model_intermediate_buffer=runtime_info,
seq_token_counts=[3, 3],
)
assert len(out.multimodal_outputs["audio"]) == 2
assert len(model.code2wav.forward_streaming_batch_calls) == 1
batch_items = model.code2wav.forward_streaming_batch_calls[0]
assert [item["index"] for item in batch_items] == [0, 1]
assert torch.equal(batch_items[0]["token"], torch.tensor([[0, 1, 2]]))
assert batch_items[1]["token_offset_tokens"] == 1
assert "rid-a" in model._stream_vocoder_cache_by_req
assert "rid-b" in model._stream_vocoder_cache_by_req
def test_sample_uses_ras_rejection_for_recent_repetition():
model = _make_talker_model()
metadata = _make_sampling_metadata(output_token_ids=[[1] * 10])
@@ -137,6 +137,7 @@ _MODEL_PROMOTE = (
"AR_DIFFUSION_KV_SCRATCH_BLOCKS_PER_BRANCH",
"COSMOS3_SOUND_TOKENIZER_CONFIG_PATH",
"COSMOS3_SOUND_TOKENIZER_PATH",
"COSYVOICE3_BATCH_FLOW",
"COSYVOICE3_ESTIMATOR_ONNX",
"COSYVOICE3_TRT",
"COSYVOICE3_TRT_CACHE",
@@ -188,6 +189,7 @@ _MODEL_REQUEST_SCOPE = (
_MODEL_EXTERNAL: tuple[str, ...] = ()
_MODEL_INTERNALIZE = (
"COSYVOICE3_BATCH_FLOW_DEBUG",
"DOTS_TTS_BETA_TRACE",
"DYNIN_S2U_VENDOR_ROOT",
"DZ_PHASE_TIMING",
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# Adapted from https://github.com/FunAudioLLM/CosyVoice/tree/main/cosyvoice/flow/DiT
# Refactored to use vllm_omni diffusion infrastructure for optimized attention backends
@@ -15,8 +15,10 @@ from torch import nn
from vllm.logger import init_logger
from x_transformers.x_transformers import RotaryEmbedding, apply_rotary_pos_emb
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata
from vllm_omni.diffusion.attention.layer import Attention as DiffusionAttention
from vllm_omni.model_executor.layers.timestep_embedding import DiTTimestepEmbedding
from vllm_omni.model_executor.models.cosyvoice3.runtime import cosyvoice3_batch_flow_profile
logger = init_logger(__name__)
@@ -52,6 +54,22 @@ def get_pos_embed_indices(start, length, max_pos, scale=1.0):
return pos
def _normalize_attention_mask(mask: torch.Tensor | None, batch_size: int, seq_len: int) -> torch.Tensor | None:
if mask is None:
return None
if mask.dim() == 2:
attn_mask = mask
elif mask.dim() == 3 and mask.shape[1] == 1:
attn_mask = mask[:, 0]
elif mask.dim() == 4:
attn_mask = mask[:, 0, -1]
else:
return None
if attn_mask.shape != (batch_size, seq_len):
return None
return attn_mask.bool()
class FeedForward(nn.Module):
"""Feed-forward network with GELU activation."""
@@ -114,42 +132,39 @@ class DiTAttention(nn.Module):
) -> torch.Tensor:
batch_size, seq_len = x.shape[0], x.shape[1]
# Project to Q, K, V
query = self.to_q(x)
key = self.to_k(x)
value = self.to_v(x)
with cosyvoice3_batch_flow_profile("cosyvoice3_dit_attention_qkv_projection"):
query = self.to_q(x)
key = self.to_k(x)
value = self.to_v(x)
# Apply rotary position embedding
if rope is not None:
freqs, xpos_scale = rope
q_xpos_scale, k_xpos_scale = (xpos_scale, xpos_scale**-1.0) if xpos_scale is not None else (1.0, 1.0)
query = apply_rotary_pos_emb(query, freqs, q_xpos_scale)
key = apply_rotary_pos_emb(key, freqs, k_xpos_scale)
with cosyvoice3_batch_flow_profile("cosyvoice3_dit_attention_rope"):
if rope is not None:
freqs, xpos_scale = rope
q_xpos_scale, k_xpos_scale = (xpos_scale, xpos_scale**-1.0) if xpos_scale is not None else (1.0, 1.0)
query = apply_rotary_pos_emb(query, freqs, q_xpos_scale)
key = apply_rotary_pos_emb(key, freqs, k_xpos_scale)
# Reshape for attention: (batch, seq, heads, head_dim)
query = query.view(batch_size, seq_len, self.heads, self.dim_head)
key = key.view(batch_size, seq_len, self.heads, self.dim_head)
value = value.view(batch_size, seq_len, self.heads, self.dim_head)
with cosyvoice3_batch_flow_profile("cosyvoice3_dit_attention_backend"):
# Reshape for attention: (batch, seq, heads, head_dim)
query = query.view(batch_size, seq_len, self.heads, self.dim_head)
key = key.view(batch_size, seq_len, self.heads, self.dim_head)
value = value.view(batch_size, seq_len, self.heads, self.dim_head)
# Use diffusion attention backend
# The diffusion Attention layer expects (batch, seq, heads, head_dim)
out = self.attn(query, key, value, attn_metadata=None)
# The diffusion Attention layer expects (batch, seq, heads, head_dim)
attn_mask = _normalize_attention_mask(mask, batch_size, seq_len)
attn_metadata = AttentionMetadata(attn_mask=attn_mask) if attn_mask is not None else None
out = self.attn(query, key, value, attn_metadata=attn_metadata)
# Some attention backends return a non-contiguous layout.
out = out.reshape(batch_size, seq_len, self.inner_dim)
out = out.to(query.dtype)
# Output projection
out = self.to_out(out)
with cosyvoice3_batch_flow_profile("cosyvoice3_dit_attention_output_projection"):
# Some attention backends return a non-contiguous layout.
out = out.reshape(batch_size, seq_len, self.inner_dim)
out = out.to(x.dtype)
out = self.to_out(out)
# Apply mask if provided
if mask is not None:
if mask.dim() == 2:
mask = mask.unsqueeze(-1)
elif mask.dim() == 4:
# (batch, heads, seq, seq) -> use last dim
mask = mask[:, 0, -1].unsqueeze(-1)
out = out.masked_fill(~mask.bool(), 0.0)
attn_mask = _normalize_attention_mask(mask, batch_size, seq_len)
if attn_mask is not None:
out = out.masked_fill(~attn_mask.unsqueeze(-1), 0.0)
return out
@@ -172,18 +187,19 @@ class DiTBlock(nn.Module):
self.ff = FeedForward(dim=dim, mult=ff_mult, dropout=dropout, approximate="tanh")
def forward(self, x, t, mask=None, rope=None):
# pre-norm & modulation for attention input
norm, gate_msa, shift_mlp, scale_mlp, gate_mlp = self.attn_norm(x, emb=t)
with cosyvoice3_batch_flow_profile("cosyvoice3_dit_adalayernorm_attention"):
norm, gate_msa, shift_mlp, scale_mlp, gate_mlp = self.attn_norm(x, emb=t)
# attention
attn_output = self.attn(x=norm, mask=mask, rope=rope)
with cosyvoice3_batch_flow_profile("cosyvoice3_dit_attention"):
attn_output = self.attn(x=norm, mask=mask, rope=rope)
# process attention output for input x
x = x + gate_msa.unsqueeze(1) * attn_output
with cosyvoice3_batch_flow_profile("cosyvoice3_dit_attention_residual"):
x = x + gate_msa.unsqueeze(1) * attn_output
ff_norm = self.ff_norm(x) * (1 + scale_mlp[:, None]) + shift_mlp[:, None]
ff_output = self.ff(ff_norm)
x = x + gate_mlp.unsqueeze(1) * ff_output
with cosyvoice3_batch_flow_profile("cosyvoice3_dit_layernorm_ffn"):
ff_norm = self.ff_norm(x) * (1 + scale_mlp[:, None]) + shift_mlp[:, None]
ff_output = self.ff(ff_norm)
x = x + gate_mlp.unsqueeze(1) * ff_output
return x
@@ -205,19 +221,20 @@ class CausalConvPositionEmbedding(nn.Module):
)
def forward(self, x: torch.Tensor, mask: torch.Tensor | None = None):
if mask is not None:
mask = mask[..., None]
x = x.masked_fill(~mask, 0.0)
with cosyvoice3_batch_flow_profile("cosyvoice3_dit_causal_conv_position_embedding"):
if mask is not None:
mask = mask[..., None]
x = x.masked_fill(~mask, 0.0)
x = x.permute(0, 2, 1)
x = F.pad(x, (self.kernel_size - 1, 0, 0, 0))
x = self.conv1(x)
x = F.pad(x, (self.kernel_size - 1, 0, 0, 0))
x = self.conv2(x)
out = x.permute(0, 2, 1)
x = x.permute(0, 2, 1)
x = F.pad(x, (self.kernel_size - 1, 0, 0, 0))
x = self.conv1(x)
x = F.pad(x, (self.kernel_size - 1, 0, 0, 0))
x = self.conv2(x)
out = x.permute(0, 2, 1)
if mask is not None:
out = out.masked_fill(~mask, 0.0)
if mask is not None:
out = out.masked_fill(~mask, 0.0)
return out
@@ -327,13 +344,15 @@ class InputEmbedding(nn.Module):
self.conv_pos_embed = CausalConvPositionEmbedding(dim=out_dim)
def forward(self, x, cond, text_embed, spks):
to_cat = [x, cond, text_embed]
if self.spk_dim > 0:
spks = repeat(spks, "b c -> b t c", t=x.shape[1])
to_cat.append(spks)
with cosyvoice3_batch_flow_profile("cosyvoice3_dit_input_projection"):
to_cat = [x, cond, text_embed]
if self.spk_dim > 0:
spks = repeat(spks, "b c -> b t c", t=x.shape[1])
to_cat.append(spks)
x = self.proj(torch.cat(to_cat, dim=-1))
x = self.conv_pos_embed(x) + x
x = self.proj(torch.cat(to_cat, dim=-1))
with cosyvoice3_batch_flow_profile("cosyvoice3_dit_input_causal_conv"):
x = self.conv_pos_embed(x) + x
return x
@@ -385,31 +404,37 @@ class DiT(nn.Module):
self.num_decoding_left_chunks = num_decoding_left_chunks
def forward(self, x, mask, mu, t, spks=None, cond=None):
x = x.transpose(1, 2)
mu = mu.transpose(1, 2)
cond = cond.transpose(1, 2)
spks = spks.unsqueeze(dim=1)
batch, seq_len = x.shape[0], x.shape[1]
if t.ndim == 0:
t = t.repeat(batch)
with cosyvoice3_batch_flow_profile("cosyvoice3_dit_prepare_inputs"):
x = x.transpose(1, 2)
mu = mu.transpose(1, 2)
cond = cond.transpose(1, 2)
spks = spks.unsqueeze(dim=1)
batch, seq_len = x.shape[0], x.shape[1]
if t.ndim == 0:
t = t.repeat(batch)
# t: conditioning time, c: context (text + masked cond audio), x: noised input audio
t = self.time_embed(t)
with cosyvoice3_batch_flow_profile("cosyvoice3_dit_time_embedding"):
t = self.time_embed(t)
x = self.input_embed(x, cond, mu, spks.squeeze(1))
rope = self.rotary_embed.forward_from_seq_len(seq_len)
with cosyvoice3_batch_flow_profile("cosyvoice3_dit_rope_and_mask"):
rope = self.rotary_embed.forward_from_seq_len(seq_len)
attn_mask = mask[:, 0].bool() if mask.dim() == 3 and mask.shape[1] == 1 else mask.bool()
if self.long_skip_connection is not None:
residual = x
attn_mask = mask.bool().repeat(1, x.size(1), 1).unsqueeze(dim=1)
for block in self.transformer_blocks:
x = block(x, t, mask=attn_mask.bool(), rope=rope)
with cosyvoice3_batch_flow_profile("cosyvoice3_dit_transformer_blocks"):
for block_index, block in enumerate(self.transformer_blocks):
with cosyvoice3_batch_flow_profile(f"cosyvoice3_dit_transformer_block_{block_index}"):
x = block(x, t, mask=attn_mask.bool(), rope=rope)
if self.long_skip_connection is not None:
x = self.long_skip_connection(torch.cat((x, residual), dim=-1))
with cosyvoice3_batch_flow_profile("cosyvoice3_dit_long_skip_projection"):
x = self.long_skip_connection(torch.cat((x, residual), dim=-1))
x = self.norm_out(x, t)
output = self.proj_out(x).transpose(1, 2)
with cosyvoice3_batch_flow_profile("cosyvoice3_dit_final_adalayernorm"):
x = self.norm_out(x, t)
with cosyvoice3_batch_flow_profile("cosyvoice3_dit_final_projection"):
output = self.proj_out(x).transpose(1, 2)
return output
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# Adopted from https://github.com/FunAudioLLM/CosyVoice/tree/main/cosyvoice/flow
"""Conditional Flow Matching (CFM) classes for audio generation."""
@@ -11,6 +11,7 @@ from omegaconf import DictConfig
from torch.nn import functional as F
from vllm.logger import init_logger
from vllm_omni.model_executor.models.cosyvoice3.runtime import cosyvoice3_batch_flow_profile
from vllm_omni.model_executor.models.cosyvoice3.utils import make_pad_mask
logger = init_logger(__name__)
@@ -74,20 +75,23 @@ class ConditionalCFM(BASECFM):
shape: (batch_size, n_feats, mel_timesteps)
"""
z = torch.randn_like(mu).to(mu.device).to(mu.dtype) * temperature
cache_size = cache.shape[2]
# fix prompt and overlap part mu and z
if cache_size != 0:
z[:, :, :cache_size] = cache[:, :, :, 0]
mu[:, :, :cache_size] = cache[:, :, :, 1]
z_cache = torch.concat([z[:, :, :prompt_len], z[:, :, -34:]], dim=2)
mu_cache = torch.concat([mu[:, :, :prompt_len], mu[:, :, -34:]], dim=2)
cache = torch.stack([z_cache, mu_cache], dim=-1)
with cosyvoice3_batch_flow_profile("cosyvoice3_cfm_noise_cache"):
z = torch.randn_like(mu).to(mu.device).to(mu.dtype) * temperature
cache_size = cache.shape[2]
# fix prompt and overlap part mu and z
if cache_size != 0:
z[:, :, :cache_size] = cache[:, :, :, 0]
mu[:, :, :cache_size] = cache[:, :, :, 1]
z_cache = torch.concat([z[:, :, :prompt_len], z[:, :, -34:]], dim=2)
mu_cache = torch.concat([mu[:, :, :prompt_len], mu[:, :, -34:]], dim=2)
cache = torch.stack([z_cache, mu_cache], dim=-1)
t_span = torch.linspace(0, 1, n_timesteps + 1, device=mu.device, dtype=mu.dtype)
if self.t_scheduler == "cosine":
t_span = 1 - torch.cos(t_span * 0.5 * torch.pi)
return self.solve_euler(z, t_span=t_span, mu=mu, mask=mask, spks=spks, cond=cond), cache
with cosyvoice3_batch_flow_profile("cosyvoice3_cfm_t_span"):
t_span = torch.linspace(0, 1, n_timesteps + 1, device=mu.device, dtype=mu.dtype)
if self.t_scheduler == "cosine":
t_span = 1 - torch.cos(t_span * 0.5 * torch.pi)
with cosyvoice3_batch_flow_profile(f"cosyvoice3_cfm_euler_{max(1, int(n_timesteps))}_steps"):
return self.solve_euler(z, t_span=t_span, mu=mu, mask=mask, spks=spks, cond=cond), cache
def solve_euler(self, x, t_span, mu, mask, spks, cond):
"""
@@ -111,26 +115,39 @@ class ConditionalCFM(BASECFM):
# Do not use concat, it may cause memory format changed and trt infer with wrong results!
# NOTE when flow run in amp mode, x.dtype is float32, which cause nan in trt fp16
# inference, so set dtype=spks.dtype
x_in = torch.zeros([2, 80, x.size(2)], device=x.device, dtype=spks.dtype)
mask_in = torch.zeros([2, 1, x.size(2)], device=x.device, dtype=spks.dtype)
mu_in = torch.zeros([2, 80, x.size(2)], device=x.device, dtype=spks.dtype)
t_in = torch.zeros([2], device=x.device, dtype=spks.dtype)
spks_in = torch.zeros([2, 80], device=x.device, dtype=spks.dtype)
cond_in = torch.zeros([2, 80, x.size(2)], device=x.device, dtype=spks.dtype)
# inference, so set dtype=spks.dtype. The batch is doubled for CFG:
# first B rows are conditioned, second B rows are unconditional.
batch_size = int(x.size(0))
estimator_batch = 2 * batch_size
estimator_dtype = spks.dtype if spks is not None else x.dtype
with cosyvoice3_batch_flow_profile("cosyvoice3_cfm_cfg_allocate_2b"):
x_in = torch.zeros([estimator_batch, 80, x.size(2)], device=x.device, dtype=estimator_dtype)
mask_in = torch.zeros([estimator_batch, 1, x.size(2)], device=x.device, dtype=estimator_dtype)
mu_in = torch.zeros([estimator_batch, 80, x.size(2)], device=x.device, dtype=estimator_dtype)
t_in = torch.zeros([estimator_batch], device=x.device, dtype=estimator_dtype)
spks_in = torch.zeros([estimator_batch, 80], device=x.device, dtype=estimator_dtype)
cond_in = torch.zeros([estimator_batch, 80, x.size(2)], device=x.device, dtype=estimator_dtype)
for step in range(1, len(t_span)):
# Classifier-Free Guidance inference introduced in VoiceBox
x_in[:] = x
mask_in[:] = mask
mu_in[0] = mu
t_in[:] = t.unsqueeze(0)
spks_in[0] = spks
cond_in[0] = cond
dphi_dt = self.forward_estimator(x_in, mask_in, mu_in, t_in, spks_in, cond_in)
dphi_dt, cfg_dphi_dt = torch.split(dphi_dt, [x.size(0), x.size(0)], dim=0)
dphi_dt = (1.0 + self.inference_cfg_rate) * dphi_dt - self.inference_cfg_rate * cfg_dphi_dt
x = x + dt * dphi_dt
t = t + dt
with cosyvoice3_batch_flow_profile("cosyvoice3_cfm_cfg_prepare_2b"):
x_in[:batch_size] = x
x_in[batch_size:] = x
mask_in[:batch_size] = mask
mask_in[batch_size:] = mask
mu_in[:batch_size] = mu
t_in[:] = t
if spks is not None:
spks_in[:batch_size] = spks
if cond is not None:
cond_in[:batch_size] = cond
with cosyvoice3_batch_flow_profile("cosyvoice3_cfm_forward_estimator"):
dphi_dt = self.forward_estimator(x_in, mask_in, mu_in, t_in, spks_in, cond_in)
with cosyvoice3_batch_flow_profile("cosyvoice3_cfm_cfg_combine"):
dphi_dt, cfg_dphi_dt = torch.split(dphi_dt, [batch_size, batch_size], dim=0)
dphi_dt = (1.0 + self.inference_cfg_rate) * dphi_dt - self.inference_cfg_rate * cfg_dphi_dt
with cosyvoice3_batch_flow_profile("cosyvoice3_cfm_euler_update"):
x = x + dt * dphi_dt
t = t + dt
sol.append(x)
if step < len(t_span) - 1:
dt = t_span[step + 1] - t
@@ -158,12 +175,12 @@ class ConditionalCFM(BASECFM):
spks_e = spks.to(io_dtype).contiguous()
cond_e = cond.to(io_dtype).contiguous()
out_e = torch.empty_like(x_e)
estimator.set_input_shape("x", (2, 80, x_e.size(2)))
estimator.set_input_shape("mask", (2, 1, x_e.size(2)))
estimator.set_input_shape("mu", (2, 80, x_e.size(2)))
estimator.set_input_shape("t", (2,))
estimator.set_input_shape("spks", (2, 80))
estimator.set_input_shape("cond", (2, 80, x_e.size(2)))
estimator.set_input_shape("x", tuple(x_e.shape))
estimator.set_input_shape("mask", tuple(mask_e.shape))
estimator.set_input_shape("mu", tuple(mu_e.shape))
estimator.set_input_shape("t", tuple(t_e.shape))
estimator.set_input_shape("spks", tuple(spks_e.shape))
estimator.set_input_shape("cond", tuple(cond_e.shape))
data_ptrs = [
x_e.data_ptr(),
mask_e.data_ptr(),
@@ -211,22 +228,25 @@ class CausalConditionalCFM(ConditionalCFM):
shape: (batch_size, n_feats, mel_timesteps)
"""
z = (
torch.randn(
(mu.size(0), mu.size(1), mu.size(2)),
device=mu.device,
dtype=mu.dtype,
with cosyvoice3_batch_flow_profile("cosyvoice3_cfm_noise_cache"):
z = (
torch.randn(
(mu.size(0), mu.size(1), mu.size(2)),
device=mu.device,
dtype=mu.dtype,
)
* temperature
)
* temperature
)
# fix prompt and overlap part mu and z
t_span = torch.linspace(0, 1, n_timesteps + 1, device=mu.device, dtype=mu.dtype)
with cosyvoice3_batch_flow_profile("cosyvoice3_cfm_t_span"):
t_span = torch.linspace(0, 1, n_timesteps + 1, device=mu.device, dtype=mu.dtype)
if self.t_scheduler == "cosine":
t_span = 1 - torch.cos(t_span * 0.5 * torch.pi)
if self.t_scheduler == "cosine":
t_span = 1 - torch.cos(t_span * 0.5 * torch.pi)
return self.solve_euler(z, t_span=t_span, mu=mu, mask=mask, spks=spks, cond=cond), None
with cosyvoice3_batch_flow_profile(f"cosyvoice3_cfm_euler_{max(1, int(n_timesteps))}_steps"):
return self.solve_euler(z, t_span=t_span, mu=mu, mask=mask, spks=spks, cond=cond), None
class CausalMaskedDiffWithDiT(torch.nn.Module):
@@ -299,36 +319,41 @@ class CausalMaskedDiffWithDiT(torch.nn.Module):
finalize: bool = False,
n_timesteps: int = 10,
):
assert token.shape[0] == 1
# xvec projection
with cosyvoice3_batch_flow_profile("cosyvoice3_cfm_speaker_embedding"):
embedding = F.normalize(embedding, dim=1)
embedding = self.spk_embed_affine_layer(embedding)
embedding = F.normalize(embedding, dim=1)
with cosyvoice3_batch_flow_profile("cosyvoice3_cfm_token_embedding_lookahead"):
# concat text and prompt_text
codec_token_len = token_len
token, total_token_len = torch.concat([prompt_token, token], dim=1), prompt_token_len + codec_token_len
mask = (~make_pad_mask(total_token_len, max_len=token.shape[1])).unsqueeze(-1).to(embedding)
token = self.input_embedding(torch.clamp(token, min=0)) * mask
# text encode
if finalize is True:
h = self.pre_lookahead_layer(token)
else:
h = self.pre_lookahead_layer(
token[:, : -self.pre_lookahead_len], context=token[:, -self.pre_lookahead_len :]
)
embedding = self.spk_embed_affine_layer(embedding)
with cosyvoice3_batch_flow_profile("cosyvoice3_cfm_repeat_to_mel_axis"):
h = h.repeat_interleave(self.token_mel_ratio, dim=1)
# concat text and prompt_text
token, token_len = torch.concat([prompt_token, token], dim=1), prompt_token_len + token_len
mask = (~make_pad_mask(token_len)).unsqueeze(-1).to(embedding)
token = self.input_embedding(torch.clamp(token, min=0)) * mask
# text encode
if finalize is True:
h = self.pre_lookahead_layer(token)
else:
h = self.pre_lookahead_layer(
token[:, : -self.pre_lookahead_len], context=token[:, -self.pre_lookahead_len :]
)
h = h.repeat_interleave(self.token_mel_ratio, dim=1)
batch_size = int(token.shape[0])
with cosyvoice3_batch_flow_profile("cosyvoice3_cfm_cond_prompt_mel"):
mel_len1, mel_len2 = prompt_feat.shape[1], h.shape[1] - prompt_feat.shape[1]
mel_len1, mel_len2 = prompt_feat.shape[1], h.shape[1] - prompt_feat.shape[1]
# get conditions
conds = torch.zeros([batch_size, mel_len1 + mel_len2, self.output_size], device=token.device).to(h.dtype)
conds[:, :mel_len1] = prompt_feat
conds = conds.transpose(1, 2)
# get conditions
conds = torch.zeros([1, mel_len1 + mel_len2, self.output_size], device=token.device).to(h.dtype)
lookahead = 0 if finalize else int(self.pre_lookahead_len)
valid_h_lens = torch.clamp(total_token_len.to(torch.long) - lookahead, min=0)
mel_lens = torch.clamp(valid_h_lens * int(self.token_mel_ratio), max=mel_len1 + mel_len2)
mask = (~make_pad_mask(mel_lens, max_len=mel_len1 + mel_len2)).to(h)
conds[:, :mel_len1] = prompt_feat
conds = conds.transpose(1, 2)
mask = (~make_pad_mask(torch.tensor([mel_len1 + mel_len2]))).to(h)
feat, _ = self.decoder(
mu=h.transpose(1, 2).contiguous(),
mask=mask.unsqueeze(1),
@@ -338,6 +363,7 @@ class CausalMaskedDiffWithDiT(torch.nn.Module):
streaming=streaming,
)
feat = feat[:, :, mel_len1:]
assert feat.shape[2] == mel_len2
with cosyvoice3_batch_flow_profile("cosyvoice3_cfm_crop_prompt_mel"):
feat = feat[:, :, mel_len1:]
assert feat.shape[2] == mel_len2
return feat.float(), None
@@ -39,6 +39,10 @@ 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.runtime import (
cosyvoice3_batch_flow_debug,
cosyvoice3_batch_flow_enabled,
)
from vllm_omni.model_executor.models.cosyvoice3.tokenizer import get_qwen_tokenizer
from vllm_omni.model_executor.models.cosyvoice3.utils import (
concat_text_with_prompt_ids,
@@ -1070,12 +1074,21 @@ class CosyVoice3Model(
request_ids_list = self._split_request_ids(flat_ids, seq_token_counts)
num_reqs = max(1, len(request_ids_list))
debug_batch_flow = cosyvoice3_batch_flow_debug()
if debug_batch_flow:
logger.info(
"CosyVoice3 code2wav debug: forward num_reqs=%d seq_token_counts=%s flat_ids=%d",
num_reqs,
seq_token_counts,
int(flat_ids.numel()),
)
sample_rate = torch.tensor(int(self.config.sample_rate), dtype=torch.int32)
empty_audio = torch.zeros((0,), dtype=torch.float32, device=input_ids.device)
audios: list[torch.Tensor] = [empty_audio] * num_reqs
srs: list[torch.Tensor] = [sample_rate] * num_reqs
if not isinstance(runtime_info, list):
runtime_info = []
streaming_flow_items: list[dict[str, object]] = []
for idx, req_ids in enumerate(request_ids_list):
raw = runtime_info[idx] if idx < len(runtime_info) and isinstance(runtime_info[idx], dict) else {}
@@ -1142,23 +1155,21 @@ class CosyVoice3Model(
with self._stream_audio_cache_lock:
cache_state = self._stream_vocoder_cache_by_req.get(req_id)
tts_speech, new_cache_state = self.code2wav.forward_streaming(
token=token.unsqueeze(0),
prompt_token=speech_token[:1],
prompt_feat=speech_feat[:1],
embedding=embedding[:1],
cache_state=cache_state,
n_timesteps=10,
token_offset_tokens=token_offset,
finalize=stream_finished,
streaming_flow_items.append(
{
"index": idx,
"req_id": req_id,
"stream_finished": stream_finished,
"token": token.unsqueeze(0),
"prompt_token": speech_token[:1],
"prompt_feat": speech_feat[:1],
"embedding": embedding[:1],
"cache_state": cache_state,
"token_offset_tokens": token_offset,
"finalize": stream_finished,
}
)
if req_id is not None and hasattr(self, "_stream_vocoder_cache_by_req"):
with self._stream_audio_cache_lock:
if new_cache_state is None or stream_finished:
self._stream_vocoder_cache_by_req.pop(req_id, None)
else:
self._stream_vocoder_cache_by_req[req_id] = new_cache_state
continue
else:
token_offset = max(0, meta.talker_prefill_offset or 0) if meta else 0
tts_speech = self.code2wav.forward(
@@ -1174,6 +1185,60 @@ class CosyVoice3Model(
audios[idx] = self._stitch_stream_audio(req_id, audio, stream_finished)
if streaming_flow_items:
if debug_batch_flow:
item_shapes = [
(
tuple(item["token"].shape), # type: ignore[union-attr]
tuple(item["prompt_token"].shape), # type: ignore[union-attr]
tuple(item["prompt_feat"].shape), # type: ignore[union-attr]
bool(item.get("finalize", False)),
)
for item in streaming_flow_items
]
logger.info(
"CosyVoice3 code2wav debug: streaming_items=%d item_shapes=%s",
len(streaming_flow_items),
item_shapes,
)
if (
len(streaming_flow_items) > 1
and cosyvoice3_batch_flow_enabled()
and hasattr(self.code2wav, "forward_streaming_batch")
):
streaming_results = self.code2wav.forward_streaming_batch(
streaming_flow_items,
n_timesteps=10,
)
else:
streaming_results = [
self.code2wav.forward_streaming(
token=item["token"], # type: ignore[arg-type]
prompt_token=item["prompt_token"], # type: ignore[arg-type]
prompt_feat=item["prompt_feat"], # type: ignore[arg-type]
embedding=item["embedding"], # type: ignore[arg-type]
cache_state=item.get("cache_state"), # type: ignore[arg-type]
n_timesteps=10,
token_offset_tokens=int(item.get("token_offset_tokens", 0)),
finalize=bool(item.get("finalize", False)),
)
for item in streaming_flow_items
]
for item, (tts_speech, new_cache_state) in zip(streaming_flow_items, streaming_results):
idx = int(item["index"])
req_id = item.get("req_id")
stream_finished = bool(item.get("stream_finished", False))
if req_id is not None and hasattr(self, "_stream_vocoder_cache_by_req"):
with self._stream_audio_cache_lock:
if new_cache_state is None or stream_finished:
self._stream_vocoder_cache_by_req.pop(req_id, None)
else:
self._stream_vocoder_cache_by_req[req_id] = new_cache_state
audio = tts_speech.reshape(-1).to(dtype=torch.float32)
audios[idx] = self._stitch_stream_audio(req_id, audio, stream_finished)
return OmniOutput(text_hidden_states=None, multimodal_outputs={"audio": audios, "sr": srs})
else:
raise ValueError(f"Unsupported model_stage: {self.model_stage}")
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""
CosyVoice3 Code2Wav Stage - Converts speech tokens to audio waveforms.
@@ -11,6 +11,9 @@ This module contains the code2wav (token-to-waveform) stage which uses:
from __future__ import annotations
from collections import Counter
from typing import cast
import numpy as np
import torch
import torch.nn as nn
@@ -27,6 +30,10 @@ from vllm_omni.model_executor.models.cosyvoice3.code2wav_core.hifigan import (
CausalHiFTGenerator,
)
from vllm_omni.model_executor.models.cosyvoice3.code2wav_core.layers import PreLookaheadLayer
from vllm_omni.model_executor.models.cosyvoice3.runtime import (
cosyvoice3_batch_flow_debug,
cosyvoice3_batch_flow_profile,
)
from vllm_omni.transformers_utils.configs.cosyvoice3 import CosyVoice3Config
logger = init_logger(__name__)
@@ -159,6 +166,9 @@ class CosyVoice3Code2Wav(nn.Module):
token_offset_tokens: int = 0,
streaming: bool = True,
finalize: bool = False,
token_lens: torch.Tensor | None = None,
prompt_token_lens: torch.Tensor | None = None,
prompt_feat_lens: torch.Tensor | None = None,
) -> torch.Tensor:
"""Generate mel features via the upstream flow-model inference path."""
flow_weight = next(self.flow_model.parameters())
@@ -169,9 +179,22 @@ class CosyVoice3Code2Wav(nn.Module):
prompt_token = prompt_token.to(device=device, dtype=torch.int32)
prompt_feat = prompt_feat.to(device=device, dtype=dtype)
embedding = embedding.to(device=device, dtype=dtype)
token_len = torch.tensor([token.shape[1]], device=device, dtype=torch.int32)
prompt_token_len = torch.tensor([prompt_token.shape[1]], device=device, dtype=torch.int32)
prompt_feat_len = torch.tensor([prompt_feat.shape[1]], device=device, dtype=torch.int32)
batch_size = int(token.shape[0])
token_len = (
token_lens.to(device=device, dtype=torch.int32)
if token_lens is not None
else torch.full((batch_size,), token.shape[1], device=device, dtype=torch.int32)
)
prompt_token_len = (
prompt_token_lens.to(device=device, dtype=torch.int32)
if prompt_token_lens is not None
else torch.full((batch_size,), prompt_token.shape[1], device=device, dtype=torch.int32)
)
prompt_feat_len = (
prompt_feat_lens.to(device=device, dtype=torch.int32)
if prompt_feat_lens is not None
else torch.full((batch_size,), prompt_feat.shape[1], device=device, dtype=torch.int32)
)
feat, _ = self.flow_model.inference(
token=token,
@@ -192,37 +215,13 @@ class CosyVoice3Code2Wav(nn.Module):
return feat
@torch.inference_mode()
def forward_streaming(
def _stream_hift_from_feat(
self,
token: torch.Tensor,
prompt_token: torch.Tensor,
prompt_feat: torch.Tensor,
embedding: torch.Tensor,
feat: torch.Tensor,
*,
cache_state: dict[str, torch.Tensor] | None = None,
n_timesteps: int = 10,
token_offset_tokens: int = 0,
finalize: bool = False,
) -> tuple[torch.Tensor, dict[str, torch.Tensor] | None]:
"""Decode streaming audio using cumulative mel + emitted-speech offset.
This mirrors upstream CosyVoice3 streaming semantics more closely than
waveform-domain overlap-add: keep a cumulative mel history per request,
re-run causal HiFT on the history, and emit only the newly grown speech
suffix. That preserves causal look-right handling without double
trimming or duplicated overlap at chunk boundaries.
"""
feat = self._forward_mel(
token=token,
prompt_token=prompt_token,
prompt_feat=prompt_feat,
embedding=embedding,
n_timesteps=n_timesteps,
token_offset_tokens=token_offset_tokens,
streaming=True,
finalize=finalize,
)
hift_weight = self.hift.m_source.l_linear.weight
chunk_mel = feat.to(device=hift_weight.device, dtype=hift_weight.dtype)
@@ -257,6 +256,160 @@ class CosyVoice3Code2Wav(nn.Module):
}
return emitted_speech.reshape(emitted_speech.shape[0], 1, -1), new_state
@torch.inference_mode()
def forward_streaming_batch(
self,
items: list[dict[str, object]],
*,
n_timesteps: int = 10,
) -> list[tuple[torch.Tensor, dict[str, torch.Tensor] | 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)
groups: dict[tuple[int, int, int, bool], list[tuple[int, dict[str, object]]]] = {}
for index, item in enumerate(items):
token = item["token"]
prompt_token = item["prompt_token"]
prompt_feat = item["prompt_feat"]
embedding = item["embedding"]
assert isinstance(token, torch.Tensor)
assert isinstance(prompt_token, torch.Tensor)
assert isinstance(prompt_feat, torch.Tensor)
assert isinstance(embedding, torch.Tensor)
key = (
int(prompt_token.shape[1]),
int(prompt_feat.shape[1]),
int(embedding.shape[1]),
bool(item.get("finalize", False)),
)
groups.setdefault(key, []).append((index, item))
if cosyvoice3_batch_flow_debug():
group_summary = {key: len(group) for key, group in groups.items()}
group_size_distribution = Counter(group_summary.values())
batchable_items = sum(size for size in group_summary.values() if size > 1)
logger.info(
"CosyVoice3 code2wav debug: forward_streaming_batch items=%d "
"groups=%s group_size_distribution=%s batchable_items=%d",
len(items),
group_summary,
dict(sorted(group_size_distribution.items())),
batchable_items,
)
for _key, group in groups.items():
if len(group) == 1:
index, item = group[0]
result = self.forward_streaming(
token=item["token"], # type: ignore[arg-type]
prompt_token=item["prompt_token"], # type: ignore[arg-type]
prompt_feat=item["prompt_feat"], # type: ignore[arg-type]
embedding=item["embedding"], # type: ignore[arg-type]
cache_state=item.get("cache_state"), # type: ignore[arg-type]
n_timesteps=n_timesteps,
token_offset_tokens=int(item.get("token_offset_tokens", 0)),
finalize=bool(item.get("finalize", False)),
)
results[index] = result
continue
token_tensors = [item["token"] for _, item in group]
assert all(isinstance(token, torch.Tensor) for token in token_tensors)
token_lens = torch.tensor(
[int(token.shape[1]) for token in token_tensors], # type: ignore[union-attr]
dtype=torch.int32,
)
max_token_len = int(token_lens.max().item())
padded_tokens = []
for token in token_tensors:
assert isinstance(token, torch.Tensor)
if int(token.shape[1]) == max_token_len:
padded_tokens.append(token)
else:
pad = torch.zeros(
(token.shape[0], max_token_len - int(token.shape[1])),
device=token.device,
dtype=token.dtype,
)
padded_tokens.append(torch.cat([token, pad], dim=1))
tokens = torch.cat(padded_tokens, dim=0)
prompt_tokens = torch.cat([item["prompt_token"] for _, item in group], dim=0) # type: ignore[list-item]
prompt_feats = torch.cat([item["prompt_feat"] for _, item in group], dim=0) # type: ignore[list-item]
embeddings = torch.cat([item["embedding"] for _, item in group], dim=0) # type: ignore[list-item]
prompt_token_lens = torch.full((len(group),), prompt_tokens.shape[1], dtype=torch.int32)
prompt_feat_lens = torch.full((len(group),), prompt_feats.shape[1], dtype=torch.int32)
finalize = bool(group[0][1].get("finalize", False))
with cosyvoice3_batch_flow_profile(f"cosyvoice3_flow_batch_b{len(group)}_t{tokens.shape[1]}"):
feat = self._forward_mel(
token=tokens,
prompt_token=prompt_tokens,
prompt_feat=prompt_feats,
embedding=embeddings,
n_timesteps=n_timesteps,
token_offset_tokens=0,
streaming=True,
finalize=finalize,
token_lens=token_lens,
prompt_token_lens=prompt_token_lens,
prompt_feat_lens=prompt_feat_lens,
)
for row, (index, item) in enumerate(group):
trim_mel = max(0, int(item.get("token_offset_tokens", 0))) * int(self.token_mel_ratio)
valid_tokens = int(token_lens[row].item())
if not finalize:
valid_tokens = max(0, valid_tokens - int(self.flow_model.pre_lookahead_len))
valid_mel = valid_tokens * int(self.token_mel_ratio)
row_feat = feat[row : row + 1, :, :valid_mel]
if trim_mel > 0:
row_feat = row_feat[:, :, trim_mel:]
results[index] = self._stream_hift_from_feat(
row_feat,
cache_state=item.get("cache_state"), # type: ignore[arg-type]
finalize=finalize,
)
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)
@torch.inference_mode()
def forward_streaming(
self,
token: torch.Tensor,
prompt_token: torch.Tensor,
prompt_feat: torch.Tensor,
embedding: torch.Tensor,
*,
cache_state: dict[str, torch.Tensor] | None = None,
n_timesteps: int = 10,
token_offset_tokens: int = 0,
finalize: bool = False,
) -> tuple[torch.Tensor, dict[str, torch.Tensor] | None]:
"""Decode streaming audio using cumulative mel + emitted-speech offset.
This mirrors upstream CosyVoice3 streaming semantics more closely than
waveform-domain overlap-add: keep a cumulative mel history per request,
re-run causal HiFT on the history, and emit only the newly grown speech
suffix. That preserves causal look-right handling without double
trimming or duplicated overlap at chunk boundaries.
"""
feat = self._forward_mel(
token=token,
prompt_token=prompt_token,
prompt_feat=prompt_feat,
embedding=embedding,
n_timesteps=n_timesteps,
token_offset_tokens=token_offset_tokens,
streaming=True,
finalize=finalize,
)
return self._stream_hift_from_feat(feat, cache_state=cache_state, finalize=finalize)
@torch.inference_mode()
def forward(
self,
@@ -0,0 +1,31 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Runtime toggles shared by the CosyVoice3 stages."""
import os
from contextlib import nullcontext
import torch
_FALSE_ENV_VALUES = ("0", "false", "False", "")
def _env_flag(name: str, default: str) -> bool:
return os.environ.get(name, default) not in _FALSE_ENV_VALUES
def cosyvoice3_batch_flow_enabled() -> bool:
"""Return whether cross-request Stage-1 flow batching is enabled."""
return _env_flag("COSYVOICE3_BATCH_FLOW", "0")
def cosyvoice3_batch_flow_debug() -> bool:
"""Return whether Stage-1 batching diagnostics are enabled."""
return _env_flag("COSYVOICE3_BATCH_FLOW_DEBUG", "0")
def cosyvoice3_batch_flow_profile(name: str):
"""Create a profiler scope only when batching diagnostics are enabled."""
if cosyvoice3_batch_flow_debug():
return torch.profiler.record_function(name)
return nullcontext()