Merge main into dev/vllm-align for vLLM 0.31 release
Signed-off-by: tzhouam <tzhouam@connect.ust.hk>
This commit is contained in:
commit
008fa25263
252 files changed
+27589
-1237
No files matched your search
@@ -0,0 +1,90 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.core_model,
|
||||
pytest.mark.cuda,
|
||||
pytest.mark.skipif(not torch.cuda.is_available() or torch.version.hip is not None, reason="requires CUDA"),
|
||||
]
|
||||
|
||||
|
||||
def _reference(logits, uniforms, top_k, top_p):
|
||||
if top_k > 0:
|
||||
threshold = logits.topk(top_k, dim=-1).values[:, -1:]
|
||||
logits = logits.masked_fill(logits < threshold, -torch.inf)
|
||||
if top_p < 1:
|
||||
values, indices = logits.sort(dim=-1, descending=True)
|
||||
probs = values.softmax(dim=-1, dtype=torch.float32)
|
||||
values[(probs.cumsum(-1) - probs) >= top_p] = -torch.inf
|
||||
logits = values.scatter(1, indices, values)
|
||||
return (logits.float() - torch.log(-torch.log(uniforms))).argmax(-1, keepdim=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("batch,vocab", [(1, 128), (7, 2048), (64, 2048)])
|
||||
@pytest.mark.parametrize("dtype", [torch.float32, torch.float16, torch.bfloat16])
|
||||
@pytest.mark.parametrize("top_k,top_p", [(-1, 0.8), (0, 1.0), (50, 0.8), (50, 0.01)])
|
||||
def test_fixed_uniform_sampling_and_graph(batch, vocab, dtype, top_k, top_p):
|
||||
from vllm_omni.model_executor.models.common.fused_sampling import sample_top_k_top_p_gumbel
|
||||
|
||||
torch.manual_seed(173)
|
||||
logits = torch.randn(batch, vocab, device="cuda", dtype=dtype)
|
||||
# The MTP caller supplies a noncontiguous row slice of [B, 15, vocab].
|
||||
uniforms = torch.rand(batch, 15, vocab, device="cuda")[:, 3].clamp_min_(1e-7)
|
||||
expected = _reference(logits, uniforms, top_k, top_p)
|
||||
actual = sample_top_k_top_p_gumbel(logits, uniforms, top_k=top_k, top_p=top_p)
|
||||
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
|
||||
graph = torch.cuda.CUDAGraph()
|
||||
with torch.cuda.graph(graph):
|
||||
captured = sample_top_k_top_p_gumbel(logits, uniforms, top_k=top_k, top_p=top_p)
|
||||
logits.add_(torch.randn_like(logits) * 0.1)
|
||||
uniforms.uniform_(1e-7, 1 - 1e-7)
|
||||
graph.replay()
|
||||
torch.testing.assert_close(captured, _reference(logits, uniforms, top_k, top_p), rtol=0, atol=0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("top_k,top_p", [(3, 0.5), (3, 1.0), (0, 0.8)])
|
||||
def test_threshold_ties_and_degenerate_rows(top_k, top_p):
|
||||
from vllm_omni.model_executor.models.common.fused_sampling import sample_top_k_top_p_gumbel
|
||||
|
||||
logits = torch.tensor(
|
||||
[[2, 2, 2, 2, 2, 1, 0, -1], [1] * 8, [-torch.inf] * 8, [torch.nan, 1, 2, 3, 4, 5, 6, 7]],
|
||||
device="cuda",
|
||||
dtype=torch.float32,
|
||||
)
|
||||
uniforms = torch.tensor([[0.4, 0.7, 0.8, 0.9, 0.2, 0.3, 0.6, 0.5]], device="cuda").expand(4, -1)
|
||||
actual = sample_top_k_top_p_gumbel(logits, uniforms, top_k=top_k, top_p=top_p)
|
||||
torch.testing.assert_close(actual, _reference(logits, uniforms, top_k, top_p), rtol=0, atol=0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dtype", [torch.float32, torch.float16, torch.bfloat16])
|
||||
def test_native_nucleus_boundaries(dtype):
|
||||
from vllm_omni.model_executor.models.common.fused_sampling import sample_top_k_top_p_gumbel
|
||||
|
||||
torch.manual_seed(1923)
|
||||
logits = torch.randn(1, 2048, device="cuda", dtype=dtype)
|
||||
values, indices = logits.sort(descending=True)
|
||||
probabilities = values.softmax(-1, dtype=torch.float32)
|
||||
before = probabilities.cumsum(-1) - probabilities
|
||||
for rank in [1, 19, 49, 1000, 2000]:
|
||||
boundary = before[0, rank]
|
||||
uniforms = torch.full_like(logits, 0.001, dtype=torch.float32)
|
||||
uniforms[0, indices[0, rank]] = 1 - 1e-7
|
||||
for direction in [-torch.inf, torch.inf]:
|
||||
top_p = torch.nextafter(boundary, boundary.new_tensor(direction)).item()
|
||||
actual = sample_top_k_top_p_gumbel(logits, uniforms, top_k=0, top_p=top_p)
|
||||
torch.testing.assert_close(actual, _reference(logits, uniforms, 0, top_p), rtol=0, atol=0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("top_p", [0.8, 1.0])
|
||||
def test_strided_logits_and_non_power_of_two_vocabulary(top_p):
|
||||
from vllm_omni.model_executor.models.common.fused_sampling import sample_top_k_top_p_gumbel
|
||||
|
||||
for seed in range(16):
|
||||
torch.manual_seed(seed)
|
||||
logits = torch.randn(128, 2050, device="cuda")[:, ::2]
|
||||
uniforms = torch.rand(128, 3, 1025, device="cuda")[:, 1].clamp_min_(1e-7)
|
||||
actual = sample_top_k_top_p_gumbel(logits, uniforms, top_k=-1, top_p=top_p)
|
||||
torch.testing.assert_close(actual, _reference(logits, uniforms, -1, top_p), rtol=0, atol=0)
|
||||
+1
-1
@@ -16,7 +16,7 @@ pytestmark = [
|
||||
@pytest.mark.parametrize("heads,kv_heads,dim", [(16, 8, 128), (8, 8, 64)])
|
||||
@pytest.mark.parametrize("positions", [[0, 1], [2], [8], [15]])
|
||||
def test_short_attention_causal_gqa_and_graph(batch, heads, kv_heads, dim, positions):
|
||||
from vllm_omni.model_executor.models.qwen3_tts.short_kv_attention import short_kv_attention
|
||||
from vllm_omni.model_executor.models.common.short_kv_attention import short_kv_attention
|
||||
|
||||
with torch.inference_mode():
|
||||
torch.manual_seed(42)
|
||||
@@ -0,0 +1,41 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""HiFT graphs consume live inputs and return independently owned waveforms."""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from tests.model_executor.models.cosyvoice3.test_cosyvoice3_incremental_hift import _make_hift
|
||||
|
||||
pytestmark = [pytest.mark.core_model]
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.parametrize("finalize", [False, True])
|
||||
@torch.inference_mode()
|
||||
def test_hift_graph_preserves_current_and_previous_request(finalize):
|
||||
hift = _make_hift().cuda().eval()
|
||||
hift.remove_weight_norm()
|
||||
hift.f0_predictor.remove_weight_norm()
|
||||
hift.enable_decode_graphs()
|
||||
generator = torch.Generator(device="cuda").manual_seed(17)
|
||||
saved: list[tuple[torch.Tensor, torch.Tensor]] = []
|
||||
for _ in range(5):
|
||||
mel = torch.randn(1, 80, 30, device="cuda", generator=generator)
|
||||
source = torch.randn(1, 1, 30 * 480, device="cuda", generator=generator)
|
||||
expected = hift._decode_eager(mel, source, finalize=finalize)
|
||||
actual = hift.decode(mel, source, finalize=finalize)
|
||||
torch.testing.assert_close(actual, expected, rtol=1e-5, atol=1e-6)
|
||||
for previous, snapshot in saved:
|
||||
assert torch.equal(previous, snapshot)
|
||||
if hift._decode_graphs.graphs:
|
||||
saved.append((actual, actual.clone()))
|
||||
assert len(hift._decode_graphs.graphs) == 1
|
||||
hift._decode_graphs.max_graphs = 1
|
||||
mel = torch.randn(1, 80, 31, device="cuda", generator=generator)
|
||||
source = torch.randn(1, 1, 31 * 480, device="cuda", generator=generator)
|
||||
actual = hift.decode(mel, source, finalize=finalize)
|
||||
expected = hift._decode_eager(mel, source, finalize=finalize)
|
||||
assert torch.equal(actual, expected)
|
||||
assert len(hift._decode_graphs.graphs) == 1
|
||||
@@ -13,6 +13,7 @@ from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import
|
||||
HiggsAudioV3TalkerForConditionalGeneration,
|
||||
)
|
||||
from vllm_omni.model_executor.models.higgs_audio_v3.model_state import HiggsModelState
|
||||
from vllm_omni.worker_v2.model_states.eager_mtp import EagerMTPState
|
||||
from vllm_omni.worker_v2.model_states.intermediate_buffer import OmniIntermediateBuffer
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
@@ -34,6 +35,8 @@ def make_state():
|
||||
_audio_continuation_id=99,
|
||||
update_decode_step_metadata=Mock(),
|
||||
)
|
||||
state._stream_pos = {}
|
||||
state._eager_state = EagerMTPState(state)
|
||||
return state
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Waveform ownership across graph replays and asynchronous output copies."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from torch import nn
|
||||
|
||||
from vllm_omni.model_executor.models.moss_tts.modeling_moss_tts_codec import MossTTSCodecDecoder
|
||||
from vllm_omni.model_executor.output_snapshot import PackedOutputSnapshot
|
||||
|
||||
pytestmark = [pytest.mark.core_model]
|
||||
|
||||
|
||||
class CausalCodec(nn.Module):
|
||||
downsample_rate = 1
|
||||
|
||||
def __init__(self, channels):
|
||||
super().__init__()
|
||||
self.weight = nn.Parameter(torch.zeros(()))
|
||||
self.channels = channels
|
||||
self.config = SimpleNamespace(codebook_size=1024)
|
||||
|
||||
def initialize_decoder_state_pool(self, capacity, scratch):
|
||||
self.state = torch.zeros(capacity + scratch, device=self.weight.device)
|
||||
|
||||
def reset_decoder_state_slots(self, slots):
|
||||
self.state[slots] = 0
|
||||
|
||||
def decode_streaming_batch(self, codes, lengths, slots, valid_rows):
|
||||
audio = codes.sum(0).float().cumsum(-1) + self.state[slots, None]
|
||||
self.state[slots] = audio[:, -1]
|
||||
channels = torch.arange(1, self.channels + 1, device=audio.device)
|
||||
return SimpleNamespace(audio=audio[:, None] * channels[None, :, None], audio_lengths=lengths)
|
||||
|
||||
|
||||
class ReplayGraph:
|
||||
"""A real CUDA graph with deliberately reused output storage."""
|
||||
|
||||
batch_sizes = [1]
|
||||
|
||||
def __init__(self, codec):
|
||||
self.codes = torch.ones((2, 1, 3), device="cuda", dtype=torch.long)
|
||||
self.slots = torch.zeros(1, device="cuda", dtype=torch.long)
|
||||
self.lengths = torch.full((1,), 3, device="cuda", dtype=torch.long)
|
||||
self.valid = torch.ones(1, device="cuda", dtype=torch.bool)
|
||||
stream = torch.cuda.Stream()
|
||||
stream.wait_stream(torch.cuda.current_stream())
|
||||
with torch.cuda.stream(stream):
|
||||
for _ in range(3):
|
||||
codec.decode_streaming_batch(self.codes, self.lengths, self.slots, self.valid)
|
||||
torch.cuda.current_stream().wait_stream(stream)
|
||||
self.graph = torch.cuda.CUDAGraph()
|
||||
with torch.cuda.graph(self.graph):
|
||||
self.output = codec.decode_streaming_batch(self.codes, self.lengths, self.slots, self.valid)
|
||||
codec.state.zero_()
|
||||
|
||||
def decode(self, codes, slots, *, allow_frame_padding):
|
||||
if codes.shape[1:] != (1, 3):
|
||||
return None
|
||||
self.codes.copy_(codes)
|
||||
self.slots.copy_(slots)
|
||||
self.graph.replay()
|
||||
return self.output.audio, self.output.audio_lengths, 1
|
||||
|
||||
|
||||
def make_decoder(*, v2=True, channels=2, device="cpu", model_type="moss_tts_local", streaming=True):
|
||||
config = SimpleNamespace(
|
||||
model_config=SimpleNamespace(
|
||||
hf_config=SimpleNamespace(n_vq=2, model_type=model_type),
|
||||
async_chunk=streaming,
|
||||
use_v2_model_runner=v2,
|
||||
),
|
||||
scheduler_config=SimpleNamespace(max_num_seqs=8),
|
||||
)
|
||||
decoder = MossTTSCodecDecoder(vllm_config=config)
|
||||
decoder._codec = CausalCodec(channels).to(device)
|
||||
decoder._n_channels = channels
|
||||
decoder._stream_max_step_frames = 3
|
||||
return decoder
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("ramp,frames", [(None, [1, 15]), ([1, 4, 8, 15], [1, 4, 8, 15]), ([1, 4, 4, 15], [1, 4, 15])])
|
||||
def test_ramp_chunks_are_captured_at_exact_frame_counts(ramp, frames):
|
||||
config = SimpleNamespace(
|
||||
model_config=SimpleNamespace(
|
||||
hf_config=SimpleNamespace(n_vq=2, model_type="moss_tts_local"),
|
||||
async_chunk=True,
|
||||
use_v2_model_runner=True,
|
||||
stage_connector_config={
|
||||
"extra": {
|
||||
"initial_codec_chunk_frames": 1,
|
||||
"codec_chunk_frames": 15,
|
||||
"codec_chunk_ramp": ramp,
|
||||
}
|
||||
},
|
||||
),
|
||||
scheduler_config=SimpleNamespace(max_num_seqs=8),
|
||||
)
|
||||
decoder = MossTTSCodecDecoder(vllm_config=config)
|
||||
assert decoder._streaming_graph_frame_sizes == frames
|
||||
assert decoder._stream_max_step_frames == 15
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize(
|
||||
"v2,streaming,model_type",
|
||||
[
|
||||
(False, True, "moss_tts_local"),
|
||||
(True, False, "moss_tts_local"),
|
||||
(True, True, "moss_tts_realtime"),
|
||||
(True, True, "moss_tts_local"),
|
||||
],
|
||||
)
|
||||
def test_gpu_output_requires_local_streaming_mrv2_and_cuda(v2, streaming, model_type):
|
||||
decoder = make_decoder(v2=v2, streaming=streaming, model_type=model_type)
|
||||
assert decoder._gpu_stream_output == (v2 and streaming and model_type == "moss_tts_local")
|
||||
session = decoder._ensure_stream_session()
|
||||
slot = session.acquire()
|
||||
assert session.step({slot: torch.ones(2, 3)})[slot].device.type == "cpu"
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.parametrize("v2", [False, True])
|
||||
@pytest.mark.parametrize("channels", [1, 2])
|
||||
@pytest.mark.parametrize("graph", [False, True])
|
||||
@torch.no_grad()
|
||||
def test_replays_terminal_tails_and_slot_reuse_preserve_pending_waveforms(v2, channels, graph):
|
||||
from vllm_omni.outputs import OmniModelRunnerOutput
|
||||
from vllm_omni.worker_v2.omni_generation_model_runner import (
|
||||
OmniGenerationAsyncOutput,
|
||||
OmniGenerationModelRunner,
|
||||
_contains_cuda_tensor,
|
||||
)
|
||||
|
||||
decoder = make_decoder(v2=v2, channels=channels, device="cuda")
|
||||
# Real Local-v1.5 weights replace the class's 24 kHz default at load time.
|
||||
decoder._sr_tensor = torch.tensor(48_000, dtype=torch.int32)
|
||||
session = decoder._ensure_stream_session()
|
||||
if graph:
|
||||
session._cudagraph_wrapper = ReplayGraph(decoder._codec)
|
||||
copy_stream = torch.cuda.Stream()
|
||||
pending = []
|
||||
# Do not materialize any output until later forwards have replayed the
|
||||
# graph, reset terminal/cancelled state, and reused those state slots.
|
||||
for round_index in range(4):
|
||||
value = round_index + 1
|
||||
codes = torch.cat(
|
||||
[
|
||||
torch.full((14,), value, device="cuda", dtype=torch.long),
|
||||
torch.full((6,), value + 10, device="cuda", dtype=torch.long),
|
||||
]
|
||||
)
|
||||
info = [{"request_id": rid, "meta": {"finished": rid == "tail"}} for rid in ["tail", "empty", "live"]]
|
||||
output = decoder.forward(codes, runtime_additional_information=info, seq_token_counts=[14, 0, 6])
|
||||
# The runner may overwrite its input buffer before codec completion.
|
||||
codes.fill_(-100)
|
||||
payload = output.multimodal_outputs
|
||||
assert _contains_cuda_tensor(payload) == v2
|
||||
assert isinstance(payload, PackedOutputSnapshot) == v2
|
||||
if v2:
|
||||
# Exactly one waveform slab, regardless of request/chunk count.
|
||||
assert sum(slab.is_cuda for _, slab in payload._groups) == 1
|
||||
result = OmniModelRunnerOutput(
|
||||
req_ids=["tail", "empty", "live"],
|
||||
req_id_to_index={"tail": 0, "empty": 1, "live": 2},
|
||||
sampled_token_ids=[[], [], []],
|
||||
)
|
||||
pending.append(
|
||||
OmniGenerationAsyncOutput(
|
||||
model_runner_output=result,
|
||||
multimodal_outputs=payload,
|
||||
num_reqs=3,
|
||||
main_stream=torch.cuda.current_stream(),
|
||||
copy_stream=copy_stream,
|
||||
)
|
||||
)
|
||||
else:
|
||||
pending.append(OmniGenerationModelRunner._build_pooler_output(output, 3))
|
||||
decoder.on_requests_finished(["live"])
|
||||
assert decoder._stream_req_slots == {}
|
||||
assert not session._leased_slots
|
||||
|
||||
for round_index, item in enumerate(pending):
|
||||
rows = item.get_output().multimodal_outputs if v2 else item
|
||||
for index, (frames, value) in enumerate([(7, round_index + 1), (0, 0), (3, round_index + 11)]):
|
||||
expected = 2 * value * torch.arange(1, frames + 1, dtype=torch.float32)
|
||||
if channels > 1:
|
||||
expected = expected[None] * torch.arange(1, channels + 1)[:, None]
|
||||
torch.testing.assert_close(rows[index]["model_outputs"], expected, rtol=0, atol=0)
|
||||
assert rows[index]["sr"].item() == 48_000
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@torch.no_grad()
|
||||
def test_owned_input_copy_clamps_entire_ragged_batch():
|
||||
from vllm_omni.worker_v2.omni_ar_model_runner import _async_copy_mm
|
||||
|
||||
decoder = make_decoder(device="cuda", channels=2)
|
||||
codes = torch.tensor([-1, 1024, 1, -10, 2, 1025, 5000, -2], device="cuda")
|
||||
output = decoder.forward(
|
||||
codes,
|
||||
runtime_additional_information=[{"request_id": name} for name in ("a", "empty", "b")],
|
||||
seq_token_counts=[6, 0, 2],
|
||||
)
|
||||
codes.fill_(-100)
|
||||
cpu = _async_copy_mm(output.multimodal_outputs, 0, pin_memory=True)
|
||||
torch.accelerator.synchronize()
|
||||
expected = torch.tensor([[0.0, 1025.0, 2049.0], [0.0, 2050.0, 4098.0]])
|
||||
torch.testing.assert_close(cpu["model_outputs"][0], expected, rtol=0, atol=0)
|
||||
assert cpu["model_outputs"][1].numel() == 0
|
||||
torch.testing.assert_close(cpu["model_outputs"][2], torch.tensor([[1023.0], [2046.0]]), rtol=0, atol=0)
|
||||
decoder.on_requests_finished(["a", "b"])
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@torch.no_grad()
|
||||
def test_pinned_metadata_reuse_waits_for_dma_and_preserves_mixed_terminals():
|
||||
decoder = make_decoder(device="cuda")
|
||||
session = decoder._ensure_stream_session()
|
||||
# Force reuse every call, with an outstanding transfer on the first call.
|
||||
# This delay tests host-buffer ownership, never performance or overlap.
|
||||
session._metadata_ring = session._metadata_ring[:1]
|
||||
slots = [session.acquire() for _ in range(3)]
|
||||
pending = []
|
||||
for step in range(8):
|
||||
order = slots[step % 3 :] + slots[: step % 3]
|
||||
terminal = {order[1]}
|
||||
torch.cuda._sleep(2_000_000)
|
||||
ids, tails = session._stage_slot_ids(order, terminal)
|
||||
pending.append((ids.clone(), tails.clone(), order, list(terminal)))
|
||||
session.close()
|
||||
for ids, tails, expected_ids, expected_tails in pending:
|
||||
assert ids.cpu().tolist() == expected_ids
|
||||
assert tails.cpu().tolist() == expected_tails
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@torch.no_grad()
|
||||
def test_mixed_terminal_reset_does_not_reset_live_slot():
|
||||
decoder = make_decoder(device="cuda", channels=1)
|
||||
session = decoder._ensure_stream_session()
|
||||
first, second = session.acquire(), session.acquire()
|
||||
codes = torch.ones(2, 3, device="cuda")
|
||||
initial = session.step({first: codes, second: 2 * codes}, terminal_slots={first})
|
||||
following = session.step({second: codes, first: codes})
|
||||
torch.testing.assert_close(initial[first].cpu(), torch.tensor([[2.0, 4.0, 6.0]]), rtol=0, atol=0)
|
||||
torch.testing.assert_close(following[first].cpu(), torch.tensor([[2.0, 4.0, 6.0]]), rtol=0, atol=0)
|
||||
torch.testing.assert_close(following[second].cpu(), torch.tensor([[14.0, 16.0, 18.0]]), rtol=0, atol=0)
|
||||
session.close()
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.parametrize("explicit_copy_stream", [False, True])
|
||||
@torch.no_grad()
|
||||
def test_output_copy_waits_for_private_codec_stream(explicit_copy_stream):
|
||||
from vllm_omni.worker_v2.omni_ar_model_runner import _async_copy_mm
|
||||
|
||||
decoder = make_decoder(device="cuda", channels=1)
|
||||
decoder._codec_stream = torch.cuda.Stream()
|
||||
with torch.cuda.stream(decoder._codec_stream):
|
||||
torch.cuda._sleep(20_000_000)
|
||||
codes = torch.ones(6, device="cuda", dtype=torch.long)
|
||||
output = decoder.forward(
|
||||
codes,
|
||||
runtime_additional_information=[{"request_id": "live"}],
|
||||
seq_token_counts=[6],
|
||||
)
|
||||
codes.fill_(-100)
|
||||
payload = output.multimodal_outputs
|
||||
assert isinstance(payload, PackedOutputSnapshot)
|
||||
assert payload.producer_event is not None
|
||||
# No explicit consumer wait here: the shared copier must honor readiness.
|
||||
copy_stream = torch.cuda.Stream()
|
||||
with torch.cuda.stream(copy_stream):
|
||||
cpu = _async_copy_mm(payload, 0, copy_stream=copy_stream if explicit_copy_stream else None, pin_memory=True)
|
||||
decoder.on_requests_finished(["live"])
|
||||
copy_stream.synchronize()
|
||||
torch.testing.assert_close(cpu["model_outputs"][0], torch.tensor([2.0, 4.0, 6.0]), rtol=0, atol=0)
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.parametrize("channels", [1, 2])
|
||||
@torch.no_grad()
|
||||
def test_short_terminal_row_crops_each_channel_into_output_slab(channels):
|
||||
decoder = make_decoder(device="cuda", channels=channels)
|
||||
session = decoder._ensure_stream_session()
|
||||
short, live = session.acquire(), session.acquire()
|
||||
codes = {short: torch.ones(2, 2, device="cuda"), live: 3 * torch.ones(2, 5, device="cuda")}
|
||||
slab = torch.empty(channels * 7, device="cuda")
|
||||
buffers = {
|
||||
short: slab[: channels * 2].view(channels, 2),
|
||||
live: slab[channels * 2 :].view(channels, 5),
|
||||
}
|
||||
result = session.step(codes, terminal_slots={short}, output_buffers=buffers)
|
||||
for slot, frames, value in [(short, 2, 1), (live, 5, 3)]:
|
||||
expected = 2 * value * torch.arange(1, frames + 1, dtype=torch.float32)[None]
|
||||
expected = expected * torch.arange(1, channels + 1)[:, None]
|
||||
assert result[slot].data_ptr() == buffers[slot].data_ptr()
|
||||
torch.testing.assert_close(result[slot].cpu(), expected, rtol=0, atol=0)
|
||||
session.close()
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.parametrize("channels", [1, 2])
|
||||
@pytest.mark.parametrize("layout", ["adjacent", "strided", "gapped"])
|
||||
@torch.no_grad()
|
||||
def test_batched_output_copy_preserves_rows_and_slab_ownership(channels, layout):
|
||||
decoder = make_decoder(device="cuda", channels=channels)
|
||||
session = decoder._ensure_stream_session()
|
||||
slots = [session.acquire() for _ in range(4)]
|
||||
slab = torch.full((6 if layout == "gapped" else 4, channels, 6), -1.0, device="cuda")
|
||||
if layout == "gapped":
|
||||
buffers = {slot: slab[row] for row, slot in zip([0, 1, 3, 4], slots, strict=True)}
|
||||
frames = 6
|
||||
elif layout == "adjacent":
|
||||
buffers = {slot: slab.reshape(4, channels * 6)[row].view(channels, 6) for row, slot in enumerate(slots)}
|
||||
frames = 6
|
||||
else:
|
||||
# Interleaved with other requests' output: the short views must not
|
||||
# overwrite the untouched part of another row/channel.
|
||||
buffers = {slot: slab[row, :, :3] for row, slot in enumerate(slots)}
|
||||
frames = 3
|
||||
codes = {slot: torch.full((2, frames), row + 1, device="cuda") for row, slot in enumerate(slots)}
|
||||
result = session.step(codes, output_buffers=buffers)
|
||||
for row, slot in enumerate(slots):
|
||||
expected = 2 * (row + 1) * torch.arange(1, frames + 1, dtype=torch.float32)[None]
|
||||
expected = expected * torch.arange(1, channels + 1)[:, None]
|
||||
assert result[slot].data_ptr() == buffers[slot].data_ptr()
|
||||
torch.testing.assert_close(result[slot].cpu(), expected, rtol=0, atol=0)
|
||||
if layout == "strided":
|
||||
assert torch.all(slab[:, :, 3:] == -1)
|
||||
elif layout == "gapped":
|
||||
assert torch.all(slab[[2, 5]] == -1)
|
||||
session.close()
|
||||
@@ -0,0 +1,146 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Chunked streaming decode must match whole-sequence decode.
|
||||
|
||||
``RingKVCache.complete`` writes a whole chunk before attending. With a ring of
|
||||
exactly ``context`` entries, a chunk of T tokens evicts up to T keys that its
|
||||
own queries should still see; for T > context it evicts part of the chunk
|
||||
itself. ``initialize_decoder_state_pool(chunk_frames=...)`` adds one chunk of
|
||||
headroom per layer so streaming reproduces the non-streaming decoder.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.models.moss_tts.audio_tokenizer_v2 import MossAudioTokenizerModel
|
||||
from vllm_omni.model_executor.models.moss_tts.configuration_moss_audio_tokenizer_v2 import (
|
||||
MossAudioTokenizerConfig,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
TRANSFORMER = {
|
||||
"module_type": "Transformer",
|
||||
"d_model": 16,
|
||||
"num_heads": 2,
|
||||
"num_layers": 1,
|
||||
"dim_feedforward": 32,
|
||||
"causal": True,
|
||||
"norm": "layer_norm",
|
||||
"positional_embedding": "rope",
|
||||
"max_period": 10000,
|
||||
"gating": "none",
|
||||
"layer_scale": 1.0,
|
||||
"conv_layout": True,
|
||||
}
|
||||
|
||||
|
||||
def tiny_codec() -> MossAudioTokenizerModel:
|
||||
# 64 Hz mono input, 4-sample code frames (16 code frames per second).
|
||||
# Decoder: transformer @16 Hz (context 0.5 s = 8 tokens) -> x2 -> transformer
|
||||
# @32 Hz (context 0.25 s = 8 tokens) -> x2 -> 64 Hz. A 6-frame chunk is 12
|
||||
# tokens at the second transformer, more than its 8-token ring.
|
||||
config = MossAudioTokenizerConfig(
|
||||
sampling_rate=64,
|
||||
downsample_rate=4,
|
||||
number_channels=1,
|
||||
enable_channel_interleave=False,
|
||||
encoder_kwargs=[
|
||||
{"module_type": "PatchedPretransform", "patch_size": 4},
|
||||
{**TRANSFORMER, "input_dimension": 4, "output_dimension": 8, "context_duration": 0.5},
|
||||
],
|
||||
decoder_kwargs=[
|
||||
{**TRANSFORMER, "input_dimension": 8, "output_dimension": 16, "context_duration": 0.5},
|
||||
{"module_type": "PatchedPretransform", "patch_size": 2},
|
||||
{**TRANSFORMER, "input_dimension": 8, "output_dimension": 2, "context_duration": 0.25},
|
||||
{"module_type": "PatchedPretransform", "patch_size": 2},
|
||||
],
|
||||
quantizer_kwargs={
|
||||
"input_dim": 8,
|
||||
"rvq_dim": 8,
|
||||
"output_dim": 8,
|
||||
"num_quantizers": 2,
|
||||
"codebook_size": 16,
|
||||
"codebook_dim": 4,
|
||||
"quantizer_type": "rlfq",
|
||||
},
|
||||
)
|
||||
torch.manual_seed(0)
|
||||
model = MossAudioTokenizerModel(config).eval()
|
||||
with torch.no_grad():
|
||||
for parameter in model.parameters():
|
||||
parameter.copy_(torch.randn_like(parameter) * 0.5)
|
||||
model.quantizer.build_decode_lut(2, dtype=torch.float32)
|
||||
return model
|
||||
|
||||
|
||||
def stream_decode(model, codes, chunk_frames, *, headroom):
|
||||
n_q, _, total = codes.shape
|
||||
model.initialize_decoder_state_pool(1, 0, chunk_frames=chunk_frames if headroom else 0)
|
||||
try:
|
||||
slot = torch.zeros(1, dtype=torch.long)
|
||||
valid = torch.ones(1, dtype=torch.bool)
|
||||
parts = []
|
||||
for start in range(0, total, chunk_frames):
|
||||
chunk = codes[:, :, start : start + chunk_frames]
|
||||
lengths = torch.full((1,), chunk.shape[-1], dtype=torch.long)
|
||||
parts.append(model.decode_streaming_batch(chunk, lengths, slot, valid).audio)
|
||||
return torch.cat(parts, dim=-1)
|
||||
finally:
|
||||
model.close_decoder_state_pool()
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def test_ring_headroom_sizes_each_layer_for_one_chunk():
|
||||
model = tiny_codec()
|
||||
transformers = [m for m in model.decoder if hasattr(m, "tokens_per_input_frame")]
|
||||
assert [m.tokens_per_input_frame for m in transformers] == [1, 2]
|
||||
assert [m.transformer.layers[0].self_attn.context for m in transformers] == [8, 8]
|
||||
model.initialize_decoder_state_pool(1, 0, chunk_frames=6)
|
||||
try:
|
||||
rings = [m.transformer.layers[0].self_attn._streaming_state.kv_cache.capacity for m in transformers]
|
||||
assert rings == [8 + 6, 8 + 12]
|
||||
finally:
|
||||
model.close_decoder_state_pool()
|
||||
model.initialize_decoder_state_pool(1, 0)
|
||||
try:
|
||||
rings = [m.transformer.layers[0].self_attn._streaming_state.kv_cache.capacity for m in transformers]
|
||||
assert rings == [8, 8]
|
||||
finally:
|
||||
model.close_decoder_state_pool()
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def test_chunked_streaming_matches_whole_sequence_only_with_headroom():
|
||||
model = tiny_codec()
|
||||
torch.manual_seed(1)
|
||||
codes = torch.randint(0, 16, (2, 1, 18))
|
||||
reference = model._decode_frame(codes).audio
|
||||
with_headroom = stream_decode(model, codes, 6, headroom=True)
|
||||
legacy = stream_decode(model, codes, 6, headroom=False)
|
||||
assert with_headroom.shape == reference.shape == legacy.shape
|
||||
torch.testing.assert_close(with_headroom, reference, rtol=1e-5, atol=1e-5)
|
||||
# The legacy context-sized ring lets the 12-token chunk evict keys inside
|
||||
# its own window, so streaming diverges from whole-sequence decoding.
|
||||
assert not torch.allclose(legacy, reference, rtol=1e-3, atol=1e-3)
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def test_padded_terminal_tail_matches_exact_tail_with_headroom():
|
||||
model = tiny_codec()
|
||||
torch.manual_seed(2)
|
||||
codes = torch.randint(0, 16, (2, 1, 14))
|
||||
history, tail = codes[:, :, :12], codes[:, :, 12:]
|
||||
padded_tail = torch.nn.functional.pad(tail, (0, 4)) # 2 real frames padded to a 6-frame step
|
||||
outputs = {}
|
||||
for name, last in [("exact", tail), ("padded", padded_tail)]:
|
||||
model.initialize_decoder_state_pool(1, 0, chunk_frames=6)
|
||||
try:
|
||||
slot, valid = torch.zeros(1, dtype=torch.long), torch.ones(1, dtype=torch.bool)
|
||||
for chunk in (history[:, :, :6], history[:, :, 6:]):
|
||||
model.decode_streaming_batch(chunk, torch.tensor([6]), slot, valid)
|
||||
audio = model.decode_streaming_batch(last, torch.tensor([last.shape[-1]]), slot, valid).audio
|
||||
outputs[name] = audio[..., : 2 * model.downsample_rate]
|
||||
finally:
|
||||
model.close_decoder_state_pool()
|
||||
torch.testing.assert_close(outputs["padded"], outputs["exact"], rtol=1e-5, atol=1e-5)
|
||||
@@ -0,0 +1,547 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""The first-chunk fast path emits audio out of band; the main path resumes the stream."""
|
||||
|
||||
import queue
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from torch import nn
|
||||
|
||||
from tests.model_executor.models.moss_tts.test_streaming_terminal_batch import session as cpu_session
|
||||
from vllm_omni.model_executor.models.moss_tts.first_chunk_fast_path import MossFirstChunkFastPath, _SlotHandoff
|
||||
from vllm_omni.model_executor.models.moss_tts.modeling_moss_tts_codec import MossTTSCodecDecoder
|
||||
from vllm_omni.worker_v2.first_audio_sender import engine_output_queue_sink
|
||||
|
||||
pytestmark = pytest.mark.core_model
|
||||
|
||||
N_VQ, SAMPLES, CHANNELS = 2, 4, 2
|
||||
|
||||
|
||||
class _Session:
|
||||
def __init__(self, capacity):
|
||||
self.free = list(range(capacity))
|
||||
self.reset_waits = []
|
||||
|
||||
def acquire(self, *, oldest=False):
|
||||
return self.free.pop(0 if oldest else -1) if self.free else None
|
||||
|
||||
def order_after_reset(self, slot, stream):
|
||||
self.reset_waits.append(slot)
|
||||
|
||||
def release(self, slot, **kwargs):
|
||||
self.free.append(slot)
|
||||
|
||||
|
||||
class _Wrapper:
|
||||
batch_sizes = [1, 2, 4, 8]
|
||||
|
||||
def __init__(self, device):
|
||||
self.state = torch.zeros(16, device=device)
|
||||
self.calls = []
|
||||
self.gate = threading.Event()
|
||||
self.gate.set()
|
||||
|
||||
def decode(self, codes, slots):
|
||||
self.gate.wait()
|
||||
self.calls.append(int(codes.shape[1]))
|
||||
base = codes.sum(0)[:, 0].float() + 100 * slots.float()
|
||||
channel = torch.arange(CHANNELS, device=codes.device).float()
|
||||
audio = base[:, None, None] + channel[None, :, None] + torch.zeros(1, 1, SAMPLES, device=codes.device)
|
||||
self.state[slots] = 1
|
||||
return audio.to(torch.bfloat16), None, int(codes.shape[1])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cuda():
|
||||
if not torch.cuda.is_available():
|
||||
pytest.skip("CUDA required")
|
||||
return torch.device("cuda")
|
||||
|
||||
|
||||
def _fast(device, capacity=4):
|
||||
session, wrapper = _Session(capacity), _Wrapper(device)
|
||||
fast = MossFirstChunkFastPath(
|
||||
session,
|
||||
wrapper,
|
||||
n_vq=N_VQ,
|
||||
frames=1,
|
||||
codebook_size=1024,
|
||||
samples_per_frame=SAMPLES,
|
||||
n_channels=CHANNELS,
|
||||
sample_rate=torch.tensor(24000, dtype=torch.int32),
|
||||
device=device,
|
||||
)
|
||||
return fast, session, wrapper
|
||||
|
||||
|
||||
class _CallbackSink:
|
||||
def __init__(self, callback):
|
||||
self.callback = callback
|
||||
|
||||
def prepare(self, request_ids):
|
||||
callback = self.callback
|
||||
|
||||
class Delivery:
|
||||
routes = {rid: (3 if rid == "r0" else 0) for rid in request_ids}
|
||||
|
||||
def __call__(self, ids, rows, sr):
|
||||
for rid, row in zip(ids, rows, strict=True):
|
||||
callback(self.routes[rid], rid, {"model_outputs": row, "sr": sr})
|
||||
|
||||
def fail(self, ids):
|
||||
pass
|
||||
|
||||
return Delivery()
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
def test_emits_each_first_chunk_and_hands_slot_to_main_path(cuda):
|
||||
fast, session, wrapper = _fast(cuda)
|
||||
emitted = {}
|
||||
done = threading.Event()
|
||||
|
||||
def sink(client, req_id, payload):
|
||||
emitted[req_id] = (client, payload["model_outputs"], int(payload["sr"]))
|
||||
if len(emitted) == 2:
|
||||
done.set()
|
||||
|
||||
slots: dict[str, int] = {}
|
||||
assert not fast.submit("r0", "k0", [1, 2], slots) # unbound: main path owns it
|
||||
fast.bind(_CallbackSink(sink))
|
||||
wrapper.gate.clear()
|
||||
assert fast.submit("r0", "k0", torch.tensor([1, 2]), slots)
|
||||
assert fast.submit("r1", "k1", [5, 7], slots)
|
||||
assert not fast.submit("r0", "k0", [1, 2], slots) # already has a slot
|
||||
assert not fast.submit("r2", "k2", [1, 2, 3, 4], slots) # not a 1-frame chunk
|
||||
assert slots == {"k0": 0, "k1": 1} and session.free == [2, 3]
|
||||
wrapper.gate.set()
|
||||
assert done.wait(10)
|
||||
fast.close()
|
||||
|
||||
assert wrapper.calls in ([1, 1], [2])
|
||||
for req_id, client, code_sum, slot in (("r0", 3, 3, 0), ("r1", 0, 12, 1)):
|
||||
got_client, wav, sr = emitted[req_id]
|
||||
assert got_client == client and sr == 24000 and wav.device.type == "cpu"
|
||||
expected = (code_sum + 100 * slot + torch.arange(CHANNELS).float())[:, None].expand(CHANNELS, SAMPLES)
|
||||
torch.testing.assert_close(wav, expected)
|
||||
assert sorted(session.reset_waits) == [0, 1]
|
||||
|
||||
# The main path consumes the marker once and is ordered after the decode.
|
||||
assert fast.take_decoded("k0") and not fast.take_decoded("k0")
|
||||
fast.order_after(0)
|
||||
fast.order_after(0) # no pending handoff: no-op
|
||||
assert wrapper.state[:2].tolist() == [1, 1]
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
def test_order_after_blocks_until_decode_finishes(cuda):
|
||||
fast, _, wrapper = _fast(cuda)
|
||||
fast.bind(_CallbackSink(lambda *args: None))
|
||||
wrapper.gate.clear()
|
||||
assert fast.submit("r0", "k0", [1, 2], {})
|
||||
finished = threading.Event()
|
||||
|
||||
def wait_for_slot():
|
||||
fast.order_after(0)
|
||||
finished.set()
|
||||
|
||||
waiter = threading.Thread(target=wait_for_slot)
|
||||
waiter.start()
|
||||
assert not finished.wait(0.2)
|
||||
wrapper.gate.set()
|
||||
assert finished.wait(10)
|
||||
waiter.join()
|
||||
fast.close()
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
def test_no_free_slot_leaves_chunk_to_main_path(cuda):
|
||||
fast, _, _ = _fast(cuda, capacity=0)
|
||||
fast.bind(_CallbackSink(lambda *args: None))
|
||||
slots: dict[str, int] = {}
|
||||
assert not fast.submit("r0", "k0", [1, 2], slots)
|
||||
assert slots == {} and not fast.take_decoded("k0")
|
||||
fast.close()
|
||||
|
||||
|
||||
class _FakeFast:
|
||||
frames = 1
|
||||
|
||||
def __init__(self, decoded, unsent=None):
|
||||
self.decoded = set(decoded)
|
||||
self.unsent = dict(unsent or {})
|
||||
self.ordered = []
|
||||
self.forgotten = []
|
||||
|
||||
def take_decoded(self, key):
|
||||
if key in self.decoded:
|
||||
self.decoded.remove(key)
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_request_slot(self, key, slots):
|
||||
return slots.get(key)
|
||||
|
||||
def order_after(self, slot):
|
||||
self.ordered.append(slot)
|
||||
|
||||
def take_unsent_audio(self, key):
|
||||
return self.unsent.pop(key, None)
|
||||
|
||||
def forget(self, key):
|
||||
self.forgotten.append(key)
|
||||
|
||||
def gate(self):
|
||||
self.gated = getattr(self, "gated", 0) + 1
|
||||
|
||||
|
||||
def _decoder(s, fast):
|
||||
decoder = object.__new__(MossTTSCodecDecoder)
|
||||
nn.Module.__init__(decoder)
|
||||
decoder._stream_max_step_frames = 15
|
||||
decoder._stream_state_capacity = 8
|
||||
decoder._stream_req_slots = {}
|
||||
decoder._ensure_stream_session = lambda: s
|
||||
decoder._first_chunk_fast_path = fast
|
||||
decoder._stream_session = s
|
||||
decoder._codec_stream = None
|
||||
return decoder
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_main_path_skips_fast_decoded_chunk_and_resumes_state():
|
||||
s = cpu_session()
|
||||
reference = cpu_session()
|
||||
fast = _FakeFast({"a"})
|
||||
decoder = _decoder(s, fast)
|
||||
# The fast path leased slot 0 and decoded frame 0 of "a" into it.
|
||||
slot = s.acquire(oldest=True)
|
||||
decoder._stream_req_slots["a"] = slot
|
||||
s.step({slot: torch.full((2, 1), 3.0)})
|
||||
ref_slot = reference.acquire()
|
||||
reference.step({ref_slot: torch.full((2, 1), 3.0)})
|
||||
|
||||
out = decoder._decode_streaming_batch([(0, "a", torch.full((2, 1), 3.0), False), (1, "b", torch.ones(2, 1), False)])
|
||||
assert 0 not in out and out[1].tolist() == [[2.0]]
|
||||
assert fast.ordered == [slot]
|
||||
|
||||
out = decoder._decode_streaming_batch([(0, "a", torch.ones(2, 3), True)])
|
||||
expected = reference.step({ref_slot: torch.ones(2, 3)})[ref_slot]
|
||||
torch.testing.assert_close(out[0], expected)
|
||||
assert "a" not in decoder._stream_req_slots and fast.ordered == [slot, slot]
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_abort_orders_release_after_fast_decode():
|
||||
s = cpu_session()
|
||||
fast = _FakeFast({"a"})
|
||||
decoder = _decoder(s, fast)
|
||||
slot = decoder._stream_req_slots["a"] = s.acquire(oldest=True)
|
||||
decoder.on_requests_finished({"a"})
|
||||
assert fast.ordered == [slot] and fast.forgotten == ["a"] and decoder._stream_req_slots == {}
|
||||
assert slot in s._free_stream_slots
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_main_path_returns_unsent_first_chunk_without_decoding_twice():
|
||||
s = cpu_session()
|
||||
first = torch.full((1, 1), 7.0)
|
||||
fast = _FakeFast({"a"}, {"a": first})
|
||||
decoder = _decoder(s, fast)
|
||||
slot = s.acquire(oldest=True)
|
||||
decoder._stream_req_slots["a"] = slot
|
||||
s.step({slot: torch.full((2, 1), 3.0)})
|
||||
|
||||
out = decoder._decode_streaming_batch([(0, "a", torch.full((2, 1), 3.0), False)])
|
||||
torch.testing.assert_close(out[0], first)
|
||||
assert fast.unsent == {} and fast.ordered == [slot]
|
||||
|
||||
# A payload containing a later frame must concatenate it after the
|
||||
# preserved first samples, while advancing state only for the later frame.
|
||||
first = torch.full((1, 1), 7.0)
|
||||
fast.decoded.add("b")
|
||||
fast.unsent["b"] = first
|
||||
slot_b = s.acquire(oldest=True)
|
||||
decoder._stream_req_slots["b"] = slot_b
|
||||
s.step({slot_b: torch.full((2, 1), 3.0)})
|
||||
out = decoder._decode_streaming_batch([(0, "b", torch.full((2, 2), 3.0), False)])
|
||||
torch.testing.assert_close(out[0][..., :1], first)
|
||||
assert out[0].shape[-1] == 2
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_oldest_acquire_reuses_least_recently_released_slot():
|
||||
s = cpu_session()
|
||||
a, b = s.acquire(), s.acquire()
|
||||
s.release(a)
|
||||
s.release(b)
|
||||
assert s.acquire() == b
|
||||
assert s.acquire(oldest=True) == 7 # never-used slots were freed before any release
|
||||
assert s._free_stream_slots[-1] == a
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
def test_failed_decode_reports_error_without_replaying_state(cuda):
|
||||
fast, _, wrapper = _fast(cuda)
|
||||
|
||||
def fail(codes, slots):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
wrapper.decode = fail
|
||||
fast.bind(_CallbackSink(lambda *args: pytest.fail("no output expected")))
|
||||
slots: dict[str, int] = {}
|
||||
assert fast.submit("r0", "k0", [1, 2], slots)
|
||||
with pytest.raises(RuntimeError, match="first-chunk decode failed"):
|
||||
fast.order_after(slots["k0"])
|
||||
assert fast.take_decoded("k0")
|
||||
fast.close()
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
def test_failed_output_handoff_retains_decoded_samples(cuda):
|
||||
fast, _, _ = _fast(cuda)
|
||||
|
||||
def reject(*args):
|
||||
raise RuntimeError("output queue closed")
|
||||
|
||||
fast.bind(_CallbackSink(reject))
|
||||
slots: dict[str, int] = {}
|
||||
assert fast.submit("r0", "k0", [1, 2], slots)
|
||||
fast.order_after(slots["k0"])
|
||||
assert fast.take_decoded("k0")
|
||||
wav = fast.take_unsent_audio("k0")
|
||||
assert wav is not None and wav.shape == (CHANNELS, SAMPLES)
|
||||
assert fast.take_unsent_audio("k0") is None
|
||||
fast.close()
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
def test_gate_orders_main_stream_after_inflight_fast_decode(cuda):
|
||||
fast, _, _ = _fast(cuda)
|
||||
side = torch.cuda.Stream()
|
||||
with torch.cuda.stream(side):
|
||||
torch.cuda._sleep(200_000_000)
|
||||
pending = torch.cuda.Event()
|
||||
pending.record(side)
|
||||
fast._inflight = pending
|
||||
fast.gate() # disabled: no wait
|
||||
unordered = torch.cuda.Event()
|
||||
unordered.record()
|
||||
fast._gate_main = True
|
||||
fast.gate()
|
||||
ordered = torch.cuda.Event()
|
||||
ordered.record()
|
||||
unordered.synchronize()
|
||||
assert not pending.query() and not ordered.query()
|
||||
ordered.synchronize()
|
||||
assert pending.query()
|
||||
|
||||
|
||||
def _cpu_fast(monkeypatch, *, scheduler=None):
|
||||
# Replace only CUDA allocations, retaining the real lifecycle and worker.
|
||||
import vllm_omni.model_executor.models.moss_tts.first_chunk_fast_path as module
|
||||
|
||||
monkeypatch.setattr(torch.Tensor, "pin_memory", lambda tensor: tensor)
|
||||
monkeypatch.setattr(torch.cuda, "Stream", lambda **kwargs: None)
|
||||
monkeypatch.setattr(torch.cuda, "Event", lambda: None)
|
||||
monkeypatch.setattr(module, "current_omni_platform", SimpleNamespace(set_device=lambda device: None))
|
||||
session = _Session(2)
|
||||
fast = MossFirstChunkFastPath(
|
||||
session,
|
||||
SimpleNamespace(batch_sizes=[1, 2]),
|
||||
n_vq=2,
|
||||
frames=1,
|
||||
codebook_size=1024,
|
||||
samples_per_frame=4,
|
||||
n_channels=1,
|
||||
sample_rate=torch.tensor(24000),
|
||||
device=torch.device("cpu:0"),
|
||||
handoff_timeout_s=0.01,
|
||||
)
|
||||
outputs: queue.Queue = queue.Queue()
|
||||
scheduler = scheduler or SimpleNamespace(requests={"r": SimpleNamespace(client_index=3)})
|
||||
sink = engine_output_queue_sink(outputs, scheduler, upstream_first_audio=False)
|
||||
return fast, session, sink, outputs
|
||||
|
||||
|
||||
def _cancel_during_admission(fast, session, scheduler, sink):
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
|
||||
class PausedSink:
|
||||
def prepare(self, ids):
|
||||
delivery = sink.prepare(ids)
|
||||
if delivery.routes:
|
||||
entered.set()
|
||||
assert release.wait(5)
|
||||
return delivery
|
||||
|
||||
fast.bind(PausedSink())
|
||||
decoder = _decoder(session, fast)
|
||||
errors = []
|
||||
finished = threading.Event()
|
||||
finishing = threading.Event()
|
||||
|
||||
def submit():
|
||||
try:
|
||||
assert fast.submit("r", "r", [1, 2], decoder._stream_req_slots)
|
||||
except BaseException as error:
|
||||
errors.append(error)
|
||||
|
||||
def cancel():
|
||||
try:
|
||||
finishing.set()
|
||||
decoder.on_requests_finished({"r"})
|
||||
finished.set()
|
||||
except BaseException as error:
|
||||
errors.append(error)
|
||||
|
||||
producer = threading.Thread(target=submit)
|
||||
cleaner = threading.Thread(target=cancel)
|
||||
producer.start()
|
||||
try:
|
||||
assert entered.wait(5)
|
||||
assert decoder._stream_req_slots == {}
|
||||
# Scheduler cancellation retires the route before the runner hook.
|
||||
scheduler.requests.pop("r")
|
||||
cleaner.start()
|
||||
assert finishing.wait(5)
|
||||
assert not finished.wait(0.1)
|
||||
release.set()
|
||||
producer.join(5)
|
||||
cleaner.join(5)
|
||||
assert not producer.is_alive() and not cleaner.is_alive()
|
||||
assert not errors, errors
|
||||
assert finished.is_set()
|
||||
assert decoder._stream_req_slots == {}
|
||||
assert fast._decoded == {} and fast._handoffs == {}
|
||||
assert sorted(session.free) == [0, 1]
|
||||
assert not fast.submit("r", "r", [1, 2], decoder._stream_req_slots)
|
||||
|
||||
# A fresh request can use and return the same capacity immediately.
|
||||
scheduler.requests["reuse"] = SimpleNamespace(client_index=3)
|
||||
release.set()
|
||||
assert fast.submit("reuse", "reuse", [1, 2], decoder._stream_req_slots)
|
||||
scheduler.requests.pop("reuse")
|
||||
decoder.on_requests_finished({"reuse"})
|
||||
assert decoder._stream_req_slots == {} and sorted(session.free) == [0, 1]
|
||||
finally:
|
||||
release.set()
|
||||
producer.join(5)
|
||||
if cleaner.ident is not None:
|
||||
cleaner.join(5)
|
||||
fast.close()
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_cancel_waits_for_first_chunk_admission_and_reclaims_slot(monkeypatch):
|
||||
scheduler = SimpleNamespace(requests={"r": SimpleNamespace(client_index=3)})
|
||||
fast, session, sink, _ = _cpu_fast(monkeypatch, scheduler=scheduler)
|
||||
|
||||
# Keep the actual queue/worker/handoff lifecycle, omitting CUDA decode.
|
||||
def decode(jobs):
|
||||
for job in jobs:
|
||||
job.handoff.done.set()
|
||||
|
||||
monkeypatch.setattr(fast, "_decode", decode)
|
||||
fast._handoff_timeout_s = 5.0
|
||||
_cancel_during_admission(fast, session, scheduler, sink)
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
def test_cancel_during_first_chunk_admission_orders_cuda_handoff_and_reuse(cuda):
|
||||
fast, session, _ = _fast(cuda, capacity=2)
|
||||
scheduler = SimpleNamespace(requests={"r": SimpleNamespace(client_index=3)})
|
||||
sink = engine_output_queue_sink(queue.Queue(), scheduler, upstream_first_audio=False)
|
||||
_cancel_during_admission(fast, session, scheduler, sink)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_slot_timeout_keeps_handoff_and_prevents_slot_reuse(monkeypatch):
|
||||
fast, _, _, _ = _cpu_fast(monkeypatch)
|
||||
handoff = _SlotHandoff()
|
||||
fast._handoffs[0] = handoff
|
||||
for _ in range(2):
|
||||
with pytest.raises(TimeoutError, match="slot 0"):
|
||||
fast.order_after(0)
|
||||
assert fast._handoffs[0] is handoff
|
||||
handoff.done.set()
|
||||
fast.order_after(0)
|
||||
assert fast._handoffs == {}
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_closed_decoder_rejects_jobs_and_cannot_restart(monkeypatch):
|
||||
fast, session, sink, _ = _cpu_fast(monkeypatch)
|
||||
fast.bind(sink)
|
||||
fast.close()
|
||||
slots: dict[str, int] = {}
|
||||
assert not fast.submit("r", "external", [1, 2], slots)
|
||||
assert slots == {} and session.free == [0, 1]
|
||||
with pytest.raises(RuntimeError, match="closed"):
|
||||
fast.bind(sink)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_missing_route_does_not_claim_slot(monkeypatch):
|
||||
fast, session, sink, outputs = _cpu_fast(monkeypatch, scheduler=SimpleNamespace(requests={}))
|
||||
fast.bind(sink)
|
||||
try:
|
||||
assert not fast.submit("gone", "external", [1, 2], {})
|
||||
assert session.free == [0, 1] and outputs.empty()
|
||||
finally:
|
||||
fast.close()
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_decode_failure_wakes_current_and_queued_handoffs(monkeypatch):
|
||||
from vllm.v1.engine import FinishReason
|
||||
|
||||
fast, _, sink, outputs = _cpu_fast(monkeypatch)
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
|
||||
def fail(jobs):
|
||||
entered.set()
|
||||
assert release.wait(2)
|
||||
raise RuntimeError("partial replay failed")
|
||||
|
||||
fast._decode = fail
|
||||
fast.bind(sink)
|
||||
slots: dict[str, int] = {}
|
||||
try:
|
||||
assert fast.submit("r", "a", [1, 2], slots)
|
||||
assert entered.wait(2)
|
||||
assert fast.submit("r", "b", [3, 4], slots)
|
||||
release.set()
|
||||
fast._thread.join(2)
|
||||
assert not fast._thread.is_alive()
|
||||
for slot in slots.values():
|
||||
with pytest.raises(RuntimeError, match="decode failed"):
|
||||
fast.order_after(slot)
|
||||
assert all(fast.take_decoded(key) for key in slots)
|
||||
assert not fast.submit("r", "c", [1, 2], slots)
|
||||
assert outputs.qsize() == 2
|
||||
while not outputs.empty():
|
||||
assert outputs.get_nowait()[1].outputs[0].finish_reason == FinishReason.ERROR
|
||||
finally:
|
||||
release.set()
|
||||
fast.close()
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_platform_failure_wakes_pending_handoff(monkeypatch):
|
||||
import vllm_omni.model_executor.models.moss_tts.first_chunk_fast_path as module
|
||||
|
||||
fast, _, _, _ = _cpu_fast(monkeypatch)
|
||||
fast._handoffs[0] = _SlotHandoff()
|
||||
|
||||
def fail(device):
|
||||
raise RuntimeError("device unavailable")
|
||||
|
||||
monkeypatch.setattr(module, "current_omni_platform", SimpleNamespace(set_device=fail))
|
||||
fast._run()
|
||||
assert fast._closed
|
||||
with pytest.raises(RuntimeError, match="decode failed"):
|
||||
fast.order_after(0)
|
||||
@@ -0,0 +1,32 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Per-request code rows keep the runner payload layout with one host copy."""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.models.moss_tts.local_model_state import _CodeRowsSnapshot
|
||||
from vllm_omni.model_executor.output_snapshot import PackedOutputSnapshot
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_rows_map_to_batch_positions_and_copy_once():
|
||||
codes = torch.arange(6, dtype=torch.long).reshape(2, 3)
|
||||
snapshot = _CodeRowsSnapshot(codes, [2, 0], 4)
|
||||
assert isinstance(snapshot, PackedOutputSnapshot) and snapshot.producer_event is None
|
||||
rows = snapshot["codes"]["audio"]
|
||||
assert [tuple(row.shape) for row in rows] == [(1, 3), (0, 3), (1, 3), (0, 3)]
|
||||
assert torch.equal(rows[2], codes[:1]) and torch.equal(rows[0], codes[1:])
|
||||
|
||||
copies = []
|
||||
|
||||
def copy(tensor):
|
||||
copies.append(tensor)
|
||||
return tensor.clone()
|
||||
|
||||
host = snapshot.copy_to_cpu(copy)["codes"]["audio"]
|
||||
assert len(copies) == 1 and copies[0] is codes
|
||||
codes.zero_()
|
||||
assert host[2].tolist() == [[0, 1, 2]] and host[0].tolist() == [[3, 4, 5]]
|
||||
assert host[1].numel() == 0 and host[1].dtype == torch.long
|
||||
@@ -0,0 +1,37 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.core_model,
|
||||
pytest.mark.cuda,
|
||||
pytest.mark.tts,
|
||||
pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required"),
|
||||
]
|
||||
|
||||
|
||||
def test_explicit_generator_bypasses_compiled_audio_sampler():
|
||||
from transformers import GPT2Config
|
||||
|
||||
from vllm_omni.model_executor.models.moss_tts.modeling_moss_tts_local_depth import (
|
||||
MossTTSLocalDepthTransformer,
|
||||
)
|
||||
|
||||
model = MossTTSLocalDepthTransformer(GPT2Config(n_embd=80, n_head=1, n_inner=160)).cuda().bfloat16().eval()
|
||||
heads = torch.nn.ModuleList([torch.nn.Linear(80, 32) for _ in range(2)]).cuda().bfloat16()
|
||||
embeddings = torch.nn.ModuleList([torch.nn.Embedding(32, 80) for _ in range(2)]).cuda().bfloat16()
|
||||
binary = torch.nn.Linear(80, 2).cuda().bfloat16()
|
||||
hidden = torch.randn(1, 80, device="cuda", dtype=torch.bfloat16)
|
||||
generator = torch.Generator(device="cuda").manual_seed(17)
|
||||
expected = model.generate_frame(hidden, heads, embeddings, binary, n_vq=2, generator=generator)
|
||||
|
||||
def forbidden(*args, **kwargs):
|
||||
raise AssertionError("compiled path selected")
|
||||
|
||||
model._compiled_audio_sampler = forbidden
|
||||
generator.manual_seed(17)
|
||||
actual = model.generate_frame(hidden, heads, embeddings, binary, n_vq=2, generator=generator)
|
||||
assert all(torch.equal(a, b) for a, b in zip(expected, actual))
|
||||
with pytest.raises(AssertionError, match="compiled path selected"):
|
||||
model.generate_frame(hidden, heads, embeddings, binary, n_vq=2)
|
||||
@@ -0,0 +1,124 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Determined text tokens retain the upstream sampling/counting contract."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
from vllm.sampling_params import SamplingParams
|
||||
from vllm.v1.worker.gpu.sample.sampler import Sampler
|
||||
from vllm.v1.worker.gpu.states import RequestState
|
||||
|
||||
from tests.model_executor.models.moss_tts.test_local_model_state import _batch, _state
|
||||
from vllm_omni.model_executor.models.moss_tts.local_model_state import MossLocalModelState
|
||||
from vllm_omni.worker_v2.omni_ar_model_runner import OmniARModelRunner
|
||||
from vllm_omni.worker_v2.omni_model_runner import OmniGPUModelRunner
|
||||
|
||||
pytestmark = pytest.mark.core_model
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.parametrize("seed", [None, 17])
|
||||
@pytest.mark.parametrize("temperature", [0.0, 1.7])
|
||||
def test_matches_real_sampler_for_mixed_prefill_decode_stop_and_reordering(seed, temperature):
|
||||
if not torch.cuda.is_available():
|
||||
pytest.skip("CUDA required")
|
||||
device = torch.device("cuda")
|
||||
reqs = RequestState(5, 32, 16, 0, 8, device)
|
||||
sampler = Sampler(5, 8, device, reqs)
|
||||
for i, length in enumerate([4, 1, 3]):
|
||||
reqs.add_request(str(i), length, [2] * length, 0, 8)
|
||||
slot = reqs.req_id_to_index[str(i)]
|
||||
sampler.add_request(slot, length, SamplingParams(temperature=temperature, top_k=3, top_p=0.8, seed=seed))
|
||||
reqs.apply_staged_writes()
|
||||
sampler.apply_staged_writes()
|
||||
state = _state(MossLocalModelState, device)
|
||||
state._direct_tokens = True
|
||||
state.model._batch_state = None
|
||||
batch = _batch(device, [2, 4, 3], [1, 2, 1])
|
||||
batch.seq_lens = torch.tensor([5, 2, 2], device=device, dtype=torch.int32)
|
||||
batch.cu_num_logits_np = np.arange(4, dtype=np.int32)
|
||||
batch.cu_num_logits = torch.tensor(batch.cu_num_logits_np, device=device)
|
||||
batch.expanded_idx_mapping = batch.idx_mapping
|
||||
batch.expanded_local_pos = torch.zeros(3, dtype=torch.int32, device=device)
|
||||
batch.logits_indices = batch.query_start_loc[1:].long() - 1
|
||||
batch.positions = torch.arange(batch.num_tokens, device=device)
|
||||
state.model._batch_should_continue = torch.tensor([False, True, True], device=device)
|
||||
with torch.inference_mode():
|
||||
logits = state.model.compute_logits(torch.ones(3, 4, device=device))
|
||||
expected = sampler(logits, batch)
|
||||
actual = state.sample_determined_tokens(batch, sampler)
|
||||
assert actual is not None
|
||||
for field in ["sampled_token_ids", "num_sampled", "num_rejected"]:
|
||||
torch.testing.assert_close(getattr(actual, field), getattr(expected, field), rtol=0, atol=0)
|
||||
assert actual.num_sampled.tolist() == [1, 0, 1]
|
||||
snapshot = actual.sampled_token_ids.clone()
|
||||
state.model._batch_should_continue.logical_not_()
|
||||
torch.testing.assert_close(actual.sampled_token_ids, snapshot, rtol=0, atol=0)
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.parametrize(
|
||||
"constraint",
|
||||
[
|
||||
{"min_tokens": 2, "stop_token_ids": [1]},
|
||||
{"allowed_token_ids": [1, 2]},
|
||||
{"logit_bias": {1: -10.0}},
|
||||
],
|
||||
)
|
||||
def test_real_sampler_constraints_disable_determined_tokens(constraint):
|
||||
if not torch.cuda.is_available():
|
||||
pytest.skip("CUDA required")
|
||||
device = torch.device("cuda")
|
||||
reqs = RequestState(5, 32, 16, 0, 8, device)
|
||||
sampler = Sampler(5, 8, device, reqs)
|
||||
reqs.add_request("constrained", 1, [2], 0, 8)
|
||||
slot = reqs.req_id_to_index["constrained"]
|
||||
sampler.add_request(slot, 1, SamplingParams(**constraint))
|
||||
state = _state(MossLocalModelState, device)
|
||||
state._direct_tokens = True
|
||||
# Use the actual upstream request registration, including its combined
|
||||
# min-token/allowed-ID/bias flag, rather than mocking that flag ourselves.
|
||||
assert state.sample_determined_tokens(SimpleNamespace(idx_mapping_np=np.array([slot])), sampler) is None
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("feature", ["logprobs", "bias", "penalty", "bad_words", "thinking", "trace", "mask", "nans"])
|
||||
def test_distribution_features_fall_back_before_using_gpu(feature):
|
||||
sampler = object.__new__(Sampler)
|
||||
sampler.compute_nans = feature == "nans"
|
||||
sampler.return_sampling_mask = feature == "mask"
|
||||
sampler.trace_replay_state = object() if feature == "trace" else None
|
||||
sampler.get_logprobs_dims = lambda rows: (1, 0) if feature == "logprobs" else None
|
||||
sampler.logit_bias_state = SimpleNamespace(use_logit_bias=np.array([feature == "bias"]))
|
||||
sampler.penalties_state = SimpleNamespace(use_penalty=np.array([feature == "penalty"]))
|
||||
sampler.bad_words_state = SimpleNamespace(num_bad_words=SimpleNamespace(np=np.array([feature == "bad_words"])))
|
||||
sampler.thinking_budget_state = SimpleNamespace(enabled=True, use_thinking_budget=np.array([feature == "thinking"]))
|
||||
state = object.__new__(MossLocalModelState)
|
||||
state._direct_tokens = True
|
||||
assert state.sample_determined_tokens(SimpleNamespace(idx_mapping_np=np.array([0])), sampler) is None
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("feature", ["grammar", "sharding", "pipeline", "speculative", "empty", "declined"])
|
||||
def test_runner_retains_upstream_path_for_unsupported_execution(mocker, feature):
|
||||
hook = mocker.Mock(return_value=None)
|
||||
|
||||
class State:
|
||||
def sample_determined_tokens(self, batch, sampler):
|
||||
return hook(batch, sampler)
|
||||
|
||||
runner = object.__new__(OmniARModelRunner)
|
||||
runner.model_state = State()
|
||||
runner.sampler = object()
|
||||
runner.batch_sharder = object() if feature == "sharding" else None
|
||||
runner.pp_handler = object() if feature == "pipeline" else None
|
||||
batch = SimpleNamespace(num_draft_tokens=int(feature == "speculative"), num_reqs=0 if feature == "empty" else 1)
|
||||
grammar = object() if feature == "grammar" else None
|
||||
fallback = mocker.patch.object(OmniGPUModelRunner, "sample", return_value="upstream")
|
||||
hidden = object()
|
||||
assert runner.sample(hidden, batch, grammar) == "upstream"
|
||||
fallback.assert_called_once_with(hidden, batch, grammar)
|
||||
assert hook.call_count == int(feature == "declined")
|
||||
@@ -0,0 +1,166 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Eager MTP emits the same frames one step earlier with identical backbone inputs."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from tests.model_executor.models.moss_tts.test_local_model_state import _admit, _batch, _state, _step
|
||||
from vllm_omni.model_executor.models.moss_tts.local_model_state import MossLocalModelState
|
||||
|
||||
pytestmark = pytest.mark.core_model
|
||||
|
||||
|
||||
@pytest.fixture(params=[pytest.param("cpu", marks=pytest.mark.cpu), pytest.param("cuda", marks=pytest.mark.cuda)])
|
||||
def device(request):
|
||||
if request.param == "cuda" and not torch.cuda.is_available():
|
||||
pytest.skip("CUDA required")
|
||||
return torch.device(request.param)
|
||||
|
||||
|
||||
def _run(state, device, schedule, stop_step):
|
||||
computed, prompt = np.zeros(5, dtype=np.int32), np.zeros(5, dtype=np.int32)
|
||||
prompt[3], prompt[0] = 4, 2
|
||||
req_states = SimpleNamespace(prompt_len=prompt, num_computed_tokens=computed)
|
||||
embeds = []
|
||||
frames: dict[int, list[torch.Tensor]] = {3: [], 0: []}
|
||||
for index, (slots, counts) in enumerate(schedule):
|
||||
state.model.force_stop = index == stop_step
|
||||
embed, payload, _ = _step(state, _batch(device, slots, counts), req_states)
|
||||
embeds.append(embed)
|
||||
rows = payload.get("codes", {}).get("audio", [])
|
||||
for slot, row in zip(slots, rows):
|
||||
if row.numel() and bool(row.ne(state.model.audio_pad_token_id).any()):
|
||||
frames[slot].append((index, row.clone()))
|
||||
for slot, count in zip(slots, counts):
|
||||
computed[slot] += count
|
||||
return embeds, frames
|
||||
|
||||
|
||||
def test_eager_frames_match_canonical_one_step_earlier(device):
|
||||
schedule = [([3, 0], [2, 2]), ([3, 0], [2, 1]), ([3, 0], [1, 1]), ([0, 3], [1, 1]), ([3, 0], [1, 1])]
|
||||
canonical, eager = (_state(MossLocalModelState, device) for _ in range(2))
|
||||
eager._local_eager_mtp = True
|
||||
for state in (canonical, eager):
|
||||
_admit(state, 3, "long", 17)
|
||||
_admit(state, 0, "short", 17)
|
||||
# Stop both streams at the canonical step that would draw the 4th step's frames.
|
||||
c_embeds, c_frames = _run(canonical, device, schedule, stop_step=4)
|
||||
e_embeds, e_frames = _run(eager, device, schedule, stop_step=3)
|
||||
|
||||
for c, e in zip(c_embeds, e_embeds):
|
||||
torch.testing.assert_close(c, e, rtol=0, atol=0)
|
||||
for slot in (3, 0):
|
||||
assert [f for _, f in c_frames[slot]] and len(c_frames[slot]) == len(e_frames[slot])
|
||||
for (ci, cf), (ei, ef) in zip(c_frames[slot], e_frames[slot]):
|
||||
assert ei == ci - 1
|
||||
torch.testing.assert_close(cf, ef, rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_stopped_stream_does_not_emit_again(device):
|
||||
state = _state(MossLocalModelState, device)
|
||||
state._local_eager_mtp = True
|
||||
_admit(state, 0, "short", 17)
|
||||
computed, prompt = np.zeros(5, dtype=np.int32), np.zeros(5, dtype=np.int32)
|
||||
prompt[0] = 2
|
||||
req_states = SimpleNamespace(prompt_len=prompt, num_computed_tokens=computed)
|
||||
emitted = []
|
||||
for index, count in enumerate([2, 1, 1, 1]):
|
||||
state.model.force_stop = index == 1
|
||||
_, payload, _ = _step(state, _batch(device, [0], [count]), req_states)
|
||||
row = payload["codes"]["audio"][0]
|
||||
emitted.append(bool(row.ne(state.model.audio_pad_token_id).any()))
|
||||
computed[0] += count
|
||||
state.model.force_stop = False
|
||||
# Frame at the prefill step, stop at step 1, then asynchronous overrun steps.
|
||||
assert emitted == [True, False, False, False]
|
||||
|
||||
|
||||
def test_unmarked_decode_row_ignores_stale_slot_state(device):
|
||||
canonical, eager = (_state(MossLocalModelState, device) for _ in range(2))
|
||||
eager._local_eager_mtp = True
|
||||
for state in (canonical, eager):
|
||||
_admit(state, 0, "short", 17)
|
||||
# A decode row whose completing prefill was not observed in this state.
|
||||
eager._keep_pool[0] = False
|
||||
eager._eager_emb_pool[0].fill_(3.0)
|
||||
computed, prompt = np.zeros(5, dtype=np.int32), np.zeros(5, dtype=np.int32)
|
||||
prompt[0], computed[0] = 2, 2
|
||||
req_states = SimpleNamespace(prompt_len=prompt, num_computed_tokens=computed)
|
||||
for state in (canonical, eager):
|
||||
state.intermediate_buffer.buffers[0]["audio_state"] = {"is_stopping": False}
|
||||
c_embed, c_payload, _ = _step(canonical, _batch(device, [0], [1]), req_states)
|
||||
e_embed, e_payload, _ = _step(eager, _batch(device, [0], [1]), req_states)
|
||||
torch.testing.assert_close(c_embed, e_embed, rtol=0, atol=0)
|
||||
torch.testing.assert_close(c_payload["codes"]["audio"][0], e_payload["codes"]["audio"][0], rtol=0, atol=0)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_first_prefill_through_runner_preserves_parent_eager_contract(monkeypatch, mocker):
|
||||
"""The Local config must not enable the parent's incompatible eager hook."""
|
||||
from vllm_omni.worker_v2 import omni_ar_model_runner as runner_module
|
||||
from vllm_omni.worker_v2.model_states.eager_mtp import EagerMTPState
|
||||
from vllm_omni.worker_v2.model_states.omni_model_state import OmniModelState
|
||||
from vllm_omni.worker_v2.omni_ar_model_runner import OmniARModelRunner
|
||||
|
||||
device = torch.device("cpu")
|
||||
template = _state(MossLocalModelState, device)
|
||||
model = template.model
|
||||
model.config = SimpleNamespace(mrv2_eager_mtp=True)
|
||||
|
||||
def initialize_parent(state, *args):
|
||||
state.__dict__.update(template.__dict__)
|
||||
state._eager_mtp, state._eager_rows = False, None
|
||||
state._eager_state = EagerMTPState(state)
|
||||
|
||||
monkeypatch.setattr(OmniModelState, "__init__", initialize_parent)
|
||||
state = MossLocalModelState(SimpleNamespace(), model, None, device)
|
||||
_admit(state, 0, "req", 17)
|
||||
batch = _batch(device, [0], [2])
|
||||
batch.req_ids = ["req"]
|
||||
computed, prompt = np.zeros(5, dtype=np.int32), np.zeros(5, dtype=np.int32)
|
||||
prompt[0] = 2
|
||||
_step(state, batch, SimpleNamespace(prompt_len=prompt, num_computed_tokens=computed))
|
||||
hook = mocker.spy(state, "run_eager_mtp")
|
||||
|
||||
runner = OmniARModelRunner.__new__(OmniARModelRunner)
|
||||
runner.execute_model_state = SimpleNamespace(
|
||||
input_batch=batch,
|
||||
hidden_states=torch.zeros(2, 4),
|
||||
finished_req_ids=set(),
|
||||
ec_connector_output=None,
|
||||
routed_experts=None,
|
||||
)
|
||||
runner._kv_extracted_req_ids = runner._last_aux_output = runner._last_multimodal_outputs = None
|
||||
runner.is_last_pp_rank, runner.pp_handler, runner.check_ep_fault = True, None, False
|
||||
runner.model_config = SimpleNamespace(async_chunk=False)
|
||||
runner.vllm_config = SimpleNamespace(model_config=SimpleNamespace(engine_output_type="text"))
|
||||
runner.model_state, runner.model = state, model
|
||||
runner.req_states = SimpleNamespace(
|
||||
all_token_ids=SimpleNamespace(gpu=torch.zeros(5, 2)),
|
||||
num_computed_tokens=SimpleNamespace(gpu=torch.from_numpy(computed)),
|
||||
prompt_len=SimpleNamespace(np=prompt, gpu=torch.from_numpy(prompt)),
|
||||
)
|
||||
runner.main_stream = runner.output_copy_stream = mocker.Mock()
|
||||
runner.eplb = runner._finalize_native_data_plane_output = runner._reserve_native_data_plane_outputs = mocker.Mock()
|
||||
runner.sampler = None
|
||||
runner.sample = mocker.Mock(
|
||||
return_value=(
|
||||
SimpleNamespace(sampled_token_ids=torch.tensor([[2]])),
|
||||
torch.ones(1),
|
||||
torch.zeros(1),
|
||||
)
|
||||
)
|
||||
runner.prompt_logprobs_worker = SimpleNamespace(compute_prompt_logprobs=mocker.Mock(return_value={}))
|
||||
runner.postprocess_sampled = mocker.Mock()
|
||||
runner.kv_connector = SimpleNamespace(post_forward=mocker.Mock(return_value=None))
|
||||
mock_out = SimpleNamespace(copy_event=None)
|
||||
monkeypatch.setattr(runner_module, "OmniAsyncOutput", mocker.Mock(return_value=mock_out))
|
||||
|
||||
assert runner.sample_tokens(None) is mock_out
|
||||
hook.assert_called_once()
|
||||
assert state._eager_mtp is False and state._eager_rows is None
|
||||
assert state._local_eager_mtp is True
|
||||
@@ -0,0 +1,286 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Compare slot lifecycle with canonical Local hooks, including output ownership."""
|
||||
|
||||
from contextlib import nullcontext
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
from vllm.v1.worker.gpu.model_states.default import DefaultModelState
|
||||
|
||||
from vllm_omni.model_executor.models.moss_tts.local_model_state import MossLocalModelState
|
||||
from vllm_omni.model_executor.models.moss_tts.modeling_moss_tts_talker import MossTTSLocalTalkerForGeneration
|
||||
from vllm_omni.worker_v2.model_states import init_omni_model_state
|
||||
from vllm_omni.worker_v2.model_states.eager_mtp import EagerMTPState
|
||||
from vllm_omni.worker_v2.model_states.intermediate_buffer import OmniIntermediateBuffer
|
||||
from vllm_omni.worker_v2.model_states.omni_model_state import OmniModelState
|
||||
|
||||
pytestmark = pytest.mark.core_model
|
||||
|
||||
|
||||
@pytest.fixture(params=[pytest.param("cpu", marks=pytest.mark.cpu), pytest.param("cuda", marks=pytest.mark.cuda)])
|
||||
def device(request):
|
||||
if request.param == "cuda" and not torch.cuda.is_available():
|
||||
pytest.skip("CUDA required")
|
||||
return torch.device(request.param)
|
||||
|
||||
|
||||
def _model(device):
|
||||
model = MossTTSLocalTalkerForGeneration.__new__(MossTTSLocalTalkerForGeneration)
|
||||
torch.nn.Module.__init__(model)
|
||||
dtype = torch.bfloat16 if device.type == "cuda" else torch.float32
|
||||
model.model = torch.nn.Module()
|
||||
weight = torch.arange(32, dtype=dtype, device=device).reshape(8, 4) / 16
|
||||
model.model.embed_tokens = torch.nn.Embedding(8, 4, _weight=weight)
|
||||
model.hidden_size, model.n_vq = 4, 2
|
||||
model.audio_pad_token_id, model.text_vocab_size = 8, 8
|
||||
model.audio_assistant_slot_token_id, model.im_end_token_id = 2, 3
|
||||
model.talker_mtp_output_key = ("audio_codes", "current")
|
||||
model.talker_mtp_graph_safe = False
|
||||
model.gpu_resident_buffer_keys = {("hidden_states", "last"), ("audio_codes", "current")}
|
||||
model.audio_lm_heads = model.audio_embeddings = model.local_text_lm_head = None
|
||||
model._audio_embed = lambda codes: codes[:, :1].expand(-1, 4).to(dtype) / 8
|
||||
model.force_stop = False
|
||||
|
||||
def frame(hidden, *_args, generator=None, temperature=None, top_k=None, top_p=None, **_kwargs):
|
||||
assert (temperature, top_k, top_p) == (1.7, 25, 0.8)
|
||||
noise = torch.randint(0, 8, (hidden.shape[0], 2), device=device, generator=generator)
|
||||
codes = (hidden[:, :2].mul(16).long() + noise) % 8
|
||||
return torch.full((hidden.shape[0],), not model.force_stop, device=device), codes
|
||||
|
||||
model.local_transformer = SimpleNamespace(generate_frame=frame)
|
||||
return model
|
||||
|
||||
|
||||
def _state(cls, device):
|
||||
state = object.__new__(cls)
|
||||
state.model = _model(device)
|
||||
state.device, state.dtype = device, state.model.model.embed_tokens.weight.dtype
|
||||
state.has_preprocess = state.has_postprocess = state.have_multimodal_outputs = True
|
||||
state.scheduler_config = SimpleNamespace(max_num_seqs=5)
|
||||
state.vllm_config = SimpleNamespace()
|
||||
state.intermediate_buffer = OmniIntermediateBuffer(5)
|
||||
state._static_inputs_embeds = torch.zeros(16, 4, device=device, dtype=state.dtype)
|
||||
state._mtp_input_ids = torch.zeros(5, device=device, dtype=torch.long)
|
||||
state._mtp_offsets = torch.zeros(5, device=device, dtype=torch.long)
|
||||
for name in ("_mtp_input_embeds", "_mtp_hidden", "_mtp_text_step"):
|
||||
setattr(state, name, torch.zeros(5, 4, device=device, dtype=state.dtype))
|
||||
state._mtp_runner = state._mtp_sample_uniforms = None
|
||||
state._mtp_generators = {}
|
||||
state._stream_pos = {}
|
||||
state._stream_decode_event = None
|
||||
state._eager_state = EagerMTPState(state)
|
||||
if cls is MossLocalModelState:
|
||||
state._init_slot_buffers(5, 4, device, state.dtype)
|
||||
return state
|
||||
|
||||
|
||||
def _admit(state, slot, name, seed):
|
||||
params = SimpleNamespace(extra_args={"tts_local_seed": seed} if seed is not None else {}, seed=None)
|
||||
with patch.object(DefaultModelState, "add_request", return_value=None):
|
||||
state.add_request(slot, SimpleNamespace(req_id=name, mm_features=[], sampling_params=params))
|
||||
state.intermediate_buffer.buffers[slot]["codes"] = {"ref": torch.tensor([[1, 2], [3, 4], [5, 6], [2, 3]])}
|
||||
|
||||
|
||||
def _batch(device, slots, counts):
|
||||
starts = np.array([0, *np.cumsum(counts)], dtype=np.int32)
|
||||
return SimpleNamespace(
|
||||
idx_mapping_np=np.array(slots),
|
||||
idx_mapping=torch.tensor(slots, device=device),
|
||||
num_reqs=len(slots),
|
||||
num_tokens=int(starts[-1]),
|
||||
num_scheduled_tokens=np.array(counts),
|
||||
query_start_loc_np=starts,
|
||||
query_start_loc=torch.tensor(starts, device=device),
|
||||
# Upstream MRV2 uses int32 token buffers, unlike the MTP graph inputs.
|
||||
input_ids=torch.full((int(starts[-1]),), 2, dtype=torch.int32, device=device),
|
||||
)
|
||||
|
||||
|
||||
def _step(state, batch, req_states, dispatcher=None):
|
||||
inputs = {"input_ids": batch.input_ids, "inputs_embeds": state._static_inputs_embeds[: batch.num_tokens]}
|
||||
with (
|
||||
patch("vllm.forward_context.set_forward_context", return_value=nullcontext()),
|
||||
patch(
|
||||
"vllm_omni.model_executor.models.moss_tts.local_model_state.set_forward_context", return_value=nullcontext()
|
||||
),
|
||||
torch.inference_mode(),
|
||||
):
|
||||
state.run_preprocess(batch, inputs, req_states, dispatcher)
|
||||
hidden = inputs["inputs_embeds"] + 0.25
|
||||
state.run_postprocess(hidden, batch)
|
||||
_, payload = state.postprocess_model_output(hidden, batch, req_states)
|
||||
last = batch.query_start_loc[1:] - 1
|
||||
tokens = state.model.compute_logits(hidden.index_select(0, last)).argmax(-1)
|
||||
result = inputs["inputs_embeds"].clone(), payload, tokens
|
||||
hidden.fill_(-100) # next graph replay can overwrite its output immediately
|
||||
return result
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seed", [None, 17])
|
||||
@pytest.mark.parametrize("batch_prefill", [False, True])
|
||||
def test_slot_matches_canonical_mixed_prefill_reorder_stop_and_reuse(device, seed, batch_prefill):
|
||||
reference, candidate = (_state(cls, device) for cls in (OmniModelState, MossLocalModelState))
|
||||
candidate._batch_prefill = batch_prefill
|
||||
for state in (reference, candidate):
|
||||
_admit(state, 3, "long", seed)
|
||||
_admit(state, 0, "short", seed)
|
||||
computed, prompt = np.zeros(5, dtype=np.int32), np.zeros(5, dtype=np.int32)
|
||||
prompt[3], prompt[0] = 4, 2
|
||||
req_states = SimpleNamespace(prompt_len=prompt, num_computed_tokens=computed)
|
||||
saved = []
|
||||
for index, (slots, counts) in enumerate(
|
||||
[
|
||||
([3, 0], [2, 2]),
|
||||
([0, 3], [1, 1]),
|
||||
([3, 0], [1, 1]),
|
||||
([3, 0], [1, 1]),
|
||||
([0, 3], [1, 1]),
|
||||
([3, 0], [1, 1]),
|
||||
]
|
||||
):
|
||||
if index == 4:
|
||||
# Cancellation/removal followed by reuse must not retain hidden,
|
||||
# reference offset, control, or the old request's RNG stream.
|
||||
for state in (reference, candidate):
|
||||
state.remove_request("short")
|
||||
assert "short" not in state._mtp_generators
|
||||
_admit(state, 0, "replacement", seed)
|
||||
computed[0], prompt[0] = 0, 1
|
||||
outputs = []
|
||||
for state in (reference, candidate):
|
||||
state.model.force_stop = index == 3
|
||||
torch.manual_seed(91 + index)
|
||||
outputs.append(_step(state, _batch(device, slots, counts), req_states))
|
||||
a, b = outputs
|
||||
torch.testing.assert_close(a[0], b[0], rtol=0, atol=0)
|
||||
torch.testing.assert_close(a[2], b[2], rtol=0, atol=0)
|
||||
if index == 3:
|
||||
assert b[2].tolist() == [3, 3]
|
||||
assert a[1].keys() == b[1].keys()
|
||||
for ar, br in zip(a[1].get("codes", {}).get("audio", []), b[1].get("codes", {}).get("audio", [])):
|
||||
torch.testing.assert_close(ar, br, rtol=0, atol=0)
|
||||
saved.append((br, br.clone()))
|
||||
for slot, count in zip(slots, counts):
|
||||
computed[slot] += count
|
||||
old = reference.intermediate_buffer.buffers[slot]
|
||||
new = candidate.intermediate_buffer.buffers[slot]
|
||||
assert old["ref_offset"] == new["ref_offset"]
|
||||
torch.testing.assert_close(candidate._hidden_pool[slot], old["hidden_states"]["last"], rtol=0, atol=0)
|
||||
assert "hidden_states" not in new and "audio_codes" not in new
|
||||
candidate._codes_pool.fill_(8)
|
||||
candidate._hidden_pool.zero_()
|
||||
for current, snapshot in saved:
|
||||
torch.testing.assert_close(current, snapshot, rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_slot_honors_external_stopping_control(device):
|
||||
states = [_state(cls, device) for cls in (OmniModelState, MossLocalModelState)]
|
||||
for state in states:
|
||||
_admit(state, 0, "stop", None)
|
||||
req = SimpleNamespace(prompt_len=np.ones(5), num_computed_tokens=np.zeros(5))
|
||||
_step(state, _batch(device, [0], [1]), req)
|
||||
state.intermediate_buffer.buffers[0]["audio_state"]["is_stopping"] = True
|
||||
req.num_computed_tokens[:] = 1
|
||||
output = _step(state, _batch(device, [0], [1]), req)
|
||||
assert output[2].tolist() == [3]
|
||||
assert output[1]["codes"]["audio"][0].tolist() == [[8, 8]]
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
def test_slot_mtp_graph_padding_reorder_and_owned_output():
|
||||
if not torch.cuda.is_available():
|
||||
pytest.skip("CUDA required")
|
||||
states = [_state(cls, torch.device("cuda")) for cls in (OmniModelState, MossLocalModelState)]
|
||||
for state in states:
|
||||
|
||||
def frame(hidden, *_args, **_kwargs):
|
||||
return hidden[:, 0] < 3, hidden[:, :2].mul(16).long() % 8
|
||||
|
||||
state.model.local_transformer.generate_frame = frame
|
||||
for slot in [0, 3, 4]:
|
||||
_admit(state, slot, f"req{slot}", None)
|
||||
candidate = states[1]
|
||||
buffers = [
|
||||
candidate._mtp_input_ids[:4],
|
||||
candidate._mtp_input_embeds[:4],
|
||||
candidate._mtp_hidden[:4],
|
||||
candidate._mtp_text_step[:4],
|
||||
]
|
||||
stream = torch.cuda.Stream()
|
||||
stream.wait_stream(torch.cuda.current_stream())
|
||||
with torch.cuda.stream(stream), torch.inference_mode():
|
||||
for _ in range(3):
|
||||
candidate.model.mtp(*buffers)
|
||||
torch.cuda.current_stream().wait_stream(stream)
|
||||
graph = torch.cuda.CUDAGraph()
|
||||
with torch.cuda.graph(graph), torch.inference_mode():
|
||||
graph_output = candidate.model.mtp(*buffers)
|
||||
|
||||
def replay(*args, **kwargs):
|
||||
assert all(a.data_ptr() == b.data_ptr() for a, b in zip(args, buffers))
|
||||
assert args[0].shape[0] == 4
|
||||
graph.replay()
|
||||
return graph_output
|
||||
|
||||
candidate._mtp_runner = replay
|
||||
candidate._is_mtp_graph_runner = lambda: True
|
||||
from vllm.config import CUDAGraphMode
|
||||
|
||||
def dispatcher(bsz):
|
||||
return SimpleNamespace(num_tokens=4, cg_mode=CUDAGraphMode.FULL)
|
||||
|
||||
req = SimpleNamespace(prompt_len=np.ones(5, dtype=np.int32), num_computed_tokens=np.zeros(5, dtype=np.int32))
|
||||
retained = []
|
||||
for slots in [[0, 3, 4], [3, 0], [4, 0, 3], [3, 4]]:
|
||||
out = [
|
||||
_step(
|
||||
state,
|
||||
_batch(torch.device("cuda"), slots, [1] * len(slots)),
|
||||
req,
|
||||
dispatcher if state is candidate else None,
|
||||
)
|
||||
for state in states
|
||||
]
|
||||
torch.testing.assert_close(out[0][0], out[1][0], rtol=0, atol=0)
|
||||
torch.testing.assert_close(out[0][2], out[1][2], rtol=0, atol=0)
|
||||
for a, b in zip(out[0][1].get("codes", {}).get("audio", []), out[1][1].get("codes", {}).get("audio", [])):
|
||||
torch.testing.assert_close(a, b, rtol=0, atol=0)
|
||||
retained.append((b, b.clone()))
|
||||
req.num_computed_tokens[slots] += 1
|
||||
with torch.inference_mode():
|
||||
for tensor in graph_output:
|
||||
tensor.fill_(7)
|
||||
for tensor, copy in retained:
|
||||
torch.testing.assert_close(tensor, copy, rtol=0, atol=0)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_factory_requires_explicit_class_capability_and_valid_state(monkeypatch):
|
||||
monkeypatch.setattr(OmniModelState, "__init__", lambda *args: None)
|
||||
dynamic = MagicMock(has_preprocess=True, create_omni_model_state=None)
|
||||
assert isinstance(init_omni_model_state(None, dynamic, None, torch.device("cpu")), OmniModelState)
|
||||
dynamic.create_mrv2_model_state.assert_not_called()
|
||||
|
||||
class Invalid:
|
||||
has_preprocess = True
|
||||
|
||||
def create_mrv2_model_state(self, *args):
|
||||
return object()
|
||||
|
||||
with pytest.raises(TypeError, match="must return a ModelState"):
|
||||
init_omni_model_state(None, Invalid(), None, torch.device("cpu"))
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("enabled", [False, True])
|
||||
def test_local_factory_default_and_option(monkeypatch, enabled):
|
||||
monkeypatch.setattr(OmniModelState, "__init__", lambda *args: None)
|
||||
monkeypatch.setattr(MossLocalModelState, "__init__", lambda *args: None)
|
||||
model = _model(torch.device("cpu"))
|
||||
model.config = SimpleNamespace(mrv2_gpu_slot_state=enabled)
|
||||
state = init_omni_model_state(None, model, None, torch.device("cpu"))
|
||||
assert type(state) is (MossLocalModelState if enabled else OmniModelState)
|
||||
@@ -0,0 +1,86 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Reference batching preserves coordinates and asynchronous source ownership."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from tests.model_executor.models.moss_tts.test_local_model_state import _batch, _state
|
||||
from vllm_omni.model_executor.models.moss_tts.local_model_state import MossLocalModelState
|
||||
from vllm_omni.model_executor.models.moss_tts.modeling_moss_tts_talker import MossTTSLocalTalkerForGeneration
|
||||
|
||||
pytestmark = pytest.mark.core_model
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("flat", [False, True])
|
||||
def test_prefill_reuses_text_embeddings_and_noncontiguous_reference_offsets(mocker, flat):
|
||||
state = _state(MossLocalModelState, torch.device("cpu"))
|
||||
state._batch_prefill = True
|
||||
codes = torch.tensor([[1, 2], [3, 4], [5, 6], [2, 3]])
|
||||
for slot, offset in [(3, 1), (0, 2)]:
|
||||
state.intermediate_buffer.buffers[slot] = {
|
||||
"req_id": str(slot),
|
||||
"codes": {"ref": codes.flatten() if flat else codes},
|
||||
"ref_offset": offset,
|
||||
}
|
||||
batch = _batch(torch.device("cpu"), [3, 0], [2, 1])
|
||||
req = SimpleNamespace(prompt_len=np.full(5, 8), num_computed_tokens=np.zeros(5))
|
||||
embeds = state._static_inputs_embeds[:3]
|
||||
expected = state.model.embed_input_ids(batch.input_ids) + state.model._audio_embed(codes[[1, 2, 2]])
|
||||
embed = mocker.spy(state.model.model.embed_tokens, "forward")
|
||||
with torch.inference_mode():
|
||||
state.run_preprocess(batch, {"input_ids": batch.input_ids, "inputs_embeds": embeds}, req)
|
||||
torch.testing.assert_close(embeds, expected, rtol=0, atol=0)
|
||||
assert embed.call_count == 1
|
||||
assert state.intermediate_buffer.buffers[3]["ref_offset"] == 3
|
||||
assert state.intermediate_buffer.buffers[0]["ref_offset"] == 3
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
def test_pending_reference_uploads_survive_staging_reuse_and_overflow():
|
||||
if not torch.cuda.is_available():
|
||||
pytest.skip("CUDA required")
|
||||
state = _state(MossLocalModelState, torch.device("cuda"))
|
||||
stream = torch.cuda.Stream()
|
||||
stream.wait_stream(torch.cuda.current_stream())
|
||||
outputs = []
|
||||
with torch.cuda.stream(stream), torch.inference_mode():
|
||||
# Keep the first H2D pending while CPU prepares all subsequent batches.
|
||||
torch.cuda._sleep(100_000_000)
|
||||
for value in range(1, 8):
|
||||
embeds = torch.zeros(4, 4, device="cuda", dtype=state.dtype)
|
||||
codes = torch.full((2, 2), value)
|
||||
state._apply_reference_batch(embeds, [(1, codes)])
|
||||
outputs.append((embeds, value))
|
||||
codes.fill_(-1)
|
||||
stream.synchronize()
|
||||
assert len(state._prefill_staging) <= 3
|
||||
for output, value in outputs:
|
||||
expected = torch.zeros_like(output)
|
||||
expected[1:3] = value / 8
|
||||
torch.testing.assert_close(output, expected, rtol=0, atol=0)
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
def test_batched_real_audio_embedding_matches_scalar_bfloat16_reduction():
|
||||
if not torch.cuda.is_available():
|
||||
pytest.skip("CUDA required")
|
||||
state = _state(MossLocalModelState, torch.device("cuda"))
|
||||
model = state.model
|
||||
model.n_vq = 12
|
||||
model.audio_vocab_size = model.audio_pad_token_id = 1024
|
||||
model._stacked_audio_emb_w = torch.randn(12, 1024, 3072, device="cuda", dtype=torch.bfloat16)
|
||||
model._audio_embed = MossTTSLocalTalkerForGeneration._audio_embed.__get__(model)
|
||||
state._static_inputs_embeds = torch.zeros(512, 3072, device="cuda", dtype=torch.bfloat16)
|
||||
references = [(start, torch.randint(0, 1025, (count, 12))) for start, count in [(0, 169), (201, 11), (220, 257)]]
|
||||
with torch.inference_mode():
|
||||
actual = torch.randn_like(state._static_inputs_embeds)
|
||||
expected = actual.clone()
|
||||
for start, codes in references:
|
||||
expected[start : start + len(codes)] += model._audio_embed(codes.to("cuda"))
|
||||
state._apply_reference_batch(actual, references)
|
||||
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
|
||||
@@ -0,0 +1,82 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
import pytest
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.core_model,
|
||||
pytest.mark.cuda,
|
||||
pytest.mark.tts,
|
||||
pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("batch,heads,dim", [(1, 4, 64), (16, 32, 80), (128, 32, 80), (3, 8, 128)])
|
||||
def test_all_depth_positions_with_strided_cache_and_dirty_future(batch, heads, dim):
|
||||
from vllm_omni.model_executor.models.moss_tts.local_short_attention import local_short_attention
|
||||
|
||||
torch.manual_seed(43)
|
||||
query = torch.randn(batch, heads, 1, dim, device="cuda", dtype=torch.bfloat16)
|
||||
key = torch.randn(batch, heads, 12, dim, device="cuda", dtype=torch.bfloat16)
|
||||
value = torch.randn_like(key)
|
||||
for position in range(12):
|
||||
# Non-contiguous prefixes retain the stride of the full 12-slot cache.
|
||||
k, v = key[:, :, : position + 1], value[:, :, : position + 1]
|
||||
before_k, before_v = key.clone(), value.clone()
|
||||
expected = F.scaled_dot_product_attention(query, k, v)
|
||||
actual = local_short_attention(query, k, v)
|
||||
assert torch.equal(key, before_k) and torch.equal(value, before_v)
|
||||
if position == 0:
|
||||
assert torch.equal(actual, v)
|
||||
torch.testing.assert_close(actual, expected, atol=0.015625, rtol=0.01)
|
||||
# Future cache contents must never affect an earlier position.
|
||||
key[:, :, position + 1 :] += 100
|
||||
value[:, :, position + 1 :] -= 100
|
||||
assert torch.equal(local_short_attention(query, k, v), actual)
|
||||
key.copy_(before_k)
|
||||
value.copy_(before_v)
|
||||
|
||||
|
||||
def test_compile_and_graph_replay_reads_fresh_inputs():
|
||||
from vllm_omni.model_executor.models.moss_tts.local_short_attention import local_short_attention
|
||||
|
||||
query = torch.randn(16, 32, 1, 96, device="cuda", dtype=torch.bfloat16)
|
||||
key = torch.randn(16, 32, 12, 96, device="cuda", dtype=torch.bfloat16)
|
||||
value = torch.randn_like(key)
|
||||
compiled = torch.compile(local_short_attention, fullgraph=True)
|
||||
for _ in range(3):
|
||||
compiled(query, key, value)
|
||||
torch.accelerator.synchronize()
|
||||
graph = torch.cuda.CUDAGraph()
|
||||
with torch.cuda.graph(graph):
|
||||
result = compiled(query, key, value)
|
||||
for _ in range(3):
|
||||
query.normal_()
|
||||
key.normal_()
|
||||
value.normal_()
|
||||
graph.replay()
|
||||
torch.testing.assert_close(result, F.scaled_dot_product_attention(query, key, value), atol=0.015625, rtol=0.01)
|
||||
|
||||
|
||||
def test_depth_block_cache_overwrite_and_compiled_positions(monkeypatch):
|
||||
from transformers import GPT2Config
|
||||
|
||||
from vllm_omni.model_executor.models.moss_tts.modeling_moss_tts_local_depth import (
|
||||
MossTTSLocalDepthTransformer,
|
||||
)
|
||||
|
||||
monkeypatch.setenv("VLLM_OMNI_MOSS_LOCAL_SHORT_ATTN", "1")
|
||||
model = MossTTSLocalDepthTransformer(GPT2Config(n_embd=320, n_head=4, n_inner=640)).cuda().bfloat16().eval()
|
||||
attn = model.h[0].attn
|
||||
attn.prepare_rope_cache(12, torch.device("cuda"), torch.bfloat16)
|
||||
cache = tuple(torch.empty(4, 4, 12, 80, device="cuda", dtype=torch.bfloat16) for _ in range(2))
|
||||
forward = torch.compile(model._forward_prefix, fullgraph=True, dynamic=True)
|
||||
with torch.inference_mode():
|
||||
for _ in range(2):
|
||||
inputs = torch.randn(4, 12, 320, device="cuda", dtype=torch.bfloat16)
|
||||
# The second frame overwrites the previous frame's K/V before use.
|
||||
for position in range(12):
|
||||
expected = model._forward_prefix(inputs[:, : position + 1])[:, -1:]
|
||||
actual = forward(inputs[:, position : position + 1], cache, position)
|
||||
torch.testing.assert_close(actual, expected, atol=0.03125, rtol=0.02)
|
||||
@@ -6,6 +6,7 @@ single-flight, and micro-batched encoding."""
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
@@ -598,3 +599,239 @@ def test_reference_resampler_is_cached_and_matches_functional_resample():
|
||||
assert info.misses == 1 and info.hits == 1
|
||||
finally:
|
||||
_reference_resampler.cache_clear()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Inline data: URI fast path #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class _DigestAudio(_FakeAudio):
|
||||
"""Resolver whose key for data: URIs is the SHA-1 digest, like serving."""
|
||||
|
||||
def __init__(self, *, digest_keys=True):
|
||||
super().__init__()
|
||||
self._digest_keys = digest_keys
|
||||
|
||||
async def resolve(self, ref_str):
|
||||
wav_list, sr, _ = await super().resolve(ref_str)
|
||||
if not self._digest_keys:
|
||||
return wav_list, sr, "rk:" + ref_str
|
||||
import hashlib
|
||||
|
||||
return wav_list, sr, hashlib.sha1(ref_str.encode("utf-8")).hexdigest()
|
||||
|
||||
def artifact_key(self, cache_key):
|
||||
return None
|
||||
|
||||
|
||||
async def test_inline_reference_skips_repeat_resolution(make_encoder):
|
||||
proc, audio = _FakeProcessor(), _DigestAudio()
|
||||
ref = "data:audio/wav;base64,QUJD"
|
||||
audio.register(ref, 5)
|
||||
enc = make_encoder(proc)
|
||||
|
||||
first, key1 = await enc.encode(ref, resolve_ref_audio=audio.resolve, get_artifact_key=audio.artifact_key)
|
||||
first.fill_(0) # callers own their copy
|
||||
second, key2 = await enc.encode(ref, resolve_ref_audio=audio.resolve, get_artifact_key=audio.artifact_key)
|
||||
|
||||
assert audio.resolve_calls == [ref]
|
||||
assert key1 == key2
|
||||
assert torch.equal(second, torch.full((3, _N_VQ), 5, dtype=torch.long))
|
||||
|
||||
|
||||
async def test_inline_index_requires_matching_resolve_key(make_encoder):
|
||||
proc, audio = _FakeProcessor(), _DigestAudio(digest_keys=False)
|
||||
ref = "data:audio/wav;base64,REVG"
|
||||
audio.register(ref, 6)
|
||||
enc = make_encoder(proc)
|
||||
|
||||
for _ in range(2):
|
||||
await enc.encode(ref, resolve_ref_audio=audio.resolve, get_artifact_key=audio.artifact_key)
|
||||
assert audio.resolve_calls == [ref, ref]
|
||||
|
||||
|
||||
async def test_inline_reference_shared_across_encoders(make_encoder, monkeypatch):
|
||||
# Keep the AF_UNIX socket path independent of pytest's nested base path.
|
||||
with TemporaryDirectory(prefix="moss-ref-") as shared_dir:
|
||||
monkeypatch.setenv("VLLM_OMNI_MOSS_REF_CODES_SHARED_DIR", shared_dir)
|
||||
ref = "data:audio/wav;base64,R0hJ"
|
||||
first_audio, second_audio = _DigestAudio(), _DigestAudio()
|
||||
first_audio.register(ref, 9)
|
||||
second_audio.register(ref, 9)
|
||||
first, second = make_encoder(_FakeProcessor()), make_encoder(_FakeProcessor())
|
||||
|
||||
a, key_a = await first.encode(
|
||||
ref, resolve_ref_audio=first_audio.resolve, get_artifact_key=first_audio.artifact_key
|
||||
)
|
||||
b, key_b = await second.encode(
|
||||
ref, resolve_ref_audio=second_audio.resolve, get_artifact_key=second_audio.artifact_key
|
||||
)
|
||||
|
||||
assert first_audio.resolve_calls == [ref] and second_audio.resolve_calls == []
|
||||
assert key_a == key_b and torch.equal(a, b)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Reference-encoder CUDA graphs: selection and processor-equivalent prep #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class _GraphsStub:
|
||||
def __init__(self, result):
|
||||
self.result = result
|
||||
self.calls: list[list[torch.Tensor]] = []
|
||||
|
||||
def encode(self, wavs):
|
||||
self.calls.append(wavs)
|
||||
return self.result
|
||||
|
||||
|
||||
class _LoudnessProcessor(_FakeProcessor):
|
||||
@staticmethod
|
||||
def loudness_normalize(wav):
|
||||
return wav * 2.0
|
||||
|
||||
|
||||
async def test_encode_prepared_prefers_graphs_and_falls_back_to_processor(make_encoder):
|
||||
proc = _LoudnessProcessor()
|
||||
enc = make_encoder(proc)
|
||||
graphs_codes = [torch.full((3, _N_VQ), 7, dtype=torch.long)]
|
||||
enc._graphs_enabled, enc._graphs = True, _GraphsStub(graphs_codes)
|
||||
prepared = [torch.ones(1, 100)]
|
||||
assert enc._encode_prepared(prepared) is graphs_codes and proc.attempt_sizes == []
|
||||
enc._graphs = _GraphsStub([None]) # no graph fits this clip
|
||||
out = enc._encode_prepared(prepared)
|
||||
assert proc.attempt_sizes == [1] and out[0].tolist() == [[1] * _N_VQ] * 3
|
||||
|
||||
|
||||
async def test_processor_prepare_matches_processor_channel_and_loudness_handling(make_encoder):
|
||||
enc = make_encoder(_LoudnessProcessor())
|
||||
mono = enc._processor_prepare(torch.ones(100))
|
||||
assert mono.shape == (2, 100) and torch.equal(mono, torch.full((2, 100), 2.0))
|
||||
many = enc._processor_prepare(torch.arange(3 * 4, dtype=torch.float32).reshape(3, 4))
|
||||
assert torch.equal(many, torch.arange(8, dtype=torch.float32).reshape(2, 4) * 2.0)
|
||||
|
||||
|
||||
async def test_processor_prepare_resamples_to_the_tokenizer_rate(make_encoder):
|
||||
torchaudio = pytest.importorskip("torchaudio")
|
||||
proc = _LoudnessProcessor(sampling_rate=48000)
|
||||
enc = make_encoder(proc) # works at 24 kHz, the Local-v1.5 reference rate
|
||||
wav = torch.randn(1, 2400)
|
||||
expected = torchaudio.functional.resample(wav.repeat(2, 1), 24000, 48000) * 2.0
|
||||
# The encoder resamples the mono clip before duplicating it. Some CPU conv
|
||||
# kernels round a one-row batch differently from a two-row one, so the two
|
||||
# orders agree to float32 precision, not bit for bit.
|
||||
torch.testing.assert_close(enc._processor_prepare(wav), expected, rtol=0, atol=1e-5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["", "host", "client"])
|
||||
async def test_prepare_captures_graphs_or_starts_the_shared_host(make_encoder, monkeypatch, tmp_path, role):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from vllm_omni.model_executor.models.moss_tts import reference_encoder as re_mod
|
||||
|
||||
enc = make_encoder(_FakeProcessor())
|
||||
enc._shares_encoder, enc._shared_codes_dir = True, str(tmp_path)
|
||||
monkeypatch.setattr(re_mod, "shared_encoder_role", lambda: role)
|
||||
captured, hosts, ready = [], [], []
|
||||
monkeypatch.setattr(enc, "_reference_graphs", lambda: captured.append(True))
|
||||
monkeypatch.setattr(enc, "_host_workers", lambda: [])
|
||||
helper = SimpleNamespace(wait_until_ready=lambda: ready.append(True))
|
||||
|
||||
def make_host(*args, **kwargs):
|
||||
hosts.append(args)
|
||||
return helper
|
||||
|
||||
monkeypatch.setattr(re_mod, "SharedReferenceEncoderHost", make_host)
|
||||
monkeypatch.setattr(re_mod, "SharedReferenceEncoderClient", lambda *args: helper)
|
||||
enc.prepare()
|
||||
enc.prepare()
|
||||
# Every API process waits for the shared host before startup completes.
|
||||
assert (len(captured), len(hosts)) == {"": (2, 0), "host": (0, 1), "client": (0, 0)}[role]
|
||||
assert len(ready) == (2 if role else 0)
|
||||
|
||||
|
||||
async def test_clips_reach_the_encoder_at_the_tokenizer_rate(make_encoder, monkeypatch):
|
||||
torchaudio = pytest.importorskip("torchaudio")
|
||||
proc = _LoudnessProcessor(sampling_rate=48000)
|
||||
enc = make_encoder(proc) # works at 24 kHz
|
||||
sent = []
|
||||
|
||||
def record_prepared(prepared):
|
||||
sent.extend(prepared)
|
||||
return [None] * len(prepared)
|
||||
|
||||
monkeypatch.setattr(enc, "_encode_shared_or_local", record_prepared)
|
||||
wav = [float(v) for v in torch.randn(2400)]
|
||||
enc._encode_batch_sync([(wav, _SR)])
|
||||
# Resampled where the request arrived (a shared encoder receives it this way); still one channel.
|
||||
expected = torchaudio.functional.resample(torch.tensor(wav)[None], 24000, 48000)
|
||||
torch.testing.assert_close(sent[0], expected, rtol=0, atol=0)
|
||||
# The eager path is told the clip is already at the tokenizer rate.
|
||||
calls = []
|
||||
|
||||
def record_encoding(wavs, sampling_rate, n_vq=None):
|
||||
calls.append(sampling_rate)
|
||||
return [None]
|
||||
|
||||
monkeypatch.setattr(proc, "encode_audios_from_wav", record_encoding)
|
||||
enc._graphs_enabled = False
|
||||
enc._encode_prepared(sent)
|
||||
assert calls == [48000]
|
||||
|
||||
|
||||
async def test_batcher_runs_up_to_max_inflight_batches_at_once():
|
||||
inside = threading.Barrier(2, timeout=5)
|
||||
seen: list[int] = []
|
||||
|
||||
def encode_batch(payload):
|
||||
inside.wait() # two batches are encoding at the same time
|
||||
seen.append(len(payload))
|
||||
return [torch.zeros(1) for _ in payload]
|
||||
|
||||
batcher = _RefEncodeBatcher(encode_batch, window_ms=0.0, max_batch=8, max_inflight=2)
|
||||
try:
|
||||
first = asyncio.ensure_future(batcher.submit([1.0], _SR))
|
||||
await asyncio.sleep(0.05) # the first batch is in flight before the second clip arrives
|
||||
await asyncio.gather(first, batcher.submit([2.0], _SR))
|
||||
finally:
|
||||
await batcher.aclose()
|
||||
assert seen == [1, 1]
|
||||
|
||||
|
||||
def test_reference_stream_priority(monkeypatch):
|
||||
from vllm_omni.model_executor.models.moss_tts import reference_encoder as re_mod
|
||||
|
||||
monkeypatch.delenv(re_mod._REF_ENCODE_PRIORITY_ENV, raising=False)
|
||||
assert re_mod.reference_stream_priority() == 0
|
||||
monkeypatch.setenv(re_mod._REF_ENCODE_PRIORITY_ENV, "high")
|
||||
expected = torch.cuda.Stream.priority_range()[1] if torch.cuda.is_available() else 0
|
||||
assert re_mod.reference_stream_priority() == expected
|
||||
|
||||
|
||||
async def test_device_prep_resamples_on_the_tokenizer_device(make_encoder, monkeypatch):
|
||||
torchaudio = pytest.importorskip("torchaudio")
|
||||
from vllm_omni.model_executor.models.moss_tts import reference_encoder as re_mod
|
||||
|
||||
proc = _LoudnessProcessor(sampling_rate=48000)
|
||||
monkeypatch.setattr(proc, "audio_tokenizer", torch.nn.Linear(1, 1), raising=False) # CPU placement
|
||||
enc = make_encoder(proc)
|
||||
enc._gpu_prep = True
|
||||
wav = torch.randn(1, 2400)
|
||||
clip = enc._encoder_input(wav)
|
||||
assert clip.shape == (1, 2400) # left at the working rate for the encoder's device
|
||||
expected = torchaudio.functional.resample(wav, 24000, 48000).repeat(2, 1) * 2.0
|
||||
torch.testing.assert_close(enc._finish_prepare(clip), expected, rtol=1e-5, atol=1e-6)
|
||||
# The eager fallback is told the clip's own rate.
|
||||
calls = []
|
||||
|
||||
def record_encoding(wavs, sampling_rate, n_vq=None):
|
||||
calls.append(sampling_rate)
|
||||
return [None]
|
||||
|
||||
monkeypatch.setattr(proc, "encode_audios_from_wav", record_encoding)
|
||||
enc._graphs_enabled = False
|
||||
enc._encode_prepared([clip])
|
||||
assert calls == [24000]
|
||||
assert re_mod._REF_ENCODE_GPU_PREP_ENV == "VLLM_OMNI_MOSS_REF_GPU_PREP"
|
||||
@@ -0,0 +1,320 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Reference-encoder CUDA graphs reproduce the tokenizer's eager encode."""
|
||||
|
||||
from contextlib import nullcontext
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from torch import nn
|
||||
|
||||
from vllm_omni.model_executor.models.moss_tts.reference_encoder_graphs import (
|
||||
MossReferenceEncoderGraphs,
|
||||
_cached_reference_rope,
|
||||
_reference_rope_factors,
|
||||
_validate_unmasked_reference_encoder,
|
||||
_windowed_attention,
|
||||
install_windowed_attention,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cuda]
|
||||
|
||||
FRAME, N_VQ = 8, 3
|
||||
|
||||
|
||||
class _Stage(nn.Module):
|
||||
"""Causal per-frame stage: frames never mix, like a causal encoder layer."""
|
||||
|
||||
def __init__(self, dim_in, dim_out):
|
||||
super().__init__()
|
||||
self.proj = nn.Linear(dim_in, dim_out)
|
||||
|
||||
def forward(self, x, lengths):
|
||||
return torch.tanh(self.proj(x)), lengths
|
||||
|
||||
|
||||
class _Quantizer(nn.Module):
|
||||
def forward(self, hidden, lengths, n_q):
|
||||
# hidden (B, frames, D): codes (n_q, B, frames) from per-frame argmax slices.
|
||||
codes = torch.stack([hidden[..., q::n_q].argmax(-1) for q in range(n_q)])
|
||||
return hidden, codes, lengths
|
||||
|
||||
|
||||
class _Tokenizer(nn.Module):
|
||||
number_channels = 2
|
||||
downsample_rate = FRAME
|
||||
sampling_rate = 64
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.encoder = nn.ModuleList([_Stage(FRAME * 2, 16), _Stage(16, 12)])
|
||||
self.quantizer = _Quantizer()
|
||||
|
||||
def _flatten_channels_for_codec(self, audio, lengths):
|
||||
batch, channels, samples = audio.shape
|
||||
frames = samples // FRAME
|
||||
values = audio.reshape(batch, channels, frames, FRAME).permute(0, 2, 1, 3).reshape(batch, frames, -1)
|
||||
return values, lengths // FRAME
|
||||
|
||||
def _codec_inference_autocast(self):
|
||||
return nullcontext()
|
||||
|
||||
def eager(self, wavs):
|
||||
out = []
|
||||
for wav in wavs:
|
||||
values, lengths = self._flatten_channels_for_codec(wav[None], torch.tensor([wav.shape[-1]]))
|
||||
for module in self.encoder:
|
||||
values, lengths = module(values, lengths)
|
||||
_, codes, _ = self.quantizer(values, lengths, N_VQ)
|
||||
out.append(codes[:, 0, : wav.shape[-1] // FRAME].transpose(0, 1).long())
|
||||
return out
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cuda():
|
||||
if not torch.cuda.is_available():
|
||||
pytest.skip("CUDA required")
|
||||
return torch.device("cuda", torch.accelerator.current_device_index())
|
||||
|
||||
|
||||
def test_cached_rope_reuses_longest_table_without_modifying_inputs(cuda):
|
||||
from types import SimpleNamespace
|
||||
|
||||
module = SimpleNamespace(rope=SimpleNamespace(max_period=10000.0))
|
||||
cache: dict[tuple[int, float, torch.device], tuple[torch.Tensor, torch.Tensor]] = {}
|
||||
torch.manual_seed(17)
|
||||
# Prime once; all subsequent sequences and batches use an immutable prefix.
|
||||
q = torch.randn(1, 3, 128, 32, device=cuda, dtype=torch.bfloat16)
|
||||
k = torch.randn_like(q)
|
||||
_cached_reference_rope(module, q, k, cache)
|
||||
stored = next(iter(cache.values()))
|
||||
for batch, length in [(1, 17), (4, 64), (2, 128)]:
|
||||
q = torch.randn(batch, 3, length, 32, device=cuda, dtype=torch.bfloat16)
|
||||
k = torch.randn_like(q)
|
||||
before = (q.clone(), k.clone())
|
||||
got = _cached_reference_rope(module, q, k, cache)
|
||||
cos, sin = _reference_rope_factors(length, 32, 10000.0, cuda)
|
||||
torch.testing.assert_close(stored[0][:length], cos, rtol=0, atol=0)
|
||||
torch.testing.assert_close(stored[1][:length], sin, rtol=0, atol=0)
|
||||
for value, source, saved in zip(got, (q, k), before):
|
||||
real, imag = source.float().reshape(batch, 3, length, 16, 2).unbind(-1)
|
||||
expected = torch.stack((real * cos - imag * sin, real * sin + imag * cos), -1).to(source.dtype)
|
||||
torch.testing.assert_close(value, expected.reshape_as(source), rtol=0, atol=0)
|
||||
torch.testing.assert_close(source, saved, rtol=0, atol=0)
|
||||
assert len(cache) == 1
|
||||
assert next(iter(cache.values())) is stored
|
||||
|
||||
|
||||
def test_cached_rope_distinguishes_period_and_head_width(cuda):
|
||||
from types import SimpleNamespace
|
||||
|
||||
cache: dict[tuple[int, float, torch.device], tuple[torch.Tensor, torch.Tensor]] = {}
|
||||
for width, period in [(32, 10000.0), (64, 10000.0), (32, 1000.0)]:
|
||||
module = SimpleNamespace(rope=SimpleNamespace(max_period=period))
|
||||
q = torch.randn(1, 2, 32, width, device=cuda, dtype=torch.bfloat16)
|
||||
_cached_reference_rope(module, q, q, cache)
|
||||
assert len(cache) == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize("batched_transfer", [False, True])
|
||||
def test_graph_codes_match_eager_per_clip_and_fall_back_outside_buckets(cuda, batched_transfer):
|
||||
torch.manual_seed(0)
|
||||
tokenizer = _Tokenizer().to(cuda)
|
||||
# Buckets of 2 s and 4 s at 64 Hz: 128 and 256 samples.
|
||||
graphs = MossReferenceEncoderGraphs(
|
||||
tokenizer, n_vq=N_VQ, batch_sizes=(1, 2, 4), bucket_seconds=(2.0, 4.0), batched_transfer=batched_transfer
|
||||
)
|
||||
graphs.capture()
|
||||
assert graphs.captured == [(1, 128), (1, 256), (2, 128), (2, 256), (4, 128), (4, 256)]
|
||||
assert graphs.max_batch == 4
|
||||
|
||||
for count, frames in [(1, [5]), (3, [16, 3, 9]), (4, [32, 1, 20, 17]), (2, [16, 16])]:
|
||||
wavs = [torch.randn(2, n * FRAME) for n in frames]
|
||||
codes = graphs.encode(wavs)
|
||||
expected = tokenizer.eager([w.to(cuda) for w in wavs])
|
||||
assert len(codes) == count
|
||||
for got, want in zip(codes, expected):
|
||||
assert got.device.type == "cpu" and got.dtype == torch.long
|
||||
torch.testing.assert_close(got, want.cpu(), rtol=0, atol=0)
|
||||
|
||||
# Longer than every bucket: left to the caller; the rest still use graphs.
|
||||
long_clip, short_clip = torch.randn(2, 33 * FRAME), torch.randn(2, 4 * FRAME)
|
||||
codes = graphs.encode([long_clip, short_clip])
|
||||
assert codes[0] is None
|
||||
torch.testing.assert_close(codes[1], tokenizer.eager([short_clip.to(cuda)])[0].cpu(), rtol=0, atol=0)
|
||||
|
||||
# Wider than every batch: split, longest first, and returned in input order.
|
||||
wavs = [torch.randn(2, n * FRAME) for n in (3, 30, 7, 12, 1)]
|
||||
for got, want in zip(graphs.encode(wavs), tokenizer.eager([w.to(cuda) for w in wavs])):
|
||||
torch.testing.assert_close(got, want.cpu(), rtol=0, atol=0)
|
||||
|
||||
# Result ownership includes the degenerate single-frame shape. Reusing
|
||||
# the pinned output slab must not overwrite a previous caller's codes.
|
||||
previous = graphs.encode([torch.randn(2, FRAME)])[0]
|
||||
snapshot = previous.clone()
|
||||
graphs.encode([torch.randn(2, 16 * FRAME) for _ in range(4)])
|
||||
torch.testing.assert_close(previous, snapshot, rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_batch_audio_budget_limits_captured_graphs(cuda):
|
||||
tokenizer = _Tokenizer().to(cuda)
|
||||
graphs = MossReferenceEncoderGraphs(
|
||||
tokenizer, n_vq=N_VQ, batch_sizes=(1, 2, 4), bucket_seconds=(2.0, 4.0), max_batch_seconds=8.0
|
||||
)
|
||||
graphs.capture()
|
||||
# 4 x 4 s exceeds the 8 s budget; a single clip is always captured.
|
||||
assert graphs.captured == [(1, 128), (1, 256), (2, 128), (2, 256), (4, 128)]
|
||||
wavs = [torch.randn(2, 30 * FRAME) for _ in range(3)]
|
||||
for got, want in zip(graphs.encode(wavs), tokenizer.eager([w.to(cuda) for w in wavs])):
|
||||
torch.testing.assert_close(got, want.cpu(), rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_finer_singleton_buckets_preserve_multiclip_batching(cuda):
|
||||
tokenizer = _Tokenizer().to(cuda)
|
||||
graphs = MossReferenceEncoderGraphs(
|
||||
tokenizer,
|
||||
n_vq=N_VQ,
|
||||
batch_sizes=(1, 2, 4),
|
||||
bucket_seconds=(2.0, 4.0),
|
||||
singleton_bucket_seconds=(1.0, 3.0),
|
||||
)
|
||||
graphs.capture()
|
||||
assert (1, 64) in graphs.captured and (1, 192) in graphs.captured
|
||||
assert (2, 64) not in graphs.captured and (4, 192) not in graphs.captured
|
||||
replay = graphs._replay
|
||||
shapes = []
|
||||
|
||||
def record(entry, wavs):
|
||||
shapes.append(tuple(entry.audio.shape))
|
||||
return replay(entry, wavs)
|
||||
|
||||
graphs._replay = record
|
||||
one = torch.randn(2, 20 * FRAME)
|
||||
out = graphs.encode([one])
|
||||
assert shapes == [(1, 2, 192)]
|
||||
torch.testing.assert_close(out[0], tokenizer.eager([one.to(cuda)])[0].cpu(), rtol=0, atol=0)
|
||||
shapes.clear()
|
||||
wavs = [one, one[:, : 8 * FRAME], one[:, : 12 * FRAME]]
|
||||
out = graphs.encode(wavs)
|
||||
assert shapes == [(4, 2, 256)]
|
||||
for got, want in zip(out, tokenizer.eager([w.to(cuda) for w in wavs])):
|
||||
torch.testing.assert_close(got, want.cpu(), rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_compiled_capture_matches_eager(cuda):
|
||||
torch.manual_seed(2)
|
||||
tokenizer = _Tokenizer().to(cuda)
|
||||
graphs = MossReferenceEncoderGraphs(
|
||||
tokenizer, n_vq=N_VQ, batch_sizes=(1, 4), bucket_seconds=(2.0, 4.0), compile_core=True
|
||||
)
|
||||
graphs.capture()
|
||||
assert graphs.captured == [(1, 128), (1, 256), (4, 128), (4, 256)]
|
||||
wavs = [torch.randn(2, n * FRAME) for n in (5, 30, 12)]
|
||||
for got, want in zip(graphs.encode(wavs), tokenizer.eager([w.to(cuda) for w in wavs])):
|
||||
torch.testing.assert_close(got, want.cpu(), rtol=0, atol=0)
|
||||
|
||||
|
||||
class _Attention(nn.Module):
|
||||
"""The attention surface the tokenizer's MHA exposes to the windowed kernel."""
|
||||
|
||||
def __init__(self, embed_dim=128, heads=2, context=5):
|
||||
super().__init__()
|
||||
self.embed_dim, self.num_heads, self.context, self.causal = embed_dim, heads, context, True
|
||||
self.in_proj = nn.Linear(embed_dim, 3 * embed_dim, bias=False)
|
||||
|
||||
def _project_qkv(self, x):
|
||||
b, t, _ = x.shape
|
||||
return self.in_proj(x).reshape(b, t, 3, self.num_heads, -1).permute(2, 0, 3, 1, 4).unbind(0)
|
||||
|
||||
def _apply_dense_rope(self, q, k):
|
||||
return q, k
|
||||
|
||||
def _forward_non_streaming_sdpa(self, x, input_lengths):
|
||||
# Reference semantics: causal, distance < context, padded rows zeroed.
|
||||
b, t, _ = x.shape
|
||||
q, k, v = self._project_qkv(x)
|
||||
pos = torch.arange(t, device=x.device)
|
||||
delta = pos.view(-1, 1) - pos.view(1, -1)
|
||||
mask = (delta >= 0) & (delta < self.context)
|
||||
out = F.scaled_dot_product_attention(q, k, v, mask[None, None])
|
||||
valid = (pos.view(1, t) < input_lengths.view(-1, 1)).view(b, 1, t, 1)
|
||||
out = torch.where(valid, out, torch.zeros((), device=out.device, dtype=out.dtype))
|
||||
return out.transpose(1, 2).reshape(b, t, self.embed_dim)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fa_version", [2, 3])
|
||||
def test_windowed_attention_matches_masked_sdpa(cuda, fa_version):
|
||||
if fa_version == 3 and torch.cuda.get_device_capability(cuda)[0] != 9:
|
||||
pytest.skip("FA3 requires Hopper")
|
||||
torch.manual_seed(1)
|
||||
model = nn.Module()
|
||||
model.encoder = nn.ModuleList([_Attention().to(cuda, torch.bfloat16)])
|
||||
attention = model.encoder[0]
|
||||
x = torch.randn(3, 40, 128, device=cuda, dtype=torch.bfloat16)
|
||||
lengths = torch.tensor([40, 17, 3], device=cuda)
|
||||
expected = attention._forward_non_streaming_sdpa(x, lengths)
|
||||
assert install_windowed_attention(model, fa_version=fa_version) == 1
|
||||
actual = attention._forward_non_streaming_sdpa(x, lengths)
|
||||
torch.testing.assert_close(actual, expected, rtol=2e-2, atol=2e-2)
|
||||
assert actual[1, 17:].count_nonzero() == 0 and actual[2, 3:].count_nonzero() == 0
|
||||
# Non-bf16 inputs keep the tokenizer's own SDPA path.
|
||||
x32 = x.float()
|
||||
attention.float()
|
||||
torch.testing.assert_close(
|
||||
attention._forward_non_streaming_sdpa(x32, lengths), expected.float(), rtol=3e-2, atol=3e-2
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fa_version", [2, 3])
|
||||
def test_unmasked_causal_queries_preserve_valid_prefix_and_ignore_future_padding(cuda, fa_version):
|
||||
if fa_version == 3 and torch.cuda.get_device_capability(cuda)[0] != 9:
|
||||
pytest.skip("FA3 requires Hopper")
|
||||
torch.manual_seed(44)
|
||||
attention = _Attention().to(cuda, torch.bfloat16)
|
||||
x = torch.randn(4, 40, 128, device=cuda, dtype=torch.bfloat16)
|
||||
lengths = torch.tensor([40, 17, 1, 0], device=cuda)
|
||||
kwargs = dict(sdpa=attention._forward_non_streaming_sdpa, fa_version=fa_version)
|
||||
with torch.no_grad():
|
||||
masked = _windowed_attention(attention, x, lengths, **kwargs)
|
||||
unmasked = _windowed_attention(attention, x, lengths, skip_padded_query_mask=True, **kwargs)
|
||||
poisoned = x.clone()
|
||||
for row, length in enumerate([40, 17, 1, 0]):
|
||||
poisoned[row, length:] = torch.randn_like(poisoned[row, length:]) * 30
|
||||
changed = _windowed_attention(attention, poisoned, lengths, skip_padded_query_mask=True, **kwargs)
|
||||
for row, length in enumerate([40, 17, 1, 0]):
|
||||
torch.testing.assert_close(unmasked[row, :length], masked[row, :length], rtol=0, atol=0)
|
||||
torch.testing.assert_close(changed[row, :length], masked[row, :length], rtol=0, atol=0)
|
||||
assert masked[row, length:].count_nonzero() == 0
|
||||
assert unmasked[1, 17:].count_nonzero() > 0
|
||||
# The FP32 fallback retains its original masking contract.
|
||||
attention.float()
|
||||
got = _windowed_attention(attention, x.float(), lengths, skip_padded_query_mask=True, **kwargs)
|
||||
expected = attention._forward_non_streaming_sdpa(x.float(), lengths)
|
||||
torch.testing.assert_close(got, expected, rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_unmasked_queries_reject_unknown_encoder_before_patching():
|
||||
tokenizer = _Tokenizer()
|
||||
with pytest.raises(ValueError, match="patch-downsample"):
|
||||
install_windowed_attention(tokenizer, skip_padded_query_mask=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_part", ["upsample", "noncausal", "quantizer", "convolution"])
|
||||
def test_unmasked_queries_reject_structures_that_can_read_invalid_rows(bad_part):
|
||||
from types import SimpleNamespace
|
||||
|
||||
patch = type("MossAudioTokenizerPatchedPretransform", (), {})()
|
||||
patch.is_downsample = bad_part != "upsample"
|
||||
projected = type("MossAudioTokenizerProjectedTransformer", (), {})()
|
||||
projected.transformer = SimpleNamespace(
|
||||
layers=[SimpleNamespace(self_attn=SimpleNamespace(causal=bad_part != "noncausal"))]
|
||||
)
|
||||
quantizer_cls = type(
|
||||
"MossAudioTokenizerResidualVQ" if bad_part != "quantizer" else "UnknownQuantizer", (nn.Module,), {}
|
||||
)
|
||||
quantizer = quantizer_cls()
|
||||
quantizer.proj = nn.Conv1d(2, 2, 3 if bad_part == "convolution" else 1)
|
||||
tokenizer = SimpleNamespace(encoder=[patch, projected], quantizer=quantizer)
|
||||
with pytest.raises(ValueError):
|
||||
_validate_unmasked_reference_encoder(tokenizer)
|
||||
@@ -0,0 +1,341 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""API processes share one reference encoder through a Unix socket."""
|
||||
|
||||
import threading
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.models.moss_tts import shared_reference_encoder as shared
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _codes(wav):
|
||||
# Deterministic per clip: frames from the clip length, values from its first sample.
|
||||
return torch.full((wav.shape[-1] // 10, 4), int(wav.reshape(-1)[0]), dtype=torch.long)
|
||||
|
||||
|
||||
class _Recorder:
|
||||
def __init__(self, gate=None):
|
||||
self.batches = []
|
||||
self.gate = gate
|
||||
|
||||
def __call__(self, wavs):
|
||||
if self.gate is not None:
|
||||
self.gate.wait(5)
|
||||
self.batches.append(len(wavs))
|
||||
return [RuntimeError("bad clip") if int(w.reshape(-1)[0]) < 0 else _codes(w) for w in wavs]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def host_dir(monkeypatch):
|
||||
monkeypatch.setattr(shared, "_host_lock_handle", None)
|
||||
# AF_UNIX limits the full socket path, including pytest directory names.
|
||||
with TemporaryDirectory(prefix="moss-ref-") as path:
|
||||
yield path
|
||||
|
||||
|
||||
def test_only_one_process_per_directory_hosts(host_dir):
|
||||
assert shared.elect_host(host_dir) is True
|
||||
assert shared.elect_host(host_dir) is False # a second open file description cannot take the lock
|
||||
|
||||
|
||||
def test_close_releases_queued_and_active_callers(host_dir):
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
|
||||
def encode(wavs):
|
||||
entered.set()
|
||||
assert release.wait(5)
|
||||
return [_codes(wav) for wav in wavs]
|
||||
|
||||
host = shared.SharedReferenceEncoderHost(host_dir, [(encode, None)])
|
||||
results: dict[str, list[torch.Tensor | BaseException]] = {}
|
||||
threads = [
|
||||
threading.Thread(target=lambda key=key: results.setdefault(key, host.encode([torch.ones(1, 20)])), daemon=True)
|
||||
for key in ("active", "queued")
|
||||
]
|
||||
try:
|
||||
threads[0].start()
|
||||
assert entered.wait(2)
|
||||
threads[1].start()
|
||||
with host._ready:
|
||||
assert host._ready.wait_for(lambda: len(host._pending) == 1, timeout=2)
|
||||
host.close()
|
||||
for thread in threads:
|
||||
thread.join(1)
|
||||
assert not thread.is_alive(), "closed encoder left a request waiting"
|
||||
assert all(isinstance(results[key][0], RuntimeError) for key in results)
|
||||
finally:
|
||||
release.set()
|
||||
host.close()
|
||||
for thread in threads:
|
||||
if thread.ident is not None:
|
||||
thread.join(1)
|
||||
|
||||
|
||||
def test_worker_result_count_mismatch_fails_entire_batch(host_dir):
|
||||
host = shared.SharedReferenceEncoderHost(host_dir, [(lambda wavs: [_codes(wavs[0])], None)])
|
||||
results = []
|
||||
caller = threading.Thread(target=lambda: results.extend(host.encode([torch.ones(1, 20)] * 2)), daemon=True)
|
||||
try:
|
||||
caller.start()
|
||||
caller.join(2)
|
||||
assert not caller.is_alive(), "missing encoder result left a request waiting"
|
||||
assert len(results) == 2
|
||||
assert all(isinstance(result, RuntimeError) for result in results)
|
||||
finally:
|
||||
host.close()
|
||||
caller.join(1)
|
||||
|
||||
|
||||
def test_client_round_trip_and_per_clip_errors(host_dir):
|
||||
host = shared.SharedReferenceEncoderHost(host_dir, [(_Recorder(), None)])
|
||||
try:
|
||||
client = shared.SharedReferenceEncoderClient(host_dir)
|
||||
wavs = [torch.full((1, 40), 3.0), torch.full((1, 70), -1.0), torch.full((1, 20), 5.0)]
|
||||
out = client.encode(wavs)
|
||||
assert out[0].dtype == torch.long and torch.equal(out[0], _codes(wavs[0]))
|
||||
assert isinstance(out[1], RuntimeError) and "bad clip" in str(out[1])
|
||||
assert torch.equal(out[2], _codes(wavs[2]))
|
||||
# The host process's own clips skip the socket.
|
||||
assert torch.equal(host.encode([wavs[2]])[0], _codes(wavs[2]))
|
||||
finally:
|
||||
host.close()
|
||||
|
||||
|
||||
def test_host_merges_clips_from_concurrent_clients(host_dir):
|
||||
gate = threading.Event()
|
||||
recorder = _Recorder(gate)
|
||||
host = shared.SharedReferenceEncoderHost(host_dir, [(recorder, None)])
|
||||
try:
|
||||
results = {}
|
||||
|
||||
def send(name, value):
|
||||
results[name] = shared.SharedReferenceEncoderClient(host_dir).encode([torch.full((1, 30), value)] * 2)
|
||||
|
||||
# Encoding is held until both clients' clips are queued.
|
||||
warm = threading.Thread(target=lambda: host.encode([torch.full((1, 10), 1.0)]))
|
||||
warm.start()
|
||||
threads = [threading.Thread(target=send, args=(n, v)) for n, v in (("a", 2.0), ("b", 4.0))]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
threading.Timer(0.5, gate.set).start()
|
||||
for thread in (warm, *threads):
|
||||
thread.join(10)
|
||||
# Both clients' clips were encoded together, not one request at a time.
|
||||
assert sum(recorder.batches) == 5 and max(recorder.batches) >= 4
|
||||
assert all(int(c[0, 0]) == 2 for c in results["a"]) and all(int(c[0, 0]) == 4 for c in results["b"])
|
||||
finally:
|
||||
host.close()
|
||||
|
||||
|
||||
def test_client_without_host_raises(host_dir, monkeypatch):
|
||||
monkeypatch.setattr(shared, "_CONNECT_TIMEOUT_S", 0.3)
|
||||
with pytest.raises((FileNotFoundError, ConnectionRefusedError)):
|
||||
shared.SharedReferenceEncoderClient(host_dir).encode([torch.zeros(1, 10)])
|
||||
|
||||
|
||||
def test_host_window_collects_across_workers_without_reserving_singletons(host_dir):
|
||||
recorder = _Recorder()
|
||||
host = shared.SharedReferenceEncoderHost(host_dir, [(recorder, None)] * 3, batch_window_ms=500)
|
||||
try:
|
||||
barrier = threading.Barrier(3)
|
||||
results = []
|
||||
|
||||
def send(value):
|
||||
barrier.wait()
|
||||
results.extend(host.encode([torch.full((1, 20), value)]))
|
||||
|
||||
threads = [threading.Thread(target=send, args=(v,)) for v in (1.0, 2.0)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
barrier.wait()
|
||||
for thread in threads:
|
||||
thread.join(5)
|
||||
assert not thread.is_alive()
|
||||
assert recorder.batches == [2]
|
||||
assert sorted(int(x[0, 0]) for x in results) == [1, 2]
|
||||
finally:
|
||||
host.close()
|
||||
|
||||
|
||||
def test_reference_encoder_client_falls_back_to_local_encode(host_dir, monkeypatch):
|
||||
from tests.model_executor.models.moss_tts.test_reference_encoder import _N_VQ, _SR, _FakeProcessor
|
||||
from vllm_omni.model_executor.models.moss_tts import reference_encoder as re_mod
|
||||
from vllm_omni.utils.speaker_cache import SpeakerEmbeddingCache
|
||||
|
||||
monkeypatch.setenv(re_mod._SHARED_CODES_DIR_ENV, host_dir)
|
||||
monkeypatch.setattr(re_mod, "shared_encoder_role", lambda: "client")
|
||||
monkeypatch.setattr(shared, "_CONNECT_TIMEOUT_S", 0.3)
|
||||
proc = _FakeProcessor()
|
||||
enc = re_mod.MossReferenceEncoder(
|
||||
proc, variant="local", n_vq=_N_VQ, sr_target=_SR, speaker_cache=SpeakerEmbeddingCache(max_bytes=1 << 20)
|
||||
)
|
||||
placed = []
|
||||
monkeypatch.setattr(enc, "_place_tokenizer_for_local_encode", lambda: placed.append(True))
|
||||
out = enc._encode_batch_sync([([7.0] * 50, _SR)])
|
||||
assert placed == [True] and proc.attempt_sizes == [1] and int(out[0][0, 0]) == 7
|
||||
|
||||
|
||||
def test_workers_encode_concurrently_after_their_warmups(host_dir):
|
||||
inside, release = threading.Barrier(3, timeout=5), threading.Event()
|
||||
warmed = []
|
||||
|
||||
def worker(name):
|
||||
def encode(wavs):
|
||||
inside.wait() # both workers are inside an encode at the same time
|
||||
release.wait(5)
|
||||
return [_codes(w) for w in wavs]
|
||||
|
||||
return encode, lambda: warmed.append(name)
|
||||
|
||||
host = shared.SharedReferenceEncoderHost(host_dir, [worker("a"), worker("b")])
|
||||
try:
|
||||
out = []
|
||||
|
||||
def submit(value):
|
||||
out.append(host.encode([torch.full((1, 20), value)]))
|
||||
|
||||
first = threading.Thread(target=submit, args=(1.0,))
|
||||
first.start()
|
||||
# The first worker is busy by now, so the second clip goes to the other one.
|
||||
threading.Event().wait(0.1)
|
||||
second = threading.Thread(target=submit, args=(2.0,))
|
||||
second.start()
|
||||
inside.wait()
|
||||
release.set()
|
||||
for thread in (first, second):
|
||||
thread.join(5)
|
||||
assert sorted(warmed) == ["a", "b"] and sorted(int(o[0][0, 0]) for o in out) == [1, 2]
|
||||
finally:
|
||||
host.close()
|
||||
|
||||
|
||||
def test_client_requests_in_flight_use_their_own_connections(host_dir):
|
||||
entered, release = threading.Semaphore(0), threading.Event()
|
||||
|
||||
def worker():
|
||||
def encode(wavs):
|
||||
entered.release()
|
||||
release.wait(5)
|
||||
return [_codes(w) for w in wavs]
|
||||
|
||||
return encode, None
|
||||
|
||||
host = shared.SharedReferenceEncoderHost(host_dir, [worker(), worker()])
|
||||
try:
|
||||
client = shared.SharedReferenceEncoderClient(host_dir)
|
||||
out = []
|
||||
threads = [
|
||||
threading.Thread(target=lambda v=v: out.append(client.encode([torch.full((1, 20), v)]))) for v in (1.0, 2.0)
|
||||
]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
# A worker holds this request, still unanswered, before the next is
|
||||
# sent. Sent together, one free worker may take both as one batch.
|
||||
assert entered.acquire(timeout=5)
|
||||
release.set() # both requests reached the host at once
|
||||
for thread in threads:
|
||||
thread.join(5)
|
||||
assert sorted(int(o[0][0, 0]) for o in out) == [1, 2]
|
||||
assert len(client._idle) == 2 # both connections are kept for reuse
|
||||
finally:
|
||||
host.close()
|
||||
|
||||
|
||||
def test_readiness_waits_for_every_worker_warmup(host_dir, monkeypatch):
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
recorder = _Recorder()
|
||||
warmed = []
|
||||
|
||||
def last_warmup():
|
||||
entered.set()
|
||||
assert release.wait(5)
|
||||
warmed.append("last")
|
||||
|
||||
host = shared.SharedReferenceEncoderHost(
|
||||
host_dir, [(recorder, lambda: warmed.append("first")), (recorder, last_warmup)]
|
||||
)
|
||||
client = shared.SharedReferenceEncoderClient(host_dir)
|
||||
try:
|
||||
assert entered.wait(5)
|
||||
monkeypatch.setattr(shared, "_STARTUP_TIMEOUT_S", 0.03)
|
||||
with pytest.raises(shared.SharedReferenceEncoderStartupError):
|
||||
host.wait_until_ready()
|
||||
with pytest.raises(shared.SharedReferenceEncoderStartupError):
|
||||
client.wait_until_ready()
|
||||
assert warmed == ["first"] and recorder.batches == [] and not client._idle
|
||||
release.set()
|
||||
monkeypatch.setattr(shared, "_STARTUP_TIMEOUT_S", 5)
|
||||
host.wait_until_ready()
|
||||
client.wait_until_ready()
|
||||
assert warmed == ["first", "last"] and recorder.batches == []
|
||||
assert len(client._idle) == 1
|
||||
assert torch.equal(client.encode([torch.ones(1, 20)])[0], _codes(torch.ones(1, 20)))
|
||||
finally:
|
||||
release.set()
|
||||
host.close()
|
||||
|
||||
|
||||
def test_slow_startup_does_not_use_encode_timeout(host_dir, monkeypatch):
|
||||
release = threading.Event()
|
||||
monkeypatch.setattr(shared, "_REQUEST_TIMEOUT_S", 0.05)
|
||||
monkeypatch.setattr(shared, "_STARTUP_TIMEOUT_S", 5, raising=False)
|
||||
host = shared.SharedReferenceEncoderHost(host_dir, [(_Recorder(), lambda: release.wait(5))])
|
||||
timer = threading.Timer(0.2, release.set)
|
||||
timer.start()
|
||||
try:
|
||||
# The first socket waits for readiness before starting its much shorter
|
||||
# encode deadline. No API warmup call is required to protect this path.
|
||||
client = shared.SharedReferenceEncoderClient(host_dir)
|
||||
assert torch.equal(client.encode([torch.ones(1, 20)])[0], _codes(torch.ones(1, 20)))
|
||||
finally:
|
||||
release.set()
|
||||
timer.join(5)
|
||||
host.close()
|
||||
|
||||
|
||||
def test_failed_host_warmup_is_not_ready(host_dir):
|
||||
def fail():
|
||||
raise RuntimeError("warmup failed")
|
||||
|
||||
recorder = _Recorder()
|
||||
host = shared.SharedReferenceEncoderHost(host_dir, [(recorder, fail)])
|
||||
try:
|
||||
with pytest.raises(shared.SharedReferenceEncoderStartupError):
|
||||
host.wait_until_ready()
|
||||
with pytest.raises(shared.SharedReferenceEncoderStartupError):
|
||||
shared.SharedReferenceEncoderClient(host_dir).wait_until_ready()
|
||||
with pytest.raises(shared.SharedReferenceEncoderStartupError):
|
||||
host.encode([torch.ones(1, 20)])
|
||||
assert recorder.batches == []
|
||||
finally:
|
||||
host.close()
|
||||
|
||||
|
||||
def test_startup_failure_does_not_trigger_local_capture(host_dir, monkeypatch):
|
||||
from unittest.mock import Mock
|
||||
|
||||
from tests.model_executor.models.moss_tts.test_reference_encoder import _N_VQ, _SR, _FakeProcessor
|
||||
from vllm_omni.model_executor.models.moss_tts import reference_encoder as re_mod
|
||||
from vllm_omni.utils.speaker_cache import SpeakerEmbeddingCache
|
||||
|
||||
monkeypatch.setenv(re_mod._SHARED_CODES_DIR_ENV, host_dir)
|
||||
monkeypatch.setattr(re_mod, "shared_encoder_role", lambda: "client")
|
||||
enc = re_mod.MossReferenceEncoder(
|
||||
_FakeProcessor(),
|
||||
variant="local",
|
||||
n_vq=_N_VQ,
|
||||
sr_target=_SR,
|
||||
speaker_cache=SpeakerEmbeddingCache(max_bytes=1 << 20),
|
||||
)
|
||||
enc._shared_client = Mock()
|
||||
enc._shared_client.encode.side_effect = shared.SharedReferenceEncoderStartupError("still compiling")
|
||||
local = Mock()
|
||||
monkeypatch.setattr(enc, "_place_tokenizer_for_local_encode", local)
|
||||
with pytest.raises(shared.SharedReferenceEncoderStartupError):
|
||||
enc._encode_shared_or_local([torch.ones(1, 20)])
|
||||
local.assert_not_called()
|
||||
@@ -0,0 +1,301 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
import copy
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
from vllm_omni.model_executor.models.moss_tts.audio_tokenizer_v2 import (
|
||||
MossAudioTokenizerMultiheadAttention,
|
||||
MossAudioTokenizerRotaryEmbedding,
|
||||
RingKVCache,
|
||||
StreamingExecutionContext,
|
||||
)
|
||||
from vllm_omni.model_executor.models.moss_tts.slot_attention import slot_ring_attention, slot_ring_attention_rows
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cuda]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"frames,capacity", [(1, 125), (2, 250), (15, 125), (30, 250), (32, 400), (60, 400), (480, 400)]
|
||||
)
|
||||
@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
|
||||
@pytest.mark.parametrize("execution", ["graph", "compiled"])
|
||||
def test_empty_tile_skip_matches_static_ring_across_reuse(frames, capacity, dtype, execution):
|
||||
torch.manual_seed(45)
|
||||
batch, heads, states, dim = 3, 2, 5, 64
|
||||
projected = torch.randn(batch, frames, 3, heads, dim, device="cuda", dtype=dtype)
|
||||
q, k, v = projected.permute(2, 0, 3, 1, 4).unbind(0)
|
||||
cache = torch.randn(2, states, heads, capacity, dim, device="cuda", dtype=dtype)
|
||||
expected_cache = cache.clone()
|
||||
end = torch.zeros(states, 2, device="cuda", dtype=torch.long)[:, 0]
|
||||
expected_end = end.clone()
|
||||
slots = torch.tensor([0, 4, 2], device="cuda")
|
||||
lengths = torch.full((batch, 2), frames, device="cuda", dtype=torch.int32)[:, 0]
|
||||
|
||||
def candidate():
|
||||
return slot_ring_attention(q, k, v, cache, end, slots, lengths, 17, skip_empty_tiles=True)
|
||||
|
||||
graph = None
|
||||
if execution == "compiled":
|
||||
run = torch.compile(candidate, fullgraph=True)
|
||||
else:
|
||||
stream = torch.cuda.Stream()
|
||||
stream.wait_stream(torch.cuda.current_stream())
|
||||
with torch.cuda.stream(stream):
|
||||
candidate()
|
||||
torch.cuda.current_stream().wait_stream(stream)
|
||||
graph = torch.cuda.CUDAGraph()
|
||||
with torch.cuda.graph(graph, stream=stream):
|
||||
actual = candidate()
|
||||
|
||||
for step, start in enumerate([0, 1, capacity - 1, capacity + 9, 0, 3 * capacity]):
|
||||
end.fill_(start)
|
||||
expected_end.fill_(start)
|
||||
cache.copy_(expected_cache)
|
||||
projected.normal_()
|
||||
slots.copy_(torch.roll(torch.arange(states, device="cuda"), step)[:batch])
|
||||
lengths.copy_(torch.tensor([frames, max(1, frames - 3), 0], device="cuda", dtype=torch.int32))
|
||||
expected = slot_ring_attention(q, k, v, expected_cache, expected_end, slots, lengths, 17)
|
||||
if graph is not None:
|
||||
graph.replay()
|
||||
else:
|
||||
actual = run()
|
||||
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
|
||||
torch.testing.assert_close(cache, expected_cache, rtol=0, atol=0)
|
||||
torch.testing.assert_close(end, expected_end, rtol=0, atol=0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
|
||||
@pytest.mark.parametrize("frames,capacity", [(1, 125), (15, 140), (480, 400), (480, 880)])
|
||||
def test_all_padding_preserves_state_and_replay_can_become_active(dtype, frames, capacity):
|
||||
q, k, v = [torch.randn(2, 2, frames, 64, device="cuda", dtype=dtype) for _ in range(3)]
|
||||
cache = torch.randn(2, 4, 2, capacity, 64, device="cuda", dtype=dtype)
|
||||
end = torch.tensor([0, capacity + 3, 9, 0], device="cuda")
|
||||
slots = torch.tensor([1, 3], device="cuda")
|
||||
lengths = torch.zeros(2, device="cuda", dtype=torch.int32)
|
||||
before_cache, before_end = cache.clone(), end.clone()
|
||||
stream = torch.cuda.Stream()
|
||||
stream.wait_stream(torch.cuda.current_stream())
|
||||
with torch.cuda.stream(stream):
|
||||
for _ in range(3):
|
||||
slot_ring_attention(q, k, v, cache, end, slots, lengths, capacity)
|
||||
torch.cuda.current_stream().wait_stream(stream)
|
||||
graph = torch.cuda.CUDAGraph()
|
||||
with torch.cuda.graph(graph):
|
||||
out = slot_ring_attention(q, k, v, cache, end, slots, lengths, capacity)
|
||||
for active in [False, True, False, True]:
|
||||
lengths.fill_(frames if active else 0)
|
||||
expected = _ring_reference(q, k, v, before_cache, before_end, slots, lengths, capacity)
|
||||
graph.replay()
|
||||
torch.testing.assert_close(cache, before_cache, rtol=0, atol=0)
|
||||
torch.testing.assert_close(end, before_end, rtol=0, atol=0)
|
||||
torch.testing.assert_close(out, expected, rtol=0.025, atol=0.025)
|
||||
if not active:
|
||||
assert out.count_nonzero() == 0
|
||||
|
||||
|
||||
def test_dense_ring_retains_last_tokens_when_chunk_exceeds_capacity():
|
||||
torch.manual_seed(19)
|
||||
capacity, frames = 400, 480
|
||||
state = RingKVCache(3, 2, 64, capacity, device=torch.device("cuda"), dtype=torch.float32)
|
||||
slots = torch.tensor([1, 0], device="cuda")
|
||||
context = StreamingExecutionContext(state_slot_ids=slots, valid_rows=torch.ones(2, device="cuda", dtype=torch.bool))
|
||||
for start in [0, frames]:
|
||||
k = torch.randn(2, 2, frames, 64, device="cuda")
|
||||
v = torch.randn_like(k)
|
||||
state.complete(k, v, execution_context=context)
|
||||
retained = torch.arange(frames - capacity, frames, device="cuda")
|
||||
destinations = (start + retained) % capacity
|
||||
torch.testing.assert_close(state.cache[0, slots][:, :, destinations], k[:, :, retained], rtol=0, atol=0)
|
||||
torch.testing.assert_close(state.cache[1, slots][:, :, destinations], v[:, :, retained], rtol=0, atol=0)
|
||||
torch.testing.assert_close(state.end_offset[slots], torch.full_like(slots, start + frames), rtol=0, atol=0)
|
||||
|
||||
|
||||
def _ring_reference(q, k, v, cache, end, slots, lengths, context):
|
||||
"""Independent chronological ring oracle with FP32 attention arithmetic."""
|
||||
capacity = cache.shape[-2]
|
||||
output = torch.zeros_like(q)
|
||||
for row, (slot, length) in enumerate(zip(slots.tolist(), lengths.tolist())):
|
||||
if length == 0:
|
||||
continue
|
||||
start = int(end[slot])
|
||||
for token in range(max(0, length - capacity), length):
|
||||
cache[0, slot, :, (start + token) % capacity] = k[row, :, token]
|
||||
cache[1, slot, :, (start + token) % capacity] = v[row, :, token]
|
||||
stop = start + length
|
||||
physical_positions = torch.full((capacity,), -1, device=q.device, dtype=torch.long)
|
||||
available = torch.arange(max(0, stop - capacity), stop, device=q.device)
|
||||
physical_positions[available % capacity] = available
|
||||
queries = torch.arange(start, stop, device=q.device)
|
||||
distance = queries[:, None] - physical_positions[None]
|
||||
mask = (physical_positions[None] >= 0) & (distance >= 0)
|
||||
if context > 0:
|
||||
mask &= distance < context
|
||||
output[row, :, :length] = F.scaled_dot_product_attention(
|
||||
q[row, :, :length].float(), cache[0, slot].float(), cache[1, slot].float(), mask[None]
|
||||
).to(q.dtype)
|
||||
end[slot] = stop
|
||||
return output
|
||||
|
||||
|
||||
@pytest.mark.parametrize("frames,capacity,head_dim", [(1, 125, 64), (15, 125, 64), (480, 400, 64), (33, 65, 32)])
|
||||
@pytest.mark.parametrize("execution", ["eager", "graph", "compiled"])
|
||||
def test_slot_attention_preserves_ring_state_and_padding(frames, capacity, head_dim, execution):
|
||||
torch.manual_seed(29)
|
||||
batch, heads, state_capacity = 3, 2, 5
|
||||
projected = torch.randn(batch, frames, 3, heads, head_dim, device="cuda", dtype=torch.bfloat16)
|
||||
q, k, v = projected.permute(2, 0, 3, 1, 4).unbind(0)
|
||||
cache = torch.randn(2, state_capacity, heads, capacity, head_dim, device="cuda", dtype=q.dtype)
|
||||
# Strided metadata is allowed by the kernel contract.
|
||||
end = torch.zeros(state_capacity, 2, device="cuda", dtype=torch.long)[:, 0]
|
||||
slots = torch.arange(batch * 2, device="cuda")[::2]
|
||||
lengths = torch.full((batch, 2), frames, device="cuda", dtype=torch.int32)[:, 0]
|
||||
context = capacity
|
||||
attention = slot_ring_attention
|
||||
if execution == "compiled":
|
||||
# Functionalization must preserve mutations of the persistent ring
|
||||
# and its offsets between calls, including slot reuse.
|
||||
attention = torch.compile(slot_ring_attention, backend="inductor", fullgraph=True)
|
||||
if execution == "graph":
|
||||
stream = torch.cuda.Stream()
|
||||
stream.wait_stream(torch.cuda.current_stream())
|
||||
with torch.cuda.stream(stream):
|
||||
for _ in range(3):
|
||||
slot_ring_attention(q, k, v, cache, end, slots, lengths, context)
|
||||
torch.cuda.current_stream().wait_stream(stream)
|
||||
graph = torch.cuda.CUDAGraph()
|
||||
with torch.cuda.graph(graph):
|
||||
actual = slot_ring_attention(q, k, v, cache, end, slots, lengths, context)
|
||||
end.zero_()
|
||||
expected_cache, expected_end = cache.clone(), end.clone()
|
||||
for step in range(8):
|
||||
slots.copy_(torch.roll(torch.arange(state_capacity, device="cuda"), step)[:batch])
|
||||
lengths.copy_(torch.tensor([frames, max(1, frames - 3), 0], device="cuda", dtype=torch.int32))
|
||||
projected.normal_()
|
||||
if step == 4:
|
||||
end[1:3].zero_()
|
||||
expected_end[1:3].zero_()
|
||||
expected = _ring_reference(q, k, v, expected_cache, expected_end, slots, lengths, context)
|
||||
if execution == "graph":
|
||||
graph.replay()
|
||||
else:
|
||||
actual = attention(q, k, v, cache, end, slots, lengths, context)
|
||||
torch.testing.assert_close(cache, expected_cache, rtol=0, atol=0)
|
||||
torch.testing.assert_close(end, expected_end, rtol=0, atol=0)
|
||||
torch.testing.assert_close(actual, expected, rtol=0.025, atol=0.025)
|
||||
padding = torch.arange(frames, device="cuda")[None] >= lengths[:, None]
|
||||
assert actual.permute(0, 2, 1, 3)[padding].count_nonzero() == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("frames,context", [(1, 9), (3, 9), (15, 125), (480, 400)])
|
||||
def test_slot_attention_integrates_with_current_mha(frames, context):
|
||||
torch.manual_seed(9)
|
||||
reference = MossAudioTokenizerMultiheadAttention(
|
||||
128,
|
||||
2,
|
||||
causal=True,
|
||||
context=context,
|
||||
rope=MossAudioTokenizerRotaryEmbedding(),
|
||||
device="cuda",
|
||||
dtype=torch.bfloat16,
|
||||
)
|
||||
candidate = copy.deepcopy(reference)
|
||||
candidate._slot_attention = slot_ring_attention
|
||||
for model in (reference, candidate):
|
||||
model._streaming_state = model._init_streaming_state(4)
|
||||
for step in range(8):
|
||||
slots = torch.tensor([1, 0, 3] if step % 2 else [0, 1, 3], device="cuda")
|
||||
valid = torch.tensor([True, True, False], device="cuda")
|
||||
execution_context = StreamingExecutionContext(state_slot_ids=slots, valid_rows=valid)
|
||||
x = torch.randn(3, frames, 128, device="cuda", dtype=torch.bfloat16)
|
||||
with torch.inference_mode():
|
||||
expected = reference(x, x, x, execution_context=execution_context)
|
||||
actual = candidate(x, x, x, execution_context=execution_context)
|
||||
torch.testing.assert_close(actual, expected, atol=0.01, rtol=0.02)
|
||||
cs, rs = candidate._streaming_state, reference._streaming_state
|
||||
torch.testing.assert_close(cs.offset, rs.offset, rtol=0, atol=0)
|
||||
torch.testing.assert_close(cs.kv_cache.end_offset, rs.kv_cache.end_offset, rtol=0, atol=0)
|
||||
torch.testing.assert_close(cs.kv_cache.cache[:, :3], rs.kv_cache.cache[:, :3], rtol=0, atol=0)
|
||||
if step == 4:
|
||||
for model in (reference, candidate):
|
||||
model._streaming_state.reset_slots(torch.tensor([0], device="cuda"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"frames,capacity,head_dim", [(1, 125, 64), (15, 140, 64), (40, 125, 64), (480, 400, 64), (33, 65, 32)]
|
||||
)
|
||||
@pytest.mark.parametrize("execution", ["eager", "graph", "compiled"])
|
||||
def test_fused_rows_attention_matches_three_launch_op_exactly(frames, capacity, head_dim, execution):
|
||||
torch.manual_seed(31)
|
||||
batch, heads, state_capacity = 3, 2, 6
|
||||
projected = torch.randn(batch, frames, 3, heads, head_dim, device="cuda", dtype=torch.bfloat16)
|
||||
q, k, v = projected.permute(2, 0, 3, 1, 4).unbind(0)
|
||||
cache = torch.randn(2, state_capacity, heads, capacity, head_dim, device="cuda", dtype=q.dtype)
|
||||
reference_cache = cache.clone()
|
||||
end = torch.zeros(state_capacity, device="cuda", dtype=torch.long)
|
||||
slots = torch.zeros(batch, device="cuda", dtype=torch.long)
|
||||
lengths = torch.zeros(batch, device="cuda", dtype=torch.int32)
|
||||
starts = torch.zeros(batch, device="cuda", dtype=torch.long)
|
||||
attention = slot_ring_attention_rows
|
||||
if execution == "compiled":
|
||||
attention = torch.compile(slot_ring_attention_rows, backend="inductor", fullgraph=True)
|
||||
if execution == "graph":
|
||||
stream = torch.cuda.Stream()
|
||||
stream.wait_stream(torch.cuda.current_stream())
|
||||
with torch.cuda.stream(stream):
|
||||
for _ in range(3):
|
||||
slot_ring_attention_rows(q, k, v, cache, starts, slots, lengths, capacity)
|
||||
torch.cuda.current_stream().wait_stream(stream)
|
||||
graph = torch.cuda.CUDAGraph()
|
||||
with torch.cuda.graph(graph):
|
||||
actual = slot_ring_attention_rows(q, k, v, cache, starts, slots, lengths, capacity)
|
||||
cache.copy_(reference_cache)
|
||||
for step in range(8):
|
||||
slots.copy_(torch.roll(torch.arange(state_capacity, device="cuda"), step)[:batch])
|
||||
lengths.copy_(torch.tensor([frames, max(1, frames - 3), 0], device="cuda", dtype=torch.int32))
|
||||
starts.copy_(end[slots])
|
||||
projected.normal_()
|
||||
expected = slot_ring_attention(q, k, v, reference_cache, end, slots, lengths, capacity)
|
||||
if execution == "graph":
|
||||
graph.replay()
|
||||
else:
|
||||
actual = attention(q, k, v, cache, starts, slots, lengths, capacity)
|
||||
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
|
||||
torch.testing.assert_close(cache, reference_cache, rtol=0, atol=0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("frames,context", [(1, 9), (15, 125), (480, 400)])
|
||||
def test_fused_rows_attention_integrates_with_mha_offsets(frames, context):
|
||||
torch.manual_seed(11)
|
||||
reference = MossAudioTokenizerMultiheadAttention(
|
||||
128,
|
||||
2,
|
||||
causal=True,
|
||||
context=context,
|
||||
rope=MossAudioTokenizerRotaryEmbedding(),
|
||||
device="cuda",
|
||||
dtype=torch.bfloat16,
|
||||
)
|
||||
candidate = copy.deepcopy(reference)
|
||||
reference._slot_attention = slot_ring_attention
|
||||
candidate._slot_attention_rows = slot_ring_attention_rows
|
||||
for model in (reference, candidate):
|
||||
model._streaming_state = model._init_streaming_state(4)
|
||||
for step in range(8):
|
||||
slots = torch.tensor([1, 0, 3] if step % 2 else [0, 1, 3], device="cuda")
|
||||
valid = torch.tensor([True, True, False], device="cuda")
|
||||
execution_context = StreamingExecutionContext(state_slot_ids=slots, valid_rows=valid)
|
||||
x = torch.randn(3, frames, 128, device="cuda", dtype=torch.bfloat16)
|
||||
with torch.inference_mode():
|
||||
expected = reference(x, x, x, execution_context=execution_context)
|
||||
actual = candidate(x, x, x, execution_context=execution_context)
|
||||
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
|
||||
cs, rs = candidate._streaming_state, reference._streaming_state
|
||||
torch.testing.assert_close(cs.offset, rs.offset, rtol=0, atol=0)
|
||||
torch.testing.assert_close(cs.kv_cache.end_offset, rs.kv_cache.end_offset, rtol=0, atol=0)
|
||||
torch.testing.assert_close(cs.kv_cache.cache, rs.kv_cache.cache, rtol=0, atol=0)
|
||||
if step == 4:
|
||||
for model in (reference, candidate):
|
||||
model._streaming_state.reset_slots(torch.tensor([0], device="cuda"))
|
||||
@@ -0,0 +1,86 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""A second streaming-decoder graph set can replay concurrently with the first."""
|
||||
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from torch import nn
|
||||
|
||||
from vllm_omni.model_executor.models.moss_tts.cuda_graph_streaming_decoder_wrapper import (
|
||||
CUDAGraphStreamingDecoderWrapper,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cuda]
|
||||
|
||||
CAP, DIM = 8, 64
|
||||
|
||||
|
||||
class _Codec(nn.Module):
|
||||
def __init__(self, device):
|
||||
super().__init__()
|
||||
self.weight = nn.Parameter(torch.randn(DIM, DIM, device=device) / DIM)
|
||||
self.state = torch.zeros(CAP + 16, DIM, device=device)
|
||||
|
||||
def decode_streaming_tensors(self, codes, lengths, slots, valid_rows):
|
||||
x = codes.float().sum(0)[..., None].expand(-1, -1, DIM) + self.state[slots][:, None]
|
||||
y = x @ self.weight
|
||||
self.state.index_copy_(0, slots, y[:, -1])
|
||||
return y.transpose(1, 2).contiguous(), lengths
|
||||
|
||||
def reset_decoder_state_slots(self, slots):
|
||||
self.state.index_fill_(0, slots.to(self.state.device), 0)
|
||||
|
||||
|
||||
def _wrapper(codec, **kwargs):
|
||||
return CUDAGraphStreamingDecoderWrapper(
|
||||
codec,
|
||||
state_capacity=CAP,
|
||||
num_quantizers=2,
|
||||
vllm_config=None,
|
||||
compiled_decode=codec.decode_streaming_tensors,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def test_private_wrapper_captures_on_its_own_stream_and_replays_concurrently():
|
||||
if not torch.cuda.is_available():
|
||||
pytest.skip("CUDA required")
|
||||
device = torch.device("cuda", torch.accelerator.current_device_index())
|
||||
codec = _Codec(device)
|
||||
main = _wrapper(codec, batch_sizes=[4], frame_sizes=[3])
|
||||
fast = _wrapper(codec, batch_sizes=[1, 2], frame_sizes=[1], scratch_base=CAP + 4, private_pool=True)
|
||||
main.warmup(device)
|
||||
fast.warmup(device)
|
||||
assert main.is_ready and fast.is_ready
|
||||
assert main._capture_stream is None and fast._capture_stream is not None
|
||||
|
||||
codes_main = torch.randint(0, 5, (2, 4, 3), device=device)
|
||||
codes_fast = torch.randint(0, 5, (2, 2, 1), device=device)
|
||||
errors = []
|
||||
|
||||
def run(wrapper, codes, slots, iters):
|
||||
stream = torch.cuda.Stream(device=device)
|
||||
slot_ids = torch.tensor(slots, device=device)
|
||||
try:
|
||||
with torch.cuda.stream(stream):
|
||||
for _ in range(iters):
|
||||
codec.reset_decoder_state_slots(slot_ids)
|
||||
audio = wrapper.decode(codes, slot_ids)[0][: len(slots)].clone()
|
||||
expected = (codes.float().sum(0)[..., None].expand(-1, -1, DIM) @ codec.weight).transpose(1, 2)
|
||||
stream.synchronize()
|
||||
torch.testing.assert_close(audio, expected, rtol=1e-3, atol=1e-3)
|
||||
except Exception as error: # surfaced below
|
||||
errors.append(error)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=run, args=(main, codes_main, [0, 1, 2, 3], 200)),
|
||||
threading.Thread(target=run, args=(fast, codes_fast, [4, 5], 200)),
|
||||
]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join(timeout=60)
|
||||
assert not any(thread.is_alive() for thread in threads), "concurrent graph replay did not finish"
|
||||
assert not errors, errors
|
||||
@@ -54,8 +54,19 @@ def test_terminal_padding_preserves_audio_live_state_and_reused_slots():
|
||||
assert [resumed[i].item() for i in (0, 1)] == [2, 36]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("graph_capacity, expected_calls", [(None, 5), (4, 5), (8, 3)])
|
||||
def test_only_existing_compatible_graph_buckets_are_coalesced(mocker, graph_capacity, expected_calls):
|
||||
@pytest.mark.parametrize(
|
||||
"graph_capacity,expected_groups",
|
||||
[
|
||||
# No graph, or graphs that do not cover the state capacity: exact tails.
|
||||
(None, {3: [0], 7: [1], 8: [2], 1: [3], 15: [4]}),
|
||||
(4, {3: [0], 7: [1], 8: [2], 1: [3], 15: [4]}),
|
||||
# Full graph coverage: finished tails (3, 7) join the padded 8-frame
|
||||
# bucket with the live 8-frame row; the finished 1-frame row keeps the
|
||||
# exact 1-frame graph and the live 15-frame row stays alone.
|
||||
(8, {8: [0, 1, 2], 1: [3], 15: [4]}),
|
||||
],
|
||||
)
|
||||
def test_terminal_tails_coalesce_into_padded_frame_buckets(mocker, graph_capacity, expected_groups):
|
||||
s = session()
|
||||
s._cudagraph_wrapper = (
|
||||
None
|
||||
@@ -81,9 +92,16 @@ def test_only_existing_compatible_graph_buckets_are_coalesced(mocker, graph_capa
|
||||
for i, (length, done) in enumerate(zip(lengths, finished, strict=True))
|
||||
]
|
||||
)
|
||||
assert step.call_count == expected_calls
|
||||
# Slots are leased in request order, so slot == row here.
|
||||
groups = {}
|
||||
for call in step.call_args_list:
|
||||
plan = call.args[0]
|
||||
groups[max(int(codes.shape[1]) for codes in plan.values())] = sorted(plan)
|
||||
terminal = call.kwargs["terminal_slots"] or set()
|
||||
assert terminal == {slot for slot in plan if finished[slot]}
|
||||
assert groups == expected_groups
|
||||
assert step.call_count == len(expected_groups)
|
||||
# Output is always cropped to the exact frame count, padded or not.
|
||||
assert [result[i].shape[-1] for i in range(5)] == lengths
|
||||
if graph_capacity == 8:
|
||||
assert list(step.call_args_list[0].args[0]) == [0, 1, 2]
|
||||
assert step.call_args_list[0].kwargs["terminal_slots"] == {0, 1}
|
||||
assert list(step.call_args_list[1].args[0]) == [3] # Preserve the exact one-frame graph.
|
||||
# Finished rows released their slots; live rows keep theirs.
|
||||
assert set(decoder._stream_req_slots) == {"2", "4"}
|
||||
@@ -0,0 +1,32 @@
|
||||
# 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.model_executor.models.qwen3_omni.first_frame_decoder import Qwen3OmniFirstFrameDecoder
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cuda]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16])
|
||||
@pytest.mark.parametrize("rows", [16, 19])
|
||||
@torch.inference_mode()
|
||||
def test_first_frame_graph_outputs_own_storage(dtype, rows):
|
||||
class Decoder(torch.nn.Linear):
|
||||
config = SimpleNamespace(num_quantizers=1)
|
||||
|
||||
def forward(self, codes):
|
||||
return codes.to(dtype) * self.weight
|
||||
|
||||
model = Decoder(1, 1, bias=False, device="cuda", dtype=dtype)
|
||||
model.weight.fill_(1)
|
||||
decoder = Qwen3OmniFirstFrameDecoder(model, sample_rate=24000)
|
||||
decoder.capture()
|
||||
codes = torch.arange(rows, device="cuda").reshape(-1, 1)
|
||||
first = decoder.decode(codes)
|
||||
second = decoder.decode(codes + 32)
|
||||
assert first.dtype == second.dtype == torch.float32
|
||||
torch.testing.assert_close(first, codes.float(), rtol=0, atol=0)
|
||||
torch.testing.assert_close(second, (codes + 32).float(), rtol=0, atol=0)
|
||||
@@ -0,0 +1,87 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from transformers.models.qwen3_omni_moe.configuration_qwen3_omni_moe import Qwen3OmniMoeCode2WavConfig
|
||||
|
||||
from vllm_omni.model_executor.models.qwen3_omni.qwen3_omni_code2wav import Qwen3OmniMoeCode2Wav
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.core_model,
|
||||
pytest.mark.cuda,
|
||||
pytest.mark.skipif(not torch.cuda.is_available() or torch.version.hip is not None, reason="requires CUDA"),
|
||||
]
|
||||
|
||||
|
||||
def _codec(enabled):
|
||||
config = Qwen3OmniMoeCode2WavConfig(
|
||||
hidden_size=32,
|
||||
intermediate_size=64,
|
||||
num_hidden_layers=1,
|
||||
num_attention_heads=4,
|
||||
num_key_value_heads=4,
|
||||
head_dim=8,
|
||||
decoder_dim=32,
|
||||
upsample_rates=[2, 2],
|
||||
upsampling_ratios=[2],
|
||||
codebook_size=16,
|
||||
num_quantizers=2,
|
||||
)
|
||||
config._attn_implementation = "sdpa"
|
||||
vllm_config = SimpleNamespace(
|
||||
model_config=SimpleNamespace(
|
||||
hf_config=config, stage_connector_config={"extra": {"codec_cudnn_benchmark": enabled}}
|
||||
)
|
||||
)
|
||||
model = Qwen3OmniMoeCode2Wav(vllm_config=vllm_config).cuda().eval()
|
||||
model.precompute_snake_caches()
|
||||
return model
|
||||
|
||||
|
||||
def _flags():
|
||||
c = torch.backends.cudnn
|
||||
return c.enabled, c.benchmark, c.benchmark_limit, c.deterministic, c.allow_tf32
|
||||
|
||||
|
||||
@torch.inference_mode()
|
||||
def test_autotuned_codec_graph_replay_restores_flags():
|
||||
torch.manual_seed(21)
|
||||
model = _codec(True)
|
||||
codes = torch.randint(0, 16, (2, 2, 8), device="cuda")
|
||||
original = _flags()
|
||||
for _ in range(3):
|
||||
model(codes)
|
||||
assert _flags() == original
|
||||
graph = torch.cuda.CUDAGraph()
|
||||
with torch.cuda.graph(graph):
|
||||
captured = model(codes)
|
||||
assert _flags() == original
|
||||
codes.random_(0, 16)
|
||||
graph.replay()
|
||||
expected = model(codes)
|
||||
torch.testing.assert_close(captured, expected)
|
||||
assert _flags() == original
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enabled", [False, True])
|
||||
@torch.inference_mode()
|
||||
def test_codec_failure_restores_cudnn_settings(mocker, enabled):
|
||||
model = _codec(enabled)
|
||||
codes = torch.randint(0, 16, (1, 2, 8), device="cuda")
|
||||
original = _flags()
|
||||
|
||||
def fail(hidden):
|
||||
if enabled:
|
||||
assert torch.backends.cudnn.benchmark
|
||||
assert torch.backends.cudnn.benchmark_limit == 10
|
||||
else:
|
||||
assert _flags() == original
|
||||
raise RuntimeError("decode failed")
|
||||
|
||||
mocker.patch.object(model, "_decode_waveform", side_effect=fail)
|
||||
with pytest.raises(RuntimeError, match="decode failed"):
|
||||
model(codes)
|
||||
assert _flags() == original
|
||||
@@ -0,0 +1,88 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import copy
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from transformers.models.qwen3_omni_moe.configuration_qwen3_omni_moe import Qwen3OmniMoeCode2WavConfig
|
||||
from transformers.models.qwen3_omni_moe.modeling_qwen3_omni_moe import (
|
||||
Qwen3OmniMoeCode2WavDecoderBlock,
|
||||
Qwen3OmniMoeSnakeBeta,
|
||||
)
|
||||
|
||||
from vllm_omni.model_executor.models.common.snake_activation import SnakeBeta
|
||||
from vllm_omni.model_executor.models.qwen3_omni.qwen3_omni_code2wav import Qwen3OmniMoeCode2Wav, use_fused_snake
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _blocks():
|
||||
config = Qwen3OmniMoeCode2WavConfig(decoder_dim=32, upsample_rates=[2, 2])
|
||||
torch.manual_seed(0)
|
||||
blocks = torch.nn.ModuleList(Qwen3OmniMoeCode2WavDecoderBlock(config, i) for i in range(2)).eval()
|
||||
with torch.no_grad():
|
||||
for module in blocks.modules():
|
||||
if isinstance(module, Qwen3OmniMoeSnakeBeta):
|
||||
module.alpha.uniform_(-0.5, 0.5)
|
||||
module.beta.uniform_(-0.5, 0.5)
|
||||
return blocks
|
||||
|
||||
|
||||
def test_decoder_blocks_swap_every_hf_snake_and_keep_weight_names():
|
||||
blocks = _blocks()
|
||||
hf_count = sum(isinstance(m, Qwen3OmniMoeSnakeBeta) for m in blocks.modules())
|
||||
keys = set(blocks.state_dict())
|
||||
|
||||
assert use_fused_snake(blocks) == hf_count > 0
|
||||
assert not any(isinstance(m, Qwen3OmniMoeSnakeBeta) for m in blocks.modules())
|
||||
assert sum(isinstance(m, SnakeBeta) for m in blocks.modules()) == hf_count
|
||||
assert set(blocks.state_dict()) == keys # checkpoint weights load unchanged
|
||||
|
||||
|
||||
def test_fused_snake_blocks_match_hf_blocks():
|
||||
reference = _blocks()
|
||||
fused = copy.deepcopy(reference)
|
||||
use_fused_snake(fused)
|
||||
fused.load_state_dict(reference.state_dict())
|
||||
x = torch.randn(2, 32, 20)
|
||||
with torch.no_grad():
|
||||
expected = x
|
||||
actual = x
|
||||
for ref_block, fused_block in zip(reference, fused):
|
||||
expected = ref_block(expected)
|
||||
actual = fused_block(actual)
|
||||
torch.testing.assert_close(actual, expected, rtol=1e-5, atol=1e-5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("option", "device", "cuda", "fused"),
|
||||
[
|
||||
(False, "cuda", True, False),
|
||||
(True, "cuda", True, True),
|
||||
(True, "cpu", True, False),
|
||||
(True, "cuda", False, False),
|
||||
],
|
||||
)
|
||||
def test_decoder_block_fusion_requires_cuda_opt_in(monkeypatch, option, device, cuda, fused):
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
monkeypatch.setattr(current_omni_platform, "is_cuda", lambda: cuda)
|
||||
config = Qwen3OmniMoeCode2WavConfig(
|
||||
hidden_size=32,
|
||||
intermediate_size=64,
|
||||
num_hidden_layers=1,
|
||||
num_attention_heads=2,
|
||||
num_key_value_heads=2,
|
||||
decoder_dim=32,
|
||||
)
|
||||
model = Qwen3OmniMoeCode2Wav(
|
||||
vllm_config=SimpleNamespace(
|
||||
model_config=SimpleNamespace(
|
||||
hf_config=config, stage_connector_config={"extra": {"codec_fused_snake": option}}
|
||||
),
|
||||
device_config=SimpleNamespace(device=device),
|
||||
)
|
||||
)
|
||||
assert any(isinstance(m, Qwen3OmniMoeSnakeBeta) for m in model.decoder.modules()) is (not fused)
|
||||
@@ -0,0 +1,208 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from transformers.models.qwen3_omni_moe.configuration_qwen3_omni_moe import Qwen3OmniMoeCode2WavConfig
|
||||
|
||||
from vllm_omni.model_executor.models.qwen3_omni.first_frame_decoder import Qwen3OmniFirstFrameDecoder
|
||||
from vllm_omni.model_executor.models.qwen3_omni.qwen3_omni import Qwen3OmniMoeForConditionalGeneration
|
||||
from vllm_omni.model_executor.models.qwen3_omni.qwen3_omni_code2wav import (
|
||||
Qwen3OmniMoeCode2Wav,
|
||||
plan_decode_groups,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
_Q = 4
|
||||
_CODEBOOK = 16
|
||||
|
||||
|
||||
def _code2wav(quantizers=_Q) -> Qwen3OmniMoeCode2Wav:
|
||||
torch.manual_seed(0)
|
||||
config = Qwen3OmniMoeCode2WavConfig(
|
||||
codebook_size=_CODEBOOK,
|
||||
num_quantizers=quantizers,
|
||||
hidden_size=32,
|
||||
intermediate_size=64,
|
||||
num_hidden_layers=2,
|
||||
num_attention_heads=2,
|
||||
num_key_value_heads=2,
|
||||
decoder_dim=32,
|
||||
sliding_window=8,
|
||||
)
|
||||
model = Qwen3OmniMoeCode2Wav(vllm_config=SimpleNamespace(model_config=SimpleNamespace(hf_config=config)))
|
||||
return model.eval()
|
||||
|
||||
|
||||
def test_first_frame_pcm_equals_code2wav_streaming_chunk0():
|
||||
code2wav = _code2wav()
|
||||
decoder = Qwen3OmniFirstFrameDecoder(code2wav, sample_rate=24000)
|
||||
codes = torch.randint(0, _CODEBOOK, (3, _Q))
|
||||
with torch.inference_mode():
|
||||
for row in range(codes.shape[0]):
|
||||
pcm = decoder.decode(codes[row : row + 1])
|
||||
chunk0 = code2wav.chunked_decode_streaming(
|
||||
codes[row].reshape(1, _Q, 1), left_context_size=[0], seq_token_counts=[_Q]
|
||||
)[0]
|
||||
assert pcm.dtype == torch.float32
|
||||
assert torch.equal(pcm[0], chunk0.reshape(-1).float())
|
||||
assert 0 < pcm.shape[-1] < int(code2wav.total_upsample)
|
||||
batched = decoder.decode(codes)
|
||||
assert batched.shape == (3, pcm.shape[-1])
|
||||
|
||||
|
||||
def test_first_frame_is_a_prefix_of_a_longer_first_chunk():
|
||||
code2wav = _code2wav()
|
||||
decoder = Qwen3OmniFirstFrameDecoder(code2wav, sample_rate=24000)
|
||||
codes = torch.randint(0, _CODEBOOK, (_Q, 4))
|
||||
with torch.inference_mode():
|
||||
first = decoder.decode(codes[:, :1].T)[0]
|
||||
chunk0 = code2wav.chunked_decode_streaming(
|
||||
codes.reshape(1, _Q, 4), left_context_size=[0], seq_token_counts=[4 * _Q]
|
||||
)[0]
|
||||
torch.testing.assert_close(chunk0.reshape(-1)[: first.numel()].float(), first)
|
||||
|
||||
|
||||
def test_chunk_ramp_adds_exact_code2wav_graph_sizes(monkeypatch):
|
||||
import vllm_omni.model_executor.models.qwen3_tts.cuda_graph_decoder_wrapper as wrapper_module
|
||||
|
||||
real = wrapper_module.CUDAGraphDecoderWrapper
|
||||
wrapper = MagicMock(compute_capture_sizes=real.compute_capture_sizes)
|
||||
monkeypatch.setattr(wrapper_module, "CUDAGraphDecoderWrapper", wrapper)
|
||||
extra = {"codec_chunk_frames": 25, "codec_left_context_frames": 25, "codec_chunk_ramp": [1, 2, 4, 8, 16, 25]}
|
||||
code2wav = SimpleNamespace(config=SimpleNamespace(num_quantizers=_Q))
|
||||
code2wav.enable_cudagraph = lambda **kw: Qwen3OmniMoeCode2Wav.enable_cudagraph(
|
||||
code2wav, device=torch.device("cuda"), **kw
|
||||
)
|
||||
model = object.__new__(Qwen3OmniMoeForConditionalGeneration)
|
||||
torch.nn.Module.__init__(model)
|
||||
model.code2wav = code2wav
|
||||
model.vllm_config = SimpleNamespace(
|
||||
model_config=SimpleNamespace(async_chunk=True, enforce_eager=False, stage_connector_config={"extra": extra})
|
||||
)
|
||||
for batches in ([], [2, 4, 8]):
|
||||
extra["codec_graph_batch_sizes"] = batches
|
||||
Qwen3OmniMoeForConditionalGeneration._maybe_enable_code2wav_cudagraph(model)
|
||||
args = wrapper.call_args.kwargs
|
||||
sizes = args["capture_sizes"]
|
||||
assert {1, 3, 7, 15, 31, 50} <= set(sizes)
|
||||
assert set(real.compute_capture_sizes(codec_chunk_frames=25, codec_left_context_frames=25)) <= set(sizes)
|
||||
assert set(args["extra_capture_shapes"]) == {
|
||||
(b, size) for b in batches for size in sizes if size <= 50 and b * size <= 325
|
||||
}
|
||||
assert code2wav._streaming_batch_sizes[50] == [1, 2, 4]
|
||||
extra.pop("codec_chunk_ramp")
|
||||
extra.pop("codec_graph_batch_sizes")
|
||||
Qwen3OmniMoeForConditionalGeneration._maybe_enable_code2wav_cudagraph(model)
|
||||
assert wrapper.call_args.kwargs["capture_sizes"] is None
|
||||
|
||||
|
||||
_SIZES = [1, 2, 3, 4, 7, 8, 15, 16, 25, 31, 32, 50, 64]
|
||||
|
||||
|
||||
def _bucket(length):
|
||||
return next((size for size in _SIZES if size >= length), None)
|
||||
|
||||
|
||||
def test_decode_groups_keep_a_uniform_batch_whole_and_split_by_the_largest_graph():
|
||||
assert plan_decode_groups([50] * 6, _bucket, lambda size: [1, 2, 4, 8]) == [(list(range(6)), 50)]
|
||||
calls = plan_decode_groups([25] * 20, _bucket, lambda size: [1, 2, 4, 8, 16])
|
||||
assert [len(rows) for rows, _length in calls] == [16, 4]
|
||||
calls = plan_decode_groups([1] * 8 + [50] * 8, _bucket, lambda size: [1, 2, 4, 8] if size < 50 else [1, 2, 4])
|
||||
assert [(len(rows), length) for rows, length in calls] == [(8, 1), (4, 50), (4, 50)]
|
||||
assert plan_decode_groups([], _bucket, lambda size: [1, 2]) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("frames", [65, 129])
|
||||
def test_decode_groups_beyond_largest_graph_use_eager(monkeypatch, frames):
|
||||
from vllm_omni.model_executor.models.qwen3_tts.cuda_graph_decoder_wrapper import CUDAGraphDecoderWrapper
|
||||
|
||||
wrapper = CUDAGraphDecoderWrapper.__new__(CUDAGraphDecoderWrapper)
|
||||
wrapper.enabled = wrapper._warmed_up = True
|
||||
wrapper._bucket_sizes = [8, 32, 64]
|
||||
wrapper._compiled_shapes = {(1, 64), (8, 64)}
|
||||
wrapper._compiled_graphs = wrapper.graphs = dict.fromkeys(wrapper._compiled_shapes, MagicMock())
|
||||
wrapper.decoder = MagicMock(side_effect=lambda codes: codes.float() * 2)
|
||||
monkeypatch.setattr(torch.cuda, "is_current_stream_capturing", lambda: False)
|
||||
groups = plan_decode_groups([frames, frames], wrapper._get_padded_size, lambda _: (1,))
|
||||
assert groups == [([0], frames), ([1], frames)]
|
||||
codes = torch.arange(2 * frames).reshape(2, 1, frames)
|
||||
for rows, width in groups:
|
||||
actual = wrapper.decode(codes[rows, :, :width])
|
||||
torch.testing.assert_close(actual, codes[rows].float() * 2, rtol=0, atol=0)
|
||||
assert wrapper.decoder.call_count == 2
|
||||
for graph in (*wrapper.graphs.values(), *wrapper._compiled_graphs.values()):
|
||||
graph.replay.assert_not_called()
|
||||
|
||||
|
||||
class _PaddingGraphs:
|
||||
def __init__(self, model, batch_sizes):
|
||||
self.model = model
|
||||
self.batch_sizes = sorted(batch_sizes)
|
||||
self.calls = []
|
||||
|
||||
def _get_padded_size(self, length):
|
||||
return _bucket(length)
|
||||
|
||||
def decode(self, codes):
|
||||
rows, _q, frames = codes.shape
|
||||
size = _bucket(frames)
|
||||
batch = next(b for b in self.batch_sizes if b >= rows)
|
||||
self.calls.append((rows, frames))
|
||||
padded = torch.zeros(batch, codes.shape[1], size, dtype=codes.dtype)
|
||||
padded[:rows, :, :frames] = codes
|
||||
out = self.model(padded)
|
||||
return out[:rows, :, : out.shape[-1] - (size - frames) * int(self.model.total_upsample)].clone()
|
||||
|
||||
|
||||
def test_grouped_streaming_decode_matches_one_padded_batch():
|
||||
code2wav = _code2wav()
|
||||
lengths = [1, 3, 50, 7, 31]
|
||||
lefts = [0, 1, 25, 3, 15]
|
||||
codes = torch.zeros(len(lengths), _Q, max(lengths), dtype=torch.long)
|
||||
for row, length in enumerate(lengths):
|
||||
codes[row, :, :length] = torch.randint(0, _CODEBOOK, (_Q, length))
|
||||
counts = [length * _Q for length in lengths]
|
||||
with torch.inference_mode():
|
||||
reference = code2wav.chunked_decode_streaming(codes, left_context_size=lefts, seq_token_counts=counts)
|
||||
graphs = _PaddingGraphs(code2wav, [1, 2, 4])
|
||||
code2wav._cudagraph_wrapper, code2wav._cudagraph_enabled = graphs, True
|
||||
code2wav._streaming_batch_sizes = {size: [1, 2, 4] for size in _SIZES}
|
||||
grouped = code2wav.chunked_decode_streaming(codes, left_context_size=lefts, seq_token_counts=counts)
|
||||
|
||||
for want, got in zip(reference, grouped, strict=True):
|
||||
assert want.shape == got.shape
|
||||
torch.testing.assert_close(got, want, rtol=1e-4, atol=1e-5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("frames", "quantizers"), [(1, 16), (4, 16), (4, 4)])
|
||||
def test_delivered_first_frame_is_removed_from_code2wav_output(frames, quantizers):
|
||||
from vllm_omni.data_entry_keys import FIRST_AUDIO_REQUIRED_KEY
|
||||
|
||||
codec = _code2wav(quantizers)
|
||||
model = object.__new__(Qwen3OmniMoeForConditionalGeneration)
|
||||
torch.nn.Module.__init__(model)
|
||||
model.model_stage, model.code2wav, model.code2wav_config = "code2wav", codec, codec.config
|
||||
model.generate_audio = lambda codes, left, counts: codec.chunked_decode_streaming(
|
||||
codes, left_context_size=left, seq_token_counts=counts
|
||||
)
|
||||
codes = torch.randint(0, _CODEBOOK, (2, quantizers, frames))
|
||||
with torch.inference_mode():
|
||||
reference = model.generate_audio(codes, [0, 0], [quantizers * frames] * 2)
|
||||
first = Qwen3OmniFirstFrameDecoder(codec, sample_rate=24000).decode(codes[0, :, :1].T)[0]
|
||||
out = model.forward(
|
||||
input_ids=codes.flatten(),
|
||||
positions=None,
|
||||
seq_token_counts=[quantizers * frames] * 2,
|
||||
runtime_additional_information=[
|
||||
{"meta": {"left_context_size": 0, "first_audio": True}},
|
||||
{"meta": {"left_context_size": 0, "first_audio": False}},
|
||||
],
|
||||
).multimodal_outputs
|
||||
torch.testing.assert_close(torch.cat([first, out["model_outputs"][0].flatten()]), reference[0].flatten())
|
||||
torch.testing.assert_close(out["model_outputs"][1], reference[1].reshape(1, -1))
|
||||
assert [bool(flag) for flag in out[FIRST_AUDIO_REQUIRED_KEY]] == [True, False]
|
||||
@@ -0,0 +1,116 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from torch import nn
|
||||
|
||||
from vllm_omni.model_executor.models.qwen3_omni.qwen3_omni import Qwen3OmniMoeForConditionalGeneration
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
_H = 4
|
||||
_Q = 3
|
||||
|
||||
|
||||
class _Talker(nn.Module):
|
||||
num_code_groups = _Q
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.text_projection = nn.Identity()
|
||||
self.code_predictor = SimpleNamespace(
|
||||
_top_k=50,
|
||||
_top_p=0.8,
|
||||
set_sampling_params=lambda **kwargs: setattr(self, "sampling", kwargs),
|
||||
)
|
||||
|
||||
|
||||
def _talker(*, async_chunk: bool = True) -> Qwen3OmniMoeForConditionalGeneration:
|
||||
model = object.__new__(Qwen3OmniMoeForConditionalGeneration)
|
||||
nn.Module.__init__(model)
|
||||
model.model_stage = "talker"
|
||||
model.talker = _Talker()
|
||||
model.vllm_config = SimpleNamespace(model_config=SimpleNamespace(async_chunk=async_chunk, use_v2_model_runner=True))
|
||||
model.tts_eos_embed = torch.full((_H,), -1.0)
|
||||
model.tts_pad_embed = torch.full((_H,), -2.0)
|
||||
model._codec_codebook_size = 2048
|
||||
return model
|
||||
|
||||
|
||||
def _rows(*values: float) -> torch.Tensor:
|
||||
return torch.tensor([[value] * _H for value in values])
|
||||
|
||||
|
||||
def _step(model, payload: dict) -> torch.Tensor:
|
||||
update: dict = {}
|
||||
text_step = model._thinker_decode_to_talker_decode(payload, torch.device("cpu"), update)
|
||||
for key, value in update.items():
|
||||
payload.setdefault(key, {}).update(value)
|
||||
return text_step.reshape(-1)
|
||||
|
||||
|
||||
def test_mtp_frame_valid_rejects_codec_eos_and_special_ids():
|
||||
layer0 = torch.tensor([0, 2047, 2048, 2150, -1])
|
||||
assert _talker().mtp_frame_valid(layer0).tolist() == [True, True, False, False, False]
|
||||
|
||||
|
||||
def test_decode_rows_are_consumed_one_per_step_in_arrival_order():
|
||||
model = _talker()
|
||||
# Two rows were already pending (cached at prefill); two more arrive at once.
|
||||
payload = {"embed": {"cached_decode": _rows(1, 2), "decode": _rows(3, 4)}, "meta": {}}
|
||||
steps = [_step(model, payload)[0].item()]
|
||||
for arrival in (None, _rows(5), None, None, None, None):
|
||||
payload["embed"]["decode"] = arrival
|
||||
steps.append(_step(model, payload)[0].item())
|
||||
assert steps == [1, 2, 3, 4, 5, -1, -2]
|
||||
assert payload["embed"]["cached_decode"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("v2", "resumable"), [(True, True), (False, False), (False, True)])
|
||||
def test_v1_and_resumable_requests_keep_the_indexed_cache_path(v2, resumable):
|
||||
model = _talker()
|
||||
model.vllm_config.model_config.use_v2_model_runner = v2
|
||||
payload = {"embed": {"cached_decode": _rows(1, 2, 3), "decode": None}, "meta": {"resumable": resumable}}
|
||||
payload["meta"]["num_processed_tokens"] = 2
|
||||
assert _step(model, payload)[0].item() == 3
|
||||
|
||||
|
||||
def test_make_omni_output_aligns_codes_to_request_spans():
|
||||
model = _talker()
|
||||
hidden = torch.zeros((8, _H)) # 5 real tokens + graph padding
|
||||
decode_codes = torch.tensor([[5, 6, 7]])
|
||||
buffers = [
|
||||
{"req_id": "_warmup_0_"}, # no preprocess, no codes
|
||||
{"req_id": "a", "codes": {"audio": torch.zeros((3, _Q), dtype=torch.long)}},
|
||||
{"req_id": "b", "codes": {"audio": decode_codes}},
|
||||
]
|
||||
out = model.make_omni_output(
|
||||
hidden, model_intermediate_buffer=buffers, request_token_spans=[(0, 1), (1, 4), (4, 5)]
|
||||
)
|
||||
codes = out.multimodal_outputs["codes"]["audio"]
|
||||
assert codes.shape == (5, _Q)
|
||||
assert codes[4].tolist() == [5, 6, 7]
|
||||
assert out.text_hidden_states.shape[0] == 5
|
||||
|
||||
|
||||
def test_make_omni_output_without_spans_keeps_v1_concat():
|
||||
model = _talker()
|
||||
buffers = [{"codes": {"audio": torch.tensor([[1, 2, 3]])}}, {"codes": {"audio": torch.tensor([[4, 5, 6]])}}]
|
||||
out = model.make_omni_output(torch.zeros((4, _H)), model_intermediate_buffer=buffers)
|
||||
assert out.multimodal_outputs["codes"]["audio"].tolist() == [[1, 2, 3], [4, 5, 6]]
|
||||
assert out.text_hidden_states.shape[0] == 2
|
||||
|
||||
|
||||
def test_eager_output_is_token_major_with_invalid_rows():
|
||||
model = _talker()
|
||||
model.eager_frames_active = True
|
||||
hidden = torch.zeros((6, _H))
|
||||
buffers = [{"req_id": "a"}, {"req_id": "b"}]
|
||||
out = model.make_omni_output(hidden, model_intermediate_buffer=buffers, request_token_spans=[(0, 3), (3, 4)])
|
||||
mm = out.multimodal_outputs
|
||||
assert mm["codes"]["audio"].shape == (4, _Q)
|
||||
assert mm["meta"]["codec_frame_valid"].tolist() == [0, 0, 0, 0]
|
||||
assert out.text_hidden_states is hidden
|
||||
@@ -104,6 +104,7 @@ def test_factory_declares_only_captures_from_earlier_ranks(monkeypatch, start, e
|
||||
if staged:
|
||||
model_config.model_stage = "thinker"
|
||||
vllm_config = SimpleNamespace(model_config=model_config, with_hf_config=lambda *args, **kwargs: None)
|
||||
vllm_config.speculative_config = None
|
||||
thinker = nn.Module()
|
||||
thinker.weight = nn.Parameter(torch.ones(1))
|
||||
thinker.language_model = SimpleNamespace(model=SimpleNamespace(start_layer=start))
|
||||
|
||||
@@ -569,6 +569,27 @@ class TestCodePredictorPerRowGenerators:
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fused_requested", [False, True])
|
||||
@pytest.mark.parametrize("invalid_input", ["uniforms", "generators"])
|
||||
def test_sampling_contract_rejected_before_dispatch(mocker, loaded_target_classes, fused_requested, invalid_input):
|
||||
predictor, talker_config = TestCodePredictorPerRowGenerators()._make_predictor(mocker, loaded_target_classes)
|
||||
predictor._fused_requested = fused_requested
|
||||
predictor._fused = mocker.Mock()
|
||||
setup = mocker.patch.object(predictor, "_setup_compile")
|
||||
uniforms = torch.full((2, 3, 1 if invalid_input == "uniforms" else 64), 0.5)
|
||||
generators = [torch.Generator()] if invalid_input == "generators" else None
|
||||
with pytest.raises(ValueError, match="sample_uniforms must have shape|one entry per row"):
|
||||
predictor(
|
||||
torch.zeros(2, dtype=torch.long),
|
||||
torch.randn(2, talker_config.hidden_size),
|
||||
torch.randn(2, talker_config.hidden_size),
|
||||
sample_uniforms=uniforms,
|
||||
generators=generators,
|
||||
)
|
||||
setup.assert_not_called()
|
||||
predictor._fused.assert_not_called()
|
||||
|
||||
|
||||
class TestCodePredictorModelDtype:
|
||||
"""Test the inner model forward with different dtypes."""
|
||||
|
||||
@@ -663,6 +684,8 @@ class TestCodePredictorGraphReplay:
|
||||
|
||||
predictor = object.__new__(code_predictor_wrapper)
|
||||
torch.nn.Module.__init__(predictor)
|
||||
predictor._fused_requested = False
|
||||
predictor._fused = None
|
||||
predictor._num_groups = 3
|
||||
predictor._model_dtype = torch.float32
|
||||
predictor._setup_compile = mocker.Mock()
|
||||
@@ -1432,3 +1455,24 @@ def test_shared_predictor_ignores_tts_fast_path_options(mocker, loaded_target_cl
|
||||
expected = (logits.float() - torch.log(-torch.log(uniforms))).argmax(-1, keepdim=True)
|
||||
actual = predictor._sample_per_call(logits, 1.0, 0, None, uniforms)
|
||||
torch.testing.assert_close(actual, expected)
|
||||
|
||||
|
||||
def test_tts_cached_predictor_keeps_its_own_step_dispatch(mocker: MockerFixture, loaded_target_classes) -> None:
|
||||
"""The TTS cache must not enter Omni's uninitialized incremental loop."""
|
||||
_, _, wrapper, _, _ = loaded_target_classes
|
||||
common_mod = sys.modules["vllm_omni.model_executor.models.common.qwen3_code_predictor"]
|
||||
mocker.patch.object(common_mod.current_omni_platform, "is_cuda", return_value=True)
|
||||
mocker.patch.object(common_mod.current_omni_platform, "is_npu", return_value=False)
|
||||
mocker.patch.object(wrapper, "_stage_connector_extra_config", return_value={"code_predictor_kv_cache": True})
|
||||
cp_config, talker_config = _make_tiny_config(loaded_target_classes)
|
||||
predictor = wrapper(vllm_config=_make_vllm_config(mocker), config=cp_config, talker_config=talker_config)
|
||||
assert predictor._kv_requested
|
||||
predictor._model_dtype = torch.float32
|
||||
predictor._codec_embeds_list = list(predictor.model.codec_embedding)
|
||||
predictor._bucket_sizes = [1]
|
||||
mocker.patch.object(predictor, "_setup_compile")
|
||||
step = mocker.patch.object(predictor, "_predict_step_logits", return_value=torch.ones(1, cp_config.vocab_size))
|
||||
codes = predictor(torch.zeros(1, dtype=torch.long), torch.zeros(1, 32), torch.zeros(1, 32), do_sample=False)
|
||||
assert step.call_count == cp_config.num_code_groups - 1
|
||||
assert codes.shape == (1, cp_config.num_code_groups)
|
||||
assert predictor._kv_buf is None
|
||||
@@ -2,6 +2,8 @@
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Supplied-uniform samples must match the existing ATen path exactly."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
@@ -12,6 +14,79 @@ pytestmark = [
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("top_k", [0, 1, 50, 2048])
|
||||
@torch.inference_mode()
|
||||
def test_fused_sample_kernel_preserves_signed_zero_cutoff_ties(top_k):
|
||||
from vllm_omni.model_executor.models.qwen3_tts.fused_code_predictor import _cp_sample_kernel
|
||||
|
||||
vocab, hidden, groups = 2048, 64, 4
|
||||
logits = torch.zeros(2, vocab, device="cuda", dtype=torch.bfloat16)
|
||||
logits[0, 1] = -0.0
|
||||
logits[1, ::2] = -0.0
|
||||
uniforms = torch.full((2, groups - 1, vocab), 0.01, device="cuda")[:, 1, :]
|
||||
uniforms[0, 1] = uniforms[1, 2] = 0.99
|
||||
codes = torch.full((2, groups), -1, device="cuda", dtype=torch.int64)
|
||||
table = torch.randn(vocab, hidden, device="cuda", dtype=torch.bfloat16)
|
||||
next_input = torch.empty(2, hidden, device="cuda", dtype=table.dtype)
|
||||
|
||||
def sample():
|
||||
_cp_sample_kernel[(2,)](
|
||||
logits, uniforms, uniforms.stride(0), codes, 1, groups, table, next_input, 1.0,
|
||||
V=vocab, TOPK=top_k, HID=hidden, HAS_NEXT=True, num_warps=4,
|
||||
) # fmt: skip
|
||||
|
||||
scaled = logits.float()
|
||||
if top_k > 0:
|
||||
scaled = scaled.masked_fill(scaled < scaled.topk(top_k).values[:, -1:], -torch.inf)
|
||||
expected = (scaled - torch.log(-torch.log(uniforms))).argmax(-1)
|
||||
assert expected.tolist() == [1, 2]
|
||||
sample()
|
||||
torch.testing.assert_close(codes[:, 1], expected)
|
||||
torch.testing.assert_close(next_input, table[expected])
|
||||
assert (codes[:, [0, 2, 3]] == -1).all()
|
||||
graph = torch.cuda.CUDAGraph()
|
||||
with torch.cuda.graph(graph):
|
||||
sample()
|
||||
codes.fill_(-1)
|
||||
graph.replay()
|
||||
torch.testing.assert_close(codes[:, 1], expected)
|
||||
torch.testing.assert_close(next_input, table[expected])
|
||||
|
||||
|
||||
@torch.inference_mode()
|
||||
def test_fused_predictor_warmup_with_capacity_one():
|
||||
from vllm_omni.model_executor.models.qwen3_tts.configuration_qwen3_tts import (
|
||||
Qwen3TTSTalkerCodePredictorConfig,
|
||||
Qwen3TTSTalkerConfig,
|
||||
)
|
||||
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_code_predictor_vllm import (
|
||||
Qwen3TTSTalkerCodePredictorForConditionalGenerationVLLM,
|
||||
)
|
||||
|
||||
cp = Qwen3TTSTalkerCodePredictorConfig(
|
||||
vocab_size=64, hidden_size=64, intermediate_size=128, num_hidden_layers=1,
|
||||
num_attention_heads=4, num_key_value_heads=2, head_dim=16, num_code_groups=4,
|
||||
) # fmt: skip
|
||||
talker = Qwen3TTSTalkerConfig(hidden_size=64, num_code_groups=4)
|
||||
config = SimpleNamespace(
|
||||
model_config=SimpleNamespace(stage_connector_config={}),
|
||||
additional_config={"code_predictor_kv_cache": True, "code_predictor_fused": True},
|
||||
scheduler_config=SimpleNamespace(max_num_seqs=1),
|
||||
)
|
||||
predictor = Qwen3TTSTalkerCodePredictorForConditionalGenerationVLLM(
|
||||
vllm_config=config, config=cp, talker_config=talker
|
||||
).to(device="cuda", dtype=torch.bfloat16)
|
||||
predictor._setup_compile()
|
||||
assert predictor._fused is not None
|
||||
hidden = torch.randn(1, 1, 64, device="cuda", dtype=torch.bfloat16)
|
||||
result = predictor(
|
||||
torch.zeros(1, 1, device="cuda", dtype=torch.long), hidden, hidden,
|
||||
sample_uniforms=torch.full((1, 3, 64), 0.5, device="cuda"),
|
||||
) # fmt: skip
|
||||
assert result.shape == (1, 4)
|
||||
assert ((result >= 0) & (result < 64)).all()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("batch", [1, 64])
|
||||
@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16, torch.float32])
|
||||
@pytest.mark.parametrize("top_k", [-1, 1, 50, 2048])
|
||||
@@ -66,3 +141,47 @@ def test_topk_gumbel_accepts_strided_supplied_uniforms(top_k):
|
||||
expected = (scaled.float() - torch.log(-torch.log(uniforms))).argmax(-1, keepdim=True)
|
||||
actual = sample_code_topk_gumbel(logits, uniforms, top_k, 1 / 0.9)
|
||||
torch.testing.assert_close(actual, expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("batch", [1, 4])
|
||||
@pytest.mark.parametrize("top_k", [1, 50])
|
||||
@torch.inference_mode()
|
||||
def test_fused_predictor_matches_full_reference_with_fixed_uniforms(mocker, batch, top_k):
|
||||
from vllm.config import VllmConfig
|
||||
|
||||
from vllm_omni.model_executor.models.qwen3_tts.configuration_qwen3_tts import (
|
||||
Qwen3TTSTalkerCodePredictorConfig,
|
||||
Qwen3TTSTalkerConfig,
|
||||
)
|
||||
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_code_predictor_vllm import (
|
||||
Qwen3TTSTalkerCodePredictorForConditionalGenerationVLLM,
|
||||
)
|
||||
|
||||
torch.manual_seed(42)
|
||||
cp = Qwen3TTSTalkerCodePredictorConfig(
|
||||
vocab_size=64, hidden_size=64, intermediate_size=128, num_hidden_layers=1,
|
||||
num_attention_heads=4, num_key_value_heads=2, head_dim=16, num_code_groups=4,
|
||||
) # fmt: skip
|
||||
config = mocker.Mock(
|
||||
spec=VllmConfig,
|
||||
model_config=mocker.Mock(stage_connector_config={}),
|
||||
additional_config={"code_predictor_kv_cache": True, "code_predictor_fused": True},
|
||||
scheduler_config=mocker.Mock(max_num_seqs=4),
|
||||
)
|
||||
predictor = Qwen3TTSTalkerCodePredictorForConditionalGenerationVLLM(
|
||||
vllm_config=config, config=cp, talker_config=Qwen3TTSTalkerConfig(hidden_size=64, num_code_groups=4)
|
||||
).to(device="cuda", dtype=torch.bfloat16)
|
||||
predictor._setup_compile()
|
||||
assert predictor._fused is not None
|
||||
ids = torch.randint(0, 64, (batch, 1), device="cuda")
|
||||
embeds = torch.randn(batch, 1, 64, device="cuda", dtype=torch.bfloat16)
|
||||
hidden = torch.randn_like(embeds)
|
||||
uniforms = torch.rand(batch, 3, 64, device="cuda").clamp_(1e-6, 1 - 1e-6)
|
||||
actual = predictor(ids, embeds, hidden, top_k=top_k, sample_uniforms=uniforms).clone()
|
||||
# Use identical parameters through the full re-prefill and ATen sampling
|
||||
# path, bypassing both the fused predictor and its frame-local KV cache.
|
||||
predictor._fused_requested = False
|
||||
predictor._fused = None
|
||||
predictor._frame_cache = None
|
||||
expected = predictor(ids, embeds, hidden, top_k=top_k, sample_uniforms=uniforms)
|
||||
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
|
||||
@@ -2,6 +2,7 @@
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Prefix graphs must follow per-request first-audio delivery, not a capture hint."""
|
||||
|
||||
import math
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
@@ -18,6 +19,38 @@ pytestmark = [pytest.mark.core_model]
|
||||
DEVICE = torch.device("cuda:0")
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("capacity,model_type", [(1, "base"), (128, "base"), (128, "custom_voice")])
|
||||
def test_stream_priming_graph_workspace_respects_token_budget(monkeypatch, capacity, model_type):
|
||||
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_talker import Qwen3TTSTalkerForConditionalGeneration
|
||||
from vllm_omni.model_executor.models.qwen3_tts.tokenizer_12hz import streaming_decoder
|
||||
|
||||
captures = []
|
||||
|
||||
def capture(stream, sizes, frames=1):
|
||||
captures.append((sizes, frames))
|
||||
return SimpleNamespace(sizes=sizes, frames=frames)
|
||||
|
||||
monkeypatch.setattr(streaming_decoder, "StreamingDecodeGraphs", capture)
|
||||
model = SimpleNamespace(
|
||||
stream_decoder=SimpleNamespace(max_frames=33),
|
||||
stream_graphs=None,
|
||||
stream_prime_graphs=None,
|
||||
stream_ref_context_frames=25,
|
||||
stream_chunk_frames=25,
|
||||
config=SimpleNamespace(tts_model_type=model_type),
|
||||
vllm_config=SimpleNamespace(scheduler_config=SimpleNamespace(max_num_batched_tokens=512)),
|
||||
)
|
||||
sizes = [size for size in [1, 2, 4, 8, 16, 32, 64, 128] if size <= capacity]
|
||||
Qwen3TTSTalkerForConditionalGeneration.capture_stream_decode_graphs(model, sizes)
|
||||
expected = [(sizes, 1)]
|
||||
if model_type == "base":
|
||||
expected.append(([size for size in sizes if size * 25 <= 512], 25))
|
||||
assert captures == expected
|
||||
Qwen3TTSTalkerForConditionalGeneration.capture_stream_decode_graphs(model, sizes)
|
||||
assert captures == expected
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("skip_flags", [(False, False), (True, True), (False, True), (True, False)])
|
||||
@pytest.mark.parametrize("initial_frames", [1, 3])
|
||||
@@ -124,3 +157,134 @@ def test_xvec_graphs_follow_request_first_audio_flags(initial_frames, time_major
|
||||
torch.testing.assert_close(got, want, atol=1e-4, rtol=1e-4)
|
||||
for key in ("ref_hidden", "ref_conv", "prefix_hidden", "suffix_quantized", "suffix_conv"):
|
||||
torch.testing.assert_close(cache[key], expected_cache[key], atol=1e-5, rtol=1e-5)
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@torch.inference_mode()
|
||||
def test_stream_decoder_preemption_restores_all_state_into_a_different_slot():
|
||||
from tests.model_executor.models.qwen3_tts.test_time_major_decoder import _make_decoder
|
||||
from vllm_omni.model_executor.models.qwen3_tts.tokenizer_12hz.streaming_decoder import StreamingCodecDecoder
|
||||
|
||||
decoder = _make_decoder().to(device=DEVICE, dtype=torch.bfloat16)
|
||||
for name, param in decoder.named_parameters():
|
||||
if name.endswith("embedding_sum"):
|
||||
param.normal_()
|
||||
decoder.config.head_dim = decoder.config.hidden_size // decoder.config.num_attention_heads
|
||||
stream = StreamingCodecDecoder(decoder, num_slots=2, dtype=torch.bfloat16)
|
||||
codes = torch.randint(0, 32, (1, 8, 2), device=DEVICE)
|
||||
slot = torch.tensor([0], device=DEVICE, dtype=torch.int32)
|
||||
for pos in range(7):
|
||||
stream(codes[:, pos : pos + 1], slot, torch.tensor([pos], device=DEVICE, dtype=torch.int32))
|
||||
saved = stream.save_slot(0)
|
||||
expected = stream(codes[:, 7:], slot, torch.tensor([7], device=DEVICE, dtype=torch.int32)).clone()
|
||||
# Both the freed slot and the destination can hold an unrelated request.
|
||||
other_codes = (codes[:, :3] + 1) % 32
|
||||
for pos in range(3):
|
||||
stream(
|
||||
other_codes[:, pos : pos + 1].expand(2, -1, -1).contiguous(),
|
||||
torch.tensor([0, 1], device=DEVICE, dtype=torch.int32),
|
||||
torch.full((2,), pos, device=DEVICE, dtype=torch.int32),
|
||||
)
|
||||
without_restore = stream(
|
||||
codes[:, 7:],
|
||||
torch.tensor([1], device=DEVICE, dtype=torch.int32),
|
||||
torch.tensor([7], device=DEVICE, dtype=torch.int32),
|
||||
).clone()
|
||||
assert not torch.equal(without_restore, expected)
|
||||
stream.restore_slot(1, saved)
|
||||
actual = stream(
|
||||
codes[:, 7:],
|
||||
torch.tensor([1], device=DEVICE, dtype=torch.int32),
|
||||
torch.tensor([7], device=DEVICE, dtype=torch.int32),
|
||||
)
|
||||
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.parametrize("use_graph", [False, True])
|
||||
@torch.inference_mode()
|
||||
def test_batched_reference_priming_matches_serial_decoder_continuation(use_graph):
|
||||
from tests.model_executor.models.qwen3_tts.test_time_major_decoder import _make_decoder
|
||||
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_talker import Qwen3TTSTalkerForConditionalGeneration
|
||||
from vllm_omni.model_executor.models.qwen3_tts.tokenizer_12hz.streaming_decoder import (
|
||||
StreamingCodecDecoder,
|
||||
StreamingDecodeGraphs,
|
||||
)
|
||||
|
||||
decoder = _make_decoder().to(device=DEVICE, dtype=torch.bfloat16)
|
||||
for name, param in decoder.named_parameters():
|
||||
if name.endswith("embedding_sum"):
|
||||
param.normal_()
|
||||
decoder.config.head_dim = decoder.config.hidden_size // decoder.config.num_attention_heads
|
||||
stream = StreamingCodecDecoder(decoder, num_slots=5, dtype=torch.bfloat16)
|
||||
graphs = StreamingDecodeGraphs(stream, [2], frames=25) if use_graph else None
|
||||
model = Qwen3TTSTalkerForConditionalGeneration.__new__(Qwen3TTSTalkerForConditionalGeneration)
|
||||
torch.nn.Module.__init__(model)
|
||||
model.stream_decoder = stream
|
||||
model.stream_prime_graphs = graphs
|
||||
model.stream_chunk_frames = 25
|
||||
refs = [torch.randint(0, 32, (length, 2), device=DEVICE) for length in [25, 3, 51, 25]]
|
||||
slots = [2, 0, 3, 4]
|
||||
frame = torch.randint(0, 32, (4, 1, 2), device=DEVICE)
|
||||
slot_tensor = torch.tensor(slots, device=DEVICE, dtype=torch.int32)
|
||||
positions = torch.tensor([len(ref) for ref in refs], device=DEVICE, dtype=torch.int32)
|
||||
for slot, ref in zip(slots, refs, strict=True):
|
||||
for t0 in range(0, len(ref), 25):
|
||||
stream(
|
||||
ref[None, t0 : t0 + 25].to(torch.int32).contiguous(),
|
||||
torch.tensor([slot], device=DEVICE, dtype=torch.int32),
|
||||
torch.tensor([t0], device=DEVICE, dtype=torch.int32),
|
||||
)
|
||||
expected = stream(frame, slot_tensor, positions).clone()
|
||||
untouched = stream.save_slot(1)
|
||||
# Reuse dirty slots; pos=0 must reset every causal layer for each group.
|
||||
model.prime_stream_decoder(list(zip(slots, refs, strict=True)))
|
||||
actual = stream(frame, slot_tensor, positions)
|
||||
torch.testing.assert_close(actual, expected, rtol=0, atol=3e-5)
|
||||
for got, want in zip(stream.save_slot(1), untouched, strict=True):
|
||||
torch.testing.assert_close(got, want, rtol=0, atol=0)
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.parametrize("use_graph", [False, True])
|
||||
@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16])
|
||||
@torch.inference_mode()
|
||||
def test_stream_frames_match_fp32_exact_decoder(use_graph, dtype):
|
||||
from tests.model_executor.models.qwen3_tts.test_time_major_decoder import _make_decoder
|
||||
from vllm_omni.model_executor.models.qwen3_tts.tokenizer_12hz.streaming_decoder import (
|
||||
StreamingCodecDecoder,
|
||||
StreamingDecodeGraphs,
|
||||
)
|
||||
|
||||
decoder = _make_decoder().to(device=DEVICE, dtype=dtype)
|
||||
# The tiny HF default initialization shrinks the deep conv stack to
|
||||
# near-zero PCM. Keep the oracle signal large enough to catch lost state.
|
||||
for module in decoder.modules():
|
||||
if isinstance(module, (torch.nn.Conv1d, torch.nn.ConvTranspose1d, torch.nn.Linear)):
|
||||
torch.nn.init.kaiming_uniform_(module.weight, a=math.sqrt(5))
|
||||
if module.bias is not None:
|
||||
module.bias.uniform_(-0.01, 0.01)
|
||||
for name, param in decoder.named_parameters():
|
||||
if name.endswith("embedding_sum"):
|
||||
param.normal_()
|
||||
decoder.config.head_dim = decoder.config.hidden_size // decoder.config.num_attention_heads
|
||||
reference = _make_decoder().to(device=DEVICE, dtype=torch.float32)
|
||||
reference.load_state_dict(decoder.state_dict())
|
||||
reference.precompute_snake_caches()
|
||||
# Cross both the attention window and KV ring wrap, checking every frame.
|
||||
codes = torch.randint(0, 32, (2, 136, 2), device=DEVICE)
|
||||
expected = reference._forward_exact(codes.transpose(1, 2))[:, 0]
|
||||
assert expected.square().sum() > 1e-3
|
||||
stream = StreamingCodecDecoder(decoder, num_slots=3, dtype=dtype)
|
||||
decode = StreamingDecodeGraphs(stream, [2]) if use_graph else stream
|
||||
slots = torch.tensor([2, 0], device=DEVICE, dtype=torch.int32)
|
||||
spf = int(stream.spf)
|
||||
for frame in range(codes.shape[1]):
|
||||
positions = torch.full((2,), frame, device=DEVICE, dtype=torch.int32)
|
||||
actual = decode(codes[:, frame : frame + 1].contiguous().to(torch.int32), slots, positions).reshape(2, -1)
|
||||
exact_frame = expected[:, frame * spf : (frame + 1) * spf]
|
||||
relative_rms = (actual - exact_frame).square().mean(1).sqrt() / exact_frame.square().mean(1).sqrt()
|
||||
# BF16 kernels are approximate; compare every frame to the independent
|
||||
# FP32 full decoder, not only one streaming implementation to itself.
|
||||
tolerance = 0.05 if dtype == torch.bfloat16 else 5e-4
|
||||
assert torch.all(relative_rms < tolerance), (frame, relative_rms)
|
||||
@@ -0,0 +1,57 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Streaming col2im preserves PCM inputs and ping-pong slot state."""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from vllm.triton_utils import triton
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from vllm_omni.model_executor.models.qwen3_tts.tokenizer_12hz.streaming_decoder import _col2im_kernel
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.tts]
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.parametrize("batch,frames,channels,rate", [(1, 1, 5, 2), (3, 3, 129, 3), (9, 1, 64, 4)])
|
||||
def test_col2im_partial_tiles_and_reordered_slot_state(batch: int, frames: int, channels: int, rate: int):
|
||||
torch.manual_seed(23)
|
||||
capacity = batch + 2
|
||||
z = torch.randn(batch, frames, 2 * rate, channels).to(torch.bfloat16)
|
||||
bias = torch.randn(channels).to(torch.bfloat16)
|
||||
state = torch.randn(2, capacity, rate, channels).to(torch.bfloat16)
|
||||
expected_state = state.clone()
|
||||
slots = torch.arange(batch - 1, -1, -1, dtype=torch.int32)
|
||||
positions = torch.arange(batch, dtype=torch.int32)
|
||||
parity = torch.arange(capacity, dtype=torch.int32) % 2
|
||||
expected = torch.empty(batch, frames, rate, channels, dtype=torch.bfloat16)
|
||||
for b in range(batch):
|
||||
slot = int(slots[b])
|
||||
side = int(parity[slot])
|
||||
for t in range(frames):
|
||||
previous = z[b, t - 1, rate:] if t else state[side, slot] if positions[b] else torch.zeros(rate, channels)
|
||||
expected[b, t] = (z[b, t, :rate].float() + previous.float() + bias.float()).to(torch.bfloat16)
|
||||
expected_state[1 - side, slot] = z[b, -1, rate:]
|
||||
|
||||
z_gpu, bias_gpu, state_gpu = z.cuda(), bias.cuda(), state.cuda()
|
||||
slots_gpu, positions_gpu, parity_gpu = slots.cuda(), positions.cuda(), parity.cuda()
|
||||
actual = torch.empty_like(expected, device="cuda")
|
||||
width = min(128, triton.next_power_of_2(channels))
|
||||
rows = batch * frames
|
||||
_col2im_kernel[(rows, rate, triton.cdiv(channels, width))](
|
||||
z_gpu,
|
||||
bias_gpu,
|
||||
state_gpu,
|
||||
slots_gpu,
|
||||
positions_gpu,
|
||||
parity_gpu,
|
||||
actual,
|
||||
frames,
|
||||
capacity,
|
||||
R=rate,
|
||||
C=channels,
|
||||
BC=width,
|
||||
num_warps=4,
|
||||
)
|
||||
torch.testing.assert_close(actual.cpu(), expected, rtol=0, atol=0)
|
||||
torch.testing.assert_close(state_gpu.cpu(), expected_state, rtol=0, atol=0)
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from vllm.platforms import current_platform
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from vllm_omni.model_executor.models.breeze_tts_2.depth_decoder import sample_logits
|
||||
@@ -16,6 +17,10 @@ VOCAB = 2052
|
||||
EOS = 2051
|
||||
|
||||
|
||||
def _captured_generator_state_supported() -> bool:
|
||||
return current_platform.is_cuda() and hasattr(torch.cuda.CUDAGraph, "register_generator_state")
|
||||
|
||||
|
||||
def _logits(count: int, *, device: str, seed: int = 100, vocab: int = VOCAB) -> list[torch.Tensor]:
|
||||
source = torch.Generator(device="cpu").manual_seed(seed)
|
||||
values = torch.randn((count, vocab), generator=source, dtype=torch.float32) * 3
|
||||
@@ -164,6 +169,14 @@ def test_cuda_replay_preserves_tokens_rng_and_owned_outputs(monkeypatch) -> None
|
||||
right[codebook].exponential_(generator=live_expected[index])
|
||||
torch.testing.assert_close(left, right, atol=0, rtol=0)
|
||||
_assert_states(actual_generators, expected_generators)
|
||||
if not _captured_generator_state_supported():
|
||||
# ROCm deliberately uses the eager sampler because CUDA graph generator-state registration is an
|
||||
# NVIDIA-only path. The loop above still verifies GPU tokens and every request-owned RNG stream.
|
||||
assert not sampler._graphs
|
||||
for value, expected in retained:
|
||||
torch.testing.assert_close(value, expected, atol=0, rtol=0)
|
||||
torch.testing.assert_close(torch.cuda.get_rng_state(), global_state, atol=0, rtol=0)
|
||||
return
|
||||
# B1/3/16/17/31/32 must use exactly buckets 1/4/16/32.
|
||||
assert len(sampler._graphs) == 4
|
||||
rows = _logits(3, device="cuda", seed=2026)
|
||||
@@ -189,6 +202,7 @@ def test_cuda_replay_preserves_tokens_rng_and_owned_outputs(monkeypatch) -> None
|
||||
@torch.inference_mode()
|
||||
def test_cuda_parameters_vocab_and_missing_graph_api(monkeypatch) -> None:
|
||||
sampler = BreezeFirstCodeSampler()
|
||||
captures_generator_state = _captured_generator_state_supported()
|
||||
actual_generators = _generators([42, 43, 44], "cuda")
|
||||
expected_generators = _generators([42, 43, 44], "cuda")
|
||||
cases = ((0.9, 50, 1.0), (0.7, 100, 0.8), (1.2, 0, 0.95), (1.0, 1, 1.0), (1.0, 0, 0.5))
|
||||
@@ -201,12 +215,12 @@ def test_cuda_parameters_vocab_and_missing_graph_api(monkeypatch) -> None:
|
||||
expected = _reference(rows, parameters, expected_generators)
|
||||
_assert_outputs(sampler.sample(rows, parameters, actual_generators), expected, rows)
|
||||
_assert_states(actual_generators, expected_generators)
|
||||
assert len(sampler._graphs) == len(cases)
|
||||
assert len(sampler._graphs) == (len(cases) if captures_generator_state else 0)
|
||||
rows = _logits(3, device="cuda", vocab=17)
|
||||
expected = _reference(rows, PARAMETERS, expected_generators)
|
||||
_assert_outputs(sampler.sample(rows, PARAMETERS, actual_generators), expected, rows)
|
||||
_assert_states(actual_generators, expected_generators)
|
||||
assert len(sampler._graphs) == len(cases) + 1
|
||||
assert len(sampler._graphs) == (len(cases) + 1 if captures_generator_state else 0)
|
||||
|
||||
class UnavailableGraph:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
@@ -243,7 +257,7 @@ def test_cuda_shared_generator_uses_sequential_eager_fallback(monkeypatch) -> No
|
||||
# The duplicate-generator guard must precede lookup of an existing graph.
|
||||
sampler.sample(rows, PARAMETERS, _generators([100, 101, 102], "cuda"))
|
||||
cached = dict(sampler._graphs)
|
||||
assert len(cached) == 1
|
||||
assert len(cached) == (1 if _captured_generator_state_supported() else 0)
|
||||
actual = torch.Generator(device="cuda").manual_seed(2**64 - 23)
|
||||
reference = torch.Generator(device="cuda").manual_seed(2**64 - 23)
|
||||
multinomial = torch.multinomial
|
||||
@@ -270,6 +284,7 @@ def test_cuda_shared_generator_uses_sequential_eager_fallback(monkeypatch) -> No
|
||||
@torch.inference_mode()
|
||||
def test_cuda_cache_limit_falls_back_without_evicting_warmed_graphs(monkeypatch) -> None:
|
||||
sampler = BreezeFirstCodeSampler()
|
||||
captures_generator_state = _captured_generator_state_supported()
|
||||
rows = _logits(1, device="cuda")
|
||||
actual_generators = _generators([42], "cuda")
|
||||
expected_generators = _generators([42], "cuda")
|
||||
@@ -279,7 +294,7 @@ def test_cuda_cache_limit_falls_back_without_evicting_warmed_graphs(monkeypatch)
|
||||
_assert_outputs(sampler.sample(rows, parameters, actual_generators), expected, rows)
|
||||
_assert_states(actual_generators, expected_generators)
|
||||
cached = dict(sampler._graphs)
|
||||
assert len(cached) == 32
|
||||
assert len(cached) == (32 if captures_generator_state else 0)
|
||||
parameters = (0.9, 33, 1.0)
|
||||
expected = _reference(rows, parameters, expected_generators)
|
||||
multinomial = torch.multinomial
|
||||
@@ -298,6 +313,11 @@ def test_cuda_cache_limit_falls_back_without_evicting_warmed_graphs(monkeypatch)
|
||||
assert all(sampler._graphs[key] is value for key, value in cached.items())
|
||||
expected = _reference(rows, (0.9, 1, 1.0), expected_generators)
|
||||
|
||||
if not captures_generator_state:
|
||||
_assert_outputs(sampler.sample(rows, (0.9, 1, 1.0), actual_generators), expected, rows)
|
||||
_assert_states(actual_generators, expected_generators)
|
||||
return
|
||||
|
||||
def unexpected_multinomial(*_args, **_kwargs):
|
||||
raise AssertionError("Cache exhaustion must retain existing captured entries")
|
||||
|
||||
|
||||
@@ -9,7 +9,11 @@ from transformers.models.t5gemma2.modeling_t5gemma2 import T5Gemma2TextEncoder
|
||||
from vllm.platforms import current_platform
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from vllm_omni.model_executor.models.breeze_tts_2.depth_decoder import BreezeDepthDecoder, sample_logits
|
||||
from vllm_omni.model_executor.models.breeze_tts_2.depth_decoder import (
|
||||
BreezeDepthDecoder,
|
||||
_depth_layer_compile_options,
|
||||
sample_logits,
|
||||
)
|
||||
from vllm_omni.model_executor.models.breeze_tts_2.modeling_breeze import BreezeForConditionalGeneration
|
||||
from vllm_omni.model_executor.models.breeze_tts_2.text_encoder_graph import (
|
||||
BreezeTextEncoderCompiled,
|
||||
@@ -19,6 +23,17 @@ from vllm_omni.model_executor.models.breeze_tts_2.text_encoder_graph import (
|
||||
pytestmark = [pytest.mark.core_model]
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize(("is_rocm", "expected"), [(True, False), (False, True)])
|
||||
def test_depth_layer_compile_options(monkeypatch, is_rocm: bool, expected: bool) -> None:
|
||||
monkeypatch.setattr(current_platform, "is_rocm", lambda: is_rocm)
|
||||
|
||||
assert _depth_layer_compile_options() == {
|
||||
"epilogue_fusion": False,
|
||||
"max_autotune": expected,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def full_precision_matmul():
|
||||
# Different padded shapes can select different TF32 kernels. Compare
|
||||
@@ -241,6 +256,7 @@ def test_depth_graph_buckets_bound_shapes_and_preserve_requests(full_precision_m
|
||||
seeds[0], seeds[-1] = 2**63 + 17, 2**64 - 1
|
||||
generators = [torch.Generator(device="cuda").manual_seed(seed) for seed in seeds]
|
||||
reference_generators = [torch.Generator(device="cuda").manual_seed(seed) for seed in seeds]
|
||||
captures_generator_state = current_platform.is_cuda() and hasattr(torch.cuda.CUDAGraph, "register_generator_state")
|
||||
|
||||
expected_greedy = torch.cat(
|
||||
[
|
||||
@@ -314,7 +330,7 @@ def test_depth_graph_buckets_bound_shapes_and_preserve_requests(full_precision_m
|
||||
branches = 2 if guidance_scale != 1 else 1
|
||||
graph_key = (request_bucket * branches, branches, temperature == 0)
|
||||
with monkeypatch.context() as rng_guard:
|
||||
if temperature > 0 and graph_key in depth._graphs:
|
||||
if temperature > 0 and graph_key in depth._graphs and captures_generator_state:
|
||||
# A warmed replay must generate noise within the CUDA
|
||||
# graph, without dispatching new RNG kernels from Python.
|
||||
rng_guard.setattr(torch.Tensor, "exponential_", reject_eager_noise)
|
||||
@@ -349,7 +365,7 @@ def test_depth_graph_buckets_bound_shapes_and_preserve_requests(full_precision_m
|
||||
expected_keys.update((batch, 2, greedy) for batch in (2, 8, 16, 32) for greedy in (False, True))
|
||||
assert set(depth._graphs) == expected_keys
|
||||
for (batch_bucket, branches, greedy), entry in depth._graphs.items():
|
||||
if greedy:
|
||||
if greedy or not captures_generator_state:
|
||||
assert entry.noise_generators is None
|
||||
else:
|
||||
assert entry.noise_generators is not None
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.model_executor.models.qwen3_omni.first_frame_decoder import talker_first_audio_enabled
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("env", "extra", "async_chunk", "v2", "expected"),
|
||||
[
|
||||
(None, {}, True, True, False),
|
||||
("1", {}, True, True, False),
|
||||
(None, {"talker_first_audio": True, "codec_chunk_ramp": [1, 2]}, True, True, True),
|
||||
("0", {"talker_first_audio": True, "codec_chunk_ramp": [1, 2]}, True, True, False),
|
||||
(None, {"talker_first_audio": True, "codec_chunk_ramp": [4, 8]}, True, True, False),
|
||||
(None, {"talker_first_audio": True, "initial_codec_chunk_frames": 1}, True, True, True),
|
||||
(None, {"talker_first_audio": True, "initial_codec_chunk_frames": 4}, True, True, False),
|
||||
(None, {"talker_first_audio": True, "codec_chunk_ramp": [1]}, True, True, False),
|
||||
(None, {"talker_first_audio": True, "codec_chunk_ramp": [1, 2]}, False, True, False),
|
||||
(None, {"talker_first_audio": True, "codec_chunk_ramp": [1, 2]}, True, False, False),
|
||||
],
|
||||
)
|
||||
def test_talker_first_audio_default_and_conditions(monkeypatch, env, extra, async_chunk, v2, expected):
|
||||
if env is None:
|
||||
monkeypatch.delenv("VLLM_OMNI_TALKER_FIRST_AUDIO", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("VLLM_OMNI_TALKER_FIRST_AUDIO", env)
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
monkeypatch.setattr(current_omni_platform, "is_cuda", lambda: True)
|
||||
config = SimpleNamespace(
|
||||
model_config=SimpleNamespace(
|
||||
async_chunk=async_chunk, use_v2_model_runner=v2, stage_connector_config={"extra": extra}
|
||||
),
|
||||
device_config=SimpleNamespace(device="cuda"),
|
||||
parallel_config=SimpleNamespace(
|
||||
tensor_parallel_size=1, pipeline_parallel_size=1, distributed_executor_backend=None
|
||||
),
|
||||
cache_config=SimpleNamespace(enable_prefix_caching=False),
|
||||
)
|
||||
assert talker_first_audio_enabled(config) is expected
|
||||
for section, field, value in [
|
||||
("device_config", "device", "cpu"),
|
||||
("parallel_config", "tensor_parallel_size", 2),
|
||||
("parallel_config", "pipeline_parallel_size", 2),
|
||||
("parallel_config", "distributed_executor_backend", "mp"),
|
||||
("cache_config", "enable_prefix_caching", True),
|
||||
]:
|
||||
with monkeypatch.context() as patch:
|
||||
patch.setattr(getattr(config, section), field, value)
|
||||
assert not talker_first_audio_enabled(config)
|
||||
monkeypatch.setattr(current_omni_platform, "is_cuda", lambda: False)
|
||||
assert not talker_first_audio_enabled(config)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("option", [False, True])
|
||||
def test_tts_requires_connector_opt_in(monkeypatch, option):
|
||||
from vllm_omni.model_executor.models.qwen3_tts import first_audio
|
||||
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_code_predictor_vllm import (
|
||||
Qwen3TTSTalkerCodePredictorForConditionalGenerationVLLM as Predictor,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(Predictor, "_stage_connector_extra_config", lambda _: {"talker_first_audio": option})
|
||||
monkeypatch.setattr(first_audio, "supports_talker_first_audio", lambda _: True)
|
||||
assert first_audio.talker_first_audio_enabled(None) is option
|
||||
Reference in new issue
Block a user