Signed-off-by: Yueqian Lin <linyueqian@outlook.com> Signed-off-by: Sy0307 <24841857+Sy0307@users.noreply.github.com> Co-authored-by: Sy0307 <24841857+Sy0307@users.noreply.github.com>
179 lines
5.9 KiB
Python
179 lines
5.9 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
"""GPU-free tests for the batched PersonaPlex slot manager (stub engine)."""
|
|
|
|
from types import SimpleNamespace
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from vllm_omni.experimental.fullduplex.personaplex.engine import FrameOutput, PrefillStep
|
|
from vllm_omni.experimental.fullduplex.personaplex.serving.batched import (
|
|
BatchedSessionManager,
|
|
SlotPhase,
|
|
)
|
|
|
|
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
|
|
|
FRAME = 4
|
|
|
|
|
|
class StubEngine:
|
|
"""Records the slot-API call sequence; echoes each row's pcm as its output."""
|
|
|
|
frame_size = FRAME
|
|
|
|
def __init__(self, batch_size: int = 4, prefill_len: int = 2) -> None:
|
|
self.config = SimpleNamespace(batch_size=batch_size)
|
|
self.prefill_len = prefill_len
|
|
self.recycled: list[int] = []
|
|
self.voice_ended: list[int] = []
|
|
self.prefill_done: list[int] = []
|
|
self.steps: list[tuple[np.ndarray, dict | None]] = []
|
|
|
|
def prefill_steps(self, persona=None):
|
|
steps = [PrefillStep("sacrifice"), PrefillStep("voice", embedding=None), PrefillStep("voice_end")]
|
|
steps += [PrefillStep("tokens", moshi_tokens=None, text_token=3, user_sine=True)] * self.prefill_len
|
|
return steps
|
|
|
|
def recycle_slot(self, b):
|
|
self.recycled.append(b)
|
|
|
|
def voice_end(self, b):
|
|
self.voice_ended.append(b)
|
|
|
|
def end_prefill(self, b):
|
|
self.prefill_done.append(b)
|
|
|
|
def step_batch(self, pcm, prefill=None):
|
|
self.steps.append((pcm.copy(), dict(prefill) if prefill else None))
|
|
return [FrameOutput(audio=pcm[b].copy(), text=None) for b in range(self.config.batch_size)]
|
|
|
|
|
|
def test_all_idle_does_not_step():
|
|
eng = StubEngine()
|
|
mgr = BatchedSessionManager(eng)
|
|
assert mgr.tick() is False
|
|
assert eng.steps == []
|
|
|
|
|
|
def test_pristine_slot_is_live_immediately():
|
|
eng = StubEngine()
|
|
mgr = BatchedSessionManager(eng)
|
|
slot, ready_now = mgr.acquire()
|
|
assert ready_now is True
|
|
assert slot.phase is SlotPhase.LIVE
|
|
assert eng.recycled == []
|
|
|
|
|
|
def test_custom_persona_prefills_and_recycles_on_tick_thread():
|
|
eng = StubEngine(prefill_len=2)
|
|
mgr = BatchedSessionManager(eng)
|
|
slot, ready_now = mgr.acquire(persona="custom")
|
|
assert ready_now is False
|
|
assert slot.phase is SlotPhase.PREFILL
|
|
assert eng.recycled == [] # deferred: reset must run between engine steps
|
|
|
|
ready = []
|
|
slot.on_ready = lambda: ready.append(True)
|
|
|
|
mgr.tick() # recycle + sacrifice
|
|
assert eng.recycled == [slot.index]
|
|
assert eng.steps[-1][1][slot.index].kind == "sacrifice"
|
|
|
|
mgr.tick() # voice frame
|
|
assert eng.steps[-1][1][slot.index].kind == "voice"
|
|
assert eng.voice_ended == []
|
|
|
|
mgr.tick() # voice_end executed BETWEEN ticks, then first tokens frame
|
|
assert eng.voice_ended == [slot.index]
|
|
assert eng.steps[-1][1][slot.index].kind == "tokens"
|
|
|
|
mgr.tick() # second tokens frame
|
|
assert not ready
|
|
mgr.tick() # prefill exhausted -> live
|
|
assert eng.prefill_done == [slot.index]
|
|
assert slot.phase is SlotPhase.LIVE
|
|
assert ready == [True]
|
|
|
|
|
|
def test_pristine_slot_after_ticking_requires_recycle():
|
|
# step_batch advances EVERY row each tick, so a pristine idle row that has
|
|
# lived through ticks has drifted from its boot prefill and must be
|
|
# recycled before a client may join it.
|
|
eng = StubEngine()
|
|
mgr = BatchedSessionManager(eng)
|
|
first, ready_now = mgr.acquire()
|
|
assert ready_now is True # claimed before any tick: truly pristine
|
|
mgr.tick()
|
|
second, ready_now2 = mgr.acquire()
|
|
assert second.index != first.index
|
|
assert ready_now2 is False
|
|
assert second.phase is SlotPhase.PREFILL
|
|
|
|
|
|
def test_used_slot_requires_recycle_even_with_default_persona():
|
|
eng = StubEngine()
|
|
mgr = BatchedSessionManager(eng)
|
|
slot, ready_now = mgr.acquire()
|
|
assert ready_now
|
|
mgr.release(slot)
|
|
slot2, ready_now2 = mgr.acquire()
|
|
assert slot2.index == slot.index
|
|
assert ready_now2 is False
|
|
assert slot2.phase is SlotPhase.PREFILL
|
|
|
|
|
|
def test_acquire_returns_none_when_full():
|
|
eng = StubEngine(batch_size=2)
|
|
mgr = BatchedSessionManager(eng)
|
|
assert mgr.acquire() is not None
|
|
assert mgr.acquire() is not None
|
|
assert mgr.acquire() is None
|
|
|
|
|
|
def test_feed_frames_epoch_guard_and_backlog_cap():
|
|
eng = StubEngine()
|
|
mgr = BatchedSessionManager(eng, max_buffered_frames=3)
|
|
slot, _ = mgr.acquire()
|
|
mgr.feed(slot, np.arange(FRAME * 2, dtype=np.float32), slot.epoch)
|
|
assert len(slot.inq) == 2
|
|
mgr.feed(slot, np.zeros(3, dtype=np.float32), slot.epoch) # partial tail buffered
|
|
assert len(slot.inq) == 2 and slot.pending.shape[0] == 3
|
|
mgr.feed(slot, np.zeros(FRAME * 5, dtype=np.float32), slot.epoch)
|
|
assert len(slot.inq) == 3 # oldest dropped beyond the cap
|
|
mgr.feed(slot, np.zeros(FRAME, dtype=np.float32), slot.epoch - 1) # stale epoch ignored
|
|
assert len(slot.inq) == 3
|
|
|
|
|
|
def test_live_slot_consumes_input_and_emits_output():
|
|
eng = StubEngine()
|
|
mgr = BatchedSessionManager(eng)
|
|
slot, _ = mgr.acquire()
|
|
got = []
|
|
slot.on_output = got.append
|
|
frame = np.full(FRAME, 0.5, dtype=np.float32)
|
|
mgr.feed(slot, frame, slot.epoch)
|
|
mgr.tick()
|
|
assert len(got) == 1
|
|
np.testing.assert_array_equal(got[0].audio, frame)
|
|
# queue drained: next tick feeds zeros
|
|
mgr.tick()
|
|
np.testing.assert_array_equal(got[1].audio, np.zeros(FRAME, dtype=np.float32))
|
|
|
|
|
|
def test_release_mid_flight_stops_emission():
|
|
eng = StubEngine()
|
|
mgr = BatchedSessionManager(eng)
|
|
slot, _ = mgr.acquire()
|
|
got = []
|
|
slot.on_output = got.append
|
|
mgr.tick()
|
|
assert len(got) == 1
|
|
mgr.release(slot)
|
|
# another slot keeps the batch non-idle
|
|
other, _ = mgr.acquire() # same slot index, now PREFILL (used)
|
|
assert other.phase is SlotPhase.PREFILL
|
|
mgr.tick()
|
|
assert len(got) == 1 # no further output for the released epoch
|