[Perf][PersonaPlex] Batch live duplex appends and replay Mimi codec frames from CUDA graphs (#8356)

Signed-off-by: Yueqian Lin <linyueqian@outlook.com>
This commit is contained in:
Yueqian Lin authored and GitHub committed 2026-10-07 00:05:08 +00:00
1 parent e61cd4290f
commit ed6bc5ae0c
14 files changed
+1643 -119

No files matched your search

+9 -5
View File
@@ -84,8 +84,10 @@ duplex (`is_enabled()` is unconditionally true). Turn-based models
## Hardware Support
Verified on GPU (Hopper-class). The serving path is plain PyTorch eager plus the
native modules, so other CUDA GPUs with enough memory are expected to work.
Verified on GPU (Hopper-class). The shipped deploy config runs the Stage 0
talker with CUDA graphs and replays the streaming Mimi encode (Stage 0) and
decode (Stage 1) frame steps from model-local CUDA graphs, bitwise equal to
eager. Other CUDA GPUs with enough memory are expected to work.
## GPU
@@ -137,9 +139,11 @@ python tests/e2e/online_serving/personaplex_realtime_duplex.py \
#### Notes
- Realtime budget is 80 ms/frame. Verified eager per-tick latency at four
concurrent sessions is ~70-74 ms on this hardware class, i.e. all four
conversations stay realtime; single-session has comfortable headroom.
- Realtime budget is 80 ms/frame. The shipped config admits two sessions; on a
141 GB card, 64 concurrent 30 s sessions stayed realtime after raising
`duplex_session.max_sessions` and both stages' `max_num_seqs` (see the
sizing notes in `vllm_omni/deploy/personaplex.yaml`). Longer sessions cost
more per step as their context fills.
- Decoding is greedy only (temperature/top-k knobs are intentionally not
exposed): greedy is what the parity gates pin against the reference
implementation, and sampling amplifies sub-bit numeric drift into divergence.
@@ -17,6 +17,7 @@ from vllm.sampling_params import SamplingParams
from tests.e2e.online_serving import personaplex_realtime_duplex as e2e_driver
from vllm_omni.config.stage_config import load_deploy_config, merge_pipeline_deploy
from vllm_omni.engine.arg_utils import OmniEngineArgs
from vllm_omni.engine.duplex.config import DuplexSessionConfig
from vllm_omni.engine.duplex.contracts import DuplexFence
from vllm_omni.engine.duplex.plugin import (
@@ -26,6 +27,7 @@ from vllm_omni.engine.duplex.plugin import (
load_duplex_plugin,
validate_duplex_plugin_sampling,
)
from vllm_omni.model_executor.models.personaplex.configuration_personaplex import PersonaPlexConfig
from vllm_omni.model_executor.models.personaplex.duplex import stage0
from vllm_omni.model_executor.models.personaplex.duplex.config import DEFAULT_PERSONA, FRAME_SIZE, SAMPLE_RATE
from vllm_omni.model_executor.models.personaplex.duplex.data_plane import PersonaPlexDataPlaneSession
@@ -106,6 +108,36 @@ def test_personaplex_deploy_is_duplex_and_propagates_capacity_to_all_model_stage
assert "personaplex_codec_max_sessions" not in deploy.connectors["connector_of_shared_memory"]["extra"]
def test_personaplex_deploy_enables_mimi_cuda_graphs_on_both_stage_configs(tmp_path: Path) -> None:
# The checkpoint ships an empty config.json; each stage's engine args build
# its PersonaPlexConfig from that plus the deploy YAML's hf_overrides.
(tmp_path / "config.json").write_text("{}")
stages = merge_pipeline_deploy(PERSONAPLEX_PIPELINE, load_deploy_config(DEPLOY_PATH))
def resolve(model_arch: str, hf_overrides: dict[str, object] | None) -> PersonaPlexConfig:
engine_args = OmniEngineArgs(
model=str(tmp_path),
model_arch=model_arch,
hf_overrides=hf_overrides,
skip_tokenizer_init=True,
trust_remote_code=True,
)
config = engine_args.create_model_config().hf_config
assert isinstance(config, PersonaPlexConfig)
return config
assert [stage.yaml_engine_args["model_arch"] for stage in stages] == [
"PersonaPlexTalkerForConditionalGeneration",
"PersonaPlexCode2Wav",
]
for stage in stages:
model_arch = stage.yaml_engine_args["model_arch"]
assert stage.yaml_engine_args["hf_overrides"] == {"mimi_cuda_graphs": True}
assert resolve(model_arch, dict(stage.yaml_engine_args["hf_overrides"])).mimi_cuda_graphs is True
# Without the deploy override the flag keeps its default.
assert resolve(model_arch, None).mimi_cuda_graphs is False
# --------------------------------------------------------------------------- #
# Session policy #
# --------------------------------------------------------------------------- #
@@ -0,0 +1,312 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Batched live-append preparation must match the per-request path exactly."""
import base64
from dataclasses import dataclass
import numpy as np
import pytest
import torch
from vllm_omni.model_executor.models.personaplex.duplex.policy import (
SILENCE_TOKENS,
SINE_TOKENS,
)
from vllm_omni.model_executor.models.personaplex.duplex.stage0 import (
PersonaPlexStage0DuplexRuntime,
)
from vllm_omni.model_executor.models.personaplex.personaplex_talker import (
PersonaPlexTalkerForConditionalGeneration,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
_WIDTH = 17 # text row + 8 agent rows + 8 user rows
_PREFILL_LEN = 18 # 2 voice rows + 2 x 6 silence frames + 3 persona tokens + the live frame
class _FakeCodec:
"""Shared streaming encoder whose codes encode the row's frame count."""
def __init__(self) -> None:
self.encode_calls = 0
self.frames: list[int] = []
def streaming_init(self, batch_size: int) -> None:
self.frames = [0] * batch_size
def encode_frame(self, pcm: torch.Tensor, active: torch.Tensor) -> torch.Tensor:
del pcm
self.encode_calls += 1
codes = torch.zeros((len(self.frames), 8), dtype=torch.long)
for row, is_active in enumerate(active.tolist()):
if is_active:
self.frames[row] += 1
codes[row] = self.frames[row] * 16 + torch.arange(8) + 1
return codes
def reset_slot(self, row: int) -> None:
self.frames[row] = 0
@dataclass(frozen=True)
class _TalkerConfig:
num_audio_codebooks: int = 16
audio_vocab_size: int = 2048
class _EmbeddingTalker:
"""Stage model whose input embedding is the token stack itself, so embeds expose the layout."""
def __init__(self) -> None:
self.device = torch.device("cpu")
self.dtype = torch.float32
self.config = _TalkerConfig()
def input_embeddings(self, stack: torch.Tensor) -> torch.Tensor:
return stack.squeeze(-1).to(torch.float32)
def _build_prefill_embed(self, tokens, offset, span, device, silence=None, user_sine=None):
del offset, silence, user_sine
values = tokens[:span].to(device=device, dtype=torch.float32)
return values[:, None].expand(-1, _WIDTH).contiguous()
_build_frame_embed = PersonaPlexTalkerForConditionalGeneration._build_frame_embed
def _depformer(text_token, hidden, *, audio_tokens, audio_provided, num_steps):
"""Deterministic stand-in whose codes depend on every input it is given."""
del hidden
assert audio_provided.dtype == torch.bool
base = audio_tokens[:, 8 : 8 + num_steps] * 7 + audio_provided[:, :num_steps].long()
return (base + text_token[:, None] + torch.arange(num_steps)) % 2048
def _talker(max_sessions: int) -> PersonaPlexTalkerForConditionalGeneration:
runtime = PersonaPlexStage0DuplexRuntime(
_EmbeddingTalker(),
model_path="/unused",
device="cpu",
codec=_FakeCodec(),
max_sessions=max_sessions,
tokenizer=lambda _text: [7, 8, 9],
voice_loader=lambda _voice: {"embeddings": torch.arange(2 * _WIDTH, dtype=torch.float32).reshape(2, _WIDTH)},
)
talker = PersonaPlexTalkerForConditionalGeneration.__new__(PersonaPlexTalkerForConditionalGeneration)
torch.nn.Module.__init__(talker)
talker._personaplex_duplex_stage0_runtime = runtime
talker._dtype = torch.float32
talker.mtp_hidden_size = _WIDTH
talker.num_active_codebooks = 8
talker.depformer = _depformer
return talker
def _append(session_id: str, seq: int, epoch: int = 0) -> dict:
pcm = np.zeros(1920, dtype="<f4")
return {
"data_plane": True,
"session_id": session_id,
"epoch": epoch,
"seq": seq,
"payload": {
"format": "pcm_f32le",
"sample_rate_hz": 24000,
"audio": base64.b64encode(pcm.tobytes()).decode("ascii"),
},
"runtime_config": {"personaplex_voice_prompt": "NATF2.pt", "personaplex_persona": "Be concise."},
}
@dataclass
class _StepResult:
embeds: dict[str, torch.Tensor]
audio_tokens: torch.Tensor
audio_provided: torch.Tensor
codes: torch.Tensor
agents: dict[tuple[str, int], torch.Tensor]
def _run_step(
talker: PersonaPlexTalkerForConditionalGeneration,
step: list[tuple[str, dict, int]],
*,
batched: bool,
text_base: int,
) -> _StepResult:
"""One scheduler step: batch preprocess, per-request preprocess, then depformer sampling.
``step`` holds ``(request_id, append, prompt_len)``; a prompt length of
``_PREFILL_LEN`` marks a first append scheduled as one full prefill span.
"""
runtime = talker._personaplex_duplex_stage0_runtime
infos = {}
spans = {}
for request_id, duplex, prompt_len in step:
spans[request_id] = prompt_len if prompt_len == _PREFILL_LEN else 1
infos[request_id] = {
"duplex": duplex,
"request_id": request_id,
"duplex_prompt_len": prompt_len,
"duplex_token_offset": prompt_len - spans[request_id],
}
if batched:
talker.preprocess_batch(req_ids=list(infos), model_intermediate_buffer=infos, device=torch.device("cpu"))
else:
runtime.encode_appends([info["duplex"] for info in infos.values()])
embeds, req_infos = {}, []
for request_id, info in infos.items():
input_ids = torch.zeros(spans[request_id], dtype=torch.long)
_, embed, update = talker.preprocess(input_ids, None, _omni_is_prefill=True, **info)
embeds[request_id] = embed
req_infos.append(update)
received = {}
def depformer(text_token, hidden, **kwargs):
received.update(kwargs)
return _depformer(text_token, hidden, **kwargs)
talker.depformer = depformer
req_ids = list(infos)
codes = talker.post_sample_talker_mtp(
input_ids=torch.arange(len(req_ids)) + text_base,
hidden_states=torch.zeros((len(req_ids), _WIDTH)),
req_ids=req_ids,
req_infos=req_infos,
)
return _StepResult(
embeds=embeds,
audio_tokens=received["audio_tokens"],
audio_provided=received["audio_provided"],
codes=codes,
agents={key: state.last_agent_codes.clone() for key, state in runtime.sessions.items()},
)
def _assert_same(batched: _StepResult, reference: _StepResult) -> None:
assert batched.embeds.keys() == reference.embeds.keys()
for request_id, embed in batched.embeds.items():
assert torch.equal(embed, reference.embeds[request_id]), request_id
assert torch.equal(batched.audio_tokens, reference.audio_tokens)
assert torch.equal(batched.audio_provided, reference.audio_provided)
assert torch.equal(batched.codes, reference.codes)
assert batched.agents.keys() == reference.agents.keys()
for key, agent in batched.agents.items():
assert torch.equal(agent, reference.agents[key]), key
def _device_prepared(talker, key: tuple[str, int]) -> bool:
state = talker._personaplex_duplex_stage0_runtime.sessions[key]
return state.prepared.depformer_audio_tokens_device is not None
def test_mixed_prefill_and_live_steps_match_the_per_request_path() -> None:
steps = [
[("a", _append("a", 1), 18), ("b", _append("b", 1), 18)],
# A first append joins a step of live appends.
[("a", _append("a", 2), 19), ("b", _append("b", 2), 19), ("c", _append("c", 1), 18)],
[("a", _append("a", 3), 20), ("b", _append("b", 3), 20), ("c", _append("c", 2), 19)],
# Only one session appends; the others keep their state.
[("c", _append("c", 3), 20)],
[("a", _append("a", 4), 21), ("c", _append("c", 4), 21)],
]
batched, reference = _talker(max_sessions=4), _talker(max_sessions=4)
for index, step in enumerate(steps):
text_base = 100 + 10 * index
result = _run_step(batched, step, batched=True, text_base=text_base)
_assert_same(result, _run_step(reference, step, batched=False, text_base=text_base))
assert _device_prepared(batched, ("a", 0)) and _device_prepared(batched, ("c", 0))
assert not _device_prepared(reference, ("a", 0))
# Batched live appends publish no host teacher-forcing copies; the per-request path still does.
host_fields = {"pplex_depformer_audio_tokens", "pplex_depformer_audio_provided"}
batched_info = batched._personaplex_duplex_stage0_runtime.sessions[("a", 0)].prepared.info_update
reference_info = reference._personaplex_duplex_stage0_runtime.sessions[("a", 0)].prepared.info_update
assert host_fields.isdisjoint(batched_info)
assert host_fields <= reference_info.keys()
runtime = batched._personaplex_duplex_stage0_runtime
assert runtime._shared_codec().encode_calls == len(steps)
assert len(runtime.sessions[("a", 0)].user_history_device) == 2
# Live embeds carry the causal user delay: cb0 from the previous frame, cb1..7 from the one before.
a4 = result.embeds["a"][0]
assert a4[9].item() == 3 * 16 + 1
assert a4[10:17].tolist() == [2 * 16 + k + 1 for k in range(1, 8)]
def test_second_append_reads_sine_for_the_missing_user_history() -> None:
talker = _talker(max_sessions=1)
def state_of():
return talker._personaplex_duplex_stage0_runtime.sessions[("a", 0)]
first = _run_step(talker, [("a", _append("a", 1), 18)], batched=True, text_base=100)
# The first append forces agent cb1..7 to silence; cb0 is sampled.
assert state_of().last_agent_codes.tolist() == [first.codes[0, 0].item(), *SILENCE_TOKENS[1:]]
result = _run_step(talker, [("a", _append("a", 2), 19)], batched=True, text_base=101)
# Live appends force only the user codebooks, so the sampled agent frame is kept as is.
assert torch.equal(state_of().last_agent_codes, result.codes[0])
embed = result.embeds["a"][0]
assert embed[0].item() == 100
assert embed[9].item() == 16 + 1
assert embed[10:17].tolist() == list(SINE_TOKENS[1:])
assert result.audio_tokens[0].tolist() == [*SILENCE_TOKENS, 2 * 16 + 1, *(16 + k + 1 for k in range(1, 8))]
assert result.audio_provided[0].tolist() == [False] * 8 + [True] * 8
def test_cancel_restart_and_recycled_slot_match_the_per_request_path() -> None:
steps = [
[("a0", _append("a", 1), 18), ("b", _append("b", 1), 18)],
[("a0", _append("a", 2), 19), ("b", _append("b", 2), 19)],
# A cancel restarts session a as epoch 1 while epoch 0's aborted request
# still has an append in this step, next to b's live append.
[("a0", _append("a", 3), 20), ("a1", _append("a", 1, epoch=1), 18), ("b", _append("b", 3), 20)],
[("a1", _append("a", 2, epoch=1), 19), ("b", _append("b", 4), 21)],
]
batched, reference = _talker(max_sessions=2), _talker(max_sessions=2)
for index, step in enumerate(steps):
text_base = 100 + 10 * index
result = _run_step(batched, step, batched=True, text_base=text_base)
_assert_same(result, _run_step(reference, step, batched=False, text_base=text_base))
runtime = batched._personaplex_duplex_stage0_runtime
assert sorted(runtime.sessions) == [("a", 1), ("b", 0)]
assert _device_prepared(batched, ("a", 1))
# The restarted epoch starts from an empty history on its recycled row.
assert result.embeds["a1"][0][10:17].tolist() == list(SINE_TOKENS[1:])
# A new session on the row of a closed one also starts from a clean history.
for talker in (batched, reference):
talker.on_requests_finished({"b"})
followups = [
[("c", _append("c", 1), 18)],
[("c", _append("c", 2), 19), ("a1", _append("a", 3, epoch=1), 20)],
]
for index, step in enumerate(followups):
text_base = 200 + 10 * index
result = _run_step(batched, step, batched=True, text_base=text_base)
_assert_same(result, _run_step(reference, step, batched=False, text_base=text_base))
assert result.embeds["c"][0][10:17].tolist() == list(SINE_TOKENS[1:])
def test_prepare_live_appends_ignores_empty_first_and_unencoded_appends() -> None:
talker = _talker(max_sessions=2)
runtime = talker._personaplex_duplex_stage0_runtime
assert runtime.prepare_live_appends([]) == 0
talker.preprocess_batch(req_ids=["x"], model_intermediate_buffer={"x": {}}, device=torch.device("cpu"))
assert runtime.sessions == {}
runtime.encode_appends([_append("a", 1)])
assert runtime.prepare_live_appends([_append("a", 1)]) == 0 # the first append keeps the prefill path
_run_step(talker, [("a", _append("a", 1), 18)], batched=False, text_base=100)
# Not encoded in this step: left to prepare_append.
assert runtime.prepare_live_appends([_append("a", 2)]) == 0
runtime.encode_appends([_append("a", 2)])
assert runtime.prepare_live_appends([_append("a", 2), _append("a", 2)]) == 1
# Already prepared: a repeat in a later step is a no-op.
assert runtime.prepare_live_appends([_append("a", 2)]) == 0
assert runtime.prepare_append(_append("a", 2), prompt_len=19, request_id="a").prompt_offset == 18
@@ -245,23 +245,23 @@ def test_repeated_append_identity_does_not_advance_codec() -> None:
def test_next_append_uses_prior_sample_and_causally_delayed_user_frame() -> None:
runtime = _runtime(_FakeCodec())
runtime.prepare_append(_duplex_info(seq=1), prompt_len=18, request_id="req")
first_agent = torch.arange(8, dtype=torch.long)
runtime.record_sample(request_id="req", text_token=torch.tensor(101), agent_codes=first_agent)
runtime.prepare_append(_duplex_info(seq=2), prompt_len=19, request_id="req")
second_agent = torch.arange(10, 18, dtype=torch.long)
runtime.record_sample(request_id="req", text_token=torch.tensor(102), agent_codes=second_agent)
runtime.prepare_append(_duplex_info(seq=3), prompt_len=20, request_id="req")
silence = torch.tensor(SILENCE_TOKENS, dtype=torch.long)
sine = torch.tensor(SINE_TOKENS, dtype=torch.long)
runtime.prepare_append(_duplex_info(seq=1), prompt_len=18, request_id="req")
# The first append teacher-forces agent cb1..7 to silence.
expected_first_effective = torch.cat([torch.tensor([0]), silence[1:]])
runtime.record_sample(request_id="req", text_token=torch.tensor(101), effective_codes=expected_first_effective)
runtime.prepare_append(_duplex_info(seq=2), prompt_len=19, request_id="req")
second_agent = torch.arange(10, 18, dtype=torch.long)
runtime.record_sample(request_id="req", text_token=torch.tensor(102), effective_codes=second_agent)
runtime.prepare_append(_duplex_info(seq=3), prompt_len=20, request_id="req")
first_call = runtime.stage_model.frame_calls[0]
assert all(torch.equal(first_call[key], silence) for key in ("last_agent", "prev_agent"))
assert all(torch.equal(first_call[key], sine) for key in ("user_d0", "user_d1"))
second_call = runtime.stage_model.frame_calls[1]
assert second_call["text_token"].tolist() == [101]
expected_first_effective = torch.cat([first_agent[:1], silence[1:]])
assert torch.equal(second_call["last_agent"], expected_first_effective)
assert torch.equal(second_call["prev_agent"], expected_first_effective)
assert torch.equal(second_call["user_d0"], torch.full((8,), 1, dtype=torch.long))
@@ -468,7 +468,7 @@ def test_cancel_overlap_full_processing_order_never_re_leases_the_aborted_epoch(
else:
restarted = runtime.prepare_append(duplex, prompt_len=prompt_len, request_id=request_id)
for request_id in ("req-e0", "req-e1"):
runtime.record_sample(request_id=request_id, text_token=5, agent_codes=list(range(8)))
runtime.record_sample(request_id=request_id, text_token=5, effective_codes=list(range(8)))
runtime.close_request("req-e0") # the engine's late finish of the aborted request
assert list(runtime.sessions) == [("session", 1)]
@@ -0,0 +1,597 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Fixed-buffer streaming state and CUDA graph replay of the PersonaPlex Mimi codec."""
import gc
import weakref
from contextlib import contextmanager
from types import SimpleNamespace
import pytest
import torch
import torch.nn as nn
from vllm_omni.model_executor.models.personaplex.personaplex_code2wav import _MIMI_DECODE_BATCH_FRAMES
from vllm_omni.model_executor.models.personaplex.personaplex_mimi import (
CODEBOOKS,
FRAME_SIZE,
PersonaPlexMimiCodec,
_MimiStreamingTransformer,
_StreamConv1d,
_StreamConvTr1d,
)
pytestmark = pytest.mark.core_model
SEED = 4321
def _mask(*rows: bool, device: torch.device | str = "cpu") -> torch.Tensor:
return torch.tensor(rows, dtype=torch.bool, device=device)
def _random_codec(device: torch.device, batch_size: int) -> PersonaPlexMimiCodec:
"""The real Mimi graph with random weights (no checkpoint download)."""
from transformers import MimiConfig, MimiModel
torch.manual_seed(SEED)
codec = PersonaPlexMimiCodec.__new__(PersonaPlexMimiCodec)
nn.Module.__init__(codec)
codec.device = device
model = MimiModel(MimiConfig())
del model.encoder_transformer, model.decoder_transformer
with torch.no_grad():
for name, buffer in model.quantizer.named_buffers():
if name.endswith("embed_sum"):
buffer.normal_()
codec.model = model.to(device).eval()
codec.dtype = torch.float32
codec.encoder_transformer = _MimiStreamingTransformer().to(device)
codec.decoder_transformer = _MimiStreamingTransformer().to(device)
for transformer in (codec.encoder_transformer, codec.decoder_transformer):
for parameter in transformer.parameters():
nn.init.normal_(parameter, std=0.02)
codec._init_streaming_stages()
codec.streaming_init(batch_size)
return codec
def _state_tensors(codec: PersonaPlexMimiCodec) -> list[torch.Tensor]:
tensors: list[torch.Tensor] = []
for state in codec._conv_states():
tensors.append(state.prev if isinstance(state, _StreamConv1d) else state.partial)
tensors.append(state._fresh)
for transformer in (codec.encoder_transformer, codec.decoder_transformer):
tensors.append(transformer._offset)
for kv in transformer._kv:
tensors.extend((kv.cache, kv.end_offset, kv.start_offset))
return tensors
def _snapshot(codec: PersonaPlexMimiCodec) -> list[torch.Tensor]:
return [tensor.clone() for tensor in _state_tensors(codec)]
@pytest.mark.cpu
@pytest.mark.parametrize("kind", ["conv", "conv_replicate", "convtr"])
def test_stream_carries_update_in_place(kind: str) -> None:
torch.manual_seed(SEED)
if kind == "convtr":
stream = _StreamConvTr1d(nn.ConvTranspose1d(2, 3, kernel_size=4, stride=2))
samples = 3
else:
pad_mode = "replicate" if kind == "conv_replicate" else "constant"
stream = _StreamConv1d(nn.Conv1d(2, 3, kernel_size=5, stride=2), pad_mode=pad_mode)
samples = 6
stream.reset(3, torch.device("cpu"), torch.float32)
carry = stream.prev if isinstance(stream, _StreamConv1d) else stream.partial
fresh = stream._fresh
pointers = (carry.data_ptr(), fresh.data_ptr())
with torch.no_grad():
for active in (_mask(True, False, True), _mask(False, True, True), _mask(True, True, True)):
stream(torch.randn(3, 2, samples), active)
current = stream.prev if isinstance(stream, _StreamConv1d) else stream.partial
assert current is carry and stream._fresh is fresh
assert (current.data_ptr(), stream._fresh.data_ptr()) == pointers
assert carry.abs().sum() > 0
assert not fresh.any()
stream.reset_slot(1)
assert not carry[1].any() and fresh[1]
assert carry[0].abs().sum() > 0 and not fresh[0]
stream.reset_all()
assert not carry.any() and fresh.all()
assert (carry.data_ptr(), fresh.data_ptr()) == pointers
@pytest.mark.cpu
def test_streaming_init_at_same_batch_size_resets_in_place() -> None:
codec = _random_codec(torch.device("cpu"), batch_size=2)
pcm = torch.randn(2, FRAME_SIZE)
first = codec.encode_frame(pcm)
codec.decode_frame(first)
pointers = [tensor.data_ptr() for tensor in _state_tensors(codec)]
codec.streaming_init(2)
assert pointers == [tensor.data_ptr() for tensor in _state_tensors(codec)]
assert torch.equal(codec.encode_frame(pcm), first)
codec.streaming_init(3)
assert codec._batch_size == 3
assert codec.encode_frame(torch.randn(3, FRAME_SIZE)).shape == (3, CODEBOOKS)
@pytest.mark.cpu
def test_capture_off_cuda_stays_eager() -> None:
codec = _random_codec(torch.device("cpu"), batch_size=2)
assert codec.capture_cuda_graphs() == []
assert codec._cuda_graphs == {}
assert codec.encode_frame(torch.randn(2, FRAME_SIZE)).shape == (2, CODEBOOKS)
@pytest.mark.cpu
def test_quantizer_decode_matches_transformers() -> None:
codec = _random_codec(torch.device("cpu"), batch_size=2)
codes = torch.randint(0, 2048, (2, CODEBOOKS, 3))
with torch.no_grad():
expected = codec.model.quantizer.decode(codes)
actual = codec._quantizer_decode(codes)
assert torch.equal(actual, expected)
def _drive(codec: PersonaPlexMimiCodec, frames: int, recycle_at: int, device: torch.device):
"""Encode/decode ``frames`` frames under a mixed active schedule with a row recycle."""
batch_size = codec._batch_size
generator = torch.Generator(device="cpu").manual_seed(SEED)
schedule = [_mask(*[(step + row) % 3 != 0 for row in range(batch_size)], device=device) for step in range(frames)]
codes_out, pcm_out, inactive_ok = [], [], True
for step, active in enumerate(schedule):
if step == recycle_at:
codec.reset_slot(1)
pcm = 0.1 * torch.randn(batch_size, FRAME_SIZE, generator=generator)
before = _snapshot(codec)
codes = codec.encode_frame(pcm, active)
wav = codec.decode_frame(codes, active)
after = _state_tensors(codec)
for row, is_active in enumerate(active.tolist()):
if is_active:
continue
for old, new in zip(before, after):
if new.dim() > 0 and new.shape[0] == batch_size:
inactive_ok &= torch.equal(old[row], new[row])
elif new.dim() > 1 and new.shape[1] == batch_size:
inactive_ok &= torch.equal(old[:, row], new[:, row])
codes_out.append(codes)
pcm_out.append(wav)
return torch.stack(codes_out), torch.stack(pcm_out), inactive_ok
@pytest.mark.cuda
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
def test_graph_replay_is_bitwise_equal_to_eager_across_recycle() -> None:
device = torch.device("cuda")
codec = _random_codec(device, batch_size=3)
eager_codes, eager_pcm, eager_inactive_ok = _drive(codec, frames=12, recycle_at=7, device=device)
assert eager_inactive_ok
assert torch.isfinite(eager_pcm).all()
codec.streaming_init(3)
pointers = [tensor.data_ptr() for tensor in _state_tensors(codec)]
assert codec.capture_cuda_graphs(decode_frame_counts=(1, 3)) == ["decode_f1", "decode_f3", "encode"]
assert pointers == [tensor.data_ptr() for tensor in _state_tensors(codec)]
graph_codes, graph_pcm, graph_inactive_ok = _drive(codec, frames=12, recycle_at=7, device=device)
assert codec._cuda_graphs["encode"].replays == 12
assert codec._cuda_graphs["decode_f1"].replays == 12
assert graph_inactive_ok
assert torch.equal(graph_codes, eager_codes)
assert torch.equal(graph_pcm, eager_pcm)
# A full reset in place (a new stream on the same codec) replays the same
# frames from a fresh state.
codec.streaming_init(3)
again_codes, again_pcm, _ = _drive(codec, frames=12, recycle_at=7, device=device)
assert torch.equal(again_codes, eager_codes)
assert torch.equal(again_pcm, eager_pcm)
assert pointers == [tensor.data_ptr() for tensor in _state_tensors(codec)]
@pytest.mark.cuda
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
@pytest.mark.parametrize("batch_size", [1, 3])
def test_multi_frame_decode_graph_matches_eager_chunks(batch_size: int) -> None:
# Code2Wav records the first single-frame delta and the full chunk, on one
# B=1 decoder per session. B=3 runs the same graphs on shared rows, where
# every call carries all rows and only some are active.
chunk = _MIMI_DECODE_BATCH_FRAMES
partial = chunk - 2
assert partial not in (1, chunk)
device = torch.device("cuda")
codec = _random_codec(device, batch_size=batch_size)
generator = torch.Generator(device="cpu").manual_seed(SEED)
# First frame alone, full chunks, a partial chunk with no graph, then a
# single frame and a full chunk again on the state the eager step left.
frame_counts = [1, chunk, chunk, partial, 1, chunk]
row_masks = [(True, True, False), (True, False, True), (True, True, True), (False, True, True)]
steps = [
(frames, None if batch_size == 1 else _mask(*row_masks[step % len(row_masks)], device=device))
for step, frames in enumerate(frame_counts)
]
chunks = [torch.randint(0, 2048, (batch_size, CODEBOOKS, frames), generator=generator) for frames, _ in steps]
eager = [codec.decode_frames(chunk_codes, active) for chunk_codes, (_, active) in zip(chunks, steps)]
codec.streaming_init(batch_size)
assert codec.capture_cuda_graphs(encode=False, decode_frame_counts=(1, chunk)) == ["decode_f1", f"decode_f{chunk}"]
graphed = [codec.decode_frames(chunk_codes, active) for chunk_codes, (_, active) in zip(chunks, steps)]
# The partial chunk has no graph, so it is the one step that ran eagerly.
assert codec._cuda_graphs["decode_f1"].replays == 2
assert codec._cuda_graphs[f"decode_f{chunk}"].replays == 3
assert f"decode_f{partial}" not in codec._cuda_graphs
for (frames, active), expected, actual in zip(steps, eager, graphed):
assert actual.shape == (batch_size, frames * FRAME_SIZE)
rows = slice(None) if active is None else active
assert torch.equal(actual[rows], expected[rows])
@pytest.mark.cuda
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
def test_failed_capture_warns_with_the_error_and_stays_eager(monkeypatch: pytest.MonkeyPatch) -> None:
from vllm_omni.model_executor.models.personaplex import personaplex_mimi_cudagraph
device = torch.device("cuda")
codec = _random_codec(device, batch_size=2)
pcm = 0.1 * torch.randn(2, FRAME_SIZE, generator=torch.Generator(device="cpu").manual_seed(SEED))
expected_codes = codec.encode_frame(pcm)
expected_pcm = codec.decode_frame(expected_codes)
codec.streaming_init(2)
class _FailingCapture:
def __enter__(self) -> None:
pass
def __exit__(self, *_args) -> None:
raise RuntimeError("CUDA error: out of memory")
warnings: list[tuple[tuple, dict]] = []
monkeypatch.setattr(torch.cuda, "graph", lambda *_args, **_kwargs: _FailingCapture())
monkeypatch.setattr(
personaplex_mimi_cudagraph.logger,
"warning",
lambda *args, **kwargs: warnings.append((args, kwargs)),
)
assert codec.capture_cuda_graphs(encode=True, decode_frame_counts=(1,)) == []
assert codec._cuda_graphs == {}
assert len(warnings) == 1
args, kwargs = warnings[0]
assert "requested" in args[0] and "capture failed" in args[0] and "eagerly" in args[0]
assert args[1:3] == ("encode/decode_f1", 2)
assert "Traceback (most recent call last):" in args[3]
assert "RuntimeError: CUDA error: out of memory" in args[3]
assert not kwargs
# The warmup frames are rolled back, so the eager codec starts a fresh stream.
assert torch.equal(codec.encode_frame(pcm), expected_codes)
assert torch.equal(codec.decode_frame(expected_codes), expected_pcm)
@pytest.mark.cuda
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
def test_capture_does_not_hide_errors_that_are_not_cuda_failures(monkeypatch: pytest.MonkeyPatch) -> None:
codec = _random_codec(torch.device("cuda"), batch_size=2)
def _broken(*_args, **_kwargs):
raise TypeError("not a capture failure")
monkeypatch.setattr(codec, "_encode_frame_eager", _broken)
with pytest.raises(TypeError, match="not a capture failure"):
codec.capture_cuda_graphs(encode=True, decode_frame_counts=())
assert codec._cuda_graphs == {}
@pytest.mark.cuda
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
def test_graph_capture_leaves_a_fresh_stream_and_resize_drops_graphs() -> None:
device = torch.device("cuda")
codec = _random_codec(device, batch_size=2)
codec.capture_cuda_graphs(encode=True, decode_frame_counts=())
assert list(codec._cuda_graphs) == ["encode"]
for state in codec._conv_states():
carry = state.prev if isinstance(state, _StreamConv1d) else state.partial
assert not carry.any() and state._fresh.all()
for transformer in (codec.encoder_transformer, codec.decoder_transformer):
assert not transformer._offset.any()
assert all(not kv.end_offset.any() for kv in transformer._kv)
# Resizing reallocates the state, so graphs recorded at the old size are dropped.
codec.streaming_init(4)
assert codec._cuda_graphs == {}
assert codec.encode_frame(torch.zeros(4, FRAME_SIZE)).shape == (4, CODEBOOKS)
class _RecordingCodec:
"""Stage 0 shared-encoder stand-in that records the graph capture request."""
def __init__(self) -> None:
self.calls: list[tuple[str, object]] = []
def streaming_init(self, batch_size: int) -> None:
self.calls.append(("streaming_init", batch_size))
def capture_cuda_graphs(self, *, encode: bool, decode_frame_counts: tuple[int, ...]) -> list[str]:
self.calls.append(("capture", {"encode": encode, "decode_frame_counts": decode_frame_counts}))
return ["encode"]
@pytest.mark.cpu
@pytest.mark.parametrize("enabled", [True, False])
def test_stage0_captures_the_shared_encoder_once_when_enabled(enabled: bool) -> None:
from vllm_omni.model_executor.models.personaplex.duplex.stage0 import PersonaPlexStage0DuplexRuntime
codec = _RecordingCodec()
runtime = PersonaPlexStage0DuplexRuntime(
object(),
model_path="/unused",
device="cpu",
codec_factory=lambda: codec,
max_sessions=4,
codec_cuda_graphs=enabled,
)
assert runtime._shared_codec() is codec
assert runtime._shared_codec() is codec
expected: list[tuple[str, object]] = [("streaming_init", 4)]
if enabled:
expected.append(("capture", {"encode": True, "decode_frame_counts": ()}))
assert codec.calls == expected
@pytest.mark.cpu
def test_resize_after_capture_warns_that_graphs_are_dropped(monkeypatch: pytest.MonkeyPatch) -> None:
from vllm_omni.model_executor.models.personaplex import personaplex_mimi
warnings: list[tuple] = []
monkeypatch.setattr(personaplex_mimi.logger, "warning", lambda *args: warnings.append(args))
codec = _random_codec(torch.device("cpu"), batch_size=2)
codec._cuda_graphs = {"encode": object()}
codec.streaming_init(2)
assert "encode" in codec._cuda_graphs and not warnings
codec.streaming_init(3)
assert codec._cuda_graphs == {}
assert len(warnings) == 1 and warnings[0][1:] == (2, 3, "encode")
@pytest.fixture
def capture_faults(monkeypatch: pytest.MonkeyPatch):
"""Exercise capture orchestration and error boundaries with CPU tensors."""
from vllm_omni.model_executor.models.personaplex import personaplex_mimi_cudagraph as graphs
state = SimpleNamespace(
errors={},
counts={},
events=[],
warnings=[],
phase="setup",
capturing=False,
graph_refs=[],
capture_refs=[],
output_refs=[],
check_released=False,
)
state.original_stream = state.stream = object()
def record(event):
state.events.append(event)
state.counts[event] = state.counts.get(event, 0) + 1
error = state.errors.get((event, state.counts[event]), state.errors.get(event))
if error is not None:
raise error
def eager(name):
def step(frame, active):
state.phase = "capture" if state.capturing else "warmup"
record(f"{name}_{state.phase}")
if state.capturing:
output = frame.clone()
state.output_refs.append(weakref.ref(output))
return output
return frame
return step
def reset():
assert state.stream is state.original_stream
if state.check_released:
assert all(ref() is None for ref in state.graph_refs + state.capture_refs + state.output_refs)
state.phase = "reset"
record("reset")
codec = SimpleNamespace(
device="cuda",
dtype=torch.float32,
_batch_size=2,
_encode_frame_eager=eager("encode"),
_decode_frames_eager=eager("decode"),
reset_streaming=reset,
)
class Graph:
def __init__(self):
record("construct")
state.graph_refs.append(weakref.ref(self))
class Capture:
def __init__(self, graph):
self.graph = graph
state.capture_refs.append(weakref.ref(self))
def __enter__(self):
record("capture_setup_sync")
record("capture_setup_empty_cache")
state.stream = object()
state.capturing = True
record("capture_begin")
def __exit__(self, exc_type, exc, traceback):
state.capturing = False
# Match torch.cuda.graph: capture_end can raise before restoration,
# and can replace a body exception with capture-invalidated.
record("capture_end")
state.stream = state.original_stream
@contextmanager
def stream_context(stream):
previous = state.stream
state.stream = stream
try:
yield
finally:
state.stream = previous
zeros, ones = torch.zeros, torch.ones
monkeypatch.setattr(torch, "zeros", lambda *args, **kwargs: zeros(*args, **(kwargs | {"device": "cpu"})))
monkeypatch.setattr(torch, "ones", lambda *args, **kwargs: ones(*args, **(kwargs | {"device": "cpu"})))
monkeypatch.setattr(torch.cuda, "is_available", lambda: True)
monkeypatch.setattr(torch.cuda, "graph_pool_handle", lambda: (0, 0))
monkeypatch.setattr(torch.cuda, "CUDAGraph", Graph)
monkeypatch.setattr(torch.cuda, "graph", lambda graph, **kwargs: Capture(graph))
monkeypatch.setattr(torch.cuda, "current_stream", lambda device: state.stream)
monkeypatch.setattr(torch.cuda, "stream", stream_context)
monkeypatch.setattr(torch.accelerator, "synchronize", lambda device: record(f"sync_{state.phase}"))
monkeypatch.setattr(graphs.logger, "warning", lambda *args, **kwargs: state.warnings.append((args, kwargs)))
state.capture = lambda: graphs.capture_mimi_frame_graphs(
codec,
encode=True,
decode_frame_counts=(1,),
warmup_iters=1,
)
return state
@pytest.mark.cpu
@pytest.mark.parametrize("step", ["encode_warmup", "decode_warmup"])
@pytest.mark.parametrize("error_type", [RuntimeError, torch.cuda.OutOfMemoryError])
def test_eager_warmup_failures_propagate_before_capture(capture_faults, step, error_type):
error = error_type("injected eager tensor failure")
capture_faults.errors[step] = error
with pytest.raises(error_type) as raised:
capture_faults.capture()
assert raised.value is error
assert capture_faults.counts.get("construct", 0) == 0
assert capture_faults.counts["reset"] == 1
assert not capture_faults.warnings
@pytest.mark.cpu
@pytest.mark.parametrize(
"point",
[
"sync_warmup",
"construct",
"capture_setup_sync",
"capture_setup_empty_cache",
"capture_begin",
"sync_capture",
"sync_reset",
"reset",
],
)
def test_device_and_reset_failures_are_never_capture_fallbacks(capture_faults, point):
# Even an otherwise recoverable error class must propagate at these boundaries.
error = torch.cuda.OutOfMemoryError("injected device or reset failure")
capture_faults.errors[point] = error
with pytest.raises(torch.cuda.OutOfMemoryError) as raised:
capture_faults.capture()
assert raised.value is error
assert not capture_faults.warnings
@pytest.mark.cpu
@pytest.mark.parametrize(
"message",
[
"tensor size mismatch",
"CUDA error: an illegal memory access was encountered",
"CUDA error: device-side assert triggered",
"CUDA error: operation failed due to a previous error during capture",
],
)
def test_unknown_or_fatal_capture_errors_propagate(capture_faults, message):
error = RuntimeError(message)
capture_faults.errors["encode_capture"] = error
with pytest.raises(RuntimeError) as raised:
capture_faults.capture()
assert raised.value is error
assert capture_faults.stream is capture_faults.original_stream
assert capture_faults.counts["reset"] == 1
assert not capture_faults.warnings
@pytest.mark.cpu
def test_recoverable_capture_failure_discards_partial_graphs_after_all_warmups(capture_faults):
error = torch.cuda.OutOfMemoryError("capture allocation failed")
capture_faults.errors[("capture_end", 2)] = error
capture_faults.check_released = True
was_enabled = gc.isenabled()
gc.disable()
try:
assert capture_faults.capture() == {}
finally:
if was_enabled:
gc.enable()
assert capture_faults.events.index("decode_warmup") < capture_faults.events.index("construct")
assert capture_faults.counts["capture_end"] == 2 # an earlier graph completed
assert capture_faults.counts["sync_reset"] == 1
assert capture_faults.stream is capture_faults.original_stream
assert len(capture_faults.warnings) == 1
assert "capture allocation failed" in capture_faults.warnings[0][0][-1]
assert "Traceback (most recent call last):" in capture_faults.warnings[0][0][-1]
assert not capture_faults.warnings[0][1]
assert error.__traceback__ is None
@pytest.mark.cpu
@pytest.mark.parametrize("recoverable", [True, False])
def test_capture_end_does_not_hide_the_original_failure(capture_faults, recoverable):
message = "CUDA error: operation not permitted when stream is capturing" if recoverable else "tensor size mismatch"
original = RuntimeError(message)
capture_faults.errors["encode_capture"] = original
invalidated = RuntimeError("CUDA error: operation failed due to a previous error during capture")
capture_faults.errors["capture_end"] = invalidated
if recoverable:
capture_faults.check_released = True
was_enabled = gc.isenabled()
gc.disable()
try:
assert capture_faults.capture() == {}
finally:
if was_enabled:
gc.enable()
assert len(capture_faults.warnings) == 1
assert original.__traceback__ is None and invalidated.__traceback__ is None
else:
with pytest.raises(RuntimeError) as raised:
capture_faults.capture()
assert raised.value is invalidated and raised.value.__context__ is original
assert not capture_faults.warnings
assert capture_faults.stream is capture_faults.original_stream
assert capture_faults.counts["reset"] == 1
@pytest.mark.cpu
@pytest.mark.parametrize("point", ["reset", "sync_reset"])
def test_capture_fallback_requires_successful_reset(capture_faults, point):
capture_faults.errors["capture_end"] = torch.cuda.OutOfMemoryError("capture allocation failed")
error = RuntimeError("codec could not reset")
capture_faults.errors[point] = error
with pytest.raises(RuntimeError) as raised:
capture_faults.capture()
assert raised.value is error
assert not capture_faults.warnings
@@ -122,6 +122,8 @@ def _make_codec_stub(mocker: MockerFixture) -> PersonaPlexMimiCodec:
num_quantizers, x.shape[0], x.shape[-1], dtype=torch.long
)
quantizer.decode.side_effect = lambda codes: torch.zeros(codes.shape[0], 1, codes.shape[-1])
# The codec decodes through its own RVQ sum; route it to the stub.
codec._quantizer_decode = quantizer.decode
codec.device = torch.device("cpu")
codec.dtype = torch.float32
@@ -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 types import SimpleNamespace
@@ -100,16 +100,18 @@ def test_post_sample_talker_mtp_uses_current_temporal_state() -> None:
received["audio_tokens"] = audio_tokens
received["audio_provided"] = audio_provided
received["num_steps"] = num_steps
return torch.arange(num_steps or 16, dtype=torch.long).reshape(1, -1)
return 100 + torch.arange(num_steps or 16, dtype=torch.long).reshape(1, -1)
model = SimpleNamespace(
_dtype=torch.float32,
num_active_codebooks=8,
depformer=depformer,
_depformer_teacher_forcing=PersonaPlexTalkerForConditionalGeneration._depformer_teacher_forcing,
_duplex_stage0_runtime=lambda: SimpleNamespace(
record_sample=lambda *, request_id, text_token, agent_codes: recorded.append(
(request_id, text_token.clone(), agent_codes.clone())
)
prepared_depformer_state=lambda _request_id: None,
record_sample=lambda *, request_id, text_token, effective_codes: recorded.append(
(request_id, text_token.clone(), effective_codes.clone())
),
),
)
method = getattr(PersonaPlexTalkerForConditionalGeneration, "post_sample_talker_mtp", None)
@@ -137,4 +139,6 @@ def test_post_sample_talker_mtp_uses_current_temporal_state() -> None:
assert torch.equal(received["audio_tokens"], torch.arange(16).reshape(1, 16))
assert torch.equal(received["audio_provided"], (torch.arange(16) > 0).reshape(1, 16))
assert [(row[0], row[1].item()) for row in recorded] == [("r1", 101)]
assert torch.equal(recorded[0][2], codes[0])
# Teacher-forced agent codebooks (1..7 here) replace the sampled ones.
assert codes[0].tolist() == list(range(100, 108))
assert recorded[0][2].tolist() == [100, 1, 2, 3, 4, 5, 6, 7]
+15 -4
View File
@@ -10,12 +10,19 @@
# first-chunk latency (not correctness).
# * Stage 1 (code2wav) wraps the external Mimi codec. Each codec frame is
# 8 active codebooks (cb 0..7) -> 1920 PCM samples @ 24 kHz / 12.5 Hz.
# * Stage 0 runs cudagraph by default; Stage 1 (Mimi) is eager because the
# external decoder performs host-to-device copies that cannot be captured.
# * Stage 0 runs cudagraph by default; Stage 1 (Mimi) runs with
# enforce_eager because the external decoder performs host-to-device copies
# that the runner cannot capture. The streaming Mimi encode (Stage 0) and
# decode (Stage 1) frame steps instead replay model-local CUDA graphs
# (hf_overrides.mimi_cuda_graphs), bitwise equal to eager. The graphs add
# about 80 MiB on Stage 0 and 5 MiB per decoder slot on Stage 1.
# * Each live session holds one 3000-frame KV window (~1.5 GiB) on Stage 0.
# To serve more sessions, raise duplex_session.max_sessions and both stages'
# max_num_seqs together, and size Stage 0 gpu_memory_utilization for the KV;
# on a 141 GB card, 16 sessions at Stage 0 utilization 0.75 stay realtime.
# max_num_seqs together, and size Stage 0 gpu_memory_utilization for
# N x 1.5 GiB of KV: on a 141 GB card, utilization 0.75 holds about 60 full
# windows. Measured there (Stage 1 at 0.15), 64 concurrent 30 s sessions
# (375 frames each) stayed realtime. Longer sessions also cost more per
# step as their context fills.
pipeline: personaplex
session_mode: duplex
duplex_session:
@@ -52,6 +59,8 @@ stages:
# Temporal transformer sliding_window is 3000 frames; keep model len within.
max_model_len: 3000
devices: "0"
hf_overrides:
mimi_cuda_graphs: true
output_connectors:
to_stage_1: connector_of_shared_memory
default_sampling_params:
@@ -77,6 +86,8 @@ stages:
max_num_batched_tokens: 65536
max_model_len: 65536
devices: "0"
hf_overrides:
mimi_cuda_graphs: true
input_connectors:
from_stage_0: connector_of_shared_memory
default_sampling_params:
@@ -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
"""Configuration for PersonaPlex (a Moshi finetune; 2-stage audio->audio pipeline).
PersonaPlex is a staged AR speech model composed of:
@@ -203,6 +203,10 @@ class PersonaPlexConfig(PretrainedConfig):
mimi_name (`str`, *optional*):
Convenience mirror of ``mimi_config.mimi_name``; if set, it
overrides the value carried inside ``mimi_config``.
mimi_cuda_graphs (`bool`, *optional*, defaults to `False`):
Replay the streaming Mimi per-frame encode (Stage 0) and decode
(Stage 1) from model-local CUDA graphs. Can be enabled through
``hf_overrides``.
"""
model_type = "personaplex"
@@ -222,6 +226,7 @@ class PersonaPlexConfig(PretrainedConfig):
audio_vocab_size: int = 2048,
num_audio_codebooks: int = 16,
mimi_name: str | None = None,
mimi_cuda_graphs: bool = False,
**kwargs: Any,
) -> None:
if temporal_config is None:
@@ -257,6 +262,7 @@ class PersonaPlexConfig(PretrainedConfig):
if mimi_name is not None:
self.mimi_config.mimi_name = mimi_name
self.mimi_name = self.mimi_config.mimi_name
self.mimi_cuda_graphs = mimi_cuda_graphs
@staticmethod
def _coerce(value: Any, config_cls: type[PretrainedConfig]) -> PretrainedConfig:
@@ -9,9 +9,10 @@ from collections.abc import Callable
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
from typing import Any
from typing import TYPE_CHECKING, Any
import numpy as np
from vllm.logger import init_logger
from vllm_omni.model_executor.common.audio.pcm import pcm_f32le_samples
from vllm_omni.model_executor.common.duplex.payload import decode_pcm_f32le_payload
@@ -24,8 +25,13 @@ from vllm_omni.model_executor.models.personaplex.duplex.policy import (
wrap_with_system_tags,
)
if TYPE_CHECKING:
import torch
_FRAME_SAMPLES = FRAME_SIZE
logger = init_logger(__name__)
class PersonaPlexStage0CapacityError(RuntimeError):
"""Every streaming encoder row is leased by a live session."""
@@ -43,6 +49,9 @@ class PersonaPlexStage0PreparedAppend:
info_update: dict[str, Any]
prefill_applied: bool
prompt_offset: int
# Device copies of the depformer teacher-forcing state (batched path only).
depformer_audio_tokens_device: torch.Tensor | None = None
depformer_audio_provided_device: torch.Tensor | None = None
@dataclass(slots=True)
@@ -63,6 +72,10 @@ class PersonaPlexStage0SessionState:
slot: int | None = None
encoded_identity: tuple[int, int] | None = None
encoded_frame: Any | None = None
# Device copies of the encoded frame and of the last two consumed user
# frames (newest last), so live appends are built without host round trips.
encoded_frame_device: torch.Tensor | None = None
user_history_device: list[torch.Tensor] = field(default_factory=list)
def _tokenizer_path(model_path: str) -> Path:
@@ -151,6 +164,7 @@ class PersonaPlexStage0DuplexRuntime:
codec: Any | None = None,
codec_factory: Callable[[], Any] | None = None,
max_sessions: int = 1,
codec_cuda_graphs: bool = False,
tokenizer=None,
voice_loader=None,
) -> None:
@@ -161,6 +175,7 @@ class PersonaPlexStage0DuplexRuntime:
self.device = device
self.max_sessions = max_sessions
self._codec_factory = codec_factory
self._codec_cuda_graphs = codec_cuda_graphs
self._codec: Any | None = None
self._free_slots: list[int] = list(reversed(range(max_sessions)))
self._tokenizer = tokenizer
@@ -170,6 +185,7 @@ class PersonaPlexStage0DuplexRuntime:
# Requests of a superseded epoch that still reached this step; they are
# finished by the engine and must not lease a row or record a sample.
self._stale_requests: set[str] = set()
self._constants_cache: dict[str, torch.Tensor] | None = None
if codec is not None:
codec.streaming_init(max_sessions)
self._codec = codec
@@ -239,13 +255,22 @@ class PersonaPlexStage0DuplexRuntime:
state.request_ids.add(request_id)
self.request_sessions[request_id] = key
if state.prepared_identity == identity and state.prepared is not None:
return state.prepared
prepared = state.prepared
if prepared.prompt_offset < 0:
prepared.prompt_offset = int(prompt_len) - int(prepared.inputs_embeds.shape[0])
if prepared.prompt_offset < 0:
raise ValueError(
"PersonaPlex scheduler prompt reservation mismatch: "
f"reserved={prompt_len}, prepared={prepared.inputs_embeds.shape[0]}"
)
return prepared
if seq <= state.last_seq:
raise ValueError(f"PersonaPlex duplex append seq must increase: last={state.last_seq}, got={seq}")
if state.encoded_identity != identity:
self._encode_rows([(state, identity, self._decode_pcm(duplex.get("payload")))])
user_frame = state.encoded_frame
self._push_user_history(state)
state.encoded_identity = None
state.encoded_frame = None
state.user_codes = user_frame if state.user_codes is None else torch.cat([state.user_codes, user_frame], dim=0)
@@ -344,44 +369,188 @@ class PersonaPlexStage0DuplexRuntime:
f"PersonaPlex scheduler prompt reservation mismatch: reserved={prompt_len}, prepared={prepared_len}"
)
input_ids = torch.zeros((prepared_len,), dtype=torch.long, device=device)
info_update = {
"pplex_user_codes": state.user_codes,
"pplex_silence_codes": silence.detach().cpu(),
"pplex_depformer_audio_tokens": depformer_audio_tokens.detach().cpu(),
"pplex_depformer_audio_provided": depformer_audio_provided.detach().cpu(),
"meta": {
"pplex_frame": state.prefill_slots + int(state.user_codes.shape[0]),
"pplex_prefill_len": state.prefill_slots,
},
"duplex": {
"stage0_prepared": True,
"prefill_applied": first_append,
"session_id": session_id,
"epoch": epoch,
"seq": seq,
},
}
prepared = PersonaPlexStage0PreparedAppend(
info_update = self._prepared_info_update(
state, seq, silence=silence.detach().cpu(), prefill_applied=first_append
)
# This path has no device teacher-forcing copies, so the talker reads
# these host tensors from the request information.
info_update["pplex_depformer_audio_tokens"] = depformer_audio_tokens.detach().cpu()
info_update["pplex_depformer_audio_provided"] = depformer_audio_provided.detach().cpu()
return self._store_prepared(
state,
seq,
PersonaPlexStage0PreparedAppend(
input_ids=input_ids,
inputs_embeds=full_embeds,
user_codes=state.user_codes,
info_update=info_update,
prefill_applied=first_append,
prompt_offset=prompt_offset,
),
)
state.prepared_identity = identity
@staticmethod
def _prepared_info_update(
state: PersonaPlexStage0SessionState,
seq: int,
*,
silence: torch.Tensor,
prefill_applied: bool,
) -> dict[str, Any]:
"""Request-information update shared by the per-request and batched append paths."""
return {
"pplex_user_codes": state.user_codes,
"pplex_silence_codes": silence,
"meta": {
"pplex_frame": state.prefill_slots + int(state.user_codes.shape[0]),
"pplex_prefill_len": state.prefill_slots,
},
"duplex": {
"stage0_prepared": True,
"prefill_applied": prefill_applied,
"session_id": state.session_id,
"epoch": state.epoch,
"seq": seq,
},
}
@staticmethod
def _store_prepared(
state: PersonaPlexStage0SessionState,
seq: int,
prepared: PersonaPlexStage0PreparedAppend,
) -> PersonaPlexStage0PreparedAppend:
"""Record ``prepared`` as the session's current append and advance its sequence."""
state.prepared_identity = (state.epoch, seq)
state.prepared = prepared
state.last_seq = seq
return prepared
@staticmethod
def _push_user_history(state: PersonaPlexStage0SessionState) -> None:
"""Move the consumed encoded frame into the device history (last two frames)."""
if state.encoded_frame_device is None:
raise RuntimeError("PersonaPlex Stage 0 append has no encoded user frame")
state.user_history_device.append(state.encoded_frame_device)
del state.user_history_device[:-2]
state.encoded_frame_device = None
def _constants(self) -> dict[str, torch.Tensor]:
"""Live-append constant frames on the device (plus host silence), built once."""
if self._constants_cache is not None:
return self._constants_cache
import torch
device, _ = self._model_device_dtype()
silence = torch.tensor(SILENCE_TOKENS, dtype=torch.long)
self._constants_cache = {
"silence": silence,
"silence_device": silence.to(device),
"sine_device": torch.tensor(SINE_TOKENS, dtype=torch.long, device=device),
"live_provided_device": torch.tensor([False] * 8 + [True] * 8, dtype=torch.bool, device=device),
"zero_text_device": torch.tensor([ZERO_TEXT_TOKEN], dtype=torch.long, device=device),
}
return self._constants_cache
def prepare_live_appends(self, appends: list[dict[str, Any]]) -> int:
"""Prepare every one-frame live append of this step with one batched embedding call.
Only appends whose frame was encoded this step and that are not the
first append of their epoch (which carries the voice/persona prefill)
take this path; the rest are left to :meth:`prepare_append`. Results are
stored exactly as :meth:`prepare_append` stores them, so its per-request
call returns the cached preparation. Returns the number prepared.
"""
import torch
batch: dict[tuple[str, int], tuple[PersonaPlexStage0SessionState, int]] = {}
for duplex in appends:
try:
session_id, epoch, seq = _append_identity(duplex)
except ValueError:
continue
key = (session_id, epoch)
state = self.sessions.get(key)
# Only an append encoded in this step and not yet consumed: its seq
# is past last_seq, so it has not been prepared either.
if state is None or state.last_seq == 0 or seq <= state.last_seq or key in batch:
continue
if state.encoded_identity != (epoch, seq):
continue
batch[key] = (state, seq)
if not batch:
return 0
c = self._constants()
device, dtype = self._model_device_dtype()
sine_d, silence_d = c["sine_device"], c["silence_device"]
texts, agents, d0s, d1s, currents = [], [], [], [], []
for state, _ in batch.values():
history = state.user_history_device # consumed frames, newest last
texts.append(
state.last_text_token.reshape(1) if state.last_text_token is not None else c["zero_text_device"]
)
agents.append(state.last_agent_codes[:8] if state.last_agent_codes is not None else silence_d)
# The frame being appended is not in the history yet: user cb0 reads
# the previous frame and user cb1..7 the one before it.
d0s.append(history[-1] if len(history) >= 1 else sine_d)
d1s.append(history[-2] if len(history) >= 2 else sine_d)
currents.append(state.encoded_frame_device)
n = len(batch)
text = torch.stack(texts)
agent = torch.stack(agents)
user_d0 = torch.stack(d0s)
user_d1 = torch.stack(d1s)
current = torch.stack(currents)
stack = torch.cat([text, agent, user_d0[:, :1], user_d1[:, 1:8]], dim=1).unsqueeze(-1)
embeds = self.stage_model.input_embeddings(stack).reshape(n, 1, -1).to(dtype=dtype)
dep_tokens_device = torch.cat([silence_d.expand(n, 8), current[:, :1], user_d0[:, 1:8]], dim=1)
input_ids = torch.zeros((n, 1), dtype=torch.long, device=device)
for row, (state, seq) in enumerate(batch.values()):
user_frame = state.encoded_frame
self._push_user_history(state)
state.encoded_identity = None
state.encoded_frame = None
state.user_codes = torch.cat([state.user_codes, user_frame], dim=0)
# The talker reads teacher forcing from the device copies below, so
# no host tokens or mask are built for these appends.
self._store_prepared(
state,
seq,
PersonaPlexStage0PreparedAppend(
input_ids=input_ids[row],
inputs_embeds=embeds[row],
user_codes=state.user_codes,
info_update=self._prepared_info_update(state, seq, silence=c["silence"], prefill_applied=False),
prefill_applied=False,
# Resolved by prepare_append from the scheduler's prompt length.
prompt_offset=-1,
depformer_audio_tokens_device=dep_tokens_device[row],
depformer_audio_provided_device=c["live_provided_device"],
),
)
return n
def prepared_depformer_state(self, request_id: str) -> tuple[torch.Tensor, torch.Tensor] | None:
"""Device teacher-forcing tensors of the request's current append, when prepared on the device."""
key = self.request_sessions.get(request_id)
state = self.sessions.get(key) if key is not None else None
prepared = state.prepared if state is not None else None
if prepared is None or prepared.depformer_audio_tokens_device is None:
return None
return prepared.depformer_audio_tokens_device, prepared.depformer_audio_provided_device
def record_sample(
self,
*,
request_id: str,
text_token: Any,
agent_codes: Any,
effective_codes: Any,
) -> None:
"""Commit one sampled temporal frame for the next live append."""
"""Commit one sampled temporal frame for the next live append.
``effective_codes`` are the frame's eight agent codes with the append's
depformer teacher forcing already applied, which the next append reads.
"""
import torch
if request_id in self._stale_requests:
@@ -396,26 +565,12 @@ class PersonaPlexStage0DuplexRuntime:
return
text = torch.as_tensor(text_token, device=self._model_device_dtype()[0], dtype=torch.long).reshape(-1)
codes = torch.as_tensor(agent_codes, device=text.device, dtype=torch.long).reshape(-1)
codes = torch.as_tensor(effective_codes, device=text.device, dtype=torch.long).reshape(-1)
if text.numel() != 1:
raise ValueError(f"PersonaPlex Stage 0 expected one sampled text token, got {text.numel()}")
if codes.numel() < 8:
raise ValueError(f"PersonaPlex Stage 0 expected at least 8 agent codes, got {codes.numel()}")
effective_codes = codes[:8].clone()
prepared = state.prepared
if prepared is None:
raise RuntimeError(f"PersonaPlex Stage 0 request has no prepared payload: {request_id}")
target = prepared.info_update.get("pplex_depformer_audio_tokens")
provided = prepared.info_update.get("pplex_depformer_audio_provided")
if not isinstance(target, torch.Tensor) or not isinstance(provided, torch.Tensor):
raise RuntimeError("PersonaPlex Stage 0 prepared payload has no depformer teacher-forcing state")
target = target.reshape(-1).to(device=effective_codes.device, dtype=torch.long)
provided = provided.reshape(-1).to(device=effective_codes.device, dtype=torch.bool)
if target.numel() < 8 or provided.numel() < 8:
raise RuntimeError("PersonaPlex Stage 0 depformer teacher-forcing state is shorter than one agent frame")
effective_codes = torch.where(provided[:8], target[:8], effective_codes)
state.last_agent_codes = effective_codes.detach()
if codes.numel() != 8:
raise ValueError(f"PersonaPlex Stage 0 expected 8 effective agent codes, got {codes.numel()}")
state.last_agent_codes = codes.detach()
state.last_text_token = text.detach()
state.sampled_identity = state.prepared_identity
@@ -482,12 +637,14 @@ class PersonaPlexStage0DuplexRuntime:
pcm[state.slot] = torch.from_numpy(samples)
active[state.slot] = True
encoded = codec.encode_frame(pcm, active)
codes_device = encoded.detach()[:, :8].to(dtype=torch.long)
# One device-to-host copy per step, whatever the number of sessions.
codes = encoded.detach().to(dtype=torch.long, device="cpu")
if codes.shape[-1] < 8:
raise RuntimeError(f"PersonaPlex Mimi encoder returned {codes.shape[-1]} codebooks, expected at least 8")
for state, identity, _ in rows:
state.encoded_frame = codes[state.slot : state.slot + 1, :8].clone()
state.encoded_frame_device = codes_device[state.slot]
state.encoded_identity = identity
def _shared_codec(self):
@@ -506,6 +663,15 @@ class PersonaPlexStage0DuplexRuntime:
device=self.device,
)
codec.streaming_init(self.max_sessions)
if self._codec_cuda_graphs:
# Rows are leased and recycled in place, so one graph at
# max_sessions with the active mask as input serves every step.
captured = codec.capture_cuda_graphs(encode=True, decode_frame_counts=())
logger.info(
"PersonaPlex Stage 0 Mimi encoder %s at %d rows",
"replays a CUDA graph" if captured else "runs eagerly",
self.max_sessions,
)
self._codec = codec
return codec
@@ -406,6 +406,28 @@ class PersonaPlexCode2Wav(nn.Module):
).eval()
for _ in range(self._max_codec_sessions)
]
if getattr(self.config, "mimi_cuda_graphs", False):
# Each decoder is leased per request and reset in place, so its B=1
# graphs stay valid. Deltas arrive as the first single frame and then
# full chunks, so those two frame counts are recorded; other counts
# run eagerly. The graphs replay one at a time on this stage's stream
# and copy their output, so they share one pool.
pool = torch.cuda.graph_pool_handle() if torch.device(str(device)).type == "cuda" else None
captured = 0
for codec in codecs:
codec.streaming_init(1)
captured += bool(
codec.capture_cuda_graphs(
encode=False,
decode_frame_counts=(1, _MIMI_DECODE_BATCH_FRAMES),
pool=pool,
)
)
logger.info(
"PersonaPlex Code2Wav replays Mimi decode from CUDA graphs on %d/%d stream(s)",
captured,
len(codecs),
)
self._set_mimi_codecs(codecs)
self._mimi_device = torch.device(str(device))
reported_sr = getattr(codecs[0].model.config, "sampling_rate", None)
@@ -29,9 +29,12 @@ codec composes with elastic slot recycling in batched duplex serving.
from __future__ import annotations
from typing import TYPE_CHECKING
import torch
import torch.nn as nn
import torch.nn.functional as F
from vllm.logger import init_logger
from vllm_omni.model_executor.models.personaplex.personaplex_temporal import (
_apply_rope,
@@ -42,13 +45,17 @@ DEFAULT_HF_REPO = "kyutai/mimi"
FRAME_SIZE = 1920
CODEBOOKS = 8
logger = init_logger(__name__)
if TYPE_CHECKING:
from vllm_omni.model_executor.models.personaplex.personaplex_mimi_cudagraph import MimiFrameGraph
def _normalize_active(active: torch.Tensor | None, all_active: torch.Tensor) -> torch.Tensor:
# Stage 0 and Code2Wav currently use one B=1 codec per session and omit
# `active`, so preserve that API by treating None as all rows active. When
# they switch to shared B>1 codecs, their batch builders must pass bool[B]:
# Stage 0 shares one codec with a row per session slot and passes bool[B]:
# True advances that row's streaming state; False keeps an absent or padded
# row's offsets and convolution carries unchanged.
# row's offsets and convolution carries unchanged. Code2Wav leases one B=1
# codec per request and omits `active`, which means all rows are active.
if active is None:
return all_active
if active.shape != all_active.shape:
@@ -107,6 +114,10 @@ class _StreamConv1d:
``pad_mode`` controls the stream-start left padding: SEANet convs use zeros
(``constant``); the down/upsample resamplers use ``replicate`` (the first
real sample), marked per row so elastic slot recycling re-primes correctly.
The carry and the fresh-row flags are allocated once in ``reset`` and only
ever updated in place, so a CUDA graph recorded over ``__call__`` keeps
reading and writing the live state across frames, row resets and sessions.
"""
def __init__(self, conv: nn.Conv1d, pad_mode: str = "constant") -> None:
@@ -122,29 +133,36 @@ class _StreamConv1d:
self.prev = torch.zeros(batch_size, self.conv.in_channels, pad, device=device, dtype=dtype)
self._fresh = torch.ones(batch_size, dtype=torch.bool, device=device)
def reset_all(self) -> None:
self.prev.zero_()
self._fresh.fill_(True)
def reset_slot(self, b: int) -> None:
self.prev[b].zero_()
self._fresh[b] = True
def __call__(self, x: torch.Tensor, active: torch.Tensor) -> torch.Tensor:
active_view = active.view(-1, 1, 1)
if self.pad_mode == "replicate":
pad = self.prev.shape[-1]
edge = x[..., 0:1].expand(-1, -1, pad)
fresh = (self._fresh & active).view(-1, 1, 1)
self.prev = torch.where(fresh, edge.to(self.prev.dtype), self.prev)
self._fresh[active] = False
self.prev.copy_(torch.where(fresh, edge.to(self.prev.dtype), self.prev))
self._fresh.logical_and_(~active)
x = torch.cat([self.prev, x], dim=-1)
t = x.shape[-1]
num_frames = max(0, (t - self.kernel) // self.stride + 1)
prev = x[..., num_frames * self.stride :]
self.prev = torch.where(active.view(-1, 1, 1), prev, self.prev)
self.prev.copy_(torch.where(active_view, x[..., num_frames * self.stride :], self.prev))
if num_frames == 0:
return x.new_zeros(x.shape[0], self.conv.out_channels, 0)
return self.conv(x[..., : (num_frames - 1) * self.stride + self.kernel])
class _StreamConvTr1d:
"""Moshi ``RawStreamingConvTranspose1d``: overlap-add tail carry per call."""
"""Moshi ``RawStreamingConvTranspose1d``: overlap-add tail carry per call.
Like ``_StreamConv1d``, the tail carry is updated in place only.
"""
def __init__(self, conv: nn.ConvTranspose1d) -> None:
self.conv = conv
@@ -159,6 +177,10 @@ class _StreamConvTr1d:
)
self._fresh = torch.ones(batch_size, dtype=torch.bool, device=device)
def reset_all(self) -> None:
self.partial.zero_()
self._fresh.fill_(True)
def reset_slot(self, b: int) -> None:
self.partial[b].zero_()
self._fresh[b] = True
@@ -168,17 +190,17 @@ class _StreamConvTr1d:
length = out.shape[-1]
tail = self.kernel - self.stride
pt = self.partial.shape[-1]
merge = self.partial.clone()
merge = self.partial
if self.conv.bias is not None:
# The carried tail already includes the bias; the fresh output adds
# it again, so subtract one copy -- except on a row's very first
# frame, where the carry is zeros by construction.
merge = merge - self.conv.bias[:, None]
merge[self._fresh & active] = 0.0
self._fresh[active] = False
first = (self._fresh & active).view(-1, 1, 1)
merge = torch.where(first, torch.zeros_like(merge), merge)
self._fresh.logical_and_(~active)
out[..., :pt] += merge
partial = out[..., length - tail :].clone()
self.partial = torch.where(active.view(-1, 1, 1), partial, self.partial)
self.partial.copy_(torch.where(active.view(-1, 1, 1), out[..., length - tail :], self.partial))
return out[..., : length - tail]
@@ -358,13 +380,16 @@ class PersonaPlexMimiCodec(nn.Module):
n_dec = self.decoder_transformer.load_weights(sd, "decoder_transformer")
assert n_enc == n_dec == 80, (n_enc, n_dec)
del sd
self._init_streaming_stages()
def _init_streaming_stages(self) -> None:
m = self.model
self._enc_stages = _walk_seanet(m.encoder.layers)
self._downsample = _StreamConv1d(m.downsample.conv, pad_mode="replicate")
self._upsample = _StreamConvTr1d(m.upsample.conv)
self._dec_stages = _walk_seanet(m.decoder.layers)
self._batch_size: int | None = None
self._cuda_graphs: dict[str, MimiFrameGraph] = {}
self._all_active: torch.Tensor
# -- streaming state ------------------------------------------------------
@@ -380,7 +405,22 @@ class PersonaPlexMimiCodec(nn.Module):
yield self._upsample
def streaming_init(self, batch_size: int) -> None:
# Re-initializing at the same batch size resets in place: the state
# tensors keep their storage, so captured frame graphs stay valid.
if getattr(self, "_batch_size", None) == batch_size:
self.reset_streaming()
return
if getattr(self, "_cuda_graphs", None):
# The state is reallocated below, so graphs recorded at the old
# batch size would read stale tensors; drop them and say so.
logger.warning(
"PersonaPlex Mimi stream resized from %s to %d rows; dropping its CUDA graphs (%s)",
self._batch_size,
batch_size,
"/".join(sorted(self._cuda_graphs)),
)
self._batch_size = batch_size
self._cuda_graphs = {}
self._all_active = torch.ones(batch_size, dtype=torch.bool, device=self.device)
for s in self._conv_states():
s.reset(batch_size, self.device, self.dtype)
@@ -390,8 +430,7 @@ class PersonaPlexMimiCodec(nn.Module):
def reset_streaming(self) -> None:
assert self._batch_size is not None
for state in self._conv_states():
for b in range(self._batch_size):
state.reset_slot(b)
state.reset_all()
self.encoder_transformer.reset_streaming()
self.decoder_transformer.reset_streaming()
@@ -401,6 +440,42 @@ class PersonaPlexMimiCodec(nn.Module):
self.encoder_transformer.reset_slot(b)
self.decoder_transformer.reset_slot(b)
def capture_cuda_graphs(
self,
*,
encode: bool = True,
decode_frame_counts: tuple[int, ...] = (1,),
warmup_iters: int = 2,
pool: tuple[int, int] | None = None,
) -> list[str]:
"""Record the per-frame codec steps at the streaming batch size.
``encode`` records ``encode_frame``; each ``F`` in ``decode_frame_counts``
records ``decode_frames`` over ``F`` frames (``decode_frame`` is ``F=1``).
Every row is recorded active; the ``active`` mask is a graph input, so
one graph serves any subset of live rows. Warmup frames run through the
real streaming state, which is then reset in place, so the codec is
fresh afterwards. ``pool`` is a CUDA graph memory pool to share with
other codecs replayed on the same stream; by default the codec gets a
private one. Returns the captured graph names; on a non-CUDA device the
codec stays eager, and so it does after a recoverable capture failure,
which is logged with its traceback. Warmup, device execution and reset
failures propagate instead of leaving an apparently usable codec.
"""
from vllm_omni.model_executor.models.personaplex.personaplex_mimi_cudagraph import (
capture_mimi_frame_graphs,
)
assert self._batch_size is not None, "call streaming_init first"
self._cuda_graphs = capture_mimi_frame_graphs(
self,
encode=encode,
decode_frame_counts=decode_frame_counts,
warmup_iters=warmup_iters,
pool=pool,
)
return sorted(self._cuda_graphs)
# -- per-frame codec -------------------------------------------------------
@staticmethod
@@ -413,32 +488,68 @@ class PersonaPlexMimiCodec(nn.Module):
x = stage(x, active) if kind in {"conv", "convtr"} else stage(x)
return x
def _frame_graph(self, name: str) -> MimiFrameGraph | None:
graph = self._cuda_graphs.get(name)
if graph is None or torch.cuda.is_current_stream_capturing():
return None
return graph
@torch.no_grad()
def encode_frame(self, pcm: torch.Tensor, active: torch.Tensor | None = None) -> torch.Tensor:
"""``[B, frame_size]`` float PCM -> ``[B, 8]`` codes."""
x = pcm.to(self.device, self.dtype).view(-1, 1, FRAME_SIZE)
active = _normalize_active(active, self._all_active)
graph = self._frame_graph("encode")
if graph is not None:
return graph.replay(pcm.reshape(-1, FRAME_SIZE), active)
return self._encode_frame_eager(pcm, active)
def _encode_frame_eager(self, pcm: torch.Tensor, active: torch.Tensor) -> torch.Tensor:
x = pcm.to(self.device, self.dtype).view(-1, 1, FRAME_SIZE)
x = self._run_stages(x, self._enc_stages, active)
x = self.encoder_transformer.step(x.transpose(1, 2), active).transpose(1, 2)
x = self._downsample(x, active)
codes = self.model.quantizer.encode(x, num_quantizers=CODEBOOKS) # [Q, B, T]
return codes[:CODEBOOKS, :, 0].transpose(0, 1).contiguous()
def _quantizer_decode(self, codes: torch.Tensor) -> torch.Tensor:
"""``MimiSplitResidualVectorQuantizer.decode`` without its host-side scalar.
The Hugging Face RVQ decode seeds its sum with ``torch.tensor(0.0)``, a
host-to-device copy that cannot be recorded in a CUDA graph. This
seeds it with an on-device zero instead; the arithmetic is unchanged.
"""
quantizer = self.model.quantizer
split = quantizer.num_semantic_quantizers
def rvq_decode(rvq, rvq_codes: torch.Tensor) -> torch.Tensor:
out = torch.zeros((), device=rvq_codes.device)
for layer, indices in zip(rvq.layers, rvq_codes.transpose(0, 1)):
out = out + layer.decode(indices)
if rvq.output_proj is not None:
out = rvq.output_proj(out)
return out
out = rvq_decode(quantizer.semantic_residual_vector_quantizer, codes[:, :split])
if codes.shape[1] > split:
out += rvq_decode(quantizer.acoustic_residual_vector_quantizer, codes[:, split:])
return out
@torch.no_grad()
def decode_frame(self, codes: torch.Tensor, active: torch.Tensor | None = None) -> torch.Tensor:
"""``[B, 8]`` codes -> ``[B, frame_size]`` float PCM."""
emb = self.model.quantizer.decode(codes.to(self.device).view(-1, CODEBOOKS, 1))
active = _normalize_active(active, self._all_active)
emb = self._upsample(emb, active)
emb = self.decoder_transformer.step(emb.transpose(1, 2), active).transpose(1, 2)
x = self._run_stages(emb, self._dec_stages, active)
return x[:, 0, :]
return self.decode_frames(codes.reshape(-1, CODEBOOKS, 1), active)
@torch.no_grad()
def decode_frames(self, codes: torch.Tensor, active: torch.Tensor | None = None) -> torch.Tensor:
"""``[B, 8, F]`` codes -> ``[B, F * frame_size]`` float PCM."""
emb = self.model.quantizer.decode(codes.to(self.device))
active = _normalize_active(active, self._all_active)
graph = self._frame_graph(f"decode_f{codes.shape[-1]}")
if graph is not None:
return graph.replay(codes, active)
return self._decode_frames_eager(codes, active)
def _decode_frames_eager(self, codes: torch.Tensor, active: torch.Tensor) -> torch.Tensor:
emb = self._quantizer_decode(codes.to(self.device))
emb = self._upsample(emb, active)
emb = self.decoder_transformer.step(emb.transpose(1, 2), active).transpose(1, 2)
x = self._run_stages(emb, self._dec_stages, active)
@@ -0,0 +1,221 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Replay the PersonaPlex streaming Mimi frame steps from CUDA graphs.
One 80 ms Mimi encode or decode frame is a few hundred small kernels (SEANet
convs, an 8-layer streaming transformer and the RVQ), so eager execution is
launch bound. Every streaming state tensor of ``PersonaPlexMimiCodec`` (conv
carries, fresh-row flags, ring KV caches and offsets) is allocated once by
``streaming_init`` and only updated in place, and row resets are in place too,
so a graph recorded at the streaming batch size stays valid across frames,
session slot recycling and full stream resets.
The ``active`` row mask is a graph input: one graph at the shared batch size
serves any subset of live rows, and inactive rows keep their state exactly as
in eager execution.
"""
from __future__ import annotations
import traceback
from collections.abc import Callable
from contextlib import ExitStack
from typing import TYPE_CHECKING
import torch
from vllm.logger import init_logger
if TYPE_CHECKING:
from vllm_omni.model_executor.models.personaplex.personaplex_mimi import PersonaPlexMimiCodec
logger = init_logger(__name__)
__all__ = ["MimiFrameGraph", "capture_mimi_frame_graphs"]
def _is_recoverable_capture_error(error: RuntimeError) -> bool:
"""Allow only known capture limitations, never an unexplained CUDA error."""
pending: list[BaseException] = [error]
seen: set[int] = set()
known_failure = False
while pending:
current = pending.pop()
if id(current) in seen:
continue
seen.add(id(current))
message = str(current).split("\n", 1)[0]
if isinstance(current, torch.cuda.OutOfMemoryError) or (
isinstance(current, RuntimeError)
and (
message.startswith("CUDA out of memory.")
or message
in {
"CUDA error: out of memory",
"CUDA error: operation not permitted when stream is capturing",
}
)
):
known_failure = True
elif not (
isinstance(current, RuntimeError)
and message == "CUDA error: operation failed due to a previous error during capture"
):
return False
# capture_end can replace a body exception with capture-invalidated.
# The original failure must also be recoverable, even if suppressed.
pending.extend(exc for exc in (current.__cause__, current.__context__) if exc is not None)
return known_failure
def _capture_error_details(error: RuntimeError) -> str:
"""Preserve diagnostics without retaining capture frames or graph pools."""
details = "".join(traceback.format_exception(error))
pending: list[BaseException] = [error]
seen: set[int] = set()
while pending:
current = pending.pop()
if id(current) in seen:
continue
seen.add(id(current))
if current.__traceback__ is not None:
# ExitStack frames can retain their own traceback in exception tuples.
traceback.clear_frames(current.__traceback__)
current.__traceback__ = None
pending.extend(exc for exc in (current.__cause__, current.__context__) if exc is not None)
return details
class MimiFrameGraph:
"""A recorded ``(frame, active) -> output`` codec step with static buffers."""
def __init__(
self,
graph: torch.cuda.CUDAGraph,
static_input: torch.Tensor,
static_active: torch.Tensor,
static_output: torch.Tensor,
eager: Callable[[torch.Tensor, torch.Tensor], torch.Tensor],
) -> None:
self.graph = graph
self.static_input = static_input
self.static_active = static_active
self.static_output = static_output
self.eager = eager
self.replays = 0
self._warned = False
def replay(self, frame: torch.Tensor, active: torch.Tensor) -> torch.Tensor:
if frame.shape != self.static_input.shape or active.shape != self.static_active.shape:
if not self._warned:
self._warned = True
logger.warning(
"PersonaPlex Mimi graph input shape %s does not match capture shape %s; running eagerly",
tuple(frame.shape),
tuple(self.static_input.shape),
)
return self.eager(frame, active)
self.static_input.copy_(frame)
self.static_active.copy_(active)
self.graph.replay()
self.replays += 1
# The static output is overwritten by the next replay.
return self.static_output.clone()
def capture_mimi_frame_graphs(
codec: PersonaPlexMimiCodec,
*,
encode: bool,
decode_frame_counts: tuple[int, ...],
warmup_iters: int = 2,
pool: tuple[int, int] | None = None,
) -> dict[str, MimiFrameGraph]:
"""Capture the codec's encode and ``F``-frame decode steps.
Returns an empty dict off CUDA or after a recoverable capture failure.
Warmup, device execution and stream-reset failures always propagate.
"""
from vllm_omni.model_executor.models.personaplex.personaplex_mimi import CODEBOOKS, FRAME_SIZE
device = torch.device(codec.device)
if device.type != "cuda" or not torch.cuda.is_available():
logger.info("PersonaPlex Mimi CUDA graphs skipped on device %s", device)
return {}
batch_size = codec._batch_size
specs: list[tuple[str, Callable, torch.Tensor]] = []
if encode:
specs.append(
(
"encode",
codec._encode_frame_eager,
torch.zeros(batch_size, FRAME_SIZE, device=device, dtype=codec.dtype),
)
)
for frames in sorted(set(decode_frame_counts)):
specs.append(
(
f"decode_f{frames}",
codec._decode_frames_eager,
torch.zeros(batch_size, CODEBOOKS, frames, device=device, dtype=torch.long),
)
)
# A private pool by default: these graphs are captured outside the model
# runner's own capture sequence, so they must not reuse its pool blocks.
if pool is None:
pool = torch.cuda.graph_pool_handle()
graphs: dict[str, MimiFrameGraph] = {}
warmed_specs: list[tuple[str, Callable, torch.Tensor, torch.Tensor]] = []
capture_error_details: str | None = None
try:
with torch.no_grad():
# Validate every eager step before capture can select the fallback.
for name, eager, static_input in specs:
static_active = torch.ones(batch_size, dtype=torch.bool, device=device)
for _ in range(max(warmup_iters, 1)):
eager(static_input, static_active)
torch.accelerator.synchronize(device)
warmed_specs.append((name, eager, static_input, static_active))
for name, eager, static_input, static_active in warmed_specs:
# capture_end may raise before torch.cuda.graph restores its
# stream. Always restore our caller's stream on that path.
with torch.cuda.stream(torch.cuda.current_stream(device)):
graph = torch.cuda.CUDAGraph()
# __enter__ synchronizes and clears the CUDA cache. A
# failure there is a setup failure, not a capture fallback.
capture = torch.cuda.graph(graph, pool=pool, capture_error_mode="thread_local")
capture.__enter__()
try:
# Other worker threads may issue CUDA calls while this records.
with ExitStack() as stack:
stack.push(capture)
static_output = eager(static_input, static_active)
except RuntimeError as error:
if not _is_recoverable_capture_error(error):
raise
capture_error_details = _capture_error_details(error)
graphs.clear()
# Release partial graphs and graph-owned output tensors
# before reset, including the last loop iteration.
graph = static_output = capture = None
break
# A device execution failure is not an optional capture failure.
torch.accelerator.synchronize(device)
graphs[name] = MimiFrameGraph(graph, static_input, static_active, static_output, eager)
finally:
# Warmup frames advanced the real streaming state; restore a fresh
# stream without reallocating the tensors the graphs point at.
codec.reset_streaming()
# Reset enqueues device work too. Do not report usable graphs or an eager
# fallback unless that work completed successfully.
torch.accelerator.synchronize(device)
if capture_error_details is not None:
logger.warning(
"PersonaPlex Mimi CUDA graphs were requested (%s at batch size %d) but capture failed; "
"the codec runs eagerly, which lowers realtime session capacity\n%s",
"/".join(name for name, _, _ in specs),
batch_size,
capture_error_details,
)
if graphs:
logger.debug("Captured PersonaPlex Mimi %s graph(s) at batch size %d", "/".join(graphs), batch_size)
return graphs
@@ -30,7 +30,7 @@ encode of the input WAV (built in ``preprocess``); live duplex is Phase 2.
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
from typing import TYPE_CHECKING, Any
import torch
from torch import nn
@@ -54,6 +54,11 @@ from vllm_omni.model_executor.models.personaplex.personaplex_embeddings import (
PersonaPlexInputEmbeddings,
)
if TYPE_CHECKING:
from vllm_omni.model_executor.models.personaplex.duplex.stage0 import (
PersonaPlexStage0DuplexRuntime,
)
__all__ = ["PersonaPlexTalkerForConditionalGeneration"]
@@ -437,6 +442,7 @@ class PersonaPlexTalkerForConditionalGeneration(nn.Module):
model_path=model_path,
device=device,
max_sessions=int(getattr(self.vllm_config.model_config, "duplex_max_sessions", 1)),
codec_cuda_graphs=bool(getattr(self.config, "mimi_cuda_graphs", False)),
)
self._personaplex_duplex_stage0_runtime = runtime
return runtime
@@ -485,7 +491,9 @@ class PersonaPlexTalkerForConditionalGeneration(nn.Module):
if isinstance(duplex, dict) and duplex.get("data_plane") is True:
appends.append(duplex)
if appends:
self._duplex_stage0_runtime().encode_appends(appends)
runtime = self._duplex_stage0_runtime()
runtime.encode_appends(appends)
runtime.prepare_live_appends(appends)
def on_requests_finished(self, finished_req_ids: set[str] | list[str]) -> None:
runtime = getattr(self, "_personaplex_duplex_stage0_runtime", None)
@@ -551,9 +559,48 @@ class PersonaPlexTalkerForConditionalGeneration(nn.Module):
)
text_token = input_ids.reshape(bsz).to(torch.long)
hidden = hidden_states.reshape(bsz, 1, -1).to(self._dtype)
audio_tokens: list[torch.Tensor] = []
audio_provided: list[torch.Tensor] = []
for info in req_infos:
runtime = self._duplex_stage0_runtime()
audio_tokens, audio_provided = self._depformer_teacher_forcing(runtime, req_ids, req_infos, hidden.device)
codes = self.depformer(
text_token,
hidden,
audio_tokens=audio_tokens,
audio_provided=audio_provided,
num_steps=self.num_active_codebooks,
).to(torch.long)
# The next append reads the agent frame with the teacher-forced
# codebooks applied; merge it once for the whole batch.
effective = torch.where(audio_provided[:, :8], audio_tokens[:, :8], codes[:, :8])
for row, request_id in enumerate(req_ids):
runtime.record_sample(
request_id=request_id,
text_token=text_token[row],
effective_codes=effective[row],
)
return codes
@staticmethod
def _depformer_teacher_forcing(
runtime: PersonaPlexStage0DuplexRuntime,
req_ids: list[str],
req_infos: list[dict[str, Any]],
device: torch.device,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Stack each request's depformer teacher-forcing tokens and mask on ``device``.
Live appends batched in ``preprocess_batch`` already hold device copies.
The rest (first appends, aborted epochs) carry host tensors in their
request information and are moved in one copy.
"""
tokens_rows: list[torch.Tensor] = []
provided_rows: list[torch.Tensor] = []
host_rows: list[int] = []
for row, (request_id, info) in enumerate(zip(req_ids, req_infos)):
prepared = runtime.prepared_depformer_state(request_id)
if prepared is not None:
tokens_rows.append(prepared[0])
provided_rows.append(prepared[1])
continue
tokens = info.get("pplex_depformer_audio_tokens")
provided = info.get("pplex_depformer_audio_provided")
if not isinstance(tokens, torch.Tensor) or not isinstance(provided, torch.Tensor):
@@ -565,29 +612,18 @@ class PersonaPlexTalkerForConditionalGeneration(nn.Module):
"PersonaPlex depformer teacher-forcing token/mask shapes differ: "
f"{tuple(tokens.shape)} != {tuple(provided.shape)}"
)
audio_tokens.append(tokens)
audio_provided.append(provided)
codes = self.depformer(
text_token,
hidden,
audio_tokens=torch.stack(audio_tokens).to(
device=hidden.device,
dtype=torch.long,
),
audio_provided=torch.stack(audio_provided).to(
device=hidden.device,
dtype=torch.bool,
),
num_steps=self.num_active_codebooks,
).to(torch.long)
runtime = self._duplex_stage0_runtime()
for row, request_id in enumerate(req_ids):
runtime.record_sample(
request_id=request_id,
text_token=text_token[row],
agent_codes=codes[row],
)
return codes
host_rows.append(row)
tokens_rows.append(tokens)
provided_rows.append(provided)
if host_rows:
host_tokens = torch.stack([tokens_rows[row] for row in host_rows]).to(device=device, dtype=torch.long)
host_provided = torch.stack([provided_rows[row] for row in host_rows]).to(device=device, dtype=torch.bool)
if len(host_rows) == len(req_ids):
return host_tokens, host_provided
for index, row in enumerate(host_rows):
tokens_rows[row] = host_tokens[index]
provided_rows[row] = host_provided[index]
return torch.stack(tokens_rows), torch.stack(provided_rows)
# ------------------------------------------------------------------
# Weight loading