[Feature][MiniCPM-o] Support configurable concurrent duplex sessions (#6021)

Signed-off-by: natureofnature <wzliu@connect.hku.hk>
This commit is contained in:
NATURE authored and GitHub committed 2026-08-25 02:27:30 +00:00
1 parent 710024fa6f
commit 702a409664
15 files changed
+1647 -129

No files matched your search

+2
View File
@@ -73,6 +73,8 @@ dev = [
"FlagEmbedding==1.4.0", # for hunyuan_image3 tests
"kernels==0.14.1", # for accuracy tests comparison with diffusers output (0.15.0 incompatible with diffusers 0.38.0)
"s3tokenizer==0.3.0", # for tts test
# Provides top-level cosyvoice2; do not co-install stepaudio2-minicpmo,
# whose top-level stepaudio2 package conflicts with this distribution.
"step-audio2==1.0.0", # Step-Audio2 / MiniCPM-o 4.5 Token2Wav (flashcosyvoice / hyperpyyaml)
]
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Unit tests for Omni AR streaming-session async placeholder handling."""
@@ -64,6 +64,9 @@ def _run_resumable_segment_stop(
session: Request,
*,
session_finished: bool = False,
handle_stopped=None,
chunk_transfer_adapter=None,
inter_stage_output=None,
):
sched = MagicMock()
sched.requests = {session.request_id: session}
@@ -75,11 +78,14 @@ def _run_resumable_segment_stop(
return [42], True
sched._update_request_with_output.side_effect = stop_request
sched._handle_stopped_request.return_value = session_finished
# vLLM 0.26 returns (kv_xfer_params, ec_xfer_params); an unconfigured
# MagicMock iterates empty and fails to unpack at the call site.
sched._free_request.return_value = (None, None)
sched.chunk_transfer_adapter = None
if handle_stopped is None:
sched._handle_stopped_request.return_value = session_finished
else:
sched._handle_stopped_request.side_effect = handle_stopped
sched.chunk_transfer_adapter = chunk_transfer_adapter
sched.running = [session]
sched.waiting_for_transfer_free = set()
sched.transfer_triggered_requests = set()
@@ -105,6 +111,7 @@ def _run_resumable_segment_stop(
model_runner_output.cudagraph_stats = None
model_runner_output.req_id_to_index = {session.request_id: 0}
model_runner_output.routed_experts = None
model_runner_output.inter_stage_outputs = [inter_stage_output] if inter_stage_output is not None else None
return OmniARScheduler.update_from_output(sched, scheduler_output, model_runner_output)
@@ -160,6 +167,41 @@ def test_update_from_output_settles_in_flight_tokens() -> None:
assert session.num_in_flight_tokens == 0
def test_resumable_segment_boundary_keeps_pre_transition_send_watermark() -> None:
"""The old segment's transfer must not observe the next segment's reset.
A queued streaming update can replace the same mutable Request while the
stop output is still being handled. The connector needs the confirmed
token count from before that replacement to avoid dropping the boundary.
"""
session = _make_request()
session.status = RequestStatus.RUNNING
session.resumable = True
session.num_computed_tokens = 26
adapter = MagicMock()
adapter._confirmed_num_computed_tokens.return_value = 26
inter_stage_output = {"codes": {"audio": [7]}}
def replace_with_next_segment(request: Request) -> bool:
request.num_computed_tokens = 0
request.status = RequestStatus.WAITING
return False
_run_resumable_segment_stop(
session,
handle_stopped=replace_with_next_segment,
chunk_transfer_adapter=adapter,
inter_stage_output=inter_stage_output,
)
adapter.save_async.assert_called_once_with(
inter_stage_output,
session,
True,
confirmed_num_computed_tokens=26,
)
def test_running_decode_step_without_inter_stage_payload_does_not_raise() -> None:
"""A decode step that neither stops nor carries an inter-stage payload.
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import threading
from collections import deque
@@ -9,9 +9,10 @@ from unittest.mock import Mock, patch
import pytest
import torch
from pytest_mock import MockerFixture
from vllm.sampling_params import SamplingParams
from vllm.v1.core.sched.scheduler import Scheduler as VLLMScheduler
from vllm.v1.metrics.stats import PrefillStats, PromptTokenStats
from vllm.v1.request import RequestStatus
from vllm.v1.request import Request, RequestStatus
from vllm_omni.core.sched.omni_ar_scheduler import OmniARScheduler
from vllm_omni.core.sched.omni_generation_scheduler import OmniGenerationScheduler
@@ -337,6 +338,121 @@ def test_save_async_uses_confirmed_tokens_for_async_scheduler_watermark(build_ad
assert len(adapter._pending_save_reqs) == 1
def test_segment_boundary_starts_new_send_watermark_before_background_flush(build_adapter):
"""A queued boundary owns the end of its deduplication generation.
The next segment can start before the save thread sends the old boundary.
Sending that old task later must not erase the new segment's watermark.
"""
adapter, _ = build_adapter(stage_id=1)
request = _req("req-stream", RequestStatus.WAITING, external_req_id="ext-stream")
request.resumable = True
request.num_computed_tokens = 0
adapter.requests_num_chunks_sent["ext-stream"] = 26
adapter.save_async(
multimodal_output=None,
request=request,
is_segment_finished=True,
confirmed_num_computed_tokens=26,
)
assert len(adapter._pending_save_reqs) == 1
boundary_task = adapter._pending_save_reqs.popleft()
assert "ext-stream" not in adapter.requests_num_chunks_sent
request.num_computed_tokens = 3
adapter.save_async(
multimodal_output=None,
request=request,
is_segment_finished=False,
)
assert len(adapter._pending_save_reqs) == 1
assert adapter.requests_num_chunks_sent["ext-stream"] == 3
adapter._send_single_request(boundary_task)
assert adapter.requests_num_chunks_sent["ext-stream"] == 3
def test_background_send_uses_enqueued_request_snapshot(build_adapter):
"""A queued segment must not observe later in-place request mutations."""
adapter, _ = build_adapter(stage_id=1)
request = Request(
request_id="req-stream",
prompt_token_ids=[1, 2],
sampling_params=SamplingParams(max_tokens=8),
pooling_params=None,
resumable=True,
)
request.external_req_id = "ext-stream"
ref_audio = torch.tensor([0.1, -0.1])
request.additional_information = {"codes": {"ref": ref_audio}, "meta": {"segment": "old"}}
request.append_output_token_ids([7])
seen_requests = []
def recording_processor(**kwargs):
queued = kwargs["request"]
seen_requests.append(
(
queued.additional_information["meta"]["segment"],
list(queued.prompt_token_ids),
list(queued.output_token_ids),
list(queued.all_token_ids),
queued.additional_information["codes"]["ref"] is ref_audio,
)
)
return OmniPayloadStruct()
adapter.custom_process_next_stage_input_func = recording_processor
adapter.save_async(
multimodal_output=None,
request=request,
is_segment_finished=True,
)
request.additional_information["meta"]["segment"] = "next"
request.prompt_token_ids.append(3)
request.append_output_token_ids([8])
adapter.save_async(
multimodal_output=None,
request=request,
is_segment_finished=True,
)
request.additional_information["meta"]["segment"] = "later"
request.prompt_token_ids.append(4)
request.append_output_token_ids([9])
assert len(adapter._pending_save_reqs) == 2
first_task = adapter._pending_save_reqs.popleft()
second_task = adapter._pending_save_reqs.popleft()
assert first_task["request"].additional_information is not request.additional_information
assert first_task["request"].additional_information["meta"] is not request.additional_information["meta"]
adapter._send_single_request(first_task)
adapter._send_single_request(second_task)
assert seen_requests == [
("old", [1, 2], [7], [1, 2, 7], True),
("next", [1, 2, 3], [7, 8], [1, 2, 7, 8], True),
]
def test_save_without_custom_processor_does_not_snapshot_request(build_adapter, mocker):
adapter, _ = build_adapter(stage_id=1)
request = _req("req-direct", RequestStatus.RUNNING, external_req_id="ext-direct")
snapshot = mocker.patch.object(
adapter,
"_snapshot_processor_request",
side_effect=AssertionError("unexpected snapshot"),
)
adapter.save_async(multimodal_output=None, request=request)
snapshot.assert_not_called()
assert adapter._pending_save_reqs.popleft()["request"] is request
def test_send_single_request_terminal_chunk_still_flushes_processor(build_adapter, monkeypatch):
"""A terminal stop is not a segment boundary (#5383), but the producer-side
processor must still receive the flush signal on the terminal chunk.
@@ -1,4 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""MiniCPM-o 4.5 Daily-Omni + Seed-TTS accuracy regression coverage.
Daily-Omni settings follow the MiniCPM interleaved AV recipe that reaches
@@ -21,13 +22,25 @@ from tests.e2e.accuracy.qwen3_omni.qwen3_omni_acc_bench_core import (
build_acc_benchmark_cli_argv,
find_vllm_cli,
)
from tests.e2e.online_serving.helpers.minicpmo_4_5_duplex import SERVER_PARAMS as DUPLEX_TEST_PARAMS
from tests.e2e.online_serving.helpers.minicpmo_4_5_duplex import (
DEPLOY_CONFIG as _DUPLEX_DEPLOY_CONFIG,
)
from tests.e2e.online_serving.helpers.minicpmo_4_5_duplex import (
SERVER_PARAMS as DUPLEX_TEST_PARAMS,
)
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniServerParams
from tests.helpers.stage_config import get_deploy_config_path
from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
_MODEL = os.environ.get("VLLM_TEST_MINICPMO_4_5_MODEL", "openbmb/MiniCPM-o-4_5")
_DEPLOY_CONFIG = get_deploy_config_path("minicpmo_4_5.yaml")
_DUPLEX_BF16_DEPLOY_CONFIG = modify_stage_config(
_DUPLEX_DEPLOY_CONFIG,
updates={
"base_config": _DEPLOY_CONFIG,
"connectors.connector_of_shared_memory.extra.code2wav_bfloat16_attention_cache": True,
},
)
_RESULT_DIR = Path(
os.environ.get(
"ACC_BENCH_RESULT_DIR",
@@ -91,6 +104,20 @@ seed_test_params = [
]
duplex_accuracy_test_params = [
*DUPLEX_TEST_PARAMS,
pytest.param(
OmniServerParams(
model=_MODEL,
stage_config_path=_DUPLEX_BF16_DEPLOY_CONFIG,
use_stage_cli=False,
server_args=list(_SEED_TTS_SERVER_ARGS),
),
id="three-stage-single-gpu-bf16-attention-cache",
),
]
def _require_vllm_cli() -> None:
try:
find_vllm_cli()
@@ -174,7 +201,7 @@ def test_minicpmo_4_5_seed_tts_wer_bench(omni_server) -> None:
@hardware_test(res={"cuda": "H100"}, num_cards=1)
@pytest.mark.parametrize("omni_server", DUPLEX_TEST_PARAMS, indirect=True)
@pytest.mark.parametrize("omni_server", duplex_accuracy_test_params, indirect=True)
def test_minicpmo_4_5_duplex_seed_tts_wer_bench(omni_server) -> None:
"""Gate Seed-TTS WER through the explicit Realtime TTS contract."""
_require_vllm_cli()
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Strict MiniCPM-o 4.5 Realtime duplex E2E scenario harness.
This script is intentionally scenario-based instead of a generic chat client.
@@ -1351,6 +1354,9 @@ async def run_demo(args: argparse.Namespace) -> dict[str, object]:
try:
await ws.send(json.dumps(_session_update_event(args)))
await _wait_for(state, lambda: state.count("session.created") > 0, timeout_s=20, label="session.created")
start_barrier = getattr(args, "start_barrier", None)
if start_barrier is not None:
await start_barrier.wait()
turn_transcripts = _turn_transcripts(args.first_turn_transcript, turns=args.turns)
turn_specs = list(zip(turn_transcripts, turn_durations, strict=True))
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Concurrent MiniCPM-o Realtime duplex and resumable-session E2E driver."""
from __future__ import annotations
@@ -14,6 +17,7 @@ from types import SimpleNamespace
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
import websockets
from websockets.asyncio.client import ClientConnection
from websockets.exceptions import ConnectionClosed
SCRIPT_DIR = Path(__file__).resolve().parent
@@ -42,6 +46,67 @@ async def run_demo(args):
return await _scenario_module().run_demo(args)
class _SynchronizedStartGate:
"""One-shot, failure-propagating start gate for Python 3.10+."""
def __init__(self, parties: int, *, timeout_s: float):
if parties < 1:
raise ValueError("synchronized start parties must be positive")
if timeout_s <= 0:
raise ValueError("synchronized start timeout must be positive")
self._parties = parties
self._timeout_s = timeout_s
self._arrived = 0
self._released = False
self._failure: BaseException | None = None
self._event = asyncio.Event()
self._lock = asyncio.Lock()
async def wait(self) -> None:
async with self._lock:
if self._failure is not None:
raise RuntimeError(f"synchronized start aborted: {self._failure!r}") from self._failure
if self._released:
return
self._arrived += 1
if self._arrived == self._parties:
self._released = True
self._event.set()
return
try:
await asyncio.wait_for(self._event.wait(), timeout=self._timeout_s)
except asyncio.TimeoutError as exc:
failure = TimeoutError(
f"synchronized start timed out after {self._timeout_s:.1f}s "
f"({self._arrived}/{self._parties} sessions ready)"
)
await self.abort(failure)
if self._released and self._failure is None:
return
raise failure from exc
if self._failure is not None:
raise RuntimeError(f"synchronized start aborted: {self._failure!r}") from self._failure
async def abort(self, failure: BaseException) -> None:
async with self._lock:
if self._released or self._failure is not None:
return
self._failure = failure
self._event.set()
async def _run_demo_with_start_gate(args: SimpleNamespace):
start_gate = getattr(args, "start_barrier", None)
try:
return await run_demo(args)
except BaseException as exc:
if start_gate is not None:
await start_gate.abort(exc)
raise
def _with_resume_mode(url: str) -> str:
parts = urlsplit(url)
query = dict(parse_qsl(parts.query, keep_blank_values=True))
@@ -101,14 +166,24 @@ def _validate_semantic_isolation(
return True
if len(expected_tokens) != len(results):
return False
for result, expected_token in zip(results, expected_tokens, strict=True):
normalized_expected_tokens = [token.strip().casefold() for token in expected_tokens]
if any(not token for token in normalized_expected_tokens):
return False
for result_index, (result, expected_token) in enumerate(zip(results, normalized_expected_tokens, strict=True)):
details = result.get("transcript_integrity")
transcripts = (
[str(item.get("transcript", "")) for item in details if isinstance(item, dict)]
if isinstance(details, list)
else []
)
if expected_token not in "".join(transcripts):
joined_transcripts = "".join(transcripts).casefold()
if expected_token not in joined_transcripts:
return False
if any(
other_token in joined_transcripts
for other_index, other_token in enumerate(normalized_expected_tokens)
if other_index != result_index
):
return False
return True
@@ -130,10 +205,14 @@ async def _receive_until(ws, event_type: str, *, timeout_s: float) -> tuple[dict
return await asyncio.wait_for(receive(), timeout=timeout_s)
def _server_event_sequences(events: list[dict[str, object]]) -> list[int]:
return [sequence for event in events if isinstance(sequence := event.get("server_event_seq"), int)]
async def _open_admission_session(
args: argparse.Namespace,
session_id: str,
) -> tuple[object, dict[str, object]]:
) -> tuple[ClientConnection, dict[str, object]]:
url = _url_with_model(
args.url,
args.model,
@@ -159,7 +238,7 @@ async def _open_admission_session(
return ws, created
async def _close_admission_session(ws, *, timeout_s: float) -> None:
async def _close_admission_session(ws: ClientConnection, *, timeout_s: float) -> None:
await ws.send(json.dumps({"type": "session.close"}))
await _receive_until(ws, "session.closed", timeout_s=timeout_s)
await ws.close()
@@ -169,8 +248,8 @@ async def _admission_probe(args: argparse.Namespace, *, limit: int) -> dict[str,
if limit < 1:
raise ValueError("admission limit must be positive")
prefix = f"admission-{uuid.uuid4().hex}"
accepted: list[tuple[object, dict[str, object]]] = []
replacement: tuple[object, dict[str, object]] | None = None
accepted: list[tuple[ClientConnection, dict[str, object]]] = []
replacement: tuple[ClientConnection, dict[str, object]] | None = None
overflow_code = None
try:
for index in range(limit):
@@ -274,14 +353,7 @@ async def _resume_probe(
generation = created.get("attachment_generation")
if not isinstance(token, str) or not isinstance(incarnation, int):
raise RuntimeError("session.created omitted resumable credentials")
last_seq = max(
(
event.get("server_event_seq", 0)
for event in first_events
if isinstance(event.get("server_event_seq"), int)
),
default=0,
)
last_seq = max(_server_event_sequences(first_events), default=0)
delay_s = args.expire_after_s if expect_expired else args.resume_after_ms / 1000
if delay_s > 0:
@@ -324,7 +396,7 @@ async def _resume_probe(
await second.send(json.dumps({"type": "session.close"}))
closed, close_events = await _receive_until(second, "session.closed", timeout_s=args.timeout_s)
replay_sequences = [event["server_event_seq"] for event in replay if isinstance(event.get("server_event_seq"), int)]
replay_sequences = _server_event_sequences(replay)
return {
"ok": (
resumed.get("session_id") == session_id
@@ -386,14 +458,7 @@ async def _takeover_probe(
generation = created.get("attachment_generation")
if not isinstance(token, str) or not isinstance(incarnation, int) or not isinstance(generation, int):
raise RuntimeError("session.created omitted takeover credentials")
last_seq = max(
(
event.get("server_event_seq", 0)
for event in first_events
if isinstance(event.get("server_event_seq"), int)
),
default=0,
)
last_seq = max(_server_event_sequences(first_events), default=0)
second = await websockets.connect(_with_resume_mode(url), max_size=64 * 1024 * 1024)
await second.send(
@@ -457,7 +522,9 @@ async def _takeover_probe(
await first.close()
def _demo_args(args: argparse.Namespace, index: int) -> SimpleNamespace:
def _demo_args(
args: argparse.Namespace, index: int, start_barrier: _SynchronizedStartGate | None = None
) -> SimpleNamespace:
validation_mode = "response-required" if args.response_required else "model-policy"
input_wav = args.session_input_wav[index] if args.session_input_wav else args.input_wav
return SimpleNamespace(
@@ -477,14 +544,16 @@ def _demo_args(args: argparse.Namespace, index: int) -> SimpleNamespace:
omit_transcript_hints=True,
validation_mode=validation_mode,
temperature=args.temperature,
scenario="sequential",
scenario=getattr(args, "scenario", "sequential"),
silence_ms=getattr(args, "silence_ms", 500),
require_audio=args.response_required,
require_distinct_inputs=False,
expect_empty_turn=[],
expect_empty_turn=list(getattr(args, "expect_empty_turn", []) or []),
short_ack_ms=350,
turns=args.turns,
timeout_s=args.timeout_s,
model_policy_settle_ms=args.model_policy_settle_ms,
start_barrier=start_barrier,
)
@@ -511,8 +580,16 @@ async def run_multi_session(args: argparse.Namespace) -> dict[str, object]:
)
lifecycle_result = await run_lifecycle_probes(args)
start_barrier = (
_SynchronizedStartGate(
args.sessions,
timeout_s=min(float(args.timeout_s), 30.0),
)
if getattr(args, "synchronized_start", False)
else None
)
session_results = await asyncio.gather(
*(run_demo(_demo_args(args, index)) for index in range(args.sessions)),
*(_run_demo_with_start_gate(_demo_args(args, index, start_barrier)) for index in range(args.sessions)),
return_exceptions=True,
)
failures = [repr(result) for result in session_results if isinstance(result, BaseException)]
@@ -596,7 +673,14 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--turns", type=int, default=1)
parser.add_argument("--first-turn-ms", type=int, default=1400)
parser.add_argument("--turn-duration-ms", type=int, action="append", default=[])
parser.add_argument("--expect-empty-turn", type=int, action="append", default=[])
parser.add_argument("--response-required", action="store_true")
parser.add_argument(
"--scenario",
choices=["sequential", "listen-only-overlap"],
default="sequential",
)
parser.add_argument("--silence-ms", type=int, default=500)
parser.add_argument("--temperature", type=float, default=None)
parser.add_argument("--disconnect-session-index", type=int)
parser.add_argument("--takeover-session-index", type=int)
@@ -604,6 +688,11 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--expire-session-index", type=int)
parser.add_argument("--expire-after-s", type=float, default=6.0)
parser.add_argument("--verify-admission-limit", type=int)
parser.add_argument(
"--synchronized-start",
action="store_true",
help="Wait until every session is created before starting input.",
)
parser.add_argument("--model-policy-settle-ms", type=int, default=600)
parser.add_argument("--timeout-s", type=float, default=120.0)
args = parser.parse_args()
@@ -615,6 +704,13 @@ def parse_args() -> argparse.Namespace:
parser.error("provide exactly one --session-expected-token per session")
if args.session_expected_token and not args.session_input_wav:
parser.error("--session-expected-token requires --session-input-wav")
normalized_expected_tokens = [token.strip().casefold() for token in args.session_expected_token]
for token_index, token in enumerate(normalized_expected_tokens):
if any(
token in other_token or other_token in token
for other_token in normalized_expected_tokens[token_index + 1 :]
):
parser.error("--session-expected-token values must not overlap after normalization")
return args
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import asyncio
import base64
import importlib.util
@@ -758,6 +761,34 @@ def test_realtime_duplex_multi_session_rejects_cross_session_response_identity()
)
def test_realtime_duplex_multi_session_rejects_overlapping_expected_tokens(monkeypatch, capsys):
demo = _load_multi_demo_module()
monkeypatch.setattr(
demo.sys,
"argv",
[
"multi.py",
"--sessions",
"2",
"--input-wav",
"fallback.wav",
"--session-input-wav",
"a.wav",
"--session-input-wav",
"b.wav",
"--session-expected-token",
"cat",
"--session-expected-token",
"cater",
],
)
with pytest.raises(SystemExit):
demo.parse_args()
assert "must not overlap after normalization" in capsys.readouterr().err
def test_realtime_duplex_multi_session_reads_nested_terminal_identity():
demo = _load_multi_demo_module()
@@ -768,6 +799,67 @@ def test_realtime_duplex_multi_session_reads_nested_terminal_identity():
)
def test_realtime_duplex_synchronized_start_gate_releases_all_participants():
demo = _load_multi_demo_module()
async def exercise():
gate = demo._SynchronizedStartGate(3, timeout_s=0.5)
released = []
async def participant(index):
await gate.wait()
released.append(index)
await asyncio.wait_for(
asyncio.gather(*(participant(index) for index in range(3))),
timeout=1.0,
)
return released
assert set(asyncio.run(exercise())) == {0, 1, 2}
def test_realtime_duplex_synchronized_start_gate_aborts_waiters_on_failure(monkeypatch):
demo = _load_multi_demo_module()
async def fake_run_demo(args):
if args.fail:
raise RuntimeError("session creation failed")
await args.start_barrier.wait()
return {"ok": True}
monkeypatch.setattr(demo, "run_demo", fake_run_demo)
async def exercise():
gate = demo._SynchronizedStartGate(2, timeout_s=0.5)
return await asyncio.wait_for(
asyncio.gather(
demo._run_demo_with_start_gate(SimpleNamespace(start_barrier=gate, fail=True)),
demo._run_demo_with_start_gate(SimpleNamespace(start_barrier=gate, fail=False)),
return_exceptions=True,
),
timeout=1.0,
)
failed, aborted = asyncio.run(exercise())
assert isinstance(failed, RuntimeError)
assert str(failed) == "session creation failed"
assert isinstance(aborted, RuntimeError)
assert "synchronized start aborted" in str(aborted)
assert "session creation failed" in str(aborted)
def test_realtime_duplex_synchronized_start_gate_has_bounded_wait():
demo = _load_multi_demo_module()
async def exercise():
gate = demo._SynchronizedStartGate(2, timeout_s=0.01)
await gate.wait()
with pytest.raises(TimeoutError, match=r"1/2 sessions ready"):
asyncio.run(exercise())
def test_realtime_duplex_demo_resolves_distinct_turn_inputs():
demo = _load_demo_module()
primary = Path("first.wav")
@@ -1,3 +1,9 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import subprocess
import sys
from contextlib import contextmanager
from pathlib import Path
from types import SimpleNamespace
@@ -5,8 +11,10 @@ import pytest
import torch
import torch.nn as nn
import vllm_omni.model_executor.models.minicpmo_4_5.batched_token2wav as batched_token2wav_module
from vllm_omni.model_executor.models.minicpmo_4_5.batched_token2wav import (
BatchedToken2Wav,
_token2wav_sdpa_context,
plan_token2wav_encode_slices,
relpos_encode_token_budget,
)
@@ -52,6 +60,9 @@ class _FakeEstimator(nn.Module):
self.blocks = [_FakeBlock()]
self.cfg_batches: list[int] = []
self.speaker_order: list[list[float]] = []
self.attention_cache_dtypes: list[torch.dtype | None] = []
self.register_buffer("att_cache_buffer", torch.ones(1), persistent=False)
self.register_buffer("cnn_cache_buffer", torch.ones(1), persistent=False)
def t_embedder(self, time):
return time[:, None]
@@ -66,7 +77,8 @@ class _FakeEstimator(nn.Module):
cnn_out,
att_out,
):
del time, mask, cnn_cache, att_cache
del time, mask, cnn_cache
self.attention_cache_dtypes.append(att_cache.dtype if isinstance(att_cache, torch.Tensor) else None)
self.cfg_batches.append(inputs.shape[0])
self.speaker_order.append(inputs[:, 2, 0].tolist())
marker = inputs[:, 1, 0]
@@ -100,16 +112,17 @@ class _FakeHiFT(nn.Module):
super().__init__()
self.calls: list[int] = []
def forward(self, mel, source):
def forward(self, batch, device):
mel = batch["speech_feat"].transpose(1, 2).to(device)
return self.inference(mel, mel.new_zeros((mel.shape[0], 1, 0)))
def inference(self, mel, source):
del source
self.calls.append(mel.shape[0])
speech = mel[:, 0].repeat_interleave(3, dim=1)
generated_source = speech[:, None]
return speech, generated_source
def inference(self, mel, source):
return self.forward(mel, source)
class _FakeToken2Wav:
def __init__(self):
@@ -140,13 +153,14 @@ class _FakeToken2Wav:
raise AssertionError("sequential __call__ fallback must never be called")
def _config(minimum: int = 1):
def _config(minimum: int = 1, initial: int = 0):
return SimpleNamespace(
model_config=SimpleNamespace(
model="/fake/model",
stage_connector_config={
"extra": {
"code2wav_min_batch_size": minimum,
"code2wav_initial_batch_size": initial,
"prompt_cache_id": "shared",
"prompt_wav": "/fake/prompt.wav",
}
@@ -155,14 +169,45 @@ def _config(minimum: int = 1):
)
def _model():
def _model(initial: int = 0, minimum: int = 1):
token2wav = _FakeToken2Wav()
backend = BatchedToken2Wav(token2wav)
model = MiniCPMO45Code2Wav(vllm_config=_config())
_enable_fake_ragged_kernel(backend)
model = MiniCPMO45Code2Wav(vllm_config=_config(minimum=minimum, initial=initial))
model.backend = backend
return model, token2wav
def _enable_fake_ragged_kernel(adapter: BatchedToken2Wav) -> None:
"""Keep fake-model tests focused on ragged grouping and row mapping."""
estimator = adapter.flow.decoder.estimator
estimator.in_proj = nn.Identity()
def fake_ragged_kernel(
estimator,
estimator_input,
time_embedding,
attn_mask,
cnn_cache,
att_cache,
cnn_cache_buffer,
att_cache_buffer,
valid_lengths,
):
del valid_lengths
return estimator.blocks_forward_chunk(
estimator_input,
time_embedding,
attn_mask,
cnn_cache,
att_cache,
cnn_cache_buffer,
att_cache_buffer,
)
adapter._blocks_forward_chunk_ragged = fake_ragged_kernel # type: ignore[method-assign]
def _info(
request_id: str,
chunk_seq: int,
@@ -256,6 +301,400 @@ def test_decode_batch_splits_overlong_prefill_inside_relpos_budget():
assert audios[0].numel() > 0
def test_ragged_overlong_prefill_uses_relpos_safe_exact_slices():
token2wav = _FakeToken2Wav()
token2wav.flow.encoder.pre_lookahead_layer = SimpleNamespace(pre_lookahead_len=3)
adapter = BatchedToken2Wav(token2wav)
_enable_fake_ragged_kernel(adapter)
prompt = adapter.prepare_prompt("shared", "/fake/prompt.wav")
states = adapter.setup_batch(prompt, 2)
setup_encodes = len(token2wav.flow.encoder.calls)
adapter._max_encode_token_frames = lambda _states: 4
tokens = [
torch.arange(10, dtype=torch.long),
torch.arange(4, dtype=torch.long),
]
last_chunks = [True, False]
audios, next_states = adapter.decode_ragged_batch(
tokens,
prompt,
states,
last_chunks=last_chunks,
)
assert token2wav.flow.encoder.calls[setup_encodes:] == [1, 1, 1, 1]
assert token2wav.flow.encoder.last_chunk_calls[setup_encodes:] == [False, False, True, False]
assert len(audios) == len(next_states) == 2
assert all(audio.numel() > 0 for audio in audios)
for row, (row_tokens, last_chunk) in enumerate(zip(tokens, last_chunks, strict=True)):
reference_token2wav = _FakeToken2Wav()
reference_token2wav.flow.encoder.pre_lookahead_layer = SimpleNamespace(pre_lookahead_len=3)
reference_adapter = BatchedToken2Wav(reference_token2wav)
reference_prompt = reference_adapter.prepare_prompt("shared", "/fake/prompt.wav")
reference_states = reference_adapter.setup_batch(reference_prompt, 1)
reference_adapter._max_encode_token_frames = lambda _states: 4
reference_audio, reference_state = reference_adapter.decode_batch(
row_tokens.unsqueeze(0),
reference_prompt,
reference_states,
last_chunk=last_chunk,
)
torch.testing.assert_close(audios[row], reference_audio[0])
for cache_name, expected in reference_state[0].flow_cache.items():
torch.testing.assert_close(
next_states[row].flow_cache[cache_name],
expected,
)
for cache_name, expected in reference_state[0].hift_cache.items():
torch.testing.assert_close(
next_states[row].hift_cache[cache_name],
expected,
)
def test_ragged_diffusion_batches_eight_rows_and_preserves_exact_results():
token2wav = _FakeToken2Wav()
adapter = BatchedToken2Wav(token2wav)
_enable_fake_ragged_kernel(adapter)
prompt = adapter.prepare_prompt("shared", "/fake/prompt.wav")
states = adapter.setup_batch(prompt, 8)
token2wav.flow.encoder.calls.clear()
token2wav.flow.decoder.estimator.cfg_batches.clear()
token2wav.hift.calls.clear()
tokens = [
*(torch.tensor([10 + row, 11 + row, 12 + row]) for row in range(6)),
torch.tensor([20, 21]),
torch.tensor([30, 31]),
]
last_chunks = [False] * 6 + [True, True]
audios, next_states = adapter.decode_ragged_batch(
tokens,
prompt,
states,
last_chunks=last_chunks,
)
assert token2wav.flow.encoder.calls == [6, 2]
assert token2wav.flow.decoder.estimator.cfg_batches == [16, 16]
assert token2wav.hift.calls == [6, 2]
assert len(audios) == len(next_states) == 8
for row, (row_tokens, last_chunk) in enumerate(zip(tokens, last_chunks, strict=True)):
reference_adapter = BatchedToken2Wav(_FakeToken2Wav())
reference_prompt = reference_adapter.prepare_prompt("shared", "/fake/prompt.wav")
reference_states = reference_adapter.setup_batch(reference_prompt, 1)
reference_audio, reference_state = reference_adapter.decode_batch(
row_tokens.unsqueeze(0),
reference_prompt,
reference_states,
last_chunk=last_chunk,
)
torch.testing.assert_close(audios[row], reference_audio[0])
for cache_name, expected in reference_state[0].flow_cache.items():
torch.testing.assert_close(
next_states[row].flow_cache[cache_name],
expected,
)
for cache_name, expected in reference_state[0].hift_cache.items():
torch.testing.assert_close(
next_states[row].hift_cache[cache_name],
expected,
)
def test_npu_patch_preserves_ragged_estimator_contract():
script = r"""
import torch
from tests.model_executor.models.minicpmo_4_5.test_code2wav_batching import (
_FakeToken2Wav,
_enable_fake_ragged_kernel,
)
from vllm_omni.model_executor.models.minicpmo_4_5.batched_token2wav import BatchedToken2Wav
import vllm_omni.platforms.npu.models.minicpmo_4_5_code2wav as npu_patch
class _FailGraphRunner:
def run(self, *args, **kwargs):
raise AssertionError("ragged decode must bypass the exact-shape NPU Graph")
npu_patch.apply_minicpmo_4_5_code2wav_patch()
adapter = BatchedToken2Wav(_FakeToken2Wav())
_enable_fake_ragged_kernel(adapter)
prompt = adapter.prepare_prompt("shared", "/fake/prompt.wav")
states = adapter.setup_batch(prompt, 2)
npu_patch._backend_graph_runners[adapter] = _FailGraphRunner()
audios, next_states = adapter.decode_ragged_batch(
[
torch.tensor([10, 11, 12]),
torch.tensor([20, 21]),
],
prompt,
states,
last_chunks=[False, True],
)
assert len(audios) == len(next_states) == 2
"""
completed = subprocess.run(
[sys.executable, "-c", script],
cwd=Path(__file__).resolve().parents[4],
capture_output=True,
text=True,
)
assert completed.returncode == 0, completed.stderr
def test_decode_cfm_enters_platform_sdpa_context(monkeypatch):
entered: list[str] = []
@contextmanager
def recording_context():
entered.append("enter")
yield
entered.append("exit")
monkeypatch.setattr(
batched_token2wav_module,
"_token2wav_sdpa_context",
lambda _device: recording_context(),
)
adapter = BatchedToken2Wav(_FakeToken2Wav())
_enable_fake_ragged_kernel(adapter)
adapter._decode_cfm(
torch.ones((2, 1, 2)),
torch.ones((2, 1)),
torch.zeros((2, 1, 2)),
cnn_cache=None,
att_cache=None,
valid_lengths=[2, 1],
)
assert entered == ["enter", "exit"]
def test_ragged_decode_bypasses_exact_shape_accelerators():
adapter = BatchedToken2Wav(_FakeToken2Wav())
_enable_fake_ragged_kernel(adapter)
class ForbiddenAccelerator:
def replay(self, *args):
pytest.fail("ragged decode must not use the exact-shape CFM graph")
def step(self, **kwargs):
pytest.fail("ragged decode must not use the exact-shape TensorRT stepper")
accelerator = ForbiddenAccelerator()
adapter._cfm_graph_wrapper = accelerator
adapter._trt_stepper = accelerator
adapter._decode_cfm(
torch.ones((2, 1, 2)),
torch.ones((2, 1)),
torch.zeros((2, 1, 2)),
cnn_cache=None,
att_cache=None,
valid_lengths=[2, 1],
)
def test_ragged_decode_fails_when_estimator_has_no_ragged_kernel():
adapter = BatchedToken2Wav(_FakeToken2Wav())
with pytest.raises(
RuntimeError,
match=r'"reason":"ragged_kernel_unavailable"',
):
adapter._decode_cfm(
torch.ones((2, 1, 2)),
torch.ones((2, 1)),
torch.zeros((2, 1, 2)),
cnn_cache=None,
att_cache=None,
valid_lengths=[2, 1],
)
def test_cfm_graph_receives_bfloat16_cache_in_compute_dtype():
adapter = BatchedToken2Wav(_FakeToken2Wav(), bfloat16_attention_cache=True)
seen_dtypes: list[torch.dtype] = []
class RecordingGraph:
def replay(self, estimator_input, time_embedding, cnn_cache, att_cache, cnn_out, att_out):
del time_embedding, cnn_cache
seen_dtypes.append(att_cache.dtype)
return estimator_input[:, :1], cnn_out, att_out
adapter._cfm_graph_wrapper = RecordingGraph()
adapter._estimator_step(
adapter.flow.decoder.estimator,
x=torch.ones((2, 1, 2)),
mu=torch.ones((2, 1, 2)),
time=torch.zeros(2),
speakers=torch.ones((2, 1)),
cond=torch.zeros((2, 1, 2)),
cnn_cache=torch.zeros((1, 2, 2, 1)),
att_cache=torch.zeros((1, 2, 1, 1, 2), dtype=torch.bfloat16),
)
assert seen_dtypes == [torch.float32]
def test_npu_sdpa_context_uses_platform_patch(monkeypatch):
entered: list[str] = []
@contextmanager
def recording_context():
entered.append("enter")
yield
entered.append("exit")
monkeypatch.setitem(
sys.modules,
"vllm_omni.platforms.npu.models.step_audio2_token2wav",
SimpleNamespace(npu_token2wav_sdpa_context=recording_context),
)
with _token2wav_sdpa_context(SimpleNamespace(type="npu")):
assert entered == ["enter"]
assert entered == ["enter", "exit"]
def test_ragged_outputs_fail_closed_on_middle_hole():
adapter = BatchedToken2Wav(_FakeToken2Wav())
prompt = adapter.prepare_prompt("shared", "/fake/prompt.wav")
states = adapter.setup_batch(prompt, 3)
audios: list[torch.Tensor | None] = [
torch.zeros(1),
None,
torch.ones(1),
]
next_states = [states[0], None, states[2]]
with pytest.raises(
RuntimeError,
match=r'"reason":"incomplete_ragged_output","audio_rows":\[1\],"state_rows":\[1\]',
):
adapter._require_complete_ragged_outputs(audios, next_states)
@pytest.mark.parametrize(
"bfloat16_attention_cache",
[False, True],
ids=["fp32-cache", "bf16-cache"],
)
def test_ragged_dit_two_step_decode_matches_per_row_exact_decode(
bfloat16_attention_cache: bool,
):
from cosyvoice2.flow.decoder_dit import DiT
torch.manual_seed(17)
template = DiT(
in_channels=4,
out_channels=1,
depth=2,
num_heads=2,
head_dim=2,
hidden_size=4,
).eval()
template_state = {name: value.detach().clone() for name, value in template.state_dict().items()}
rand_noise = torch.randn(1, 1, 32)
def make_adapter() -> BatchedToken2Wav:
token2wav = _FakeToken2Wav()
estimator = DiT(
in_channels=4,
out_channels=1,
depth=2,
num_heads=2,
head_dim=2,
hidden_size=4,
).eval()
estimator.load_state_dict(template_state)
token2wav.flow.decoder.estimator = estimator
token2wav.flow.decoder.rand_noise = rand_noise.clone()
return BatchedToken2Wav(
token2wav,
bfloat16_attention_cache=bfloat16_attention_cache,
)
ragged_adapter = make_adapter()
ragged_prompt = ragged_adapter.prepare_prompt("shared", "/fake/prompt.wav")
ragged_states = ragged_adapter.setup_batch(ragged_prompt, 2)
exact_adapters = [make_adapter(), make_adapter()]
exact_prompts = [adapter.prepare_prompt("shared", "/fake/prompt.wav") for adapter in exact_adapters]
exact_states = [
adapter.setup_batch(prompt, 1) for adapter, prompt in zip(exact_adapters, exact_prompts, strict=True)
]
tolerance = (2e-2, 2e-3) if bfloat16_attention_cache else (1e-5, 1e-6)
steps = [
(
[torch.tensor([10, 11, 12]), torch.tensor([20, 21, 22])],
[False, True],
),
(
[torch.tensor([13, 14]), torch.tensor([22, 23, 24])],
[True, True],
),
]
first_step_history_width: int | None = None
for step, (tokens, last_chunks) in enumerate(steps):
if step == 1:
assert first_step_history_width is not None
assert all(
int(state.flow_cache["estimator_att_cache"].shape[4]) == first_step_history_width
for state in ragged_states
)
ragged_audios, ragged_states = ragged_adapter.decode_ragged_batch(
tokens,
ragged_prompt,
ragged_states,
last_chunks=last_chunks,
)
for row, (adapter, prompt) in enumerate(zip(exact_adapters, exact_prompts, strict=True)):
exact_audios, exact_states[row] = adapter.decode_batch(
tokens[row].unsqueeze(0),
prompt,
exact_states[row],
last_chunk=last_chunks[row],
)
torch.testing.assert_close(
ragged_audios[row],
exact_audios[0],
rtol=tolerance[0],
atol=tolerance[1],
)
for cache_name, expected in exact_states[row][0].flow_cache.items():
actual = ragged_states[row].flow_cache[cache_name]
assert actual.dtype == expected.dtype
torch.testing.assert_close(
actual,
expected,
rtol=tolerance[0],
atol=tolerance[1],
)
for cache_name, expected in exact_states[row][0].hift_cache.items():
torch.testing.assert_close(
ragged_states[row].hift_cache[cache_name],
expected,
rtol=tolerance[0],
atol=tolerance[1],
)
estimator_att = ragged_states[0].flow_cache["estimator_att_cache"]
assert estimator_att.shape[4] > 0
if step == 0:
first_step_history_width = int(estimator_att.shape[4])
else:
assert first_step_history_width is not None
assert int(estimator_att.shape[4]) > first_step_history_width
def test_fade_in_out_limits_overlap_to_available_previous_audio():
speech = torch.arange(6, dtype=torch.float32).reshape(1, -1)
previous = torch.full((1, 3), 2.0)
@@ -295,6 +734,69 @@ def test_estimator_cache_stack_split_round_trip_preserves_cfg_rows():
)
def test_bfloat16_estimator_attention_cache_materializes_only_current_timestep():
token2wav = _FakeToken2Wav()
adapter = BatchedToken2Wav(token2wav, bfloat16_attention_cache=True)
prompt = adapter.prepare_prompt("shared", "/fake/prompt.wav")
states = adapter.setup_batch(prompt, 2)
assert all(state.flow_cache["estimator_att_cache"].dtype == torch.bfloat16 for state in states)
assert (
states[0].flow_cache["estimator_att_cache"].data_ptr() != states[1].flow_cache["estimator_att_cache"].data_ptr()
)
stacked = adapter._stack_flow_cache(states)
assert stacked["estimator_att_cache"].dtype == torch.bfloat16
restored = adapter._split_flow_cache(stacked, 2)
for original, round_tripped in zip(states, restored, strict=True):
assert round_tripped["estimator_att_cache"].dtype == torch.bfloat16
torch.testing.assert_close(
round_tripped["estimator_att_cache"],
original.flow_cache["estimator_att_cache"],
)
_, next_states = adapter.decode_batch(
torch.tensor([[10, 11], [20, 21]]),
prompt,
states,
last_chunk=False,
)
assert all(state.flow_cache["estimator_att_cache"].dtype == torch.bfloat16 for state in next_states)
assert token2wav.flow.decoder.estimator.attention_cache_dtypes[-2:] == [torch.float32, torch.float32]
def test_bfloat16_estimator_attention_cache_materializes_each_timestep():
adapter = BatchedToken2Wav(_FakeToken2Wav(), bfloat16_attention_cache=True)
_, estimator_cnn, estimator_att = adapter._decode_cfm(
torch.tensor([[[1.0, 2.0]], [[3.0, 4.0]]]),
torch.ones((2, 1)),
torch.zeros((2, 1, 2)),
cnn_cache=None,
att_cache=None,
)
assert estimator_cnn.dtype == torch.float32
assert estimator_att.dtype == torch.bfloat16
assert estimator_att.shape[:3] == (2, 1, 4)
def test_float32_estimator_attention_cache_materializes_each_timestep():
adapter = BatchedToken2Wav(_FakeToken2Wav())
_, estimator_cnn, estimator_att = adapter._decode_cfm(
torch.tensor([[[1.0, 2.0]], [[3.0, 4.0]]]),
torch.ones((2, 1)),
torch.zeros((2, 1, 2)),
cnn_cache=None,
att_cache=None,
)
assert estimator_cnn.dtype == torch.float32
assert estimator_att.dtype == torch.float32
assert estimator_att.shape[:3] == (2, 1, 4)
def test_model_preserves_output_slots_and_prefers_runtime_codes():
model, token2wav = _model()
output = _forward(
@@ -386,6 +888,28 @@ def test_initial_empty_segment_marker_initializes_stream_without_audio():
assert "duplex" in model._states
def test_initial_empty_segment_markers_respect_initial_batch_limit():
model, token2wav = _model(initial=2)
names = ["a", "b", "c", "d", "e"]
boundaries = []
for name in names:
boundary = _info(name, 0, [])
boundary["meta"].update(
{
"code_flat_numel": 0,
"tts_is_last_chunk": True,
"turn_end": False,
}
)
boundaries.append(boundary)
output = _forward(model, boundaries)
assert token2wav.flow.encoder.calls == [2, 2, 1]
assert [audio.numel() for audio in output.multimodal_outputs["model_outputs"]] == [0] * len(names)
assert set(model._states) == set(names)
def test_shared_runtime_prompt_recreates_missing_file_before_second_owner(tmp_path, monkeypatch):
monkeypatch.setattr("tempfile.gettempdir", lambda: str(tmp_path))
model, _ = _model()
@@ -647,11 +1171,56 @@ def test_singleton_and_mixed_shape_buckets_use_same_batched_backend_without_fall
assert token2wav.hift.calls[-2:] == [1, 1]
def test_initial_batch_limit_allows_later_full_batch():
model, token2wav = _model(initial=4)
names = [f"request-{index}" for index in range(8)]
for chunk_seq in (0, 1):
infos = [_info(name, chunk_seq, [1, 2]) for name in names]
_forward(model, infos)
assert token2wav.hift.calls[-2:] == [4, 4]
token2wav.hift.calls.clear()
_forward(model, [_info(name, 2, [1, 2]) for name in names])
assert token2wav.hift.calls == [8]
def test_initial_batch_partition_preserves_minimum():
model, token2wav = _model(initial=4, minimum=3)
names = [f"request-{index}" for index in range(6)]
_forward(model, [_info(name, 0, [1, 2]) for name in names])
assert token2wav.hift.calls == [3, 3]
def test_initial_batch_partition_rejects_impossible_remainder():
model, _ = _model(initial=4, minimum=3)
names = [f"request-{index}" for index in range(5)]
with pytest.raises(RuntimeError, match="initial_batch_partition_below_minimum"):
_forward(model, [_info(name, 0, [1, 2]) for name in names])
@pytest.mark.parametrize("chunk_seqs", [[0, 2, 2, 2], [0, 0, 0, 2]])
def test_initial_steady_partition_rejects_undersized_wave(chunk_seqs):
model, _ = _model(initial=4, minimum=3)
bucket = [SimpleNamespace(chunk_seq=chunk_seq) for chunk_seq in chunk_seqs]
with pytest.raises(RuntimeError, match="decode_wave_below_minimum"):
list(model._iter_decode_batches([bucket]))
def test_backend_failure_does_not_commit_any_request_state(monkeypatch):
model, _ = _model()
_forward(
model,
[_info(name, 0, [index + 1, index + 2]) for index, name in enumerate(("a", "b", "c", "d"))],
[
_info("a", 0, [1, 2], cache_epoch=0),
_info("b", 0, [2, 3], cache_epoch=0),
_info("c", 0, [3, 4], cache_epoch=1),
_info("d", 0, [4, 5], cache_epoch=1),
],
)
before = dict(model._states)
original = model.backend.decode_batch
@@ -671,8 +1240,8 @@ def test_backend_failure_does_not_commit_any_request_state(monkeypatch):
[
_info("a", 1, [5, 6]),
_info("b", 1, [7, 8]),
_info("c", 1, [9, 10, 11]),
_info("d", 1, [12, 13, 14]),
_info("c", 1, [9, 10], cache_epoch=1),
_info("d", 1, [12, 13], cache_epoch=1),
],
)
assert call_count == 2
+43 -1
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from contextlib import contextmanager
from types import SimpleNamespace
@@ -196,6 +196,7 @@ def test_code2wav_patch_reads_stage_additional_config(
model = SimpleNamespace(
backend=None,
_extra_config=lambda: {},
vllm_config=SimpleNamespace(
additional_config={
"code2wav_enable_npu_graph": enabled,
@@ -224,6 +225,47 @@ def test_code2wav_patch_reads_stage_additional_config(
assert model.backend not in code2wav_patch._backend_graph_runners
@pytest.mark.parametrize("value", [True, "true", "false", "0", "off"])
def test_code2wav_patch_rejects_bfloat16_attention_cache(monkeypatch, value):
built = False
model = SimpleNamespace(
backend=None,
_extra_config=lambda: {"code2wav_bfloat16_attention_cache": value},
)
def build_backend(_instance):
nonlocal built
built = True
monkeypatch.setattr(code2wav_patch, "_original_build_backend", build_backend)
with pytest.raises(ValueError, match="currently supported only on CUDA"):
code2wav_patch._patched_build_backend(model)
assert not built
@pytest.mark.parametrize("value", [False, None, 0, ""])
def test_code2wav_patch_allows_disabled_bfloat16_attention_cache(monkeypatch, value):
built = False
model = SimpleNamespace(
backend=None,
_extra_config=lambda: {"code2wav_bfloat16_attention_cache": value},
)
def build_backend(instance):
nonlocal built
built = True
instance.backend = object()
monkeypatch.setattr(code2wav_patch, "_original_build_backend", build_backend)
code2wav_patch._patched_build_backend(model)
assert built
@pytest.mark.parametrize("with_cache", [False, True])
def test_code2wav_estimator_graph_dispatch_is_platform_owned(monkeypatch, with_cache):
calls = []
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
from collections import defaultdict
@@ -463,7 +466,10 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
request.resumable = False
stopped = True
confirmed_num_computed_tokens = None
if stopped:
if self.chunk_transfer_adapter is not None:
confirmed_num_computed_tokens = self.chunk_transfer_adapter._confirmed_num_computed_tokens(request)
if model_runner_output.routed_experts is not None:
routed_experts = omni_routed_experts_for_request(model_runner_output.routed_experts, request)
@@ -555,6 +561,7 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
inter_stage_output,
request,
is_segment_finished,
confirmed_num_computed_tokens=confirmed_num_computed_tokens,
)
self._remove_stopped_requests_from_queues(
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
import time
@@ -1,6 +1,7 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import copy
import importlib
import time
from collections import defaultdict, deque
@@ -10,6 +11,7 @@ from typing import Any
import torch
from vllm.v1.metrics.stats import PrefillStats
from vllm.v1.request import Request, RequestStatus
from vllm.v1.utils import ConstantList
from vllm_omni.data_entry_keys import MetaStruct, OmniPayloadStruct, unflatten_payload
@@ -118,6 +120,38 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
num_placeholders = int(getattr(request, "num_output_placeholders", 0) or 0)
return max(0, num_computed - num_placeholders)
@staticmethod
def _snapshot_processor_request(request: Request) -> Request:
"""Snapshot mutable processor inputs at save-queue admission time."""
def snapshot_container(value: Any) -> Any:
if isinstance(value, dict):
return {key: snapshot_container(item) for key, item in value.items()}
if isinstance(value, list):
return [snapshot_container(item) for item in value]
if isinstance(value, tuple):
return tuple(snapshot_container(item) for item in value)
return value
snapshot = copy.copy(request)
for name in (
"additional_information",
"prompt_token_ids",
):
if hasattr(request, name):
setattr(snapshot, name, snapshot_container(getattr(request, name)))
for private_name, public_name in (
("_all_token_ids", "all_token_ids"),
("_output_token_ids", "output_token_ids"),
):
token_ids = getattr(request, private_name, None)
if isinstance(token_ids, list):
frozen_ids = token_ids.copy()
setattr(snapshot, private_name, frozen_ids)
setattr(snapshot, public_name, ConstantList(frozen_ids))
return snapshot
@staticmethod
def _refresh_generation_chunk_prefill_state(request: Request) -> None:
request.num_prompt_tokens = len(request.prompt_token_ids)
@@ -169,6 +203,7 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
multimodal_output: dict[str, Any] | None = None,
request: Request | None = None,
is_segment_finished: bool = False,
confirmed_num_computed_tokens: int | None = None,
):
"""Build and enqueue one chunk for asynchronous sending.
@@ -186,10 +221,13 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
multimodal_output: Per-request multimodal output dictionary
request: Request object
is_segment_finished: whether the segment of request is finished
confirmed_num_computed_tokens: committed token count captured
before a streaming transition can mutate ``request``
"""
is_finished = request.is_finished() and not request.resumable
confirmed_num_computed_tokens = self._confirmed_num_computed_tokens(request)
if confirmed_num_computed_tokens is None:
confirmed_num_computed_tokens = self._confirmed_num_computed_tokens(request)
# If the request is preempted, skip the already saved chunks.
if confirmed_num_computed_tokens < self.requests_num_chunks_sent.get(request.external_req_id, 0):
@@ -204,11 +242,17 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
self.requests_num_chunks_sent[request.external_req_id] = confirmed_num_computed_tokens
task = {
"multimodal_output": multimodal_output,
"request": request,
"request": (
self._snapshot_processor_request(request) if self.custom_process_next_stage_input_func else request
),
"is_finished": is_finished,
"is_segment_finished": is_segment_finished,
}
self._pending_save_reqs.append(task)
if is_segment_finished:
# The queued FIFO item now owns the old segment. Start the next
# segment's deduplication watermark before the worker sends it.
self.requests_num_chunks_sent.pop(request.external_req_id, None)
with self._save_cond:
self._save_cond.notify()
@@ -425,7 +469,6 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
if is_segment_finished:
self.code_prompt_token_ids.pop(external_req_id, None)
self.requests_num_chunks_sent.pop(external_req_id, None)
self.ramp_chunk_count.pop(external_req_id, None)
cached_ic = getattr(self, "_cached_ic", None)
if cached_ic is not None:
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Strict, state-explicit batching for MiniCPM-o 4.5 Token2wav."""
from __future__ import annotations
@@ -7,7 +7,7 @@ from __future__ import annotations
from collections.abc import Mapping
from contextlib import nullcontext
from dataclasses import dataclass
from typing import Any
from typing import Any, cast
import torch
import torch.nn as nn
@@ -94,6 +94,17 @@ def _autocast_disabled(device: torch.device):
return nullcontext()
def _token2wav_sdpa_context(device: torch.device):
if device.type != "npu":
return nullcontext()
from vllm_omni.platforms.npu.models.step_audio2_token2wav import (
npu_token2wav_sdpa_context,
)
return npu_token2wav_sdpa_context()
def tensor_signature(value: torch.Tensor) -> tuple[tuple[int, ...], str, str]:
return tuple(value.shape), str(value.dtype), value.device.type
@@ -152,6 +163,7 @@ class BatchedToken2Wav(nn.Module):
connector_config: Mapping[str, int] | None = None,
hift_graph_config: Mapping[str, Any] | None = None,
cfm_graph_config: Mapping[str, Any] | None = None,
bfloat16_attention_cache: bool = False,
):
super().__init__()
self._token2wav = token2wav
@@ -185,6 +197,10 @@ class BatchedToken2Wav(nn.Module):
del speech, source
torch.accelerator.empty_cache()
self.float16 = bool(token2wav.float16)
self._estimator_att_compute_dtype = torch.float16 if self.float16 else torch.float32
self._estimator_att_cache_dtype = (
torch.bfloat16 if bfloat16_attention_cache else self._estimator_att_compute_dtype
)
self.n_timesteps = int(token2wav.n_timesteps)
self.mel_cache_len = int(token2wav.mel_cache_len)
self.source_cache_len = int(token2wav.source_cache_len)
@@ -310,9 +326,7 @@ class BatchedToken2Wav(nn.Module):
return max(1, int(stride)) if stride is not None else 2
def _max_encode_token_frames(self, states: list[BatchedToken2WavState]) -> int:
att = None
if states:
att = self._stack_flow_cache(states).get("conformer_att_cache")
att = states[0].flow_cache.get("conformer_att_cache") if states else None
offset1 = int(att.shape[3] // 2) if att is not None else 0
lookahead = self._pre_lookahead_len() or 0
return relpos_encode_token_budget(
@@ -382,8 +396,10 @@ class BatchedToken2Wav(nn.Module):
cond: torch.Tensor,
cnn_cache: torch.Tensor | None,
att_cache: torch.Tensor | None,
attn_mask: torch.Tensor | None = None,
valid_lengths: list[int] | None = None,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
if self._trt_stepper is not None:
if self._trt_stepper is not None and valid_lengths is None:
out, new_cnn, new_att = self._trt_stepper.step(
x=x,
mu=mu,
@@ -399,32 +415,139 @@ class BatchedToken2Wav(nn.Module):
speaker_features = speakers.unsqueeze(-1).expand(-1, -1, width)
estimator_input = torch.cat((x, mu, speaker_features, cond), dim=1)
cnn_out, att_out = self._estimator_buffers(estimator, estimator_input, att_cache)
if self._cfm_graph_wrapper is not None:
if cnn_cache is None:
cnn_cache = torch.zeros_like(cnn_out)
if att_cache is None:
att_cache = estimator_input.new_zeros(att_out.shape[:3] + (0,) + att_out.shape[4:])
old_cnn: Any = cnn_cache if cnn_cache is not None else [None] * len(estimator.blocks)
old_att: Any = att_cache if att_cache is not None else [None] * len(estimator.blocks)
if isinstance(old_att, torch.Tensor) and old_att.dtype != estimator_input.dtype:
old_att = old_att.to(dtype=estimator_input.dtype)
if self._cfm_graph_wrapper is not None and valid_lengths is None:
graph_cnn = torch.zeros_like(cnn_out) if cnn_cache is None else cnn_cache
graph_att = (
estimator_input.new_zeros(att_out.shape[:3] + (0,) + att_out.shape[4:])
if att_cache is None
else old_att
)
return self._cfm_graph_wrapper.replay(
estimator_input,
time_embedding,
cnn_cache,
att_cache,
graph_cnn,
graph_att,
cnn_out,
att_out,
)
if valid_lengths is not None:
if not hasattr(estimator, "in_proj"):
raise RuntimeError('MiniCPMO45Code2WavBatchError {"reason":"ragged_kernel_unavailable"}')
result = self._blocks_forward_chunk_ragged(
estimator,
estimator_input,
time_embedding,
attn_mask,
old_cnn,
old_att,
cnn_out,
att_out,
valid_lengths,
)
else:
result = estimator.blocks_forward_chunk(
estimator_input,
time_embedding,
attn_mask,
old_cnn,
old_att,
cnn_out,
att_out,
)
old_cnn: Any = cnn_cache if cnn_cache is not None else [None] * len(estimator.blocks)
old_att: Any = att_cache if att_cache is not None else [None] * len(estimator.blocks)
result = estimator.blocks_forward_chunk(
estimator_input,
time_embedding,
None,
old_cnn,
old_att,
cnn_out,
att_out,
)
return result, cnn_out, att_out
@staticmethod
def _gather_causal_cache(
history: torch.Tensor,
valid_lengths: torch.Tensor,
width: int,
) -> torch.Tensor:
indices = valid_lengths[:, None] + torch.arange(width, device=history.device)[None, :]
return history.gather(
2,
indices[:, None, :].expand(-1, int(history.shape[1]), -1),
)
def _blocks_forward_chunk_ragged(
self,
estimator: nn.Module,
estimator_input: torch.Tensor,
time_embedding: torch.Tensor,
attn_mask: torch.Tensor | None,
cnn_cache: Any,
att_cache: Any,
cnn_cache_buffer: torch.Tensor,
att_cache_buffer: torch.Tensor,
valid_lengths: list[int],
) -> torch.Tensor:
"""Run one padded DiT batch while capturing exact per-row CNN state."""
lengths = torch.tensor(
(*valid_lengths, *valid_lengths),
device=estimator_input.device,
dtype=torch.long,
)
x = estimator.in_proj(estimator_input.transpose(1, 2))
for block_index, block in enumerate(estimator.blocks):
(
shift_msa,
scale_msa,
gate_msa,
shift_mlp,
scale_mlp,
gate_mlp,
shift_conv,
scale_conv,
gate_conv,
) = block.adaLN_modulation(time_embedding).chunk(9, dim=-1)
normalized = block.norm1(x) * (1 + scale_msa) + shift_msa
x_att, new_att = block.attn.forward_chunk(
normalized,
att_cache[block_index],
attn_mask,
)
x = x + gate_msa * x_att
conv_input = block.norm3(x) * (1 + scale_conv) + shift_conv
old_cnn = cnn_cache[block_index]
if old_cnn is None:
width = int(block.conv.block[1].causal_padding[0])
old_cnn = conv_input.new_zeros(
(
int(conv_input.shape[0]),
int(block.conv.in_channels + block.conv.out_channels),
width,
)
)
old_cnn1, old_cnn2 = old_cnn.split((block.conv.in_channels, block.conv.out_channels), dim=1)
conv1_input = block.conv.block[0](conv_input)
conv1_output, _ = block.conv.block[1].forward_chunk(conv1_input, old_cnn1)
new_cnn1 = self._gather_causal_cache(
torch.cat((old_cnn1, conv1_input), dim=2),
lengths,
int(old_cnn1.shape[2]),
)
conv2_input = block.conv.block[2:6](conv1_output)
conv2_output, _ = block.conv.block[6].forward_chunk(conv2_input, old_cnn2)
new_cnn2 = self._gather_causal_cache(
torch.cat((old_cnn2, conv2_input), dim=2),
lengths,
int(old_cnn2.shape[2]),
)
x = x + gate_conv * block.conv.block[7](conv2_output)
x = x + gate_mlp * block.mlp(block.norm2(x) * (1 + scale_mlp) + shift_mlp)
cnn_cache_buffer[block_index].copy_(torch.cat((new_cnn1, new_cnn2), dim=1))
att_cache_buffer[block_index][:, :, : int(new_att.shape[2]), :].copy_(new_att)
return estimator.final_layer(x, time_embedding).transpose(1, 2)
def _decode_cfm(
self,
mu: torch.Tensor,
@@ -433,7 +556,12 @@ class BatchedToken2Wav(nn.Module):
*,
cnn_cache: torch.Tensor | None,
att_cache: torch.Tensor | None,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
valid_lengths: list[int] | None = None,
) -> tuple[
torch.Tensor,
torch.Tensor,
torch.Tensor | list[torch.Tensor],
]:
decoder = self.flow.decoder
estimator = decoder.estimator
batch_size = int(mu.shape[0])
@@ -458,36 +586,114 @@ class BatchedToken2Wav(nn.Module):
mu_cfg = torch.cat((mu, torch.zeros_like(mu)), dim=0)
speakers_cfg = torch.cat((speakers, torch.zeros_like(speakers)), dim=0)
cond_cfg = torch.cat((cond, torch.zeros_like(cond)), dim=0)
next_cnn: list[torch.Tensor] = []
next_att: list[torch.Tensor] = []
dt = timeline[1] - timeline[0]
for step in range(self.n_timesteps):
old_cnn = cnn_cache[step] if cnn_cache is not None else None
old_att = att_cache[step] if att_cache is not None else None
estimate, step_cnn, step_att = self._estimator_step(
estimator,
x=torch.cat((x, x), dim=0),
mu=mu_cfg,
time=torch.cat((time, time), dim=0),
speakers=speakers_cfg,
cond=cond_cfg,
cnn_cache=old_cnn,
att_cache=old_att,
attn_mask = None
if valid_lengths is not None:
if len(valid_lengths) != batch_size:
raise ValueError(f"valid length count {len(valid_lengths)} != batch {batch_size}")
cfg_lengths = torch.tensor(
(*valid_lengths, *valid_lengths),
device=mu.device,
)
conditional, unconditional = estimate.split(batch_size, dim=0)
velocity = (1.0 + decoder.inference_cfg_rate) * conditional - decoder.inference_cfg_rate * unconditional
x = x + dt * velocity
time = time + dt
if step + 1 < self.n_timesteps:
dt = timeline[step + 2] - time[0]
next_cnn.append(step_cnn)
next_att.append(step_att)
return x, torch.stack(next_cnn), torch.stack(next_att)
positions = torch.arange(int(mu.shape[2]), device=mu.device)
valid_queries = positions.unsqueeze(0) < cfg_lengths.unsqueeze(1)
current_keys = valid_queries.unsqueeze(1).expand(-1, int(mu.shape[2]), -1)
old_keys = torch.ones(
(2 * batch_size, int(mu.shape[2]), offset),
dtype=torch.bool,
device=mu.device,
)
attn_mask = valid_queries.unsqueeze(2) & torch.cat((current_keys, old_keys), dim=2)
next_cnn: list[torch.Tensor] = []
next_att_cache: torch.Tensor | None = None
ragged_att_cache: list[torch.Tensor] | None = None
dt = timeline[1] - timeline[0]
with _token2wav_sdpa_context(mu.device):
for step in range(self.n_timesteps):
old_cnn = cnn_cache[step] if cnn_cache is not None else None
old_att = att_cache[step] if att_cache is not None else None
estimate, step_cnn, step_att = self._estimator_step(
estimator,
x=torch.cat((x, x), dim=0),
mu=mu_cfg,
time=torch.cat((time, time), dim=0),
speakers=speakers_cfg,
cond=cond_cfg,
cnn_cache=old_cnn,
att_cache=old_att,
attn_mask=attn_mask,
valid_lengths=valid_lengths,
)
conditional, unconditional = estimate.split(batch_size, dim=0)
velocity = (1.0 + decoder.inference_cfg_rate) * conditional - decoder.inference_cfg_rate * unconditional
x = x + dt * velocity
time = time + dt
if step + 1 < self.n_timesteps:
dt = timeline[step + 2] - time[0]
next_cnn.append(step_cnn)
if valid_lengths is not None:
if ragged_att_cache is None:
ragged_att_cache = [
torch.empty(
(
self.n_timesteps,
int(step_att.shape[0]),
2,
int(step_att.shape[2]),
valid_length + offset,
int(step_att.shape[4]),
),
device=step_att.device,
dtype=self._estimator_att_cache_dtype,
)
for valid_length in valid_lengths
]
current_width = int(mu.shape[2])
for row, (valid_length, row_cache) in enumerate(zip(valid_lengths, ragged_att_cache, strict=True)):
for cfg_row, source_row in enumerate((row, batch_size + row)):
row_cache[step, :, cfg_row, :, :valid_length].copy_(
step_att[:, source_row, :, :valid_length]
)
if offset:
row_cache[step, :, cfg_row, :, valid_length:].copy_(
step_att[:, source_row, :, current_width:]
)
del step_att
continue
if next_att_cache is None:
next_att_cache = torch.empty(
(self.n_timesteps, *step_att.shape),
device=step_att.device,
dtype=self._estimator_att_cache_dtype,
)
next_att_cache[step].copy_(step_att)
del step_att
if ragged_att_cache is not None:
return x, torch.stack(next_cnn), ragged_att_cache
assert next_att_cache is not None
return x, torch.stack(next_cnn), next_att_cache
@staticmethod
def _split_flow_cache(cache: dict[str, torch.Tensor], batch_size: int) -> list[dict[str, torch.Tensor]]:
def _split_flow_cache(self, cache: dict[str, torch.Tensor], batch_size: int) -> list[dict[str, torch.Tensor]]:
result: list[dict[str, torch.Tensor]] = []
for row in range(batch_size):
estimator_att = cache["estimator_att_cache"]
if estimator_att.dtype == self._estimator_att_cache_dtype:
request_att = torch.cat(
(
estimator_att[:, :, row : row + 1],
estimator_att[:, :, batch_size + row : batch_size + row + 1],
),
dim=2,
).detach()
else:
request_shape = list(estimator_att.shape)
request_shape[2] = 2
request_att = torch.empty(
request_shape,
device=estimator_att.device,
dtype=self._estimator_att_cache_dtype,
)
request_att[:, :, 0:1].copy_(estimator_att[:, :, row : row + 1])
request_att[:, :, 1:2].copy_(estimator_att[:, :, batch_size + row : batch_size + row + 1])
result.append(
{
"conformer_cnn_cache": cache["conformer_cnn_cache"][row : row + 1].detach().clone(),
@@ -499,29 +705,23 @@ class BatchedToken2Wav(nn.Module):
),
dim=2,
).detach(),
"estimator_att_cache": torch.cat(
(
cache["estimator_att_cache"][:, :, row : row + 1],
cache["estimator_att_cache"][:, :, batch_size + row : batch_size + row + 1],
),
dim=2,
).detach(),
"estimator_att_cache": request_att,
}
)
return result
@staticmethod
def _stack_flow_cache(states: list[BatchedToken2WavState]) -> dict[str, torch.Tensor]:
def _stack_flow_cache(self, states: list[BatchedToken2WavState]) -> dict[str, torch.Tensor]:
flows = [state.flow_cache for state in states]
conditional_cnn = [flow["estimator_cnn_cache"][:, :, 0:1] for flow in flows]
unconditional_cnn = [flow["estimator_cnn_cache"][:, :, 1:2] for flow in flows]
conditional_att = [flow["estimator_att_cache"][:, :, 0:1] for flow in flows]
unconditional_att = [flow["estimator_att_cache"][:, :, 1:2] for flow in flows]
estimator_att = torch.cat((*conditional_att, *unconditional_att), dim=2)
return {
"conformer_cnn_cache": torch.cat([flow["conformer_cnn_cache"] for flow in flows], dim=0),
"conformer_att_cache": torch.cat([flow["conformer_att_cache"] for flow in flows], dim=1),
"estimator_cnn_cache": torch.cat((*conditional_cnn, *unconditional_cnn), dim=2),
"estimator_att_cache": torch.cat((*conditional_att, *unconditional_att), dim=2),
"estimator_att_cache": estimator_att,
}
def setup_batch(
@@ -729,3 +929,188 @@ class BatchedToken2Wav(nn.Module):
]
audios = [emitted[row].reshape(-1).to(dtype=torch.float32) for row in range(batch_size)]
return audios, next_states
@staticmethod
def _require_complete_ragged_outputs(
audios: list[torch.Tensor | None],
next_states: list[BatchedToken2WavState | None],
) -> tuple[list[torch.Tensor], list[BatchedToken2WavState]]:
missing_audio_rows = [row for row, audio in enumerate(audios) if audio is None]
missing_state_rows = [row for row, state in enumerate(next_states) if state is None]
if missing_audio_rows or missing_state_rows:
raise RuntimeError(
"MiniCPMO45Code2WavBatchError "
f'{{"reason":"incomplete_ragged_output","audio_rows":{missing_audio_rows},'
f'"state_rows":{missing_state_rows}}}'
)
return (
cast(list[torch.Tensor], audios),
cast(list[BatchedToken2WavState], next_states),
)
def decode_ragged_batch(
self,
tokens: list[torch.Tensor],
features: PromptFeatures,
states: list[BatchedToken2WavState],
*,
last_chunks: list[bool],
) -> tuple[list[torch.Tensor], list[BatchedToken2WavState]]:
batch_size = len(tokens)
if batch_size != len(states) or batch_size != len(last_chunks):
raise ValueError("ragged token, state, and final-flag batches must have the same size")
if batch_size == 0:
return [], []
lookahead = self._pre_lookahead_len()
for row, (row_tokens, last_chunk) in enumerate(zip(tokens, last_chunks, strict=True)):
num_frames = int(row_tokens.numel())
if lookahead is not None and not last_chunk and num_frames <= lookahead:
raise RuntimeError(
"MiniCPMO45Code2WavBatchError "
f'{{"reason":"chunk_below_lookahead_window","row":{row},'
f'"frames":{num_frames},"minimum":{lookahead + 1}}}'
)
max_frames = self._max_encode_token_frames(states)
if any(int(row_tokens.numel()) > max_frames for row_tokens in tokens):
# decode_batch owns the RelPos-safe encoder slicing used by
# non-async Talker dumps. Preserve that path for overlong rows;
# ordinary short duplex chunks continue through one padded DiT.
exact_groups: dict[tuple[int, bool], list[int]] = {}
for row, (row_tokens, last_chunk) in enumerate(zip(tokens, last_chunks, strict=True)):
exact_groups.setdefault((int(row_tokens.numel()), last_chunk), []).append(row)
audios: list[torch.Tensor | None] = [None] * batch_size
next_states: list[BatchedToken2WavState | None] = [None] * batch_size
for (_, last_chunk), rows in exact_groups.items():
group_audio, group_states = self.decode_batch(
torch.stack([tokens[row] for row in rows], dim=0),
features,
[states[row] for row in rows],
last_chunk=last_chunk,
)
for group_row, row in enumerate(rows):
audios[row] = group_audio[group_row]
next_states[row] = group_states[group_row]
return self._require_complete_ragged_outputs(audios, next_states)
encoder_groups: dict[tuple[int, bool], list[int]] = {}
for row, (row_tokens, last_chunk) in enumerate(zip(tokens, last_chunks, strict=True)):
encoder_groups.setdefault((int(row_tokens.numel()), last_chunk), []).append(row)
hidden_rows: list[torch.Tensor | None] = [None] * batch_size
conformer_cnn_rows: list[torch.Tensor | None] = [None] * batch_size
conformer_att_rows: list[torch.Tensor | None] = [None] * batch_size
for (_, last_chunk), rows in encoder_groups.items():
group_states = [states[row] for row in rows]
group_cache = self._stack_flow_cache(group_states)
group_tokens = torch.stack([tokens[row] for row in rows], dim=0)
with self._autocast(group_tokens.device):
hidden, conformer_cnn, conformer_att = self._encode_chunk(
group_tokens,
last_chunk=last_chunk,
cnn_cache=group_cache["conformer_cnn_cache"],
att_cache=group_cache["conformer_att_cache"],
)
for group_row, row in enumerate(rows):
hidden_rows[row] = hidden[group_row : group_row + 1]
conformer_cnn_rows[row] = conformer_cnn[group_row : group_row + 1]
conformer_att_rows[row] = conformer_att[:, group_row : group_row + 1]
missing_hidden_rows = [row for row, value in enumerate(hidden_rows) if value is None]
if missing_hidden_rows:
raise RuntimeError(
f'MiniCPMO45Code2WavBatchError {{"reason":"incomplete_encoder_output","rows":{missing_hidden_rows}}}'
)
resolved_hidden = cast(list[torch.Tensor], hidden_rows)
hidden_lengths = [int(value.shape[1]) for value in resolved_hidden]
max_hidden_length = max(hidden_lengths)
padded_hidden = resolved_hidden[0].new_zeros((batch_size, max_hidden_length, int(resolved_hidden[0].shape[2])))
for row, hidden in enumerate(resolved_hidden):
padded_hidden[row, : int(hidden.shape[1])].copy_(hidden[0])
flow_cache = self._stack_flow_cache(states)
speakers = features.speaker_embedding.expand(batch_size, -1)
with self._autocast(padded_hidden.device):
projected_speakers = self.flow.spk_embed_affine_layer(F.normalize(speakers, dim=1))
cond = torch.zeros_like(padded_hidden).transpose(1, 2).contiguous()
chunk_mel, estimator_cnn, estimator_att = self._decode_cfm(
padded_hidden.transpose(1, 2).contiguous(),
projected_speakers,
cond,
cnn_cache=flow_cache["estimator_cnn_cache"],
att_cache=flow_cache["estimator_att_cache"],
valid_lengths=hidden_lengths,
)
prompt_len = int(features.mels.shape[1])
assert isinstance(estimator_att, list)
new_flow: list[dict[str, torch.Tensor]] = []
for row, row_estimator_att in enumerate(estimator_att):
conformer_cnn = conformer_cnn_rows[row]
conformer_att = conformer_att_rows[row]
assert conformer_cnn is not None and conformer_att is not None
if row_estimator_att.shape[4] > prompt_len + 100:
row_estimator_att = torch.cat(
(
row_estimator_att[..., :prompt_len, :],
row_estimator_att[..., -100:, :],
),
dim=4,
)
if conformer_att.shape[3] > prompt_len + 100:
conformer_att = torch.cat(
(
conformer_att[..., :prompt_len, :],
conformer_att[..., -100:, :],
),
dim=3,
)
new_flow.append(
{
"conformer_cnn_cache": conformer_cnn.detach().clone(),
"conformer_att_cache": conformer_att.detach().clone(),
"estimator_cnn_cache": torch.cat(
(
estimator_cnn[:, :, row : row + 1],
estimator_cnn[:, :, batch_size + row : batch_size + row + 1],
),
dim=2,
).detach(),
"estimator_att_cache": row_estimator_att,
}
)
audios: list[torch.Tensor | None] = [None] * batch_size
next_states: list[BatchedToken2WavState | None] = [None] * batch_size
vocoder_groups: dict[tuple[int, bool], list[int]] = {}
for row, last_chunk in enumerate(last_chunks):
vocoder_groups.setdefault((hidden_lengths[row], last_chunk), []).append(row)
for (hidden_length, last_chunk), rows in vocoder_groups.items():
old_mel = torch.cat([states[row].hift_cache["mel"] for row in rows], dim=0)
old_source = torch.cat([states[row].hift_cache["source"] for row in rows], dim=0)
old_speech = torch.cat([states[row].hift_cache["speech"] for row in rows], dim=0)
group_mel = torch.cat(
[chunk_mel[row : row + 1, :, :hidden_length] for row in rows],
dim=0,
)
mel = torch.cat((old_mel, group_mel), dim=2)
speech, source = self._hift_inference(mel, old_source)
if old_speech.shape[-1] > 0:
window = self.speech_window.to(device=speech.device, dtype=speech.dtype)
speech = self._fade_in_out(speech, old_speech, window)
next_hift = {
"mel": mel[..., -self.mel_cache_len :].detach(),
"source": source[..., -self.source_cache_len :].detach(),
"speech": speech[..., -self.source_cache_len :].detach(),
}
emitted = speech if last_chunk else speech[..., : -self.source_cache_len]
for group_row, row in enumerate(rows):
audios[row] = emitted[group_row].reshape(-1).to(dtype=torch.float32)
next_states[row] = BatchedToken2WavState(
flow_cache=new_flow[row],
hift_cache={
name: value[group_row : group_row + 1].detach().clone() for name, value in next_hift.items()
},
)
return self._require_complete_ragged_outputs(audios, next_states)
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Strict batched codec-to-waveform stage for MiniCPM-o 4.5."""
from __future__ import annotations
@@ -135,7 +135,7 @@ class _WorkItem:
class MiniCPMO45Code2Wav(nn.Module):
"""LLM_GENERATION model that admits only true exact-shape GPU batches."""
"""LLM_GENERATION model with request-owned state and compatible batching."""
input_modalities = "audio"
have_multimodal_outputs = True
@@ -184,6 +184,11 @@ class MiniCPMO45Code2Wav(nn.Module):
self._min_batch_size = int(extra.get("code2wav_min_batch_size", 1))
if self._min_batch_size < 1:
raise ValueError("MiniCPM-o Code2Wav code2wav_min_batch_size must be >= 1")
self._initial_batch_size = int(extra.get("code2wav_initial_batch_size", 0))
if self._initial_batch_size < 0:
raise ValueError("MiniCPM-o Code2Wav code2wav_initial_batch_size must be >= 0")
if self._initial_batch_size and self._initial_batch_size < self._min_batch_size:
raise ValueError("MiniCPM-o Code2Wav code2wav_initial_batch_size must be 0 or >= code2wav_min_batch_size")
self._default_prompt_id = str(extra.get("prompt_cache_id", "HT_ref_audio"))
self._prompt_wav_override = extra.get("prompt_wav")
@@ -482,13 +487,63 @@ class MiniCPMO45Code2Wav(nn.Module):
return (
item.prompt_cache_id,
item.prompt_wav,
int(item.tokens.numel()),
cache_signature,
item.last_chunk,
item.tts_is_last_chunk,
item.cache_epoch,
)
def _iter_limited_batches(
self,
buckets: Iterable[list[_WorkItem]],
*,
maximum: int,
) -> Iterable[list[_WorkItem]]:
for bucket in buckets:
if not bucket:
continue
if not maximum:
yield bucket
continue
batch_count = (len(bucket) + maximum - 1) // maximum
if len(bucket) < batch_count * self._min_batch_size:
raise _batch_error(
"initial_batch_partition_below_minimum",
size=len(bucket),
minimum=self._min_batch_size,
maximum=maximum,
)
batch_size, larger_batches = divmod(len(bucket), batch_count)
start = 0
for batch_index in range(batch_count):
stop = start + batch_size + (batch_index < larger_batches)
yield bucket[start:stop]
start = stop
def _iter_decode_batches(
self,
buckets: Iterable[list[_WorkItem]],
) -> Iterable[list[_WorkItem]]:
for bucket in buckets:
if not self._initial_batch_size:
yield bucket
continue
initial = [item for item in bucket if item.chunk_seq <= 1]
steady = [item for item in bucket if item.chunk_seq > 1]
undersized = [
{"wave": wave, "size": len(items)}
for wave, items in (("initial", initial), ("steady", steady))
if items and len(items) < self._min_batch_size
]
if undersized:
raise _batch_error(
"decode_wave_below_minimum",
minimum=self._min_batch_size,
waves=undersized,
)
yield from self._iter_limited_batches([initial], maximum=self._initial_batch_size)
if steady:
yield steady
@torch.inference_mode()
def forward(
self,
@@ -624,7 +679,10 @@ class MiniCPMO45Code2Wav(nn.Module):
(item.prompt_cache_id, item.prompt_wav),
[],
).append(item)
for bucket in initial_marker_buckets.values():
for bucket in self._iter_limited_batches(
initial_marker_buckets.values(),
maximum=self._initial_batch_size,
):
try:
features = self.backend.prepare_prompt(
bucket[0].prompt_cache_id,
@@ -656,7 +714,7 @@ class MiniCPMO45Code2Wav(nn.Module):
prompt_wav=item.prompt_wav,
token2wav=state,
)
for bucket in buckets.values():
for bucket in self._iter_decode_batches(buckets.values()):
batch_size = len(bucket)
try:
features = self.backend.prepare_prompt(
@@ -667,13 +725,23 @@ class MiniCPMO45Code2Wav(nn.Module):
states = self.backend.setup_batch(features, batch_size)
else:
states = [item.previous.token2wav for item in bucket if item.previous is not None]
tokens = torch.stack([item.tokens for item in bucket], dim=0)
audios, next_states = self.backend.decode_batch(
tokens,
features,
states,
last_chunk=bucket[0].last_chunk,
)
token_lengths = {int(item.tokens.numel()) for item in bucket}
last_chunk_values = {item.last_chunk for item in bucket}
if len(token_lengths) > 1 or len(last_chunk_values) > 1:
audios, next_states = self.backend.decode_ragged_batch(
[item.tokens for item in bucket],
features,
states,
last_chunks=[item.last_chunk for item in bucket],
)
else:
tokens = torch.stack([item.tokens for item in bucket], dim=0)
audios, next_states = self.backend.decode_batch(
tokens,
features,
states,
last_chunk=bucket[0].last_chunk,
)
except Exception as exc:
self._prune_unowned_runtime_prompts()
if isinstance(exc, RuntimeError) and str(exc).startswith("MiniCPMO45Code2WavBatchError "):
@@ -822,4 +890,5 @@ class MiniCPMO45Code2Wav(nn.Module):
connector_config=self._connector_config,
hift_graph_config=self._hift_graph_config,
cfm_graph_config=self._cfm_graph_config,
bfloat16_attention_cache=bool(extra.get("code2wav_bfloat16_attention_cache", False)),
)
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Inject MiniCPM-o Code2Wav NPUGraph acceleration on Ascend."""
from __future__ import annotations
@@ -7,6 +7,7 @@ from __future__ import annotations
import os
from collections.abc import Mapping
from contextlib import nullcontext
from typing import cast
from weakref import WeakKeyDictionary
import torch
@@ -24,6 +25,7 @@ _original_decode_batch = None
_backend_graph_runners: WeakKeyDictionary[object, NPUExactGraphRunner] = WeakKeyDictionary()
_ENABLE_KEY = "code2wav_enable_npu_graph"
_MAX_GRAPHS_KEY = "code2wav_max_npu_graphs"
_BF16_ATTENTION_CACHE_KEY = "code2wav_bfloat16_attention_cache"
def _config_bool(value: object, default: bool) -> bool:
@@ -104,10 +106,18 @@ def _patched_estimator_step(
cond,
cnn_cache,
att_cache,
attn_mask=None,
valid_lengths=None,
):
assert _original_estimator_step is not None
graph_runner = _backend_graph_runners.get(self)
if graph_runner is None or self._trt_stepper is not None or self._cfm_graph_wrapper is not None:
if (
graph_runner is None
or self._trt_stepper is not None
or self._cfm_graph_wrapper is not None
or attn_mask is not None
or valid_lengths is not None
):
return _original_estimator_step(
self,
estimator,
@@ -118,6 +128,8 @@ def _patched_estimator_step(
cond=cond,
cnn_cache=cnn_cache,
att_cache=att_cache,
attn_mask=attn_mask,
valid_lengths=valid_lengths,
)
if (cnn_cache is None) != (att_cache is None):
raise ValueError("estimator CNN and attention caches must both be present or absent")
@@ -198,8 +210,15 @@ def _patched_build_backend(self) -> None:
if self.backend is not None:
return
extra = self._extra_config()
if bool(extra.get(_BF16_ATTENTION_CACHE_KEY, False)):
raise ValueError(
"MiniCPM-o Code2Wav code2wav_bfloat16_attention_cache is "
"currently supported only on CUDA; leave it unset or false on NPU."
)
config = _graph_config(self)
max_graphs = max(0, int(config.get(_MAX_GRAPHS_KEY, 32)))
max_graphs = max(0, int(cast(int | str, config.get(_MAX_GRAPHS_KEY, 32))))
graph_enabled = max_graphs > 0 and _config_bool(config.get(_ENABLE_KEY), False)
if graph_enabled:
# NPUOmniPlatform enables internal format for quantized LLM kernels.