[Bugfix] Honor request-level seed in VoxCPM2 CFM noise (#7866)

Signed-off-by: tly <2200895168@qq.com>
This commit is contained in:
tlysanhuo authored and GitHub committed 2026-09-20 16:24:38 +08:00
1 parent 6a03e45c34
commit ea62f86718
2 files changed
+170 -10

No files matched your search

@@ -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)
@@ -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"):