363 lines
14 KiB
Python
363 lines
14 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|||
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||
|
|
"""CPU coverage for Gepard's prompt layout, streaming-decode window and config.
|
||
|
|
|
||
|
|
The e2e test needs a GPU, NeMo and the checkpoint, so it only runs nightly.
|
||
|
|
These pin the parts that are pure logic and break silently: which frames each
|
||
|
|
decode covers, which samples survive the lookback trim, the prompt layout, and
|
||
|
|
that the packaged deploy config sits where the example looks for it.
|
||
|
|
|
||
|
|
The codec is replaced by a shift-invariant stand-in whose output depends only
|
||
|
|
on the codes, which makes "chunked emissions concatenate to one whole-history
|
||
|
|
decode" an exact assertion — the contract lookback exists to provide.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
import torch
|
||
|
|
import torch.nn as nn
|
||
|
|
|
||
|
|
import vllm_omni
|
||
|
|
from vllm_omni.config.stage_config import load_deploy_config
|
||
|
|
from vllm_omni.model_executor.models.gepard.configuration_gepard import GepardConfig
|
||
|
|
from vllm_omni.model_executor.models.gepard.gepard_talker import (
|
||
|
|
CHUNK_FRAMES,
|
||
|
|
FIRST_CHUNK_FRAMES,
|
||
|
|
GepardTalkerForConditionalGeneration,
|
||
|
|
_GepardState,
|
||
|
|
)
|
||
|
|
from vllm_omni.model_executor.models.gepard.nanocodec import (
|
||
|
|
LOOKBACK_FRAMES,
|
||
|
|
NanoCodec,
|
||
|
|
apply_end_of_speech_tail,
|
||
|
|
)
|
||
|
|
from vllm_omni.model_executor.models.gepard.prompt import build_gepard_prompt_ids
|
||
|
|
|
||
|
|
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||
|
|
|
||
|
|
SAMPLES_PER_FRAME = 1024
|
||
|
|
NUM_HEADS = 32
|
||
|
|
SAMPLE_RATE = 22050
|
||
|
|
# Text-token count at or above which the layout is left unrepeated, read from
|
||
|
|
# the checkpoint defaults so the boundary cases follow the config.
|
||
|
|
APPLY_BELOW = GepardConfig().text_repetition_apply_below
|
||
|
|
|
||
|
|
|
||
|
|
class _ShiftInvariantCodec:
|
||
|
|
"""Frame tagged ``v`` -> samples ``v*SPF .. v*SPF+SPF-1``, wherever it sits."""
|
||
|
|
|
||
|
|
def __init__(self) -> None:
|
||
|
|
self.windows: list[int] = [] # frame count of every decode call
|
||
|
|
|
||
|
|
def decode_from_codes(self, codes: torch.Tensor, codes_len: torch.Tensor):
|
||
|
|
assert codes.shape[0] == 1, "streaming decode is per-request"
|
||
|
|
assert codes.shape[1] == NUM_HEADS, f"expected {NUM_HEADS} code channels"
|
||
|
|
tags = codes[0, 0, :].to(torch.int64) # (T,)
|
||
|
|
self.windows.append(int(tags.shape[0]))
|
||
|
|
offsets = torch.arange(SAMPLES_PER_FRAME, dtype=torch.int64)
|
||
|
|
audio = (tags.unsqueeze(1) * SAMPLES_PER_FRAME + offsets).reshape(1, -1)
|
||
|
|
return audio.to(torch.float32), codes_len * SAMPLES_PER_FRAME
|
||
|
|
|
||
|
|
|
||
|
|
def _make_codec() -> tuple[NanoCodec, _ShiftInvariantCodec]:
|
||
|
|
codec = NanoCodec(codec_id="test/fake-codec", sample_rate=SAMPLE_RATE)
|
||
|
|
fake = _ShiftInvariantCodec()
|
||
|
|
codec._codec = fake # bypasses load(); NeMo is never imported
|
||
|
|
assert codec.is_loaded
|
||
|
|
return codec, fake
|
||
|
|
|
||
|
|
|
||
|
|
def _make_talker(codec: NanoCodec) -> GepardTalkerForConditionalGeneration:
|
||
|
|
"""A real talker with only the audio-path attributes populated.
|
||
|
|
|
||
|
|
``__init__`` needs a VllmConfig and builds the backbone; the methods under
|
||
|
|
test touch only these four attributes.
|
||
|
|
"""
|
||
|
|
talker = GepardTalkerForConditionalGeneration.__new__(GepardTalkerForConditionalGeneration)
|
||
|
|
nn.Module.__init__(talker) # sets up _parameters/_buffers/_modules only
|
||
|
|
talker._codec = codec
|
||
|
|
talker._audio_queue = []
|
||
|
|
talker._active_states = {}
|
||
|
|
talker._deferred_cleanup_ids = set()
|
||
|
|
return talker
|
||
|
|
|
||
|
|
|
||
|
|
def _frame(tag: int) -> torch.Tensor:
|
||
|
|
"""One committed frame: 32 per-dimension codes, all carrying the tag."""
|
||
|
|
return torch.full((NUM_HEADS,), tag, dtype=torch.long)
|
||
|
|
|
||
|
|
|
||
|
|
def _expected_samples(num_frames: int) -> torch.Tensor:
|
||
|
|
offsets = torch.arange(SAMPLES_PER_FRAME, dtype=torch.int64)
|
||
|
|
tags = torch.arange(num_frames, dtype=torch.int64).unsqueeze(1)
|
||
|
|
return (tags * SAMPLES_PER_FRAME + offsets).reshape(-1).to(torch.float32)
|
||
|
|
|
||
|
|
|
||
|
|
def _drain(talker) -> torch.Tensor:
|
||
|
|
"""Concatenate and clear the queued deltas, as make_omni_output does."""
|
||
|
|
if not talker._audio_queue:
|
||
|
|
return torch.empty(0)
|
||
|
|
out = torch.cat([a.reshape(-1) for _, a in talker._audio_queue])
|
||
|
|
talker._audio_queue.clear()
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def _generate(talker, state, num_frames: int, *, stop_at_end: bool) -> None:
|
||
|
|
"""Drive the commit loop the way forward() does, one frame per step."""
|
||
|
|
for tag in range(num_frames):
|
||
|
|
state.frames.append(_frame(tag))
|
||
|
|
state.frame_count += 1
|
||
|
|
state.past_first_step = True
|
||
|
|
talker._emit_audio(state, is_final=stop_at_end and tag == num_frames - 1)
|
||
|
|
|
||
|
|
|
||
|
|
# Counts straddling both cadence thresholds, where an off-by-one hides.
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
"num_frames",
|
||
|
|
[
|
||
|
|
1,
|
||
|
|
2,
|
||
|
|
FIRST_CHUNK_FRAMES - 1,
|
||
|
|
FIRST_CHUNK_FRAMES,
|
||
|
|
FIRST_CHUNK_FRAMES + 1,
|
||
|
|
CHUNK_FRAMES - 1,
|
||
|
|
CHUNK_FRAMES,
|
||
|
|
CHUNK_FRAMES + 1,
|
||
|
|
FIRST_CHUNK_FRAMES + CHUNK_FRAMES,
|
||
|
|
79,
|
||
|
|
200,
|
||
|
|
],
|
||
|
|
)
|
||
|
|
def test_chunked_emissions_equal_a_whole_history_decode(num_frames: int) -> None:
|
||
|
|
codec, _fake = _make_codec()
|
||
|
|
talker = _make_talker(codec)
|
||
|
|
state = _GepardState(request_id="req-0")
|
||
|
|
|
||
|
|
_generate(talker, state, num_frames, stop_at_end=True)
|
||
|
|
|
||
|
|
torch.testing.assert_close(_drain(talker), _expected_samples(num_frames))
|
||
|
|
assert state.emitted_frames == num_frames
|
||
|
|
|
||
|
|
|
||
|
|
def test_every_frame_is_emitted_exactly_once() -> None:
|
||
|
|
"""The trim must not drop or duplicate samples at a chunk boundary."""
|
||
|
|
codec, _fake = _make_codec()
|
||
|
|
talker = _make_talker(codec)
|
||
|
|
state = _GepardState(request_id="req-0")
|
||
|
|
num_frames = FIRST_CHUNK_FRAMES + 2 * CHUNK_FRAMES + 3
|
||
|
|
|
||
|
|
_generate(talker, state, num_frames, stop_at_end=True)
|
||
|
|
audio = _drain(talker)
|
||
|
|
|
||
|
|
assert audio.numel() == num_frames * SAMPLES_PER_FRAME
|
||
|
|
# Tags are globally unique, so a duplicated or dropped frame shows up here.
|
||
|
|
assert len(torch.unique(audio)) == audio.numel()
|
||
|
|
|
||
|
|
|
||
|
|
def test_decode_windows_carry_lookback_context() -> None:
|
||
|
|
"""Every decode after the first must re-decode LOOKBACK_FRAMES of context."""
|
||
|
|
codec, fake = _make_codec()
|
||
|
|
talker = _make_talker(codec)
|
||
|
|
state = _GepardState(request_id="req-0")
|
||
|
|
num_frames = FIRST_CHUNK_FRAMES + 2 * CHUNK_FRAMES
|
||
|
|
|
||
|
|
_generate(talker, state, num_frames, stop_at_end=True)
|
||
|
|
|
||
|
|
assert len(fake.windows) >= 3, "expected a first chunk plus steady-state chunks"
|
||
|
|
assert fake.windows[0] == FIRST_CHUNK_FRAMES, "the first chunk has no left context"
|
||
|
|
for window in fake.windows[1:]:
|
||
|
|
assert window == CHUNK_FRAMES + LOOKBACK_FRAMES
|
||
|
|
|
||
|
|
|
||
|
|
def test_codec_runs_once_per_chunk_not_once_per_frame() -> None:
|
||
|
|
codec, fake = _make_codec()
|
||
|
|
talker = _make_talker(codec)
|
||
|
|
state = _GepardState(request_id="req-0")
|
||
|
|
num_frames = FIRST_CHUNK_FRAMES + 3 * CHUNK_FRAMES
|
||
|
|
|
||
|
|
_generate(talker, state, num_frames, stop_at_end=True)
|
||
|
|
|
||
|
|
assert len(fake.windows) == 4
|
||
|
|
assert sum(fake.windows) < num_frames * 2 # far below one decode per frame
|
||
|
|
|
||
|
|
|
||
|
|
def test_final_emit_drains_a_partial_chunk() -> None:
|
||
|
|
"""A request can end mid-chunk (max_tokens, STOP); the frames committed
|
||
|
|
since the last cadence boundary must still reach the queue.
|
||
|
|
|
||
|
|
Arithmetic only: this drives ``_emit_audio`` directly, so it says nothing
|
||
|
|
about *whether* the engine reaches it on the request's last step. That
|
||
|
|
wiring lives in ``test_gepard_wiring.py`` — a bug there leaves this green.
|
||
|
|
"""
|
||
|
|
codec, _fake = _make_codec()
|
||
|
|
talker = _make_talker(codec)
|
||
|
|
state = _GepardState(request_id="req-0")
|
||
|
|
num_frames = FIRST_CHUNK_FRAMES + CHUNK_FRAMES + 5
|
||
|
|
|
||
|
|
_generate(talker, state, num_frames, stop_at_end=False)
|
||
|
|
assert state.emitted_frames < num_frames, "a partial chunk should still be pending"
|
||
|
|
|
||
|
|
talker._emit_audio(state, is_final=True)
|
||
|
|
|
||
|
|
torch.testing.assert_close(_drain(talker), _expected_samples(num_frames))
|
||
|
|
assert state.emitted_frames == num_frames
|
||
|
|
|
||
|
|
|
||
|
|
def test_emitting_with_nothing_pending_is_a_no_op() -> None:
|
||
|
|
codec, fake = _make_codec()
|
||
|
|
talker = _make_talker(codec)
|
||
|
|
state = _GepardState(request_id="req-0")
|
||
|
|
|
||
|
|
_generate(talker, state, 4, stop_at_end=True)
|
||
|
|
calls_after_flush = len(fake.windows)
|
||
|
|
talker._audio_queue.clear()
|
||
|
|
|
||
|
|
talker._emit_audio(state, is_final=True)
|
||
|
|
|
||
|
|
assert len(fake.windows) == calls_after_flush, "codec ran with no new frames"
|
||
|
|
assert talker._audio_queue == []
|
||
|
|
|
||
|
|
|
||
|
|
def test_concurrent_requests_keep_separate_windows() -> None:
|
||
|
|
codec, _fake = _make_codec()
|
||
|
|
talker = _make_talker(codec)
|
||
|
|
states = [_GepardState(request_id=f"req-{i}") for i in range(2)]
|
||
|
|
|
||
|
|
for state in states:
|
||
|
|
_generate(talker, state, FIRST_CHUNK_FRAMES, stop_at_end=True)
|
||
|
|
|
||
|
|
by_req: dict[str, list[torch.Tensor]] = {}
|
||
|
|
for req_id, audio in talker._audio_queue:
|
||
|
|
by_req.setdefault(req_id, []).append(audio.reshape(-1))
|
||
|
|
assert set(by_req) == {"req-0", "req-1"}
|
||
|
|
for chunks in by_req.values():
|
||
|
|
torch.testing.assert_close(torch.cat(chunks), _expected_samples(FIRST_CHUNK_FRAMES))
|
||
|
|
|
||
|
|
|
||
|
|
def test_end_of_speech_tail_fades_and_pads() -> None:
|
||
|
|
audio = torch.ones(SAMPLE_RATE) # 1 s of full-scale signal
|
||
|
|
out = apply_end_of_speech_tail(audio, SAMPLE_RATE, fade_ms=10.0, silence_ms=20.0)
|
||
|
|
|
||
|
|
fade = int(SAMPLE_RATE * 10.0 / 1000.0)
|
||
|
|
pad = int(SAMPLE_RATE * 20.0 / 1000.0)
|
||
|
|
assert out.numel() == SAMPLE_RATE + pad
|
||
|
|
assert torch.all(out[-pad:] == 0.0), "trailing pad must be silent"
|
||
|
|
assert out[SAMPLE_RATE - fade - 1] == 1.0, "the fade must not reach back past fade_ms"
|
||
|
|
# Monotonically ramped to zero — the pad alone would leave the click.
|
||
|
|
ramp = out[SAMPLE_RATE - fade : SAMPLE_RATE]
|
||
|
|
assert torch.all(ramp[:-1] >= ramp[1:])
|
||
|
|
assert ramp[-1] == 0.0
|
||
|
|
|
||
|
|
|
||
|
|
def test_end_of_speech_tail_is_a_no_op_by_default() -> None:
|
||
|
|
audio = torch.ones(128)
|
||
|
|
out = apply_end_of_speech_tail(audio, SAMPLE_RATE, fade_ms=0.0, silence_ms=0.0)
|
||
|
|
assert out is audio
|
||
|
|
|
||
|
|
|
||
|
|
def _repeat_count(ids: list[int], cfg: GepardConfig) -> int:
|
||
|
|
"""How many ``[SOT ... EOT]`` copies the layout carries."""
|
||
|
|
return ids.count(cfg.start_of_text)
|
||
|
|
|
||
|
|
|
||
|
|
def test_gepard_prompt_layout() -> None:
|
||
|
|
"""The prompt carries the speaker slots and exactly one SOS, at the end."""
|
||
|
|
cfg = GepardConfig()
|
||
|
|
ids = build_gepard_prompt_ids([1, 2, 3], config=cfg)
|
||
|
|
|
||
|
|
slots = [cfg.speaker_token_base + i for i in range(cfg.num_speaker_prefix)]
|
||
|
|
assert ids[: cfg.num_speaker_prefix] == slots
|
||
|
|
assert ids.count(cfg.start_of_speech) == 1, "only the canonical copy may carry SOS"
|
||
|
|
assert ids[-1] == cfg.start_of_speech, "the first frame is sampled from the SOS position"
|
||
|
|
|
||
|
|
|
||
|
|
def test_short_text_repeats_to_reach_the_target_token_mass() -> None:
|
||
|
|
"""A short text is repeated until its region holds ~target_text_tokens."""
|
||
|
|
cfg = GepardConfig()
|
||
|
|
text = [1, 2, 3, 4] # target 16 / 4 -> 4 copies
|
||
|
|
ids = build_gepard_prompt_ids(text, config=cfg)
|
||
|
|
|
||
|
|
assert _repeat_count(ids, cfg) == 4
|
||
|
|
assert ids.count(cfg.start_of_speech) == 1, "only the last copy opens the speech region"
|
||
|
|
assert ids[-1] == cfg.start_of_speech
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
("num_text_tokens", "repeated"),
|
||
|
|
[
|
||
|
|
(APPLY_BELOW - 1, True),
|
||
|
|
(APPLY_BELOW, False),
|
||
|
|
(APPLY_BELOW + 1, False),
|
||
|
|
],
|
||
|
|
)
|
||
|
|
def test_apply_below_is_the_repetition_boundary(num_text_tokens: int, repeated: bool) -> None:
|
||
|
|
"""Both sides of the threshold: at apply_below the text is left alone.
|
||
|
|
|
||
|
|
The layout has to match training here or WER collapses, and the boundary
|
||
|
|
is where an off-by-one would be invisible in a single happy-path clip.
|
||
|
|
"""
|
||
|
|
cfg = GepardConfig()
|
||
|
|
ids = build_gepard_prompt_ids(list(range(1, num_text_tokens + 1)), config=cfg)
|
||
|
|
|
||
|
|
assert (_repeat_count(ids, cfg) > 1) is repeated
|
||
|
|
|
||
|
|
|
||
|
|
def test_max_repeats_caps_a_one_token_text() -> None:
|
||
|
|
"""target/1 would ask for 16 copies; the cap keeps the prefill bounded."""
|
||
|
|
cfg = GepardConfig()
|
||
|
|
ids = build_gepard_prompt_ids([1], config=cfg)
|
||
|
|
|
||
|
|
assert _repeat_count(ids, cfg) == cfg.text_repetition_max_repeats
|
||
|
|
|
||
|
|
|
||
|
|
def test_repetition_thresholds_are_read_from_the_config() -> None:
|
||
|
|
"""The numbers come from the checkpoint sidecar, not from literals.
|
||
|
|
|
||
|
|
A checkpoint whose text_repetition block differs must change the layout;
|
||
|
|
duplicating the values in the builder would silently ignore it.
|
||
|
|
"""
|
||
|
|
cfg = GepardConfig(text_repetition={"enabled": True, "target_text_tokens": 9, "apply_below": 8, "max_repeats": 2})
|
||
|
|
assert _repeat_count(build_gepard_prompt_ids([1, 2, 3], config=cfg), cfg) == 2 # ceil(9/3)=3, capped at 2
|
||
|
|
assert _repeat_count(build_gepard_prompt_ids(list(range(8)), config=cfg), cfg) == 1 # at apply_below
|
||
|
|
|
||
|
|
|
||
|
|
def test_repetition_can_be_disabled() -> None:
|
||
|
|
cfg = GepardConfig(text_repetition={"enabled": False})
|
||
|
|
ids = build_gepard_prompt_ids([1], config=cfg)
|
||
|
|
|
||
|
|
assert _repeat_count(ids, cfg) == 1
|
||
|
|
assert ids[-1] == cfg.start_of_speech
|
||
|
|
|
||
|
|
|
||
|
|
def test_empty_text_is_rejected() -> None:
|
||
|
|
"""An empty text would still assemble a valid layout and be voiced.
|
||
|
|
|
||
|
|
``[slots, SOT, EOT, SOS]`` parses, prefills and generates: the failure is
|
||
|
|
audible, not raised, which is the worst shape for a caller to debug.
|
||
|
|
"""
|
||
|
|
with pytest.raises(ValueError, match="at least one text token"):
|
||
|
|
build_gepard_prompt_ids([], config=GepardConfig())
|
||
|
|
|
||
|
|
|
||
|
|
def test_packaged_deploy_config_is_where_the_example_looks_for_it() -> None:
|
||
|
|
"""The example resolves its default off ``vllm_omni.__file__``; tests reach
|
||
|
|
the same YAML through ``tests.helpers.stage_config``, so nothing covered the
|
||
|
|
packaged location until this.
|
||
|
|
|
||
|
|
``pipeline: gepard`` is the load-bearing key: the checkpoint self-identifies
|
||
|
|
as qwen3_5_text, so without the pin the architectures fallback routes it to
|
||
|
|
the diffusion registry.
|
||
|
|
|
||
|
|
This asserts the location, not the example's constant — importing the
|
||
|
|
example would pull ``Omni`` and NeMo into a CPU test. Running the quick
|
||
|
|
start verbatim stays a release check.
|
||
|
|
"""
|
||
|
|
path = Path(vllm_omni.__file__).resolve().parent / "deploy" / "gepard.yaml"
|
||
|
|
assert path.is_file(), f"packaged deploy config missing at {path}"
|
||
|
|
|
||
|
|
deploy = load_deploy_config(path)
|
||
|
|
assert deploy.pipeline == "gepard"
|
||
|
|
assert deploy.stages, "deploy config declares no stages"
|