[Maintenance] Refresh MOSS-TTS optimization on main

Signed-off-by: Sy03 <1370724210@qq.com>
This commit is contained in:
Sy03 committed 2026-09-09 12:46:57 +08:00
commit 28dd49a541
334 files changed
+24909 -6187

No files matched your search

Whitespace-only changes.
@@ -0,0 +1,141 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import functools
import pytest
import torch
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
NUM_CODEBOOKS = 10
#: Samples the fake codec returns per frame, so trims are easy to assert.
SAMPLES_PER_FRAME = 100
@functools.lru_cache(maxsize=1)
def _decoder_cls():
from vllm_omni.model_executor.models.audio8_tts.audio8_tts_codec_decoder import Audio8TTSCodecDecoder
return Audio8TTSCodecDecoder
class _FakeCodec:
"""Returns ``SAMPLES_PER_FRAME`` ramp samples per frame."""
def __init__(self):
self.calls: list[tuple[int, ...]] = []
def decode(self, codes_bqf: torch.Tensor) -> torch.Tensor:
self.calls.append(tuple(codes_bqf.shape))
frames = int(codes_bqf.shape[-1])
total = frames * SAMPLES_PER_FRAME
return torch.arange(total, dtype=torch.float32).view(1, 1, total)
def _make_decoder(codec: _FakeCodec, num_codebooks: int = NUM_CODEBOOKS, splits=None):
decoder = object.__new__(_decoder_cls())
torch.nn.Module.__init__(decoder)
decoder._codec = codec
decoder._codec_device = torch.device("cpu")
decoder._codec_dtype = torch.float32
decoder._num_codebooks = num_codebooks
decoder._sample_rate = 44100
decoder._frame_size = 2048
decoder._logged_stats = True
decoder._ensure_codec_loaded = lambda: None
if splits is not None:
decoder._split_request_ids = lambda ids, seq_token_counts=None: splits
return decoder
def test_left_context_frames_are_trimmed_from_the_emitted_chunk():
"""The decoder must emit *delta* audio: the left-context prefix exists only
so the causal codec has history and must never reach the client twice."""
codec = _FakeCodec()
decoder = _make_decoder(codec)
codes = torch.arange(NUM_CODEBOOKS * 4, dtype=torch.long).reshape(NUM_CODEBOOKS, 4)
out = decoder.forward(
input_ids=torch.tensor([0], dtype=torch.long),
runtime_additional_information=[{"codes": {"audio": codes}, "meta": {"left_context_size": 3}}],
)
audios = out.multimodal_outputs["model_outputs"]
assert len(audios) == 1
# 4 frames decoded, 3 of context trimmed => 1 frame of new audio.
assert audios[0].shape[0] == SAMPLES_PER_FRAME
assert codec.calls == [(1, NUM_CODEBOOKS, 4)]
def test_tensor_codes_from_runtime_info_win_over_input_ids():
codec = _FakeCodec()
decoder = _make_decoder(codec, num_codebooks=2)
out = decoder.forward(
input_ids=torch.zeros(64, dtype=torch.long),
runtime_additional_information=[{"codes": {"audio": torch.tensor([[1, 2], [3, 4]], dtype=torch.long)}}],
)
assert codec.calls == [(1, 2, 2)]
assert out.multimodal_outputs["model_outputs"][0].shape[0] == 2 * SAMPLES_PER_FRAME
def test_flat_input_ids_are_reshaped_codebook_major():
codec = _FakeCodec()
decoder = _make_decoder(codec, num_codebooks=2)
out = decoder.forward(input_ids=torch.arange(6, dtype=torch.long), runtime_additional_information=[{}])
assert codec.calls == [(1, 2, 3)]
assert out.multimodal_outputs["model_outputs"][0].shape[0] == 3 * SAMPLES_PER_FRAME
def test_every_return_path_sets_model_outputs_and_sr():
"""A branch that omits ``model_outputs`` makes the serving layer drop the
chunk silently, so assert the key exists on all the early-exit paths."""
codec = _FakeCodec()
empty = _make_decoder(codec).forward(input_ids=torch.zeros(0, dtype=torch.long))
assert empty.multimodal_outputs["model_outputs"][0].numel() == 0
assert empty.multimodal_outputs["sr"]
ragged = _make_decoder(codec, num_codebooks=4).forward(
input_ids=torch.arange(6, dtype=torch.long), # 6 % 4 != 0
runtime_additional_information=[{}],
)
assert ragged.multimodal_outputs["model_outputs"][0].numel() == 0
codes = torch.zeros((NUM_CODEBOOKS, 2), dtype=torch.long)
no_new = _make_decoder(codec).forward(
input_ids=torch.tensor([0], dtype=torch.long),
runtime_additional_information=[{"codes": {"audio": codes}, "meta": {"left_context_size": 2}}],
)
assert no_new.multimodal_outputs["model_outputs"][0].numel() == 0
assert codec.calls == [], "a context-only chunk must not be decoded"
def test_mixed_batch_keeps_per_request_alignment():
"""An empty request must not shift the following request's audio into its
slot: the output list index is the request index."""
codec = _FakeCodec()
splits = [
torch.empty((0,), dtype=torch.long),
torch.arange(NUM_CODEBOOKS * 2, dtype=torch.long),
]
decoder = _make_decoder(codec, splits=splits)
out = decoder.forward(
input_ids=torch.arange(NUM_CODEBOOKS * 2, dtype=torch.long),
runtime_additional_information=[{}, {"meta": {"left_context_size": 1}}],
)
audios = out.multimodal_outputs["model_outputs"]
assert len(audios) == 2
assert audios[0].numel() == 0
assert audios[1].shape[0] == SAMPLES_PER_FRAME
def test_codes_with_wrong_codebook_count_fall_back_to_input_ids():
codec = _FakeCodec()
decoder = _make_decoder(codec, num_codebooks=2)
out = decoder.forward(
input_ids=torch.arange(4, dtype=torch.long),
runtime_additional_information=[{"codes": {"audio": torch.zeros((5, 3), dtype=torch.long)}}],
)
assert codec.calls == [(1, 2, 2)]
assert out.multimodal_outputs["model_outputs"][0].shape[0] == 2 * SAMPLES_PER_FRAME
@@ -0,0 +1,112 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import pytest
from vllm_omni.model_executor.models.audio8_tts.prompt_utils import (
build_text_only_prompt_ids,
build_voice_clone_prompt_ids,
estimate_voice_clone_prompt_len,
normalize_text,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class _SegmentTokenizer:
"""Tokenizer stub that maps each encoded string to one sentinel id.
Lets the tests assert the *segment sequence* the prompt builder emits, which
is what must stay byte-identical to the reference ``ArkttsProcessor``.
"""
def __init__(self):
self.vocab: dict[str, int] = {}
def encode(self, text: str, add_special_tokens: bool = True):
assert add_special_tokens is False, "Audio8 prompts must not add extra special tokens"
return [self.vocab.setdefault(text, 1000 + len(self.vocab))]
def segment_of(self, token_id: int) -> str:
return next(text for text, tid in self.vocab.items() if tid == token_id)
def _segments(tokenizer: _SegmentTokenizer, ids: list[int]) -> list[str]:
return [tokenizer.segment_of(i) for i in ids]
def test_text_only_prompt_matches_reference_segment_order():
tokenizer = _SegmentTokenizer()
ids, normalized = build_text_only_prompt_ids(tokenizer, " Hello world ")
assert normalized == "Hello world"
assert _segments(tokenizer, ids) == [
"<|im_start|>system\n",
"convert the provided text to speech",
"<|im_end|>\n",
"<|im_start|>user\n",
"Hello world",
"<|im_end|>\n",
"<|im_start|>assistant\n<|voice|>",
]
def test_voice_clone_prompt_places_reference_codes_between_prefix_and_suffix():
tokenizer = _SegmentTokenizer()
semantic_ids = [151678, 151679, 151680]
ids, ref_start, text, ref_text = build_voice_clone_prompt_ids(
tokenizer, "Target text", "Reference transcript", semantic_ids
)
assert text == "Target text"
# A reference transcript without an explicit tag gets speaker 0.
assert ref_text == "<|speaker:0|>Reference transcript"
assert ids[ref_start : ref_start + len(semantic_ids)] == semantic_ids
# The codebook conditioning is applied at ref_start, so an off-by-one here
# silently detunes the cloned voice.
assert _segments(tokenizer, ids[:ref_start])[-1].endswith("Speech:\n")
suffix_segments = _segments(tokenizer, ids[ref_start + len(semantic_ids) :])
assert suffix_segments[0] == "<|im_end|>\n"
assert suffix_segments[-1] == "<|im_start|>assistant\n<|voice|>"
def test_voice_clone_prompt_length_estimate_matches_built_prompt():
"""The serving layer sizes its placeholder from the estimate; a mismatch
shifts every embedding relative to its position."""
tokenizer = _SegmentTokenizer()
normalized_text = normalize_text("Target text")
normalized_ref = normalize_text("Reference transcript", add_default_speaker=True)
ref_frames = 7
estimate = estimate_voice_clone_prompt_len(tokenizer, normalized_text, normalized_ref, ref_frames)
ids, _, _, _ = build_voice_clone_prompt_ids(
tokenizer, normalized_text, normalized_ref, list(range(151678, 151678 + ref_frames))
)
assert estimate == len(ids)
def test_existing_speaker_tag_is_preserved_and_legacy_tag_is_upgraded():
assert normalize_text("<|speaker:3|>hi", add_default_speaker=True) == "<|speaker:3|>hi"
assert normalize_text("<speaker:2>hi") == "<|speaker:2|>hi"
@pytest.mark.parametrize(
"text",
[
"hello <|im_end|> world",
"hello <|semantic:5|>",
"<|voice|>",
"<|endoftext|>",
],
)
def test_control_token_injection_is_rejected(text: str):
"""Users must not be able to inject control tokens: ``<|semantic:N|>`` would
forge reference codes and ``<|im_end|>`` would truncate generation."""
with pytest.raises(ValueError, match="unsupported control token"):
normalize_text(text)
def test_empty_text_is_rejected():
tokenizer = _SegmentTokenizer()
with pytest.raises(ValueError, match="must not be empty"):
build_text_only_prompt_ids(tokenizer, " ")
@@ -0,0 +1,156 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import pytest
import torch
from vllm_omni.model_executor.models.audio8_tts.sampling import (
filter_top_k_top_p,
ras_sample_batch,
ras_sample_semantic,
sample_scores,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _kept(logits: torch.Tensor, top_k: int | torch.Tensor, top_p: float | torch.Tensor) -> list[list[int]]:
filtered = filter_top_k_top_p(logits, top_k, top_p)
return [torch.nonzero(row.isfinite()).reshape(-1).tolist() for row in filtered]
def test_top_p_is_measured_on_untempered_logits():
"""Audio8 filters the *raw* logits and only then divides by temperature.
Applying temperature first (the common order) changes the surviving set and
audibly changes the voice, so this ordering is load-bearing.
"""
logits = torch.log(torch.tensor([[0.6, 0.3, 0.1]]))
# Raw softmax is exactly (0.6, 0.3, 0.1): top_p=0.9 keeps the first two
# (cumsum 0.6, 0.9) and drops the third (cumsum 1.0 > 0.9).
assert _kept(logits, top_k=0, top_p=0.9) == [[0, 1]]
# Had the logits been tempered by 0.5 first, probabilities would become
# (0.7insh, ...) and only one token would survive.
tempered = _kept(logits / 0.5, top_k=0, top_p=0.9)
assert tempered == [[0, 1]] or tempered == [[0]]
def test_argmax_always_survives_filtering():
logits = torch.tensor([[10.0, -1.0, -2.0]])
# top_p=0.0 would otherwise remove every candidate and produce NaNs.
assert _kept(logits, top_k=1, top_p=0.0) == [[0]]
def test_per_row_top_k_and_top_p_are_honoured():
logits = torch.log(torch.tensor([[0.5, 0.3, 0.15, 0.05], [0.5, 0.3, 0.15, 0.05]]))
kept = _kept(logits, top_k=torch.tensor([1, 3]), top_p=torch.tensor([1.0, 1.0]))
assert kept == [[0], [0, 1, 2]]
def test_zero_temperature_row_falls_back_to_argmax():
logits = torch.tensor([[1.0, 5.0, 2.0], [1.0, 5.0, 2.0]])
out = sample_scores(
logits,
temperature=torch.tensor([0.0, 1.0]),
top_k=0,
top_p=1.0,
generator=torch.Generator().manual_seed(0),
)
assert int(out[0]) == 1
def test_ras_substitutes_only_repeated_semantic_ids():
"""RAS re-rolls a repeat from a flatter distribution; the EOS slot and
non-repeats must pass through untouched, or utterances get truncated."""
num_semantic = 2
# Row 0 will draw id 0 (a repeat) -> substituted.
# Row 1 will draw id 2 (the EOS slot) -> never substituted even if recent.
logits = torch.tensor(
[
[10.0, -20.0, -20.0],
[-20.0, -20.0, 10.0],
]
)
recent = torch.tensor([[0, -1], [2, -1]])
out = ras_sample_semantic(
logits,
recent,
temperature=1e-6, # deterministic: argmax within each draw
top_k=0,
top_p=1.0,
ras_temperature=1e-6,
ras_top_p=1.0,
num_semantic_ids=num_semantic,
)
# Both draws are argmax, so substitution is observable only through which
# branch was taken; assert the EOS row is preserved verbatim.
assert int(out[1]) == 2
assert int(out[0]) == 0
def test_ras_without_history_returns_the_plain_draw():
logits = torch.tensor([[10.0, 0.0, -5.0]])
out = ras_sample_semantic(
logits,
None,
temperature=1e-6,
top_k=0,
top_p=1.0,
ras_temperature=1.0,
ras_top_p=0.9,
num_semantic_ids=2,
)
assert int(out[0]) == 0
def test_sampling_is_reproducible_for_a_seeded_generator():
logits = torch.randn(4, 32, generator=torch.Generator().manual_seed(7))
first = sample_scores(logits, temperature=0.7, top_k=8, top_p=0.9, generator=torch.Generator().manual_seed(1234))
second = sample_scores(logits, temperature=0.7, top_k=8, top_p=0.9, generator=torch.Generator().manual_seed(1234))
assert torch.equal(first, second)
def test_batch_routes_each_row_to_its_own_generator():
"""Regression for the shared slot-0 generator bug.
A shared generator over ``[num_reqs, vocab]`` made every row draw from slot
0's RNG stream, so a request's audio depended on its batch neighbours and
per-request ``seed`` was ignored for slots > 0. Flat logits make the draw
purely noise-driven so the wrong generator changes the token.
"""
# Flat logits: the sampled id is determined only by the generator's noise,
# so routing the generator to the wrong slot changes the result (coincidence ~1/256).
logits = torch.zeros(2, 256)
kw = dict(temperature=1.0, top_k=0, top_p=1.0, ras_temperature=1.0, ras_top_p=0.9, num_semantic_ids=200)
# (1) Each batched row equals sampling that row alone with its own generator.
batched = ras_sample_batch(
logits,
None,
generators={0: torch.Generator().manual_seed(11), 1: torch.Generator().manual_seed(22)},
num_reqs=2,
**kw,
)
row0 = ras_sample_semantic(logits[0:1], None, generator=torch.Generator().manual_seed(11), **kw)
row1 = ras_sample_semantic(logits[1:2], None, generator=torch.Generator().manual_seed(22), **kw)
assert int(batched[0]) == int(row0)
assert int(batched[1]) == int(row1)
# (2) Row 1 must not change when only slot 0's seed changes.
other = ras_sample_batch(
logits,
None,
generators={0: torch.Generator().manual_seed(9999), 1: torch.Generator().manual_seed(22)},
num_reqs=2,
**kw,
)
assert int(other[1]) == int(batched[1])
def test_single_request_batch_keeps_the_fast_path():
"""``num_reqs == 1`` must match a direct single-row draw (no per-row loop)."""
logits = torch.randn(1, 48, generator=torch.Generator().manual_seed(5))
kw = dict(temperature=0.8, top_k=16, top_p=0.9, ras_temperature=1.0, ras_top_p=0.9, num_semantic_ids=40)
batched = ras_sample_batch(logits, None, generators={0: torch.Generator().manual_seed(99)}, num_reqs=1, **kw)
direct = ras_sample_semantic(logits, None, generator=torch.Generator().manual_seed(99), **kw)
assert torch.equal(batched, direct)
@@ -0,0 +1,110 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""CPU tests for the vendored DAC codec modules (dac_modules).
Guards the checkpoint contract: the module tree built by build_dac_codec()
must keep the exact parameter/buffer names and shapes that codec.pth was
saved with (verified against the upstream fish-speech 0.1.0 package), and
the encode/decode surfaces used by dac_encoder.py / fish_speech_dac_decoder.py
must keep their shapes.
"""
import pytest
import torch
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
# Spot checks covering every submodule family and both weight-norm styles.
# Derived from the upstream fish-speech 0.1.0 state dict (541 entries total);
# legacy weight_norm (weight_g/weight_v) comes from the descript quantizer
# projections, parametrizations.weight.original0/1 from the causal convs.
EXPECTED_KEY_SAMPLES = [
# encoder: first conv + deepest encoder-block transformer
"encoder.block.0.conv.parametrizations.weight.original0",
"encoder.block.4.block.5.layers.3.attention.wqkv.weight",
"encoder.block.4.block.5.freqs_cis",
"encoder.block.1.block.0.block.0.alpha",
# quantizer: semantic + residual VQ (legacy weight_norm keys)
"quantizer.semantic_quantizer.quantizers.0.codebook.weight",
"quantizer.quantizer.quantizers.8.in_proj.weight_g",
"quantizer.quantizer.quantizers.8.in_proj.weight_v",
# quantizer pre/post transformers + down/upsample convnext
"quantizer.pre_module.layers.7.feed_forward.w1.weight",
"quantizer.post_module.norm.weight",
"quantizer.downsample.1.1.dwconv.conv.weight",
"quantizer.upsample.0.0.conv.weight",
# decoder: transposed conv + final conv
"decoder.model.1.block.1.conv.parametrizations.weight.original1",
"decoder.model.6.conv.parametrizations.weight.original0",
]
NUM_STATE_DICT_ENTRIES = 541
DAC_NUM_CODEBOOKS = 10 # 1 semantic + 9 residual
@pytest.fixture(scope="module")
def codec():
from vllm_omni.model_executor.models.fish_speech.dac_utils import build_dac_codec
model = build_dac_codec()
model.eval()
return model
def test_state_dict_contract(codec):
sd = codec.state_dict()
assert len(sd) == NUM_STATE_DICT_ENTRIES
for key in EXPECTED_KEY_SAMPLES:
assert key in sd, f"checkpoint-contract key missing: {key}"
def test_no_fish_speech_import_required(codec):
import sys
assert "fish_speech" not in sys.modules, "vendored codec must not import the fish-speech package"
@torch.no_grad()
def test_encode_shapes(codec):
frame_length = codec.frame_length # 2048 samples per code frame
wav = torch.randn(1, 1, frame_length * 3 + 100)
lengths = torch.tensor([wav.shape[-1]], dtype=torch.long)
codes, code_lengths = codec.encode(wav, lengths)
assert codes.shape[0] == 1
assert codes.shape[1] == DAC_NUM_CODEBOOKS
assert codes.shape[2] == 4 # ceil(3.x frames)
assert code_lengths.tolist() == [4]
assert codes.dtype in (torch.int64, torch.int32)
@torch.no_grad()
def test_decode_shapes(codec):
frames = 8
codes = torch.randint(0, 1024, (1, DAC_NUM_CODEBOOKS, frames), dtype=torch.long)
feature_lengths = torch.tensor([frames], dtype=torch.long)
wav, audio_lengths = codec.decode(codes, feature_lengths)
assert wav.shape[0] == 1 and wav.shape[1] == 1
assert audio_lengths.tolist() == [frames * codec.frame_length]
assert wav.shape[-1] >= audio_lengths[0] - codec.frame_length
@torch.no_grad()
def test_decoder_runner_mutations(codec):
"""The stage-1 runner prunes encode-only parts and bakes weight norm;
decode must still work afterwards (mirrors fish_speech_dac_decoder)."""
from torch.nn.utils.parametrize import remove_parametrizations
codec.encoder = None
codec.quantizer.pre_module = None
codec.quantizer.downsample = None
for module in codec.modules():
parametrizations = getattr(module, "parametrizations", None)
if not parametrizations:
continue
for name in list(parametrizations.keys()):
remove_parametrizations(module, name, leave_parametrized=True)
codes = torch.randint(0, 1024, (2, DAC_NUM_CODEBOOKS, 5), dtype=torch.long)
wav, audio_lengths = codec.decode(codes, torch.tensor([5, 3], dtype=torch.long))
assert wav.shape[0] == 2
assert audio_lengths.tolist() == [5 * codec.frame_length, 3 * codec.frame_length]
@@ -11,13 +11,15 @@ import asyncio
import json
import os
import time
from collections import OrderedDict, defaultdict
from collections import defaultdict
from types import SimpleNamespace
import pytest
import torch
from tests.helpers.stage_config import get_deploy_config_path
from vllm_omni.entrypoints.openai.tts_adapters.base import SpeechServingContext
from vllm_omni.entrypoints.openai.tts_adapters.higgs_audio_v3 import HiggsAudioV3Adapter
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -1683,31 +1685,21 @@ class TestVoiceCloneReferenceCache:
assert tok._resolve_audio_tokenizer_dir() is None
def test_higgs_v3_ref_code_cache_returns_clone(self):
from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech
serving = object.__new__(OmniOpenAIServingSpeech)
serving._higgs_audio_v3_ref_code_cache = OrderedDict()
serving._higgs_audio_v3_ref_code_cache_bytes = 0
serving._higgs_audio_v3_ref_code_inflight = {}
adapter = HiggsAudioV3Adapter(SpeechServingContext(server=SimpleNamespace()))
codes = torch.arange(16, dtype=torch.long).reshape(2, 8)
serving._put_higgs_audio_v3_ref_codes("ref-a", codes)
adapter._put_higgs_audio_v3_ref_codes("ref-a", codes)
cached = serving._get_higgs_audio_v3_ref_codes("ref-a")
cached = adapter._get_higgs_audio_v3_ref_codes("ref-a")
assert cached is not None
cached.fill_(0)
cached_again = serving._get_higgs_audio_v3_ref_codes("ref-a")
cached_again = adapter._get_higgs_audio_v3_ref_codes("ref-a")
assert cached_again is not None
assert torch.equal(cached_again, codes)
def test_higgs_v3_ref_code_inflight_deduplicates_concurrent_encode(self):
from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech
serving = object.__new__(OmniOpenAIServingSpeech)
serving._higgs_audio_v3_ref_code_cache = OrderedDict()
serving._higgs_audio_v3_ref_code_cache_bytes = 0
serving._higgs_audio_v3_ref_code_inflight = {}
adapter = HiggsAudioV3Adapter(SpeechServingContext(server=SimpleNamespace()))
calls = 0
def encode_reference_audio(_wav, _sr):
@@ -1722,7 +1714,7 @@ class TestVoiceCloneReferenceCache:
async def run():
return await asyncio.gather(
*[
serving._resolve_higgs_audio_v3_ref_codes(
adapter._resolve_higgs_audio_v3_ref_codes(
"ref-a",
object(),
24000,
@@ -1741,7 +1733,7 @@ class TestVoiceCloneReferenceCache:
assert torch.equal(codes, torch.arange(16, dtype=torch.long).reshape(2, 8) + 1)
cached, cache_hit, inflight_wait = asyncio.run(
serving._resolve_higgs_audio_v3_ref_codes(
adapter._resolve_higgs_audio_v3_ref_codes(
"ref-a",
object(),
24000,
@@ -91,7 +91,11 @@ def _minimal_model(mocker: MockerFixture):
decoder=decoder,
config=SimpleNamespace(avg_pooler=2, stride_size=2, hop_length=240),
)
model._tokenizer_service = SimpleNamespace(audio_tokenizer=audio_tok, cuda_graph_wrapper=None)
model._tokenizer_service = SimpleNamespace(
audio_tokenizer=audio_tok,
cuda_graph_wrapper=None,
device="cpu",
)
return model, audio_tok
@@ -0,0 +1,115 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Regression tests for the MiMo-Audio tokenizer-worker cache key.
The worker cache key used to keep only the device *type*, so an explicit
``MIMO_AUDIO_TOKENIZER_DEVICE=cuda:1`` silently loaded the tokenizer on the
process's current CUDA device and shared one cache entry with ``cuda:0``.
The index must survive both the cache key and the worker constructor.
CPU-only and weight-free: the worker class is replaced by a stub so no
tokenizer weights are loaded.
"""
from __future__ import annotations
import functools
import pytest
torch = pytest.importorskip("torch")
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
_TOK_PATH = "/models/mimo-audio-tokenizer"
@functools.lru_cache(maxsize=1)
def _code2wav_module():
"""Defer the import (pulls vLLM model_executor) until first use."""
from vllm_omni.model_executor.models.mimo_audio import mimo_audio_code2wav
return mimo_audio_code2wav
class _StubWorker:
def __init__(self, device_str: str, config_path: str | None, audio_tokenizer_path: str):
self.device = device_str
self.config_path = config_path
self.audio_tokenizer_path = audio_tokenizer_path
@pytest.fixture
def isolated_worker_cache(monkeypatch):
"""Empty worker cache with the real worker replaced by a stub."""
mod = _code2wav_module()
monkeypatch.setattr(mod, "MiMoAudioTokenizerWorker", _StubWorker)
monkeypatch.setattr(mod, "_TOKENIZER_WORKER_CACHE", {})
return mod
@pytest.mark.parametrize("device", [torch.device("cuda:1"), "cuda:1"])
def test_cache_key_keeps_cuda_index(device):
"""Both torch.device and string spellings retain the index."""
mod = _code2wav_module()
key = mod._normalize_tokenizer_worker_cache_key(device, None, _TOK_PATH)
assert key[0] == "cuda:1"
def test_cache_key_cpu_unchanged():
mod = _code2wav_module()
key = mod._normalize_tokenizer_worker_cache_key(torch.device("cpu"), None, _TOK_PATH)
assert key[0] == "cpu"
def test_cache_key_bare_cuda_resolves_to_current_device(monkeypatch):
"""A bare "cuda" shares the entry with its equivalent indexed spelling."""
mod = _code2wav_module()
monkeypatch.setattr(torch.cuda, "is_available", lambda: True)
monkeypatch.setattr(torch.accelerator, "current_device_index", lambda: 3)
bare = mod._normalize_tokenizer_worker_cache_key(torch.device("cuda"), None, _TOK_PATH)
indexed = mod._normalize_tokenizer_worker_cache_key(torch.device("cuda:3"), None, _TOK_PATH)
assert bare[0] == "cuda:3"
assert bare == indexed
def test_worker_constructed_on_requested_index(isolated_worker_cache):
mod = isolated_worker_cache
worker = mod.get_tokenizer_worker(
device=torch.device("cuda:1"),
config_path=None,
audio_tokenizer_path=_TOK_PATH,
)
assert worker.device == "cuda:1"
def test_distinct_cuda_indices_get_distinct_workers(isolated_worker_cache):
mod = isolated_worker_cache
first = mod.get_tokenizer_worker(
device=torch.device("cuda:0"),
config_path=None,
audio_tokenizer_path=_TOK_PATH,
)
second = mod.get_tokenizer_worker(
device=torch.device("cuda:1"),
config_path=None,
audio_tokenizer_path=_TOK_PATH,
)
assert first is not second
assert (first.device, second.device) == ("cuda:0", "cuda:1")
def test_same_device_reuses_cached_worker(isolated_worker_cache):
mod = isolated_worker_cache
first = mod.get_tokenizer_worker(
device=torch.device("cuda:1"),
config_path=None,
audio_tokenizer_path=_TOK_PATH,
)
second = mod.get_tokenizer_worker(
device="cuda:1",
config_path=None,
audio_tokenizer_path=_TOK_PATH,
)
assert first is second
@@ -1,12 +1,16 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from types import SimpleNamespace
import pytest
import torch
from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech
from vllm_omni.entrypoints.openai.tts_adapters.base import SpeechServingContext
from vllm_omni.entrypoints.openai.tts_adapters.ming_tts import (
MingTTSAdapter,
parse_ming_instruction_fields,
)
from vllm_omni.model_executor.models.common.ming.aggregator import Aggregator
from vllm_omni.model_executor.models.common.ming.audio_vae import AudioVAEConfig
from vllm_omni.model_executor.models.common.ming.cfm_cudagraph import CFMGraphExecutor, CFMSampler
@@ -174,19 +178,19 @@ def test_ming_dense_validation_rejects_semantic_audio_vae_config():
def test_ming_instruction_parser_preserves_dense_and_flash_defaults():
"""Ming dense and Ming flash keep distinct instruction defaults."""
serving = object.__new__(OmniOpenAIServingSpeech)
serving.uploaded_speakers = {"uploaded": {}}
serving = SimpleNamespace(uploaded_speakers={"uploaded": {}})
adapter = MingTTSAdapter(SpeechServingContext(server=serving))
dense_plain = serving._parse_ming_instruction(SimpleNamespace(instructions="calm", language=None, voice=None))
dense_plain = adapter._parse_ming_instruction(SimpleNamespace(instructions="calm", language=None, voice=None))
assert dense_plain == "calm"
dense_with_fields = serving._parse_ming_instruction(
dense_with_fields = adapter._parse_ming_instruction(
SimpleNamespace(instructions="calm", language="Auto", voice="灵小甄")
)
assert dense_with_fields == {"IP": "灵小甄", "风格": "calm"}
flash_fields = serving._parse_ming_instruction_fields(
SimpleNamespace(instructions="calm", language="粤语", voice="灵小甄")
flash_fields = parse_ming_instruction_fields(
serving, SimpleNamespace(instructions="calm", language="粤语", voice="灵小甄")
)
assert flash_fields == {"风格": "calm"}
@@ -0,0 +1,138 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Regression tests for the MiniCPM-o 4.5 ``audio_embeds`` input path.
Two defects made the OpenAI ``audio_embeds`` content part unusable with this model:
1. ``MiniCPMOAudioEmbeddingItems`` registered itself under ``modality="image"``
(copy-pasted from ``MiniCPMVImageEmbeddingItems``), so every request failed with
``Modality 'image' not found. Available modalities: {'audio'}``.
2. ``_get_prompt_updates`` sized the placeholder from
``sum(map(len, single_audio_embeds))``. ``MiniCPMOAudioEmbeddingInputs`` declares
``TensorShape("bn", "s", "h")``, so one item is ``(s, h)`` and that expression
iterates rows and sums the hidden size -- ``s * h`` instead of ``s``. A one-second
slice of shape ``(10, 4096)`` asked for 40960 placeholder tokens instead of 10,
pushing every real request past ``max_model_len``.
"""
from __future__ import annotations
import math
from types import SimpleNamespace
import pytest
import torch
from vllm.multimodal.parse import MultiModalDataItems
from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_omni_llm import (
MiniCPMO45OmniLLMMultiModalProcessor,
MiniCPMO45OmniLLMProcessingInfo,
MiniCPMOAudioEmbeddingItems,
_minicpmo_field_config,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
POOL_STEP = 5
HIDDEN_SIZE = 4096
#: Audio embedding vectors produced per second: 100 mel -> 50 cnn -> (50-5)//5+1.
EMBEDS_PER_SECOND = 10
class _FakeProcessor:
"""Minimal processor surface used by ``get_audio_placeholder``."""
def __init__(self) -> None:
self.pool_step = POOL_STEP
self.audio_processor = SimpleNamespace(hop_length=160)
self.image_processor = SimpleNamespace(mean=[0.0], std=[1.0])
def get_audio_placeholder(
self,
audio_lens: int,
chunk_input: bool = True,
chunk_length: int = 1,
) -> str:
del chunk_input, chunk_length
feature_lens = math.ceil(audio_lens / self.audio_processor.hop_length)
cnn_feature_lens = (feature_lens - 1) // 2 + 1
output_lens = (cnn_feature_lens - self.pool_step) // self.pool_step + 1
return "<unk>" * output_lens
class _IdentityTokenizer:
"""``_get_prompt_updates`` round-trips the image/video patterns through the
tokenizer to detect encode/decode drift; an identity pair keeps that a no-op."""
def encode(self, text: str, add_special_tokens: bool = True) -> str:
del add_special_tokens
return text
def decode(self, tokens: str) -> str:
return tokens
def _processing_info() -> MiniCPMO45OmniLLMProcessingInfo:
processor = _FakeProcessor()
tokenizer = _IdentityTokenizer()
info = object.__new__(MiniCPMO45OmniLLMProcessingInfo)
info.ctx = SimpleNamespace(
tokenizer=tokenizer,
get_tokenizer=lambda: tokenizer,
get_hf_config=lambda: SimpleNamespace(audio_pool_step=POOL_STEP),
get_hf_processor=lambda **kwargs: processor,
)
return info
def _audio_placeholder_for(single_audio_embeds: torch.Tensor) -> str:
"""Run the real prompt-update path and return the audio placeholder text.
Goes through ``_get_prompt_updates`` rather than recomputing the length, so the
``sum(map(len, ...))`` regression is actually exercised.
"""
info = _processing_info()
proc = object.__new__(MiniCPMO45OmniLLMMultiModalProcessor)
proc.info = info
items = MiniCPMOAudioEmbeddingItems(
{"audio_embeds": single_audio_embeds.unsqueeze(0)},
fields_factory=_minicpmo_field_config,
)
mm_items = MultiModalDataItems({"audio": items})
updates = proc._get_prompt_updates(mm_items, {}, {})
audio_update = next(u for u in updates if u.modality == "audio")
return audio_update.content(0).full
def test_audio_embedding_items_use_the_audio_modality() -> None:
"""Registering under ``image`` makes every ``audio_embeds`` request fail."""
items = MiniCPMOAudioEmbeddingItems(
{"audio_embeds": torch.zeros(1, EMBEDS_PER_SECOND, HIDDEN_SIZE)},
fields_factory=_minicpmo_field_config,
)
assert items.modality == "audio"
@pytest.mark.parametrize("num_seconds", [1, 3, 30])
def test_placeholder_length_matches_embedding_count(num_seconds: int) -> None:
"""An ``(s, h)`` item must yield exactly ``s`` placeholder tokens."""
num_embeds = num_seconds * EMBEDS_PER_SECOND
placeholder = _audio_placeholder_for(torch.zeros(num_embeds, HIDDEN_SIZE))
assert placeholder.count("<unk>") == num_embeds
def test_placeholder_does_not_scale_with_hidden_size() -> None:
"""The count must depend on ``s`` alone, never on ``h``.
``sum(map(len, ...))`` returned ``s * h``, so the placeholder grew with the model's
hidden size. Two items with the same ``s`` but different ``h`` must agree.
"""
narrow = _audio_placeholder_for(torch.zeros(EMBEDS_PER_SECOND, 8))
wide = _audio_placeholder_for(torch.zeros(EMBEDS_PER_SECOND, HIDDEN_SIZE))
assert narrow.count("<unk>") == wide.count("<unk>") == EMBEDS_PER_SECOND
@@ -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
"""Unit tests for MiniCPM-o 4.5 code2wav model-dir resolution (#5442).
In hub/CI deployments ``model_config.model`` is a repo id rather than a local
@@ -27,7 +27,7 @@ def _no_hub(monkeypatch):
def _fail(*args, **kwargs): # pragma: no cover - must not be reached
raise AssertionError("snapshot_download must not be called here")
monkeypatch.setattr(huggingface_hub, "snapshot_download", _fail)
monkeypatch.setattr(huggingface_hub.HfApi, "snapshot_download", _fail)
def test_local_directory_is_returned_unchanged(tmp_path, monkeypatch):
@@ -38,13 +38,13 @@ def test_local_directory_is_returned_unchanged(tmp_path, monkeypatch):
def test_repo_id_resolves_via_snapshot_download(tmp_path, monkeypatch):
calls = {}
def _fake_snapshot_download(model_ref, revision=None, allow_patterns=None):
def _fake_snapshot_download(self, model_ref, revision=None, allow_patterns=None):
calls["model_ref"] = model_ref
calls["revision"] = revision
calls["allow_patterns"] = allow_patterns
return str(tmp_path / "snapshot")
monkeypatch.setattr(huggingface_hub, "snapshot_download", _fake_snapshot_download)
monkeypatch.setattr(huggingface_hub.HfApi, "snapshot_download", _fake_snapshot_download)
resolved = _resolve_model_dir("openbmb/MiniCPM-o-4_5", revision="abc123")
assert resolved == str(tmp_path / "snapshot")
assert calls["model_ref"] == "openbmb/MiniCPM-o-4_5"
@@ -56,7 +56,7 @@ def test_snapshot_download_failure_propagates(monkeypatch):
def _raise(*args, **kwargs):
raise FileNotFoundError("offline and not cached")
monkeypatch.setattr(huggingface_hub, "snapshot_download", _raise)
monkeypatch.setattr(huggingface_hub.HfApi, "snapshot_download", _raise)
with pytest.raises(FileNotFoundError):
_resolve_model_dir("openbmb/MiniCPM-o-4_5")
@@ -10,6 +10,10 @@ Covers:
- ``process_audios``: >30s audio unpads per chunk without ``TypeError``
- multiple >30s audios unpad in chunk order
- <=30s audio unpadding byte-identical to the pre-fix behavior
- ``MiniCPMOAudioFeatureInputs``: a batch mixing a >30s and a <=30s audio
(different slice counts) validates instead of raising, whether from a
single request with multiple audios of different lengths or from
multiple requests sharing an encoder batch
"""
from __future__ import annotations
@@ -26,6 +30,7 @@ from vllm.multimodal.inputs import (
from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_omni_llm import (
MiniCPMO45OmniLLMMultiModalProcessor,
MiniCPMOAudioFeatureInputs,
MiniCPMOMultiModalDataParser,
_minicpmo_field_config,
)
@@ -152,3 +157,43 @@ class TestProcessAudiosUnpadding:
assert len(features) == 1
assert features[0].shape == (_FEAT_DIM, 980)
assert torch.equal(features[0], fake_outputs["audio_features"][0][:, :980])
class TestMixedChunkCountBatchValidation:
def test_batch_mixes_different_slice_counts(self) -> None:
# A >30s audio (3 slices) and a <=30s audio (2 slices) scheduled in
# the same encoder batch: audio_feature_lens is a list of per-audio
# tensors with different lengths (3 vs 2). This is the shape produced
# both by a single request with two audios of different lengths and
# by two separate requests batched together. Before the fix this
# raised ValueError: "audio_feature_lens contains inconsistent shapes".
audio_feature_lens = [
torch.tensor([_CHUNK_FRAMES, _CHUNK_FRAMES, 1]),
torch.tensor([_CHUNK_FRAMES, 1]),
]
audio_features = [torch.zeros(_FEAT_DIM, _CHUNK_FRAMES) for _ in range(5)]
inputs = MiniCPMOAudioFeatureInputs(
type="audio_features",
audio_features=audio_features,
audio_feature_lens=audio_feature_lens,
)
assert inputs.audio_feature_lens is audio_feature_lens
def test_single_slice_count_batch_still_validated(self) -> None:
# Same slice count per audio (the common case) must still validate,
# unaffected by marking "s" dynamic.
audio_feature_lens = [
torch.tensor([_CHUNK_FRAMES, 1]),
torch.tensor([_CHUNK_FRAMES, 1]),
]
audio_features = [torch.zeros(_FEAT_DIM, _CHUNK_FRAMES) for _ in range(4)]
inputs = MiniCPMOAudioFeatureInputs(
type="audio_features",
audio_features=audio_features,
audio_feature_lens=audio_feature_lens,
)
assert inputs.audio_feature_lens is audio_feature_lens
@@ -6,19 +6,27 @@ Tests for OmniVoice CUDA Graph generator wrapper numerical equivalence.
Verifies that _OmniVoiceCUDAGraphForward produces results equivalent to
eager mode across three scenarios:
- Exact-size inputs (no padding) → bit-identical
- Padded inputs (padded to nearest bucket) → correct slicing, exact match
for position-independent models
- Oversized inputs (fallback to eager) → bit-identical
- Padded inputs (padded to nearest bucket) → correct slicing and exact match
- Oversized inputs (128-aligned lazy capture) → bit-identical
Uses a lightweight synthetic generator to keep warmup fast and avoid
loading actual model weights.
Uses a small randomly initialized OmniVoiceGenerator so every test exercises
the production embedding, transformer, varlen-attention, and logits paths
without loading checkpoint weights.
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
import torch
import torch.nn as nn
from vllm.utils.math_utils import round_up
from vllm_omni.model_executor.models.omnivoice.omnivoice_generator import (
OmniVoiceGenerator,
_OmniVoiceCUDAGraphForward,
)
from vllm_omni.transformers_utils.configs.omnivoice import OmniVoiceConfig
pytestmark = [
pytest.mark.core_model,
@@ -29,53 +37,7 @@ pytestmark = [
DEVICE = torch.device("cuda:0")
NUM_CB = 8
VOCAB = 1025
HIDDEN = 64
CAPTURE_SIZES = [32, 64, 128]
HEAD_DIM = 64
# ---------------------------------------------------------------------------
# Synthetic generator: matches _OmniVoiceCUDAGraphForward's interface
# ---------------------------------------------------------------------------
class _SyntheticGenerator:
"""Position-independent Embedding+Linear replacing OmniVoiceGenerator's 28-layer transformer.
Each token position is processed independently (no attention), so padded
runs produce bit-identical results to non-padded runs on the same positions.
This makes the padding/slicing logic easy to verify exactly.
"""
class _Cfg:
num_audio_codebook = NUM_CB
def __init__(self, device: torch.device):
self.config = self._Cfg()
self.text_embedding = nn.Embedding(1000, HIDDEN).to(device).eval()
self._linear = nn.Linear(HIDDEN, NUM_CB * VOCAB, bias=False).to(device).eval()
self._rope_table: torch.Tensor | None = None
@property
def model_dtype(self) -> torch.dtype:
"""Part of the interface _OmniVoiceCUDAGraphForward expects of a generator."""
return self.text_embedding.weight.dtype
def _rope_table_for(self, seq_len: int, device: torch.device, dtype: torch.dtype) -> torch.Tensor:
"""Part of the interface _OmniVoiceCUDAGraphForward expects of a generator."""
return torch.zeros(seq_len, HEAD_DIM, device=device, dtype=dtype)
def _step_forward(
self,
input_ids: torch.Tensor,
audio_mask: torch.Tensor,
attention_mask: torch.Tensor | None,
rope_table: torch.Tensor,
) -> torch.Tensor:
two_b, _, S = input_ids.shape
x = self.text_embedding(input_ids[:, 0, :].clamp(0, 999)) # [two_b, S, H]
logits = self._linear(x) # [two_b, S, 8*1025]
return logits.view(two_b, S, NUM_CB, VOCAB).permute(0, 2, 1, 3) # [two_b, 8, S, 1025]
# ---------------------------------------------------------------------------
@@ -83,18 +45,37 @@ class _SyntheticGenerator:
# ---------------------------------------------------------------------------
def _eager(gen: _SyntheticGenerator, ids: torch.Tensor, mask: torch.Tensor, attn: torch.Tensor) -> torch.Tensor:
"""Run _step_forward with a zero RoPE table (synthetic gen ignores it)."""
two_b, _, S = ids.shape
return gen._step_forward(ids, mask, attn, gen._rope_table_for(S, ids.device, torch.float32))
def _eager(gen: OmniVoiceGenerator, ids: torch.Tensor, mask: torch.Tensor, cu_seqs) -> torch.Tensor:
"""Run the production varlen step outside CUDA Graph."""
seq_len = ids.shape[0]
rope_table = gen._rope_table_for(seq_len, ids.device, gen.model_dtype)
return gen._step_forward(ids, mask, cu_seqs, rope_table)
def _bucketed_eager(gen, wrapper, ids, mask, cu_seqs, batch_size) -> torch.Tensor:
"""Run eager with the exact padded shape and metadata used by Graph."""
seq_len = ids.shape[0]
bucket = wrapper._find_bucket(batch_size, seq_len)
if bucket is None:
bucket = round_up(seq_len, wrapper._LAZY_CAPTURE_ALIGNMENT)
padded_ids, padded_mask = wrapper._pad_inputs(ids, mask, bucket)
padded_cu_seqs = cu_seqs.clone()
padded_cu_seqs[-1] = bucket
return _eager(gen, padded_ids, padded_mask, padded_cu_seqs)[:, :seq_len, :]
def _make_inputs(seq_len: int, device: torch.device = DEVICE):
two_b = 2
ids = torch.randint(0, 100, (two_b, NUM_CB, seq_len), dtype=torch.long, device=device)
mask = torch.ones(two_b, seq_len, dtype=torch.bool, device=device)
attn = torch.ones(two_b, 1, seq_len, seq_len, dtype=torch.bool, device=device)
return ids, mask, attn
ids = torch.randint(0, 100, (seq_len, NUM_CB), dtype=torch.long, device=device)
mask = torch.ones(seq_len, dtype=torch.bool, device=device)
cond_len = (seq_len + 1) // 2
uncond_len = seq_len - cond_len
cu_seqs = torch.tensor(
[0, cond_len, cond_len + uncond_len, cond_len + uncond_len],
dtype=torch.int32,
device=device,
)
return ids, mask, cu_seqs
# ---------------------------------------------------------------------------
@@ -105,15 +86,26 @@ def _make_inputs(seq_len: int, device: torch.device = DEVICE):
@pytest.fixture(scope="module")
def gen():
torch.manual_seed(42)
return _SyntheticGenerator(DEVICE)
config = OmniVoiceConfig(
audio_vocab_size=VOCAB,
num_audio_codebook=NUM_CB,
llm_config={
"hidden_size": 64,
"num_hidden_layers": 2,
"num_attention_heads": 4,
"num_key_value_heads": 2,
"intermediate_size": 128,
"vocab_size": 128,
"max_position_embeddings": 4096,
"head_dim": 16,
},
enable_cuda_graph=False,
)
return OmniVoiceGenerator(config, SimpleNamespace(max_num_seqs=2)).to(DEVICE).eval()
@pytest.fixture(scope="module")
def wrapper(gen):
from vllm_omni.model_executor.models.omnivoice.omnivoice_generator import (
_OmniVoiceCUDAGraphForward,
)
w = _OmniVoiceCUDAGraphForward(gen, capture_sizes=CAPTURE_SIZES)
w.warmup(DEVICE)
return w
@@ -127,10 +119,10 @@ def wrapper(gen):
@pytest.mark.parametrize("seq_len", CAPTURE_SIZES)
def test_exact_size_bit_identical(gen, wrapper, seq_len):
"""When input exactly matches a captured bucket, output must be bit-identical to eager."""
ids, mask, attn = _make_inputs(seq_len)
ids, mask, cu_seqs = _make_inputs(seq_len)
with torch.no_grad():
eager_out = _eager(gen, ids, mask, attn)
graph_out = wrapper(ids, mask, attn)
eager_out = _eager(gen, ids, mask, cu_seqs)
graph_out = wrapper(ids, mask, cu_seqs, 1)
torch.testing.assert_close(graph_out, eager_out, atol=0, rtol=0)
@@ -140,40 +132,36 @@ def test_exact_size_bit_identical(gen, wrapper, seq_len):
@pytest.mark.parametrize("seq_len", [1, 15, 33, 60, 100])
def test_padded_output_shape(gen, wrapper, seq_len):
def test_padded_output_shape(wrapper, seq_len):
"""Graph output must be sliced back to actual seq_len, not the bucket size."""
ids, mask, attn = _make_inputs(seq_len)
ids, mask, cu_seqs = _make_inputs(seq_len)
with torch.no_grad():
graph_out = wrapper(ids, mask, attn)
assert graph_out.shape == (2, NUM_CB, seq_len, VOCAB)
graph_out = wrapper(ids, mask, cu_seqs, 1)
assert graph_out.shape == (NUM_CB, seq_len, VOCAB)
@pytest.mark.parametrize("seq_len", [15, 33, 60, 100])
def test_padded_output_matches_eager(gen, wrapper, seq_len):
"""Padded graph output must equal eager output at actual positions.
The synthetic model has no attention across positions, so zero-padding
does not affect non-padded positions — exact match is expected.
"""
ids, mask, attn = _make_inputs(seq_len)
"""Padded graph output must equal eager output at actual positions."""
ids, mask, cu_seqs = _make_inputs(seq_len)
with torch.no_grad():
eager_out = _eager(gen, ids, mask, attn)
graph_out = wrapper(ids, mask, attn)
eager_out = _bucketed_eager(gen, wrapper, ids, mask, cu_seqs, 1)
graph_out = wrapper(ids, mask, cu_seqs, 1)
torch.testing.assert_close(graph_out, eager_out, atol=0, rtol=0)
# ---------------------------------------------------------------------------
# 3. Oversized inputs → fallback to eager (lazy capture), bit-identical
# 3. Oversized inputs → aligned lazy capture, bit-identical
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("seq_len", [129, 200, 256])
def test_fallback_eager_bit_identical(gen, wrapper, seq_len):
"""Sequences exceeding the largest bucket fall back to lazy capture → bit-identical."""
ids, mask, attn = _make_inputs(seq_len)
def test_aligned_lazy_capture_bit_identical(gen, wrapper, seq_len):
"""Sequences beyond the static plan use aligned lazy capture and remain bit-identical."""
ids, mask, cu_seqs = _make_inputs(seq_len)
with torch.no_grad():
eager_out = _eager(gen, ids, mask, attn)
graph_out = wrapper(ids, mask, attn)
eager_out = _bucketed_eager(gen, wrapper, ids, mask, cu_seqs, 1)
graph_out = wrapper(ids, mask, cu_seqs, 1)
torch.testing.assert_close(graph_out, eager_out, atol=0, rtol=0)
@@ -184,13 +172,73 @@ def test_fallback_eager_bit_identical(gen, wrapper, seq_len):
def test_deterministic_across_calls(wrapper):
"""Same input must produce identical output on repeated CUDA graph replays."""
ids, mask, attn = _make_inputs(32)
ids, mask, cu_seqs = _make_inputs(32)
with torch.no_grad():
out1 = wrapper(ids, mask, attn)
out2 = wrapper(ids, mask, attn)
out1 = wrapper(ids, mask, cu_seqs, 1).clone()
out2 = wrapper(ids, mask, cu_seqs, 1).clone()
torch.testing.assert_close(out1, out2, atol=0, rtol=0)
def test_replay_uses_updated_cu_seqs(gen, wrapper):
"""The same graph key must honor new sequence boundaries on replay."""
ids, mask, _ = _make_inputs(48)
cu_seqs_a = torch.tensor([0, 32, 48, 48], dtype=torch.int32, device=DEVICE)
cu_seqs_b = torch.tensor([0, 17, 48, 48], dtype=torch.int32, device=DEVICE)
with torch.no_grad():
eager_a = _bucketed_eager(gen, wrapper, ids, mask, cu_seqs_a, 1)
graph_a = wrapper(ids, mask, cu_seqs_a, 1).clone()
eager_b = _bucketed_eager(gen, wrapper, ids, mask, cu_seqs_b, 1)
graph_b = wrapper(ids, mask, cu_seqs_b, 1).clone()
torch.testing.assert_close(graph_a, eager_a, atol=0, rtol=0)
torch.testing.assert_close(graph_b, eager_b, atol=0, rtol=0)
assert not torch.equal(graph_a, graph_b)
def test_batch_two_cu_seqs_matches_eager(gen, wrapper):
"""B=2 uses four real sequence segments plus the fixed tail slot."""
ids, mask, _ = _make_inputs(48)
cu_seqs = torch.tensor([0, 10, 18, 32, 48, 48], dtype=torch.int32, device=DEVICE)
assert cu_seqs.numel() == 2 * 2 + 2
with torch.no_grad():
eager_out = _bucketed_eager(gen, wrapper, ids, mask, cu_seqs, 2)
graph_out = wrapper(ids, mask, cu_seqs, 2).clone()
torch.testing.assert_close(graph_out, eager_out, atol=0, rtol=0)
def test_lazy_graph_cache_uses_lru_eviction(wrapper):
"""A lazy-cache hit must protect that graph from the next eviction."""
original_limit = wrapper._MAX_LAZY_GRAPHS
wrapper._lazy_graphs.clear()
wrapper._MAX_LAZY_GRAPHS = 2
try:
# Static B=1 coverage ends at 128. These lengths round to three
# distinct 128-aligned lazy keys: 256, 384, and 512.
for seq_len in (129, 257):
ids, mask, cu_seqs = _make_inputs(seq_len)
with torch.no_grad():
wrapper(ids, mask, cu_seqs, 1)
assert list(wrapper._lazy_graphs) == [(1, 256), (1, 384)]
# Refresh key 256, making key 384 the least recently used entry.
ids, mask, cu_seqs = _make_inputs(129)
with torch.no_grad():
wrapper(ids, mask, cu_seqs, 1)
assert list(wrapper._lazy_graphs) == [(1, 384), (1, 256)]
# Inserting key 512 must evict key 384, not the recently hit key 256.
ids, mask, cu_seqs = _make_inputs(385)
with torch.no_grad():
wrapper(ids, mask, cu_seqs, 1)
assert list(wrapper._lazy_graphs) == [(1, 256), (1, 512)]
finally:
wrapper._MAX_LAZY_GRAPHS = original_limit
wrapper._lazy_graphs.clear()
# ---------------------------------------------------------------------------
# 5. _find_bucket logic (CPU, no CUDA graph)
# ---------------------------------------------------------------------------
@@ -203,58 +251,17 @@ def test_find_bucket_returns_nearest_bucket():
)
w = _OmniVoiceCUDAGraphForward.__new__(_OmniVoiceCUDAGraphForward)
w._capture_sizes = [32, 64, 128]
w.capture_bucket_sizes_by_batch = {
1: [32, 64, 128],
2: [64, 128, 256],
}
w._graphs = {}
assert w._find_bucket(1) == 32
assert w._find_bucket(32) == 32
assert w._find_bucket(33) == 64
assert w._find_bucket(64) == 64
assert w._find_bucket(128) == 128
assert w._find_bucket(129) is None
assert w._find_bucket(1000) is None
# ---------------------------------------------------------------------------
# 6. enable_cuda_graph=False produces same tokens as enable_cuda_graph=True
# ---------------------------------------------------------------------------
def test_cuda_graph_disabled_matches_eager_generator():
"""OmniVoiceGenerator with enable_cuda_graph=False must produce the same
_step_forward output as one with enable_cuda_graph=True (after warmup).
Uses a 2-layer config so warmup completes quickly without loading weights.
"""
from vllm_omni.model_executor.models.omnivoice.omnivoice_generator import OmniVoiceGenerator
from vllm_omni.transformers_utils.configs.omnivoice import OmniVoiceConfig
cfg = OmniVoiceConfig()
cfg.llm_num_hidden_layers = 2
cfg.num_hidden_layers = 2
torch.manual_seed(0)
gen_eager = OmniVoiceGenerator(cfg).to(DEVICE).eval()
gen_eager.config.enable_cuda_graph = False
gen_eager._cuda_graph_fwd = None
torch.manual_seed(0)
gen_graph = OmniVoiceGenerator(cfg).to(DEVICE).eval()
gen_graph.config.cuda_graph_capture_sizes = [64]
from vllm_omni.model_executor.models.omnivoice.omnivoice_generator import _OmniVoiceCUDAGraphForward
gen_graph._cuda_graph_fwd = _OmniVoiceCUDAGraphForward(gen_graph, capture_sizes=[64])
gen_graph._cuda_graph_fwd.warmup(DEVICE)
seq_len = 64
ids = torch.zeros(2, cfg.num_audio_codebook, seq_len, dtype=torch.long, device=DEVICE)
mask = torch.ones(2, seq_len, dtype=torch.bool, device=DEVICE)
attn = torch.ones(2, 1, seq_len, seq_len, dtype=torch.bool, device=DEVICE)
rope_table = gen_eager._rope_table_for(seq_len, DEVICE, gen_eager.model_dtype)
with torch.no_grad():
eager_logits = gen_eager._step_forward(ids, mask, attn, rope_table)
graph_logits = gen_graph._cuda_graph_fwd(ids, mask, attn)
torch.testing.assert_close(graph_logits, eager_logits, atol=0, rtol=0)
assert w._find_bucket(1, 1) == 32
assert w._find_bucket(1, 32) == 32
assert w._find_bucket(1, 33) == 64
assert w._find_bucket(1, 128) == 128
assert w._find_bucket(1, 129) is None
assert w._find_bucket(2, 33) == 64
assert w._find_bucket(2, 129) == 256
assert w._find_bucket(3, 64) is None
@@ -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
"""The OmniVoice generator packs q/k/v and gate/up into fused projections.
The HF checkpoint stores those five tensors separately, so packing them is a
@@ -14,6 +14,8 @@ that would leave one partly written raises instead.
from __future__ import annotations
from types import SimpleNamespace
import pytest
import torch
@@ -49,6 +51,13 @@ def _config() -> OmniVoiceConfig:
)
def _generator() -> OmniVoiceGenerator:
return OmniVoiceGenerator(
_config(),
SimpleNamespace(max_num_seqs=1),
)
def _checkpoint_shards() -> dict[str, torch.Tensor]:
"""The five per-layer tensors an OmniVoice checkpoint actually stores."""
torch.manual_seed(0)
@@ -64,7 +73,7 @@ def _checkpoint_shards() -> dict[str, torch.Tensor]:
def test_every_fused_parameter_is_written() -> None:
generator = OmniVoiceGenerator(_config())
generator = _generator()
shards = _checkpoint_shards()
loaded = generator._load_fused_projections(shards)
@@ -77,7 +86,7 @@ def test_every_fused_parameter_is_written() -> None:
def test_packed_weight_matches_the_shards_it_came_from() -> None:
"""Packing order is q,k,v and gate,up -- the split in forward() assumes it."""
generator = OmniVoiceGenerator(_config())
generator = _generator()
shards = _checkpoint_shards()
generator._load_fused_projections(shards)
@@ -101,7 +110,7 @@ def test_packed_weight_matches_the_shards_it_came_from() -> None:
def test_no_fused_parameter_keeps_its_random_init() -> None:
"""The corruption this guards against: a fused param never written at all."""
generator = OmniVoiceGenerator(_config())
generator = _generator()
before = {
name: param.detach().clone()
for name, param in generator.named_parameters()
@@ -121,7 +130,7 @@ def test_no_fused_parameter_keeps_its_random_init() -> None:
["self_attn.k_proj", "self_attn.v_proj", "self_attn.q_proj", "mlp.gate_proj", "mlp.up_proj"],
)
def test_a_missing_shard_raises_instead_of_loading_partially(dropped: str) -> None:
generator = OmniVoiceGenerator(_config())
generator = _generator()
shards = _checkpoint_shards()
del shards[f"llm.layers.0.{dropped}.weight"]
@@ -130,7 +139,7 @@ def test_a_missing_shard_raises_instead_of_loading_partially(dropped: str) -> No
def test_a_wrong_shaped_shard_raises() -> None:
generator = OmniVoiceGenerator(_config())
generator = _generator()
shards = _checkpoint_shards()
shards["llm.layers.0.self_attn.q_proj.weight"] = torch.randn(NUM_HEADS * HEAD_DIM + 1, HIDDEN)
@@ -140,7 +149,7 @@ def test_a_wrong_shaped_shard_raises() -> None:
def test_a_checkpoint_missing_a_whole_layer_raises() -> None:
"""Half-loaded is the dangerous state: it neither errors nor works."""
generator = OmniVoiceGenerator(_config())
generator = _generator()
shards = {k: v for k, v in _checkpoint_shards().items() if not k.startswith("llm.layers.1.")}
with pytest.raises(ValueError, match="fused projections"):
@@ -149,7 +158,7 @@ def test_a_checkpoint_missing_a_whole_layer_raises() -> None:
def test_a_checkpoint_with_no_fused_shards_is_left_alone() -> None:
"""An unrelated state_dict must not trip the completeness check."""
generator = OmniVoiceGenerator(_config())
generator = _generator()
assert generator._load_fused_projections({"audio_heads.weight": torch.randn(4, 4)}) == set()
@@ -162,7 +171,7 @@ def test_load_weights_wires_the_packing_in(tmp_path, monkeypatch: pytest.MonkeyP
"""
import safetensors.torch
generator = OmniVoiceGenerator(_config())
generator = _generator()
shards = _checkpoint_shards()
before = generator.layers[0].self_attn.qkv_proj.weight.detach().clone()
@@ -1,183 +1,79 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""OmniVoice attention masks must follow the model dtype, not a hardcoded float32.
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""OmniVoice SDPA fallback masks must not depend on the model dtype.
SDPA requires the additive attention mask to carry the same dtype as the query.
The generator used to materialize that mask as float32 unconditionally, in three
places (the shared per-forward mask, the CUDA-graph capture buffer, and the
replay-time normalization), so every half-precision deployment died on the first
attention call with::
The previous padded attention path used an additive floating-point mask. SDPA
requires such a mask to have the same dtype as the query, and constructing it
as float32 caused half-precision OmniVoice inference to fail on CUDA.
RuntimeError: invalid dtype for bias - should match query's dtype
(the exact wording depends on which SDPA backend is selected for the shape; the
math backend words the same rejection as ``attn_mask.dtype``.)
That left OmniVoice servable only in float32, even though the upstream k2-fsa
implementation runs it in float16.
Note the split between the CPU and CUDA tests below: the CPU SDPA backend
silently accepts the mismatched mask, so only the CUDA tests can pin the actual
crash. The CPU tests cover the mask contract itself, which is what the fix
changes and what a future regression would break first.
The packed-varlen path no longer needs an additive mask. When SDPA fallback is
required, ``_attention_metadata_from_cu_seqs`` builds a boolean block mask.
Boolean SDPA masks have no dtype coupling with the query, so no mask coercion
is required for float16 or bfloat16 inference.
"""
from __future__ import annotations
import pytest
import torch
import torch.nn.functional as F
from vllm_omni.model_executor.models.omnivoice.omnivoice_generator import (
OmniVoiceAttention,
OmniVoiceGenerator,
_additive_float_mask,
_attention_metadata_from_cu_seqs,
)
from vllm_omni.transformers_utils.configs.omnivoice import OmniVoiceConfig
HALF_DTYPES = [torch.float16, torch.bfloat16]
ALL_DTYPES = HALF_DTYPES + [torch.float32]
cpu_test = pytest.mark.core_model
cuda_test = pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
def _tiny_config() -> OmniVoiceConfig:
"""A real OmniVoiceConfig sized so the module fits in a unit test."""
return OmniVoiceConfig(
llm_config={
"hidden_size": 32,
"num_hidden_layers": 2,
"num_attention_heads": 4,
"num_key_value_heads": 2,
"head_dim": 8,
"intermediate_size": 64,
"vocab_size": 64,
"max_position_embeddings": 128,
},
enable_cuda_graph=False,
@cpu_test
@pytest.mark.cpu
def test_sdpa_fallback_mask_is_dtype_independent() -> None:
"""The fallback mask is boolean rather than an additive model-dtype mask."""
cu_seqs = torch.tensor([0, 2, 3, 6, 6], dtype=torch.int32)
metadata = _attention_metadata_from_cu_seqs(
cu_seqs,
6,
needs_sdpa_mask=True,
)
def _inputs(dtype: torch.dtype, device: torch.device) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
hidden = torch.randn(2, 6, 32, device=device, dtype=dtype)
bool_mask = torch.ones(2, 1, 6, 6, dtype=torch.bool, device=device)
bool_mask[:, :, :, 4:] = False
rope_table = torch.zeros(6, 8, device=device, dtype=dtype)
rope_table[:, :4] = 1.0 # cos = 1, sin = 0: no rotation, so the test is about the mask
return hidden, bool_mask, rope_table
# --------------------------------------------------------------------------
# The mask contract (CPU)
# --------------------------------------------------------------------------
@cpu_test
@pytest.mark.cpu
@pytest.mark.parametrize("dtype", ALL_DTYPES)
def test_additive_float_mask_uses_requested_dtype(dtype: torch.dtype) -> None:
out = _additive_float_mask(torch.tensor([[True, False]]), dtype)
assert out.dtype == dtype
@cpu_test
@pytest.mark.cpu
def test_additive_float_mask_keeps_mask_semantics() -> None:
"""Attend positions stay at 0.0 and masked ones at -inf, in half precision too."""
out = _additive_float_mask(torch.tensor([[True, False]]), torch.float16)
assert out[0, 0].item() == 0.0
assert out[0, 1].item() == float("-inf")
@cpu_test
@pytest.mark.cpu
def test_additive_float_mask_requires_an_explicit_dtype() -> None:
"""The float32 default is the bug; keeping the parameter required is the fix."""
with pytest.raises(TypeError):
_additive_float_mask(torch.tensor([[True]])) # type: ignore[call-arg]
@cpu_test
@pytest.mark.cpu
@pytest.mark.parametrize("dtype", ALL_DTYPES)
def test_generator_model_dtype_tracks_its_weights(dtype: torch.dtype) -> None:
"""The single source of truth the three former float32 literals now defer to."""
assert OmniVoiceGenerator(_tiny_config()).to(dtype).model_dtype == dtype
@cpu_test
@pytest.mark.cpu
@pytest.mark.parametrize("dtype", HALF_DTYPES)
def test_attention_accepts_a_model_dtype_mask(dtype: torch.dtype) -> None:
attn = OmniVoiceAttention(_tiny_config()).to(dtype).eval()
hidden, bool_mask, rope_table = _inputs(dtype, torch.device("cpu"))
with torch.inference_mode():
out = attn(hidden, rope_table, attention_mask=_additive_float_mask(bool_mask, dtype))
assert out.shape == hidden.shape
assert out.dtype == dtype
assert torch.isfinite(out).all()
# --------------------------------------------------------------------------
# The crash itself (CUDA only — the CPU backend does not reject the mismatch)
# --------------------------------------------------------------------------
assert metadata.attn_mask is not None
assert metadata.attn_mask.dtype == torch.bool
@cuda_test
@pytest.mark.parametrize("dtype", HALF_DTYPES)
def test_float32_mask_is_what_broke_half_precision(dtype: torch.dtype) -> None:
"""Pin the original failure so a float32 default cannot come back unnoticed."""
attn = OmniVoiceAttention(_tiny_config()).to(device="cuda:0", dtype=dtype).eval()
hidden, bool_mask, rope_table = _inputs(dtype, torch.device("cuda:0"))
with pytest.raises(RuntimeError, match=r"(invalid dtype for bias|attn_mask)"), torch.inference_mode():
attn(hidden, rope_table, attention_mask=_additive_float_mask(bool_mask, torch.float32))
@cuda_test
@pytest.mark.parametrize("dtype", HALF_DTYPES)
def test_real_path_forward_runs_in_half_precision(dtype: torch.dtype) -> None:
"""The regression, on the production path with the Triton norm kernels live."""
attn = OmniVoiceAttention(_tiny_config()).to(device="cuda:0", dtype=dtype).eval()
hidden, bool_mask, rope_table = _inputs(dtype, torch.device("cuda:0"))
with torch.inference_mode():
out = attn(hidden, rope_table, attention_mask=_additive_float_mask(bool_mask, dtype))
assert out.dtype == dtype
assert torch.isfinite(out).all()
@cuda_test
@pytest.mark.parametrize("dtype", HALF_DTYPES)
def test_generator_forward_runs_in_half_precision(dtype: torch.dtype) -> None:
"""End-to-end over the real iterative loop, which is where the float32 mask was built.
This is the test that fails on an unfixed tree: ``forward`` materialized the
shared SDPA mask as float32 regardless of the weights' dtype.
"""
def test_sdpa_fallback_mask_needs_no_half_precision_coercion(
dtype: torch.dtype,
) -> None:
"""Half-precision SDPA accepts the production bool mask without casting."""
device = torch.device("cuda:0")
config = _tiny_config()
generator = OmniVoiceGenerator(config).to(device=device, dtype=dtype).eval()
seq_len = 6
cu_seqs = torch.tensor([0, 2, 3, 6, 6], dtype=torch.int32, device=device)
seq_len, target_len = 12, 4
input_ids = torch.zeros(2, config.num_audio_codebook, seq_len, dtype=torch.long, device=device)
input_ids[:, 1:, :] = config.audio_mask_id
audio_mask = torch.zeros(2, seq_len, dtype=torch.bool, device=device)
audio_mask[:, seq_len - target_len :] = True
attention_mask = torch.ones(2, 1, seq_len, seq_len, dtype=torch.bool, device=device)
metadata = _attention_metadata_from_cu_seqs(
cu_seqs,
seq_len,
needs_sdpa_mask=True,
)
assert metadata.attn_mask is not None
assert metadata.attn_mask.dtype == torch.bool
query = torch.randn(1, 2, seq_len, 8, device=device, dtype=dtype)
key = torch.randn_like(query)
value = torch.randn_like(query)
with torch.inference_mode():
tokens = generator(
input_ids,
audio_mask,
attention_mask,
target_lens=[target_len],
seed=0,
num_step=2,
output = F.scaled_dot_product_attention(
query,
key,
value,
attn_mask=metadata.attn_mask,
)
assert tokens.shape == (1, config.num_audio_codebook, target_len)
assert tokens.dtype == torch.long
assert output.dtype == dtype
assert torch.isfinite(output).all()
@@ -1,12 +1,15 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
import pytest
from huggingface_hub import hf_hub_download
from tokenizers import Tokenizer as HFTokenizer
from vllm_omni.diffusion.models.omnivoice.pipeline_omnivoice import (
_tokenize_with_nonverbal_tags,
)
from vllm_omni.transformers_utils.repo_utils import hf_api
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -18,7 +21,7 @@ def _ids(tok: HFTokenizer, s: str) -> list[int]:
class TestNonVerbalTags:
@classmethod
def setup_class(cls):
tokenizer_path = hf_hub_download(repo_id="k2-fsa/OmniVoice", filename="tokenizer.json")
tokenizer_path = hf_api().hf_hub_download(repo_id="k2-fsa/OmniVoice", filename="tokenizer.json")
cls.tokenizer = HFTokenizer.from_file(tokenizer_path)
def test_plain_text_fallback(self):
@@ -0,0 +1,228 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from types import SimpleNamespace
import pytest
import torch
from vllm_omni.diffusion.data import DiffusionOutput
from vllm_omni.diffusion.models.omnivoice.pipeline_omnivoice import (
OmniVoicePipeline,
_PreparedOmniVoiceRequest,
)
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.model_executor.models.omnivoice.omnivoice_generator import (
_attention_metadata_from_cu_seqs,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_sdpa_fallback_mask_preserves_packed_sequence_boundaries() -> None:
cu_seqs = torch.tensor([0, 2, 3, 6, 6], dtype=torch.int32)
metadata = _attention_metadata_from_cu_seqs(cu_seqs, 6, needs_sdpa_mask=True)
assert metadata.attn_mask is not None
expected = torch.tensor(
[
[1, 1, 0, 0, 0, 0],
[1, 1, 0, 0, 0, 0],
[0, 0, 1, 0, 0, 0],
[0, 0, 0, 1, 1, 1],
[0, 0, 0, 1, 1, 1],
[0, 0, 0, 1, 1, 1],
],
dtype=torch.bool,
)
torch.testing.assert_close(metadata.attn_mask[0, 0], expected)
def test_eager_attention_metadata_honors_exact_max_seqlen() -> None:
cu_seqs = torch.tensor([0, 2, 3, 6, 6], dtype=torch.int32)
metadata = _attention_metadata_from_cu_seqs(
cu_seqs,
6,
needs_sdpa_mask=False,
max_seqlen=3,
)
assert metadata.extra["max_seqlen_q"] == 3
assert metadata.extra["max_seqlen_k"] == 3
def test_request_batch_prepare_error_does_not_skip_valid_request() -> None:
"""A malformed request must not prevent another request from completing."""
prepared = _PreparedOmniVoiceRequest(
input_ids=torch.zeros(5, 8, dtype=torch.long),
audio_mask=torch.ones(5, dtype=torch.bool),
cond_len=3,
target_len=2,
seed=42,
)
def prepare_request(prompt, extra):
del extra
if prompt == "bad":
return DiffusionOutput(error="invalid prompt")
return prepared
def collate_requests(requests):
assert requests == [prepared]
return prepared.input_ids, prepared.audio_mask, [prepared.cond_len]
generator_calls = []
def generate_tokens(**kwargs):
generator_calls.append(kwargs)
assert kwargs["target_lens"] == [prepared.target_len]
return torch.ones(1, 8, prepared.target_len, dtype=torch.long)
pipeline = SimpleNamespace(
_prepare_request_input=prepare_request,
_collate_request_inputs=collate_requests,
generator=generate_tokens,
decoder=lambda tokens: tokens.float(),
num_step=32,
guidance_scale=7.0,
t_shift=1.0,
layer_penalty_factor=0.0,
position_temperature=0.0,
class_temperature=0.0,
)
sampling = OmniDiffusionSamplingParams(num_inference_steps=32, guidance_scale=7.0)
batch = DiffusionRequestBatch(
requests=[
OmniDiffusionRequest(prompt="bad", sampling_params=sampling, request_id="bad"),
OmniDiffusionRequest(
prompt="good",
sampling_params=OmniDiffusionSamplingParams(num_inference_steps=32, guidance_scale=7.0),
request_id="good",
),
]
)
outputs = OmniVoicePipeline.forward(pipeline, batch)
assert len(outputs) == 2
assert outputs[0].error == "invalid prompt"
assert outputs[1].error is None
torch.testing.assert_close(outputs[1].output, torch.ones(1, 8, 2))
assert generator_calls[0]["guidance_scale"] == 7.0
def test_request_batch_honors_explicit_sampling_overrides() -> None:
"""Request steps and guidance must override the OmniVoice defaults."""
prepared = _PreparedOmniVoiceRequest(
input_ids=torch.zeros(5, 8, dtype=torch.long),
audio_mask=torch.ones(5, dtype=torch.bool),
cond_len=3,
target_len=2,
seed=42,
)
captured_sampling = []
def generate_tokens(**kwargs):
captured_sampling.append((kwargs["num_step"], kwargs["guidance_scale"]))
return torch.ones(1, 8, prepared.target_len, dtype=torch.long)
pipeline = SimpleNamespace(
_prepare_request_input=lambda prompt, extra: prepared,
_collate_request_inputs=lambda requests: (prepared.input_ids, prepared.audio_mask, [prepared.cond_len]),
generator=generate_tokens,
decoder=lambda tokens: tokens.float(),
num_step=32,
guidance_scale=2.0,
t_shift=1.0,
layer_penalty_factor=0.0,
position_temperature=0.0,
class_temperature=0.0,
)
batch = DiffusionRequestBatch(
requests=[
OmniDiffusionRequest(
prompt="hello",
sampling_params=OmniDiffusionSamplingParams(num_inference_steps=12, guidance_scale=6.5),
request_id="request",
)
]
)
outputs = OmniVoicePipeline.forward(pipeline, batch)
assert outputs[0].error is None
assert captured_sampling == [(12, 6.5)]
def test_request_batch_prepare_error_preserves_surrounding_output_indices() -> None:
"""A middle prepare error must not shift valid outputs into the wrong slots."""
prepared_before = _PreparedOmniVoiceRequest(
input_ids=torch.zeros(3, 8, dtype=torch.long),
audio_mask=torch.ones(3, dtype=torch.bool),
cond_len=2,
target_len=1,
seed=1,
)
prepared_after = _PreparedOmniVoiceRequest(
input_ids=torch.zeros(5, 8, dtype=torch.long),
audio_mask=torch.ones(5, dtype=torch.bool),
cond_len=3,
target_len=2,
seed=2,
)
def prepare_request(prompt, extra):
del extra
if prompt == "before":
return prepared_before
if prompt == "after":
return prepared_after
return DiffusionOutput(error="invalid middle request")
def collate_requests(requests):
assert requests == [prepared_before, prepared_after]
return (
torch.cat([prepared_before.input_ids, prepared_after.input_ids]),
torch.cat([prepared_before.audio_mask, prepared_after.audio_mask]),
[prepared_before.cond_len, prepared_after.cond_len],
)
def generate_tokens(**kwargs):
assert kwargs["target_lens"] == [1, 2]
before = torch.full((1, 8, 1), 11, dtype=torch.long)
after = torch.full((1, 8, 2), 22, dtype=torch.long)
return torch.cat([before, after], dim=-1)
pipeline = SimpleNamespace(
_prepare_request_input=prepare_request,
_collate_request_inputs=collate_requests,
generator=generate_tokens,
decoder=lambda tokens: tokens.float(),
num_step=32,
guidance_scale=2.0,
t_shift=1.0,
layer_penalty_factor=0.0,
position_temperature=0.0,
class_temperature=0.0,
)
batch = DiffusionRequestBatch(
requests=[
OmniDiffusionRequest(
prompt=prompt,
sampling_params=OmniDiffusionSamplingParams(num_inference_steps=32),
request_id=prompt,
)
for prompt in ("before", "bad", "after")
]
)
outputs = OmniVoicePipeline.forward(pipeline, batch)
assert len(outputs) == 3
torch.testing.assert_close(outputs[0].output, torch.full((1, 8, 1), 11.0))
assert outputs[1].error == "invalid middle request"
torch.testing.assert_close(outputs[2].output, torch.full((1, 8, 2), 22.0))
@@ -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
"""
Tests for code predictor dtype alignment (fix for #2385).
@@ -20,6 +20,9 @@ import pytest
import torch
import torch.nn.functional as F
from pytest_mock import MockerFixture
from transformers.models.qwen3_omni_moe.configuration_qwen3_omni_moe import (
Qwen3OmniMoeTalkerCodePredictorConfig,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -41,6 +44,7 @@ _COMMON = os.path.join(_MODELS, "common")
def _load_module(name: str, filename: str):
path = os.path.abspath(os.path.join(_BASE, filename))
spec = importlib.util.spec_from_file_location(name, path)
assert spec is not None and spec.loader is not None
mod = importlib.util.module_from_spec(spec)
sys.modules[name] = mod # register before exec (needed for dataclasses etc.)
spec.loader.exec_module(mod)
@@ -83,7 +87,7 @@ def _build_mock_modules(mocker: MockerFixture) -> dict[str, object]:
vllm_parallel_mock = mocker.MagicMock()
vllm_parallel_mock.VocabParallelEmbedding = torch.nn.Embedding
custom_op_mock = types.ModuleType("vllm_omni.diffusion.layers.custom_op")
custom_op_mock.CustomOp = NativeCustomOp
setattr(custom_op_mock, "CustomOp", NativeCustomOp)
return {
"vllm_omni": mocker.MagicMock(),
@@ -119,6 +123,7 @@ def _load_target_classes(mocker: MockerFixture):
common_spec = importlib.util.spec_from_file_location(
"vllm_omni.model_executor.models.common.qwen3_code_predictor", common_cp_path
)
assert common_spec is not None and common_spec.loader is not None
common_cp_mod = importlib.util.module_from_spec(common_spec)
sys.modules["vllm_omni.model_executor.models.common.qwen3_code_predictor"] = common_cp_mod
common_spec.loader.exec_module(common_cp_mod)
@@ -209,6 +214,80 @@ def test_npu_custom_ops_use_fused_norm_and_cached_rope(mocker: MockerFixture, lo
torch.testing.assert_close(sin, rotary.sin_cached[position_ids].to(torch.float16))
@pytest.mark.parametrize(
("rope_kwargs", "expected_theta"),
[
pytest.param(
{"rope_theta": 1_000_000.0},
1_000_000.0,
id="serialized_checkpoint",
),
pytest.param(
{
"rope_theta": 10_000.0,
"rope_parameters": {"rope_type": "default", "rope_theta": 1_000_000.0},
},
1_000_000.0,
id="nested_precedence",
),
pytest.param({}, 10_000.0, id="default"),
],
)
def test_code_predictor_rotary_uses_qwen3_omni_rope_parameters(
loaded_target_classes,
rope_kwargs,
expected_theta,
) -> None:
"""Follow Transformers 5 deserialization and precedence for Qwen3-Omni."""
_ = loaded_target_classes
config = Qwen3OmniMoeTalkerCodePredictorConfig(
hidden_size=16,
num_attention_heads=1,
head_dim=16,
num_code_groups=16,
**rope_kwargs,
)
assert not hasattr(config, "rope_theta")
assert config.rope_parameters["rope_theta"] == expected_theta
common_mod = sys.modules["vllm_omni.model_executor.models.common.qwen3_code_predictor"]
rotary = common_mod._RotaryEmbedding(config)
expected = 1.0 / (expected_theta ** (torch.arange(0, 16, 2, dtype=torch.float32) / 16))
torch.testing.assert_close(rotary.inv_freq, expected, rtol=0, atol=0)
@pytest.mark.parametrize("theta", [10_000.0, 1_000_000.0])
@pytest.mark.parametrize(
"legacy_top_level_only",
[False, True],
ids=["normalized", "legacy_top_level"],
)
def test_code_predictor_rotary_preserves_qwen3_tts_rope_theta(
loaded_target_classes,
theta,
legacy_top_level_only,
) -> None:
"""Keep the shared predictor compatible with current and legacy Qwen3-TTS."""
config_class = loaded_target_classes[0]
config = config_class(
hidden_size=16,
num_attention_heads=1,
head_dim=16,
num_code_groups=16,
rope_theta=theta,
)
assert config.rope_parameters["rope_theta"] == theta
if legacy_top_level_only:
delattr(config, "rope_parameters")
common_mod = sys.modules["vllm_omni.model_executor.models.common.qwen3_code_predictor"]
rotary = common_mod._RotaryEmbedding(config)
expected = 1.0 / (theta ** (torch.arange(0, 16, 2, dtype=torch.float32) / 16))
torch.testing.assert_close(rotary.inv_freq, expected, rtol=0, atol=0)
class TestCodePredictorDtypeAlignment:
"""Test that code predictor buffers match model parameter dtype."""