255 lines
11 KiB
Python
255 lines
11 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||
"""Startup warmup probe for duplex ``/v1/realtime``.
|
||
|
||
Moved out of ``api_server.py`` under P0.2 of #5227. The
|
||
``omni_run_server_worker`` scheduling and ``/v1/realtime`` hold-clients
|
||
preamble stay in ``api_server.py`` until P0.3.
|
||
|
||
Video-required models default to one short audio-plus-frame turn so the four
|
||
pipeline stages compile real shapes before a client is accepted. That is
|
||
separate from the per-stage JIT kernel registry, which can finish in ~0.01 s
|
||
without compiling anything.
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
import math
|
||
import struct
|
||
import time
|
||
import uuid
|
||
from io import BytesIO
|
||
|
||
import httpx
|
||
import pybase64 as base64
|
||
from vllm.logger import init_logger
|
||
|
||
logger = init_logger(__name__)
|
||
|
||
# First-request compiles (Triton mRoPE, vision pos-embed, Talker torch.compile,
|
||
# Code2Wav) landed ~30–40 s after the socket was already "ready" on a cold
|
||
# process, then the short reply still has to be spoken. Hold clients past that.
|
||
DUPLEX_WARMUP_CLIENT_WAIT_S = 180
|
||
DUPLEX_WARMUP_AUDIO_WAIT_S = 150
|
||
_WARMUP_SAMPLE_RATE_HZ = 16000
|
||
_WARMUP_AUDIO_SECONDS = 0.4
|
||
# 640x480 is divisible by Qwen3-VL's patch*merge (32) and above the
|
||
# processor's shortest_edge, so the vision tower sees a real camera-sized grid
|
||
# rather than an upscaled thumbnail.
|
||
_WARMUP_FRAME_WIDTH = 640
|
||
_WARMUP_FRAME_HEIGHT = 480
|
||
_WARMUP_INSTRUCTIONS = "启动暖机。请只用这四个字回答,不要输出 silent:暖机完成。"
|
||
|
||
|
||
def lookup_duplex_plugin(engine_client: object) -> object | None:
|
||
engine = getattr(engine_client, "engine", None)
|
||
plugin = getattr(engine, "plugin", None) if engine is not None else None
|
||
if plugin is None:
|
||
plugin = getattr(engine_client, "plugin", None)
|
||
return plugin
|
||
|
||
|
||
def startup_warmup_kind(plugin: object | None, warmup_frames: int) -> str | None:
|
||
"""Which throwaway realtime session to run before admitting clients.
|
||
|
||
``video_turn`` is one short audio chunk plus one image. It is the default
|
||
for models that require video, including when ``warmup_frames`` is 0.
|
||
``silent_frames`` is the older audio-only path and stays opt-in.
|
||
A negative ``warmup_frames`` disables both. ``None`` means do not hold clients.
|
||
"""
|
||
if warmup_frames < 0:
|
||
return None
|
||
required: object = frozenset()
|
||
if plugin is not None and hasattr(plugin, "capabilities"):
|
||
caps = plugin.capabilities(max_sessions=1)
|
||
required = getattr(caps, "required_input_modalities", frozenset())
|
||
if isinstance(required, frozenset | set) and "video" in required:
|
||
return "video_turn"
|
||
if warmup_frames > 0:
|
||
return "silent_frames"
|
||
return None
|
||
|
||
|
||
def _short_pcm_f32le_b64() -> str:
|
||
"""Non-silent PCM so Stage0 ASR actually runs.
|
||
|
||
All-zero audio is either rejected or replaced before the ASR forward, which
|
||
would leave the mRoPE kernel uncompiled.
|
||
"""
|
||
n = int(_WARMUP_SAMPLE_RATE_HZ * _WARMUP_AUDIO_SECONDS)
|
||
pcm = bytearray()
|
||
for i in range(n):
|
||
sample = 0.2 * math.sin(2.0 * math.pi * 220.0 * i / _WARMUP_SAMPLE_RATE_HZ)
|
||
pcm += struct.pack("<f", sample)
|
||
return base64.b64encode(bytes(pcm)).decode("ascii")
|
||
|
||
|
||
def _warmup_jpeg_b64() -> str:
|
||
from PIL import Image
|
||
|
||
image = Image.new("RGB", (_WARMUP_FRAME_WIDTH, _WARMUP_FRAME_HEIGHT), (255, 255, 255))
|
||
image.paste((0, 0, 0), (_WARMUP_FRAME_WIDTH // 2, 0, _WARMUP_FRAME_WIDTH, _WARMUP_FRAME_HEIGHT))
|
||
buf = BytesIO()
|
||
image.save(buf, format="JPEG", quality=70)
|
||
return base64.b64encode(buf.getvalue()).decode("ascii")
|
||
|
||
|
||
async def _warmup_duplex_realtime(app, args, warmup_frames: int) -> None:
|
||
"""Run one throwaway realtime session before real clients are admitted.
|
||
|
||
Video-required models send one short non-silent audio chunk and one image
|
||
so each stage compiles the shape a real turn uses. Audio-primary models
|
||
keep the opt-in silent-frame path. ``/v1/realtime`` holds other connections
|
||
until this finishes (see ``duplex_warmup_done``).
|
||
"""
|
||
warmup_done = getattr(app.state, "duplex_warmup_done", None)
|
||
try:
|
||
try:
|
||
import websockets
|
||
except ImportError:
|
||
logger.warning("Duplex warmup skipped: the 'websockets' package is not installed.")
|
||
return
|
||
engine_client = getattr(app.state, "engine_client", None)
|
||
plugin = lookup_duplex_plugin(engine_client) if engine_client is not None else None
|
||
kind = startup_warmup_kind(plugin, warmup_frames)
|
||
if kind is None:
|
||
return
|
||
served = getattr(args, "served_model_name", None)
|
||
if isinstance(served, list | tuple) and served:
|
||
model_name = served[0]
|
||
elif isinstance(served, str) and served:
|
||
model_name = served
|
||
else:
|
||
model_name = args.model
|
||
# One silence unit as the engine-side plugin defines it (DuplexOmniEngine.plugin).
|
||
frame_samples = int(getattr(plugin, "silence_continuation_samples", 16000))
|
||
native_append = plugin.capabilities(max_sessions=1).supports_core_resumable_request if plugin else True
|
||
from vllm_omni.clients.duplex import build_realtime_url
|
||
|
||
url = (
|
||
build_realtime_url(f"ws://127.0.0.1:{args.port}/v1/realtime", model_name, autostart=False)
|
||
+ "&vllm_omni_warmup=1"
|
||
)
|
||
if kind == "video_turn":
|
||
logger.info(
|
||
"Duplex warmup starting: one %.2f s audio chunk and one %dx%d frame.",
|
||
_WARMUP_AUDIO_SECONDS,
|
||
_WARMUP_FRAME_WIDTH,
|
||
_WARMUP_FRAME_HEIGHT,
|
||
)
|
||
else:
|
||
logger.info("Duplex warmup starting: %d silent frames of %d samples.", warmup_frames, frame_samples)
|
||
start = time.time()
|
||
# The socket is bound before uvicorn finishes starting, so a plain
|
||
# connect can be accepted and then reset. Wait for /health first.
|
||
async with httpx.AsyncClient() as http:
|
||
for _ in range(120):
|
||
try:
|
||
if (await http.get(f"http://127.0.0.1:{args.port}/health", timeout=2)).status_code == 200:
|
||
break
|
||
except httpx.HTTPError:
|
||
pass
|
||
await asyncio.sleep(1)
|
||
else:
|
||
logger.warning("Duplex warmup: /health never became ready; skipping warmup.")
|
||
return
|
||
session: dict[str, object] = {
|
||
"session_id": f"warmup-{uuid.uuid4().hex[:8]}",
|
||
"model": model_name,
|
||
"modalities": ["audio", "text"],
|
||
"input_audio_format": "pcm_f32le",
|
||
"output_audio_format": "pcm16",
|
||
"idle_timeout_s": 60,
|
||
"turn_detection": None,
|
||
"extra_body": {"auto_response": native_append},
|
||
}
|
||
if kind == "video_turn":
|
||
# The default AURA prompt answers with <|silent|> unless the user
|
||
# asked for something. This session is thrown away; force one short
|
||
# spoken reply so Talker and Code2Wav compile too. auto_response
|
||
# matches the web demo: a commit must actually start a turn.
|
||
session["instructions"] = _WARMUP_INSTRUCTIONS
|
||
session["extra_body"] = {"auto_response": True}
|
||
async with websockets.connect(url, max_size=64 * 1024 * 1024, open_timeout=10) as ws:
|
||
await ws.send(json.dumps({"type": "session.update", "session": session}))
|
||
saw_audio = False
|
||
sent = 0
|
||
|
||
async def _recv_until(predicate, timeout_s: float) -> bool:
|
||
deadline = time.monotonic() + timeout_s
|
||
while time.monotonic() < deadline:
|
||
try:
|
||
raw = await asyncio.wait_for(ws.recv(), timeout=max(0.05, deadline - time.monotonic()))
|
||
except (TimeoutError, asyncio.TimeoutError):
|
||
return False
|
||
event = json.loads(raw)
|
||
if predicate(event):
|
||
return True
|
||
return False
|
||
|
||
if not await _recv_until(lambda e: e.get("type") == "session.created", 30):
|
||
logger.warning("Duplex warmup: no session.created within 30 s; aborting warmup.")
|
||
return
|
||
if kind == "video_turn":
|
||
await ws.send(
|
||
json.dumps(
|
||
{
|
||
"type": "input_audio_buffer.append",
|
||
"audio": _short_pcm_f32le_b64(),
|
||
"format": "pcm_f32le",
|
||
"sample_rate_hz": _WARMUP_SAMPLE_RATE_HZ,
|
||
"is_speech": True,
|
||
"video_frames": [_warmup_jpeg_b64()],
|
||
}
|
||
)
|
||
)
|
||
sent = 1
|
||
else:
|
||
silence = base64.b64encode(bytes(frame_samples * 4)).decode("ascii")
|
||
while sent < warmup_frames:
|
||
await ws.send(json.dumps({"type": "input_audio_buffer.append", "audio": silence}))
|
||
sent += 1
|
||
await asyncio.sleep(0.08)
|
||
if not native_append:
|
||
commit: dict[str, object] = {"type": "input_audio_buffer.commit", "final": True}
|
||
if kind == "video_turn":
|
||
commit["create_response"] = True
|
||
await ws.send(json.dumps(commit))
|
||
# First output audio means Code2Wav ran, so the Talker compile
|
||
# ahead of it has run too. Do not wait out the whole utterance.
|
||
saw_audio = await _recv_until(
|
||
lambda e: e.get("type") == "response.output_audio.delta",
|
||
DUPLEX_WARMUP_AUDIO_WAIT_S,
|
||
)
|
||
# Close the session EXPLICITLY: a bare websocket close parks the
|
||
# session in its disconnect grace window, where it would keep
|
||
# occupying the max_sessions=1 slot and block the first client.
|
||
await ws.send(json.dumps({"type": "session.close"}))
|
||
await _recv_until(lambda e: e.get("type") == "session.closed", 10)
|
||
if kind == "video_turn":
|
||
logger.info(
|
||
"Duplex warmup finished in %.1f s (one audio+frame turn, first audio %s).",
|
||
time.time() - start,
|
||
"received" if saw_audio else "NOT received",
|
||
)
|
||
else:
|
||
logger.info(
|
||
"Duplex warmup finished in %.1f s (%d silent frames, first audio %s).",
|
||
time.time() - start,
|
||
sent,
|
||
"received" if saw_audio else "NOT received",
|
||
)
|
||
except Exception:
|
||
logger.exception("Duplex warmup failed; continuing to serve (first session pays cold-start costs).")
|
||
finally:
|
||
if warmup_done is not None:
|
||
warmup_done.set()
|
||
|
||
|
||
__all__ = [
|
||
"DUPLEX_WARMUP_CLIENT_WAIT_S",
|
||
"_warmup_duplex_realtime",
|
||
"lookup_duplex_plugin",
|
||
"startup_warmup_kind",
|
||
]
|