371 lines
14 KiB
Python
371 lines
14 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
|
|
|
"""Focused scheduler tests for deadline-aligned silence continuation.
|
|
|
|
These tests exercise the production ``_schedule_silence_continuation`` path
|
|
(and the append acceptance callback it installs) rather than copying the
|
|
scheduling arithmetic. The monotonic clock is a fake so the deadline math is
|
|
deterministic, and ``asyncio.sleep`` is simulated by advancing that clock.
|
|
Append completion is controlled by gating the stage port's ``submit``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from collections.abc import Callable
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
import vllm_omni.engine.duplex.session.model_channel as model_channel_module
|
|
import vllm_omni.engine.duplex.session.runner as runner_module
|
|
from tests.engine.duplex.test_session_runner import (
|
|
Harness,
|
|
append_audio,
|
|
close_harness,
|
|
open_harness,
|
|
tts_output,
|
|
)
|
|
from vllm_omni.engine.duplex.contracts import DuplexStageSubmission
|
|
|
|
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
|
|
|
CHUNK_PERIOD_S = 1.0
|
|
|
|
|
|
class FakeClock:
|
|
"""A monotonic clock whose value the test controls directly."""
|
|
|
|
def __init__(self, start: float = 0.0) -> None:
|
|
self.value = start
|
|
|
|
def __call__(self) -> float:
|
|
return self.value
|
|
|
|
|
|
async def _active_response_harness() -> Harness:
|
|
"""Open a harness and drive a TTS segment so a response is active."""
|
|
h = await open_harness()
|
|
await h.run(append_audio())
|
|
request_id = h.stage0_request_id()
|
|
await h.deliver_and_settle(tts_output(request_id, samples=24000, text="hello"))
|
|
assert h.session.active_response_id is not None
|
|
return h
|
|
|
|
|
|
def _continuation_kwargs(h: Harness) -> dict[str, object]:
|
|
"""The scheduler arguments the model channel would pass for this session."""
|
|
return {
|
|
"request_id": h.session.active_request_id,
|
|
"owner_id": f"response:{h.session.active_response_id}",
|
|
"response_id": h.session.active_response_id,
|
|
"response_owned": True,
|
|
"expected_epoch": h.session.epoch,
|
|
"expected_model_turn_id": h.session.turn_id,
|
|
}
|
|
|
|
|
|
def _install_fake_clock(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
*,
|
|
clock: FakeClock,
|
|
real_sleep: Callable[..., Any],
|
|
) -> list[float]:
|
|
"""Point both modules at the fake clock and simulate ``asyncio.sleep``.
|
|
|
|
``real_sleep`` is the unpatched ``asyncio.sleep`` captured before patching;
|
|
the simulated sleep advances the fake clock by the requested delay and then
|
|
yields control so the append task can run. The returned ``stall`` list is
|
|
added once to the next *positive* sleep so a test can model a wake that
|
|
happens late (after the planned deadline); zero-duration scheduling yields
|
|
do not consume it.
|
|
"""
|
|
monkeypatch.setattr(runner_module.time, "monotonic", clock)
|
|
monkeypatch.setattr(model_channel_module.time, "monotonic", clock)
|
|
stall: list[float] = [0.0]
|
|
|
|
async def fake_sleep(delay: float) -> None:
|
|
clock.value += delay
|
|
if delay > 0:
|
|
clock.value += stall[0]
|
|
stall[0] = 0.0
|
|
await real_sleep(0)
|
|
|
|
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
|
return stall
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_normal_cadence_keeps_deadlines_aligned(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Processing time reduces the remaining sleep; consecutive deadlines align.
|
|
|
|
Each accepted unit becomes the new anchor (its submission time), and the
|
|
following continuation advances from the stored deadline rather than
|
|
resetting to now.
|
|
"""
|
|
clock = FakeClock(start=100.0)
|
|
real_sleep = asyncio.sleep
|
|
_install_fake_clock(monkeypatch, clock=clock, real_sleep=real_sleep)
|
|
h = await _active_response_harness()
|
|
try:
|
|
# Real input accepted at t=100.0; the model processes for 0.4 s.
|
|
h.runner.model_state.last_native_submit_monotonic = 100.0
|
|
clock.value = 100.4
|
|
|
|
# First continuation: due at 101.0, so only 0.6 s of sleep remains.
|
|
scheduled = await h.runner._schedule_silence_continuation(
|
|
h.runner.model.silence_unit_payload(),
|
|
**_continuation_kwargs(h),
|
|
)
|
|
assert scheduled is True
|
|
first_task = h.runner.tasks.append_tail
|
|
assert first_task is not None
|
|
assert await first_task
|
|
# The accepted unit re-anchored the chain to its own submission time
|
|
# (101.0) and stored the following deadline (102.0).
|
|
assert h.runner.model_state.last_native_submit_monotonic == pytest.approx(101.0)
|
|
assert h.runner.model_state.silence_deadline_monotonic == pytest.approx(102.0)
|
|
|
|
# Model produces the continuation's audio; the next unit is planned
|
|
# from the stored deadline (no drift): the following deadline is 103.0.
|
|
clock.value = 101.3
|
|
scheduled = await h.runner._schedule_silence_continuation(
|
|
h.runner.model.silence_unit_payload(),
|
|
**_continuation_kwargs(h),
|
|
)
|
|
assert scheduled is True
|
|
second_task = h.runner.tasks.append_tail
|
|
assert second_task is not None
|
|
assert await second_task
|
|
assert h.runner.model_state.silence_deadline_monotonic == pytest.approx(103.0)
|
|
finally:
|
|
await close_harness(h)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_in_flight_real_append_skips_outdated_silence(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A real append accepted after the silence was planned re-anchors the chain.
|
|
|
|
The planned silence queues behind the real append; once the real append
|
|
completes and re-anchors, ``before_append`` detects the changed anchor
|
|
(numeric timestamp) and the outdated silence is skipped (no submission,
|
|
no callback overwrite).
|
|
"""
|
|
clock = FakeClock(start=200.0)
|
|
real_sleep = asyncio.sleep
|
|
_install_fake_clock(monkeypatch, clock=clock, real_sleep=real_sleep)
|
|
h = await _active_response_harness()
|
|
state = h.runner.model_state
|
|
submissions_before = len(h.port.submissions)
|
|
|
|
original_submit = h.port.submit
|
|
entered = asyncio.Event()
|
|
release = asyncio.Event()
|
|
|
|
async def gated_submit(submission: DuplexStageSubmission) -> Any:
|
|
entered.set()
|
|
await release.wait()
|
|
return await original_submit(submission)
|
|
|
|
monkeypatch.setattr(h.port, "submit", gated_submit)
|
|
try:
|
|
# Existing anchor at t=200.0.
|
|
state.last_native_submit_monotonic = 200.0
|
|
state.silence_deadline_monotonic = None
|
|
|
|
# The real append starts at t+0.8 and blocks inside the gate.
|
|
clock.value = 200.8
|
|
real_append_task = await h.runner._start_append(
|
|
h.runner.model.silence_unit_payload(),
|
|
final=False,
|
|
silence_continuation=False,
|
|
)
|
|
await entered.wait()
|
|
assert not real_append_task.done()
|
|
|
|
# At t+1.0 the silence is planned using the existing anchor; its append
|
|
# task waits behind the real append.
|
|
clock.value = 201.0
|
|
scheduled = await h.runner._schedule_silence_continuation(
|
|
h.runner.model.silence_unit_payload(),
|
|
**_continuation_kwargs(h),
|
|
)
|
|
assert scheduled is True
|
|
silence_task = h.runner.tasks.append_tail
|
|
assert silence_task is not None
|
|
assert not silence_task.done()
|
|
|
|
# At t+1.1 the real append is released; both appends finish.
|
|
clock.value = 201.1
|
|
release.set()
|
|
assert await real_append_task
|
|
assert await silence_task
|
|
|
|
# Exactly one additional submission happened: the real append. The
|
|
# outdated silence was skipped, so its callback never overwrote the
|
|
# new anchor (the real append's submission time t+0.8), and the
|
|
# deadline the real append cleared stays cleared.
|
|
assert len(h.port.submissions) == submissions_before + 1
|
|
assert state.last_native_submit_monotonic == pytest.approx(200.8)
|
|
assert state.silence_deadline_monotonic is None
|
|
|
|
# A fresh continuation uses the new anchor. The clock is left at t+1.1:
|
|
# the scheduler sleeps the remaining 0.7 s (deadline t+1.8), which
|
|
# verifies re-anchoring controls the wait. Following deadline t+2.8.
|
|
scheduled = await h.runner._schedule_silence_continuation(
|
|
h.runner.model.silence_unit_payload(),
|
|
**_continuation_kwargs(h),
|
|
)
|
|
assert scheduled is True
|
|
fresh_task = h.runner.tasks.append_tail
|
|
assert fresh_task is not None
|
|
assert await fresh_task
|
|
assert state.last_native_submit_monotonic == pytest.approx(201.8)
|
|
assert state.silence_deadline_monotonic == pytest.approx(202.8)
|
|
finally:
|
|
release.set()
|
|
await close_harness(h)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_long_stall_saves_future_deadline_from_actual_submission(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A submission more than one period past the planned deadline restarts.
|
|
|
|
The schedule plans the following deadline at ``planned + period``, but the
|
|
actual submission happens much later. The accepted continuation must save
|
|
``submit_time + period`` so the next unit is not immediately due.
|
|
"""
|
|
clock = FakeClock(start=300.0)
|
|
real_sleep = asyncio.sleep
|
|
stall = _install_fake_clock(monkeypatch, clock=clock, real_sleep=real_sleep)
|
|
h = await _active_response_harness()
|
|
state = h.runner.model_state
|
|
try:
|
|
# Plan a continuation whose deadline is 1.0 s in the future. The wake
|
|
# is delayed 2 s past the planned submission (anchor + 3.0 during the
|
|
# scheduler's sleep), so the append accepts 2 s late.
|
|
anchor = clock.value
|
|
state.last_native_submit_monotonic = anchor
|
|
state.silence_deadline_monotonic = None
|
|
stall[0] = 2.0
|
|
scheduled = await h.runner._schedule_silence_continuation(
|
|
h.runner.model.silence_unit_payload(),
|
|
**_continuation_kwargs(h),
|
|
)
|
|
assert scheduled is True
|
|
append_task = h.runner.tasks.append_tail
|
|
assert append_task is not None
|
|
assert await append_task
|
|
# anchor + 3.0 > anchor + 2.0 (planned deadline + period) -> save
|
|
# submit_time + period = anchor + 4.0.
|
|
assert state.last_native_submit_monotonic == pytest.approx(anchor + 3.0)
|
|
assert state.silence_deadline_monotonic == pytest.approx(anchor + 4.0)
|
|
finally:
|
|
await close_harness(h)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_small_wakeup_delay_preserves_planned_deadline(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Ordinary wakeup jitter (within one period) keeps the planned cadence.
|
|
|
|
A submission 0.2 s past the planned deadline must not reset the chain: the
|
|
stored deadline stays at ``planned + period`` so drift does not accumulate.
|
|
"""
|
|
clock = FakeClock(start=400.0)
|
|
real_sleep = asyncio.sleep
|
|
stall = _install_fake_clock(monkeypatch, clock=clock, real_sleep=real_sleep)
|
|
h = await _active_response_harness()
|
|
state = h.runner.model_state
|
|
try:
|
|
anchor = clock.value
|
|
state.last_native_submit_monotonic = anchor
|
|
state.silence_deadline_monotonic = None
|
|
stall[0] = 0.2
|
|
|
|
scheduled = await h.runner._schedule_silence_continuation(
|
|
h.runner.model.silence_unit_payload(),
|
|
**_continuation_kwargs(h),
|
|
)
|
|
assert scheduled is True
|
|
append_task = h.runner.tasks.append_tail
|
|
assert append_task is not None
|
|
assert await append_task
|
|
|
|
# Submitted at anchor + period + 0.2 (within one period): the planned
|
|
# following deadline is kept.
|
|
assert state.last_native_submit_monotonic == pytest.approx(anchor + CHUNK_PERIOD_S + 0.2)
|
|
assert state.silence_deadline_monotonic == pytest.approx(anchor + 2 * CHUNK_PERIOD_S)
|
|
finally:
|
|
await close_harness(h)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exact_one_period_late_preserves_planned_deadline(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Exactly one period late is not "more than one period late".
|
|
|
|
The strict ``>`` comparison means a submission exactly at the planned
|
|
following deadline keeps the planned cadence (no extra catch-up unit).
|
|
"""
|
|
clock = FakeClock(start=500.0)
|
|
real_sleep = asyncio.sleep
|
|
stall = _install_fake_clock(monkeypatch, clock=clock, real_sleep=real_sleep)
|
|
h = await _active_response_harness()
|
|
state = h.runner.model_state
|
|
try:
|
|
anchor = clock.value
|
|
state.last_native_submit_monotonic = anchor
|
|
state.silence_deadline_monotonic = None
|
|
stall[0] = 1.0
|
|
|
|
scheduled = await h.runner._schedule_silence_continuation(
|
|
h.runner.model.silence_unit_payload(),
|
|
**_continuation_kwargs(h),
|
|
)
|
|
assert scheduled is True
|
|
append_task = h.runner.tasks.append_tail
|
|
assert append_task is not None
|
|
assert await append_task
|
|
|
|
# Submitted exactly at the planned following deadline: kept.
|
|
assert state.last_native_submit_monotonic == pytest.approx(anchor + 2 * CHUNK_PERIOD_S)
|
|
assert state.silence_deadline_monotonic == pytest.approx(anchor + 2 * CHUNK_PERIOD_S)
|
|
finally:
|
|
await close_harness(h)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_failed_append_leaves_timing_unchanged(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A failed append never advances the timing chain."""
|
|
clock = FakeClock(start=600.0)
|
|
real_sleep = asyncio.sleep
|
|
_install_fake_clock(monkeypatch, clock=clock, real_sleep=real_sleep)
|
|
h = await _active_response_harness()
|
|
state = h.runner.model_state
|
|
try:
|
|
state.last_native_submit_monotonic = 600.0
|
|
state.silence_deadline_monotonic = 601.0
|
|
before_anchor = state.last_native_submit_monotonic
|
|
before_deadline = state.silence_deadline_monotonic
|
|
h.port.fail_submit = RuntimeError("boom")
|
|
|
|
scheduled = await h.runner._schedule_silence_continuation(
|
|
h.runner.model.silence_unit_payload(),
|
|
**_continuation_kwargs(h),
|
|
)
|
|
assert scheduled is True
|
|
append_task = h.runner.tasks.append_tail
|
|
assert append_task is not None
|
|
# The append fails: its task reports failure, and timing is untouched.
|
|
assert await append_task is False
|
|
assert state.last_native_submit_monotonic == before_anchor
|
|
assert state.silence_deadline_monotonic == before_deadline
|
|
finally:
|
|
await close_harness(h)
|