diff --git a/tests/model_executor/models/voxcpm2/test_request_seed_cfm_noise.py b/tests/model_executor/models/voxcpm2/test_request_seed_cfm_noise.py new file mode 100644 index 000000000..df49667f8 --- /dev/null +++ b/tests/model_executor/models/voxcpm2/test_request_seed_cfm_noise.py @@ -0,0 +1,116 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Request-level seeds must control the VoxCPM2 CFM noise stream (#7658). + +``/v1/audio/speech`` folds ``request.seed`` into ``SamplingParams.seed``, which +reaches vLLM's sampler but never the CFM (flow-matching) noise draws: every +noise site used the global RNG, so identical text + seed returned different +audio. A seeded request now carries a dedicated ``torch.Generator`` in its +``_RequestState``; these tests pin the noise semantics without instantiating +the full model. +""" + +from __future__ import annotations + +import functools + +import pytest + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +torch = pytest.importorskip("torch") + + +@functools.lru_cache(maxsize=1) +def _talker_cls(): + """Defer talker import (pulls vLLM model_executor) until first use.""" + from vllm_omni.model_executor.models.voxcpm2.voxcpm2_talker import ( + VoxCPM2TalkerForConditionalGeneration, + ) + + return VoxCPM2TalkerForConditionalGeneration + + +def _make_talker(deterministic_cfm_noise: bool = False): + cls = _talker_cls() + talker = cls.__new__(cls) + talker._deterministic_cfm_noise = deterministic_cfm_noise + return talker + + +def _make_state(request_id: str = "req-1", seed: int | None = 42): + from vllm_omni.model_executor.models.voxcpm2.voxcpm2_talker import _RequestState + + state = _RequestState(request_id=request_id) + if seed is not None: + state.cfm_generator = torch.Generator(device="cpu") + state.cfm_generator.manual_seed(seed) + return state + + +def test_seeded_noise_is_deterministic_across_requests(): + talker = _make_talker() + + a1 = torch.empty(1, 128, 5) + a2 = torch.empty(1, 128, 5) + talker._fill_deterministic_cfm_noise_for_state(_make_state(seed=7), a1) + talker._fill_deterministic_cfm_noise_for_state(_make_state(seed=7), a2) + + assert torch.equal(a1, a2) + + +def test_seed_value_changes_the_noise_stream(): + talker = _make_talker() + + a = torch.empty(1, 128, 5) + b = torch.empty(1, 128, 5) + talker._fill_deterministic_cfm_noise_for_state(_make_state(seed=7), a) + talker._fill_deterministic_cfm_noise_for_state(_make_state(seed=8), b) + + assert not torch.equal(a, b) + + +def test_seeded_noise_advances_with_each_draw(): + talker = _make_talker() + state = _make_state(seed=7) + + first = torch.empty(1, 128, 5) + second = torch.empty(1, 128, 5) + talker._fill_deterministic_cfm_noise_for_state(state, first) + talker._fill_deterministic_cfm_noise_for_state(state, second) + + assert not torch.equal(first, second) + + +def test_seeded_noise_is_batch_position_independent(): + """A seeded row draws the same bytes whether it sits alone or mid-batch.""" + talker = _make_talker() + + solo = torch.empty(1, 128, 5) + talker._fill_deterministic_cfm_noise_for_state(_make_state(seed=7), solo) + + batch = torch.empty(3, 128, 5) + talker._fill_deterministic_cfm_noise_for_state(_make_state(seed=7), batch[1:2]) + + assert torch.equal(solo, batch[1:2]) + + +def test_has_deterministic_noise_prefers_request_seed_over_flag(): + unflagged = _make_talker(deterministic_cfm_noise=False) + assert unflagged._has_deterministic_cfm_noise(_make_state(seed=1)) + assert not unflagged._has_deterministic_cfm_noise(_make_state(seed=None)) + + flagged = _make_talker(deterministic_cfm_noise=True) + assert flagged._has_deterministic_cfm_noise(_make_state(seed=None)) + + +def test_request_seed_wins_over_replay_hash(): + """The per-request generator keys noise off the seed, not the request id.""" + talker = _make_talker(deterministic_cfm_noise=True) + + r1 = torch.empty(1, 128, 5) + r2 = torch.empty(1, 128, 5) + talker._fill_deterministic_cfm_noise_for_state(_make_state(request_id="111", seed=7), r1) + talker._fill_deterministic_cfm_noise_for_state(_make_state(request_id="222", seed=7), r2) + + assert torch.equal(r1, r2) diff --git a/vllm_omni/model_executor/models/voxcpm2/voxcpm2_talker.py b/vllm_omni/model_executor/models/voxcpm2/voxcpm2_talker.py index f20e86a89..47c6f1dd3 100644 --- a/vllm_omni/model_executor/models/voxcpm2/voxcpm2_talker.py +++ b/vllm_omni/model_executor/models/voxcpm2/voxcpm2_talker.py @@ -77,6 +77,7 @@ class _ForwardContextLike(Protocol): class VoxCPM2PreprocessInput(TypedDict, total=False): additional_information: dict[str, Any] request_id: str + _omni_seed: int | None text_token_ids: list[list[int]] reference_audio: object ref_audio: object @@ -250,6 +251,10 @@ def _encode_raw_audio( @dataclasses.dataclass class _RequestState: request_id: str + # Per-request CFM noise generator, seeded once from the request's seed at + # first prefill chunk (runner passes it as ``_omni_seed``). None means the + # request carried no seed and draws noise from the global RNG stream. + cfm_generator: torch.Generator | None = None curr_embed_for_next: torch.Tensor | None = None prev_feat_embed: torch.Tensor | None = None curr_prefix_feat_cond: torch.Tensor | None = None @@ -1706,8 +1711,8 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): with _NvtxRange("voxcpm2.cfm.graph_copy_cond"): graph.cond.copy_(cond) with _NvtxRange("voxcpm2.cfm.graph_noise"): - if self._deterministic_cfm_noise: - self._fill_deterministic_cfm_noise(state, graph.noise) + if self._has_deterministic_cfm_noise(state): + self._fill_deterministic_cfm_noise_for_state(state, graph.noise) else: graph.noise.normal_() with _NvtxRange("voxcpm2.cfm.graph_replay"): @@ -1928,6 +1933,11 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): g.prefix_feat_cond[last : last + 1].expand(graph_size - num_reqs, -1, -1) ) g.cfm_noise.normal_() + # Seeded rows overwrite their slice from their own generator, so the + # noise a seeded request sees does not depend on batch composition. + for i, state in enumerate(states[:num_reqs]): + if state.cfm_generator is not None: + g.cfm_noise[i : i + 1].normal_(generator=state.cfm_generator) self._perf.stop("unified.copy_inputs") self._perf.start("unified.replay") @@ -2370,14 +2380,14 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): with _NvtxRange("voxcpm2.cfm"): if self._cfm_buffers is not None: if self._enable_cfm_cuda_graph and dit_h.device.type == current_omni_platform.device_type: - if self._deterministic_cfm_noise and not self._enable_cfm_prealloc_output: + if self._has_deterministic_cfm_noise(state) and not self._enable_cfm_prealloc_output: graph = self._get_cfm_cuda_graph(dit_h, cond) with _NvtxRange("voxcpm2.cfm.graph_copy_mu"): graph.mu.copy_(dit_h) with _NvtxRange("voxcpm2.cfm.graph_copy_cond"): graph.cond.copy_(cond) with _NvtxRange("voxcpm2.cfm.graph_noise"): - self._fill_deterministic_cfm_noise(state, graph.noise) + self._fill_deterministic_cfm_noise_for_state(state, graph.noise) with _NvtxRange("voxcpm2.cfm.graph_replay"): graph.graph.replay() with _NvtxRange("voxcpm2.cfm.graph_output_clone"): @@ -2385,9 +2395,9 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): if not self._enable_cfm_prealloc_output: return self._run_cfm_cuda_graph(dit_h, cond).transpose(1, 2) return self._run_cfm_cuda_graph_to_state_buffer(state, dit_h, cond) - if self._deterministic_cfm_noise: + if self._has_deterministic_cfm_noise(state): noise = self._cfm_buffers.noise[: dit_h.shape[0]] - self._fill_deterministic_cfm_noise(state, noise) + self._fill_deterministic_cfm_noise_for_state(state, noise) return _optimized_solve_euler_with_noise( self.tts.feat_decoder, dit_h, @@ -2419,6 +2429,23 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): cfg_value=self._cfg_value, ).transpose(1, 2) + def _has_deterministic_cfm_noise(self, state: _RequestState) -> bool: + """Whether this request's CFM noise is drawn deterministically.""" + return state.cfm_generator is not None or self._deterministic_cfm_noise + + def _fill_deterministic_cfm_noise_for_state(self, state: _RequestState, out: torch.Tensor) -> None: + """Deterministically fill ``out`` for a request with deterministic noise. + + A request-level seed wins: the noise stream is a pure function of the + seed, so identical text + seed reproduces byte-identical audio + regardless of batch composition or request id. The replay-only + ``deterministic_cfm_noise`` hash applies otherwise. + """ + if state.cfm_generator is not None: + out.normal_(generator=state.cfm_generator) + else: + self._fill_deterministic_cfm_noise(state, out) + def _fill_deterministic_cfm_noise(self, state: _RequestState, out: torch.Tensor) -> None: """Fill CFM noise deterministically for benchmark replay only.""" request_key = state.request_id.split("_", 1)[0] @@ -2462,10 +2489,14 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): batched_cond = torch.cat(conds, dim=0) b = batched_dit_h.shape[0] - if self._deterministic_cfm_noise and self._cfm_buffers is not None: + any_seeded = any(state.cfm_generator is not None for state, _, _ in batch) + if (self._deterministic_cfm_noise or any_seeded) and self._cfm_buffers is not None: noise = self._cfm_buffers.noise[:b] for i, (state, _, _) in enumerate(batch): - self._fill_deterministic_cfm_noise(state, noise[i : i + 1]) + if self._has_deterministic_cfm_noise(state): + self._fill_deterministic_cfm_noise_for_state(state, noise[i : i + 1]) + else: + noise[i : i + 1].normal_() else: noise = torch.randn( b, @@ -2602,10 +2633,14 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): with _NvtxRange("voxcpm2.dit_proj"): dit_h = dit_proj(lm_h, batch_out) cond = pfc.transpose(1, 2).contiguous() - if self._deterministic_cfm_noise and self._cfm_buffers is not None: + any_seeded = any(state.cfm_generator is not None for state in states) + if (self._deterministic_cfm_noise or any_seeded) and self._cfm_buffers is not None: noise = self._cfm_buffers.noise[: dit_h.size(0)] for i, state in enumerate(states): - self._fill_deterministic_cfm_noise(state, noise[i : i + 1]) + if self._has_deterministic_cfm_noise(state): + self._fill_deterministic_cfm_noise_for_state(state, noise[i : i + 1]) + else: + noise[i : i + 1].normal_() pred_feat = _optimized_solve_euler_with_noise( self.tts.feat_decoder, dit_h, @@ -3152,6 +3187,15 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): state.precomputed_stop_logits = None state.precomputed_is_stopping = None state.last_audio_patch_gpu = None + # SamplingParams.seed reaches vLLM's own sampler but never the + # CFM noise draws below, so a seeded request threads its seed + # into a dedicated generator here (same pattern as gepard). + state.cfm_noise_step = 0 + state.cfm_generator = None + seed = info_dict.get("_omni_seed") + if seed is not None: + state.cfm_generator = torch.Generator(device=self._device) + state.cfm_generator.manual_seed(int(seed)) if not hasattr(state, "pending_audio_chunks_gpu"): state.pending_audio_chunks_gpu = [] if not hasattr(state, "pending_audio_copies"):