diff --git a/recipes/OpenBMB/VoxCPM2.md b/recipes/OpenBMB/VoxCPM2.md index 42f770a00..04106560d 100644 --- a/recipes/OpenBMB/VoxCPM2.md +++ b/recipes/OpenBMB/VoxCPM2.md @@ -141,6 +141,12 @@ The deploy config at is loaded automatically by the model registry (HF `model_type=voxcpm2`). Pass `--deploy-config ` to override. +The `engine_extras.hf_overrides.voxcpm2_runtime_config` section controls +LocDiT CFM generation. `inference_timesteps`, `cfg_value`, and +`cfg_cutoff_ratio` default to `10`, `2.0`, and `1.0`, respectively. +`inference_timesteps` is clamped to at least `2`: with CFG-Zero*, a single +step would skip all estimator calls and return the initial noise. + #### Verification **Server cold-start**: ~60 s from `vllm serve` to `Application startup diff --git a/tests/model_executor/models/voxcpm2/test_runtime_config.py b/tests/model_executor/models/voxcpm2/test_runtime_config.py new file mode 100644 index 000000000..c755da805 --- /dev/null +++ b/tests/model_executor/models/voxcpm2/test_runtime_config.py @@ -0,0 +1,116 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +from dataclasses import dataclass + +import pytest +import torch + +from vllm_omni.model_executor.models.voxcpm2.runtime_config import _VoxCPM2RuntimeConfig + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +@dataclass +class _ModelConfig: + voxcpm2_runtime_config: dict[str, object] + + +@dataclass +class _VllmConfig: + model_config: _ModelConfig + + +@pytest.mark.parametrize( + ("key", "value", "expected"), + [ + ("inference_timesteps", 4, 4), + ("inference_timesteps", "6", 6), + ("inference_timesteps", -3, 2), + ("inference_timesteps", 0, 2), + ("inference_timesteps", 1, 2), + ("inference_timesteps", 2, 2), + ("cfg_value", "1.5", 1.5), + ("cfg_cutoff_ratio", "0.5", 0.5), + ("cfg_cutoff_ratio", -1.0, 0.0), + ("cfg_cutoff_ratio", 2.0, 1.0), + ], +) +def test_generation_config(key: str, value: object, expected: int | float) -> None: + vllm_config = _VllmConfig(_ModelConfig({key: value})) + + config = _VoxCPM2RuntimeConfig.from_vllm_config(vllm_config) + + assert getattr(config, key) == expected + + +def test_generation_config_defaults() -> None: + config = _VoxCPM2RuntimeConfig() + + assert config.inference_timesteps == 10 + assert config.cfg_value == 2.0 + assert config.cfg_cutoff_ratio == 1.0 + + +class _CountingEstimator(torch.nn.Module): + def __init__(self) -> None: + super().__init__() + self.calls = 0 + + def forward(self, x, mu, t, cond, dt): + self.calls += 1 + return mu[:, :1, None].expand_as(x) + + +@pytest.mark.parametrize("value", ["nan", "inf", "-inf"]) +def test_cfg_value_rejects_nonfinite(value: str) -> None: + with pytest.raises(ValueError, match="cfg_value must be finite"): + _VoxCPM2RuntimeConfig.from_vllm_config(_VllmConfig(_ModelConfig({"cfg_value": value}))) + + +@pytest.mark.parametrize("timesteps", [-3, 0, 1, 2, 4, 10]) +@pytest.mark.parametrize("batch_size", [1, 3]) +@pytest.mark.parametrize("cfg_value", [1.5, 2.0]) +@pytest.mark.parametrize("cutoff", [0.0, 1.0]) +def test_generation_config_runs_estimator(timesteps: int, batch_size: int, cfg_value: float, cutoff: float) -> None: + from vllm_omni.model_executor.models.voxcpm2.voxcpm2_talker import ( + _CFMBufferManager, + _optimized_solve_euler_with_noise, + ) + + config = _VoxCPM2RuntimeConfig.from_vllm_config( + _VllmConfig( + _ModelConfig({"inference_timesteps": timesteps, "cfg_value": cfg_value, "cfg_cutoff_ratio": cutoff}) + ) + ) + cfm = torch.nn.Module() + cfm.estimator = _CountingEstimator() + buffers = _CFMBufferManager( + device=torch.device("cpu"), + dtype=torch.float32, + feat_dim=2, + patch_size=2, + dit_hidden_size=2, + max_batch_size=batch_size, + ) + noise = torch.ones(batch_size, 2, 2) + output = _optimized_solve_euler_with_noise( + cfm, + mu=torch.ones(batch_size, 2), + patch_size=2, + cond=torch.zeros_like(noise), + noise=noise, + n_timesteps=config.inference_timesteps, + cfg_value=config.cfg_value, + buffers=buffers, + cfg_cutoff_ratio=config.cfg_cutoff_ratio, + ) + + assert cfm.estimator.calls == max(2, timesteps) - 1 + assert torch.isfinite(output).all() + assert not torch.equal(output, noise) + torch.testing.assert_close(noise, torch.ones_like(noise)) + if cutoff == 1.0: + t_span = buffers.get_t_span(config.inference_timesteps) + expected = noise - cfg_value * (t_span[1] - t_span[-1]) + torch.testing.assert_close(output, expected) diff --git a/vllm_omni/deploy/voxcpm2.yaml b/vllm_omni/deploy/voxcpm2.yaml index 63996f1b7..d5b04d5f6 100644 --- a/vllm_omni/deploy/voxcpm2.yaml +++ b/vllm_omni/deploy/voxcpm2.yaml @@ -31,6 +31,12 @@ stages: engine_extras: hf_overrides: voxcpm2_runtime_config: + # LocDiT generation settings affect synthesis quality. Keep the defaults + # unless alternative values have been validated for your workload. + inference_timesteps: 10 + cfg_value: 2.0 + cfg_cutoff_ratio: 1.0 + audio_emit_every: 1 vae_decode_every: 3 enable_batched_cfm: true diff --git a/vllm_omni/model_executor/models/voxcpm2/runtime_config.py b/vllm_omni/model_executor/models/voxcpm2/runtime_config.py index 207be70f2..9e67770ac 100644 --- a/vllm_omni/model_executor/models/voxcpm2/runtime_config.py +++ b/vllm_omni/model_executor/models/voxcpm2/runtime_config.py @@ -4,6 +4,7 @@ from __future__ import annotations import dataclasses +import math from typing import Any from vllm.logger import init_logger @@ -24,6 +25,8 @@ class _VoxCPM2RuntimeConfig: enable_loc_dit_skip_qkv_contig: bool = True enable_loc_dit_reduce_overhead_no_cg: bool = False enable_loc_dit_fullgraph_no_cg: bool = False + inference_timesteps: int = 10 + cfg_value: float = 2.0 cfg_cutoff_ratio: float = 1.0 decode_graph_capture_policy: str = "all" enable_vae_cuda_graph: bool = False @@ -116,10 +119,14 @@ class _VoxCPM2RuntimeConfig: return bool(value) if isinstance(default, int) and not isinstance(default, bool): value = int(value) + if key == "inference_timesteps": + return max(2, value) if key in {"audio_emit_every", "vae_decode_every", "batched_fsq_fusion_max_batch"}: return max(1, value) return value if isinstance(default, float): + if key == "cfg_value" and not math.isfinite(float(value)): + raise ValueError("VoxCPM2 cfg_value must be finite.") if key == "cfg_cutoff_ratio": return min(1.0, max(0.0, float(value))) return float(value) diff --git a/vllm_omni/model_executor/models/voxcpm2/voxcpm2_talker.py b/vllm_omni/model_executor/models/voxcpm2/voxcpm2_talker.py index 4894ac5f7..a9ea04e94 100644 --- a/vllm_omni/model_executor/models/voxcpm2/voxcpm2_talker.py +++ b/vllm_omni/model_executor/models/voxcpm2/voxcpm2_talker.py @@ -922,8 +922,8 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): self._tts.residual_lm = None torch.accelerator.empty_cache() - self._inference_timesteps = 10 - self._cfg_value = 2.0 + self._inference_timesteps = self._runtime_config.inference_timesteps + self._cfg_value = self._runtime_config.cfg_value self._cfg_cutoff_ratio = self._runtime_config.cfg_cutoff_ratio # Number of trailing latent frames to keep as VAE receptive-field context # for sliding-window streaming decode. 12 matches the nanovllm reference