[Perf][PersonaPlex] Serve 16 concurrent realtime duplex sessions on one GPU (#8192)
This commit is contained in:
1 parent
36b74f99f6
commit
f5bdb20e9d
12 files changed
+563
-102
No files matched your search
@@ -101,6 +101,8 @@ def test_personaplex_deploy_is_duplex_and_propagates_capacity_to_all_model_stage
|
||||
assert deploy.session_mode == "duplex"
|
||||
assert deploy.duplex_session.max_sessions == 2
|
||||
assert [stage.yaml_engine_args.get("duplex_max_sessions") for stage in stages] == [2, 2]
|
||||
# Lockstep frames of all sessions must share a step (see the deploy comment).
|
||||
assert stages[0].yaml_engine_args.get("async_scheduling") is False
|
||||
assert "personaplex_codec_max_sessions" not in deploy.connectors["connector_of_shared_memory"]["extra"]
|
||||
|
||||
|
||||
|
||||
@@ -13,8 +13,10 @@ from vllm_omni.model_executor.models.personaplex.duplex.policy import (
|
||||
SINE_TOKENS,
|
||||
)
|
||||
from vllm_omni.model_executor.models.personaplex.duplex.stage0 import (
|
||||
PersonaPlexStage0CapacityError,
|
||||
PersonaPlexStage0DuplexRuntime,
|
||||
PersonaPlexStage0PreparedAppend,
|
||||
PersonaPlexStage0StaleEpochError,
|
||||
)
|
||||
from vllm_omni.model_executor.models.personaplex.personaplex_talker import (
|
||||
PersonaPlexTalkerForConditionalGeneration,
|
||||
@@ -24,20 +26,30 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class _FakeCodec:
|
||||
"""Shared streaming encoder whose code for a row is that row's frame count."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.encode_calls = 0
|
||||
self.reset_calls = 0
|
||||
self.reset_slots: list[int] = []
|
||||
self.frames: list[int] = []
|
||||
|
||||
def streaming_init(self, batch_size: int) -> None:
|
||||
assert batch_size == 1
|
||||
self.frames = [0] * batch_size
|
||||
|
||||
def encode_frame(self, pcm: torch.Tensor) -> torch.Tensor:
|
||||
assert pcm.shape == (1, 1920)
|
||||
def encode_frame(self, pcm: torch.Tensor, active: torch.Tensor) -> torch.Tensor:
|
||||
assert pcm.shape == (len(self.frames), 1920)
|
||||
assert active.shape == (len(self.frames),)
|
||||
self.encode_calls += 1
|
||||
return torch.full((1, 8), self.encode_calls, dtype=torch.long)
|
||||
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]
|
||||
return codes
|
||||
|
||||
def reset_streaming(self) -> None:
|
||||
self.reset_calls += 1
|
||||
def reset_slot(self, row: int) -> None:
|
||||
self.reset_slots.append(row)
|
||||
self.frames[row] = 0
|
||||
|
||||
|
||||
class _FakeTalker:
|
||||
@@ -101,20 +113,14 @@ def _duplex_info(*, seq: int, session_id: str = "session", epoch: int = 0):
|
||||
}
|
||||
|
||||
|
||||
def _runtime(*codecs: _FakeCodec) -> PersonaPlexStage0DuplexRuntime:
|
||||
def _runtime(codec: _FakeCodec, max_sessions: int = 1) -> PersonaPlexStage0DuplexRuntime:
|
||||
voice_embeddings = torch.arange(8, dtype=torch.float32).reshape(2, 1, 1, 4)
|
||||
codec_args: dict[str, object] = {"codec": codecs[0]}
|
||||
if len(codecs) > 1:
|
||||
available = iter(codecs)
|
||||
codec_args = {
|
||||
"codec_factory": lambda: next(available),
|
||||
"max_sessions": len(codecs),
|
||||
}
|
||||
return PersonaPlexStage0DuplexRuntime(
|
||||
_FakeTalker(),
|
||||
model_path="/unused",
|
||||
device="cpu",
|
||||
**codec_args,
|
||||
codec=codec,
|
||||
max_sessions=max_sessions,
|
||||
tokenizer=lambda _text: [7, 8, 9],
|
||||
voice_loader=lambda _voice: {
|
||||
"embeddings": voice_embeddings,
|
||||
@@ -268,9 +274,7 @@ def _prepare_two_sessions(
|
||||
|
||||
|
||||
def test_live_sessions_keep_independent_streaming_encoders() -> None:
|
||||
first_codec = _FakeCodec()
|
||||
second_codec = _FakeCodec()
|
||||
runtime = _runtime(first_codec, second_codec)
|
||||
runtime = _runtime(_FakeCodec(), max_sessions=2)
|
||||
|
||||
first_1, second_1, first_2, second_2 = _prepare_two_sessions(runtime)
|
||||
|
||||
@@ -281,7 +285,7 @@ def test_live_sessions_keep_independent_streaming_encoders() -> None:
|
||||
|
||||
|
||||
def test_stage0_session_capacity_fails_before_codec_state_is_shared() -> None:
|
||||
runtime = _runtime(_FakeCodec(), _FakeCodec())
|
||||
runtime = _runtime(_FakeCodec(), max_sessions=2)
|
||||
_prepare_two_sessions(runtime)
|
||||
|
||||
with pytest.raises(RuntimeError, match="capacity 2"):
|
||||
@@ -291,21 +295,23 @@ def test_stage0_session_capacity_fails_before_codec_state_is_shared() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_close_session_resets_and_reuses_released_codec() -> None:
|
||||
first_codec = _FakeCodec()
|
||||
second_codec = _FakeCodec()
|
||||
runtime = _runtime(first_codec, second_codec)
|
||||
def test_close_session_resets_only_its_row_and_reuses_it() -> None:
|
||||
codec = _FakeCodec()
|
||||
runtime = _runtime(codec, max_sessions=2)
|
||||
_prepare_two_sessions(runtime)
|
||||
closed_slot = runtime.sessions[("session", 0)].slot
|
||||
other_slot = runtime.sessions[("other", 0)].slot
|
||||
|
||||
runtime.close_session("session", 0)
|
||||
|
||||
assert first_codec.reset_calls == 1
|
||||
assert second_codec.reset_calls == 0
|
||||
assert codec.reset_slots == [closed_slot]
|
||||
replacement = runtime.prepare_append(
|
||||
_duplex_info(seq=1, session_id="replacement"),
|
||||
prompt_len=18,
|
||||
)
|
||||
assert replacement.user_codes[:, 0].tolist() == [3]
|
||||
assert runtime.sessions[("replacement", 0)].slot == closed_slot
|
||||
assert replacement.user_codes[:, 0].tolist() == [1]
|
||||
assert codec.frames[other_slot] == 2
|
||||
|
||||
|
||||
def test_a_new_epoch_replays_the_prefill_and_recycles_the_codec() -> None:
|
||||
@@ -324,8 +330,8 @@ def test_a_new_epoch_replays_the_prefill_and_recycles_the_codec() -> None:
|
||||
assert restarted.user_codes.shape == (1, 8)
|
||||
assert list(runtime.sessions) == [("session", 1)]
|
||||
assert runtime.request_sessions == {"req-e1": ("session", 1)}
|
||||
assert codec.reset_calls == 1
|
||||
assert runtime.sessions[("session", 1)].codec is codec
|
||||
assert codec.reset_slots == [0]
|
||||
assert runtime.sessions[("session", 1)].slot == 0
|
||||
|
||||
|
||||
def test_a_late_finish_of_the_old_epoch_request_does_not_close_the_new_state() -> None:
|
||||
@@ -341,11 +347,164 @@ def test_a_late_finish_of_the_old_epoch_request_does_not_close_the_new_state() -
|
||||
|
||||
|
||||
def test_stage0_capacity_counts_live_epochs_not_superseded_ones() -> None:
|
||||
runtime = _runtime(_FakeCodec(), _FakeCodec())
|
||||
runtime = _runtime(_FakeCodec(), max_sessions=2)
|
||||
runtime.prepare_append(_duplex_info(seq=1), prompt_len=18)
|
||||
runtime.prepare_append(_duplex_info(seq=1, session_id="other"), prompt_len=18)
|
||||
|
||||
# Restarting one session must not need a third codec.
|
||||
# Restarting one session must not need a third encoder row.
|
||||
runtime.prepare_append(_duplex_info(seq=1, epoch=1), prompt_len=18)
|
||||
|
||||
assert sorted(runtime.sessions) == [("other", 0), ("session", 1)]
|
||||
|
||||
|
||||
def test_encode_appends_batches_sessions_into_one_encoder_call() -> None:
|
||||
codec = _FakeCodec()
|
||||
runtime = _runtime(codec, max_sessions=4)
|
||||
|
||||
runtime.encode_appends([_duplex_info(seq=1), _duplex_info(seq=1, session_id="other")])
|
||||
first = runtime.prepare_append(_duplex_info(seq=1), prompt_len=18)
|
||||
other = runtime.prepare_append(_duplex_info(seq=1, session_id="other"), prompt_len=18)
|
||||
|
||||
assert codec.encode_calls == 1
|
||||
assert first.user_codes[:, 0].tolist() == [1]
|
||||
assert other.user_codes[:, 0].tolist() == [1]
|
||||
|
||||
|
||||
def test_encode_appends_leaves_rows_without_a_new_append_untouched() -> None:
|
||||
codec = _FakeCodec()
|
||||
runtime = _runtime(codec, max_sessions=2)
|
||||
runtime.prepare_append(_duplex_info(seq=1), prompt_len=18)
|
||||
runtime.prepare_append(_duplex_info(seq=1, session_id="other"), prompt_len=18)
|
||||
other_slot = runtime.sessions[("other", 0)].slot
|
||||
|
||||
runtime.encode_appends([_duplex_info(seq=2)])
|
||||
runtime.prepare_append(_duplex_info(seq=2), prompt_len=19)
|
||||
|
||||
assert codec.frames[other_slot] == 1
|
||||
assert runtime.sessions[("session", 0)].user_codes[:, 0].tolist() == [1, 2]
|
||||
|
||||
|
||||
def test_encode_appends_skips_an_identity_that_was_already_encoded_or_prepared() -> None:
|
||||
codec = _FakeCodec()
|
||||
runtime = _runtime(codec)
|
||||
|
||||
# A chunked first prefill puts the same append in two scheduler steps.
|
||||
runtime.encode_appends([_duplex_info(seq=1)])
|
||||
runtime.encode_appends([_duplex_info(seq=1)])
|
||||
runtime.prepare_append(_duplex_info(seq=1), prompt_len=18)
|
||||
runtime.encode_appends([_duplex_info(seq=1)])
|
||||
retry = runtime.prepare_append(_duplex_info(seq=1), prompt_len=18)
|
||||
|
||||
assert codec.encode_calls == 1
|
||||
assert retry.user_codes[:, 0].tolist() == [1]
|
||||
|
||||
|
||||
def test_encode_appends_leaves_over_capacity_appends_to_prepare_append() -> None:
|
||||
codec = _FakeCodec()
|
||||
runtime = _runtime(codec)
|
||||
|
||||
runtime.encode_appends([_duplex_info(seq=1), _duplex_info(seq=1, session_id="other")])
|
||||
|
||||
assert list(runtime.sessions) == [("session", 0)]
|
||||
with pytest.raises(RuntimeError, match="capacity 1"):
|
||||
runtime.prepare_append(_duplex_info(seq=1, session_id="other"), prompt_len=18)
|
||||
|
||||
|
||||
def _restart_overlap(runtime: PersonaPlexStage0DuplexRuntime) -> tuple[dict, dict]:
|
||||
"""Epoch 0 of a session is live, then a cancel restarts it as epoch 1 while
|
||||
epoch 0's aborted request still has an append in the same scheduler step."""
|
||||
runtime.prepare_append(_duplex_info(seq=1), prompt_len=18, request_id="req-e0")
|
||||
runtime.prepare_append(_duplex_info(seq=2), prompt_len=19, request_id="req-e0")
|
||||
return _duplex_info(seq=3), _duplex_info(seq=1, epoch=1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("max_sessions", [1, 2])
|
||||
@pytest.mark.parametrize("encode_old_first", [True, False])
|
||||
@pytest.mark.parametrize("prepare_old_first", [True, False])
|
||||
def test_cancel_overlap_full_processing_order_never_re_leases_the_aborted_epoch(
|
||||
max_sessions: int, encode_old_first: bool, prepare_old_first: bool
|
||||
) -> None:
|
||||
codec = _FakeCodec()
|
||||
runtime = _runtime(codec, max_sessions=max_sessions)
|
||||
old, new = _restart_overlap(runtime)
|
||||
|
||||
# One scheduler step: batched encode, then per-request prepare, then sampling.
|
||||
runtime.encode_appends([old, new] if encode_old_first else [new, old])
|
||||
order = [("req-e0", old, 19), ("req-e1", new, 18)]
|
||||
restarted = None
|
||||
for request_id, duplex, prompt_len in order if prepare_old_first else order[::-1]:
|
||||
if request_id == "req-e0":
|
||||
with pytest.raises(PersonaPlexStage0StaleEpochError):
|
||||
runtime.prepare_append(duplex, prompt_len=prompt_len, request_id=request_id)
|
||||
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.close_request("req-e0") # the engine's late finish of the aborted request
|
||||
|
||||
assert list(runtime.sessions) == [("session", 1)]
|
||||
assert len(runtime._free_slots) == max_sessions - 1
|
||||
assert restarted is not None and restarted.user_codes[:, 0].tolist() == [1]
|
||||
assert codec.encode_calls == 3
|
||||
assert runtime.sessions[("session", 1)].sampled_identity == (1, 1)
|
||||
|
||||
|
||||
def test_codec_init_failure_propagates_after_one_attempt() -> None:
|
||||
attempts = 0
|
||||
|
||||
def failing_codec():
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
raise RuntimeError("CUDA out of memory while building the Mimi encoder")
|
||||
|
||||
runtime = PersonaPlexStage0DuplexRuntime(
|
||||
_FakeTalker(),
|
||||
model_path="/unused",
|
||||
device="cpu",
|
||||
codec_factory=failing_codec,
|
||||
max_sessions=16,
|
||||
tokenizer=lambda _text: [7, 8, 9],
|
||||
voice_loader=lambda _voice: {},
|
||||
)
|
||||
appends = [_duplex_info(seq=1, session_id=f"s{i}") for i in range(16)]
|
||||
|
||||
with pytest.raises(RuntimeError, match="out of memory"):
|
||||
runtime.encode_appends(appends)
|
||||
assert attempts == 1
|
||||
assert not runtime.sessions
|
||||
|
||||
|
||||
def test_capacity_error_is_a_dedicated_exception() -> None:
|
||||
runtime = _runtime(_FakeCodec(), max_sessions=1)
|
||||
runtime.prepare_append(_duplex_info(seq=1), prompt_len=18)
|
||||
|
||||
with pytest.raises(PersonaPlexStage0CapacityError, match="capacity 1"):
|
||||
runtime.prepare_append(_duplex_info(seq=1, session_id="other"), prompt_len=18)
|
||||
|
||||
|
||||
def test_talker_gives_the_aborted_epoch_a_neutral_row_in_the_same_step() -> None:
|
||||
codec = _FakeCodec()
|
||||
runtime = _runtime(codec, max_sessions=1)
|
||||
old, new = _restart_overlap(runtime)
|
||||
talker = PersonaPlexTalkerForConditionalGeneration.__new__(PersonaPlexTalkerForConditionalGeneration)
|
||||
torch.nn.Module.__init__(talker)
|
||||
talker._personaplex_duplex_stage0_runtime = runtime
|
||||
talker._dtype = torch.float32
|
||||
talker.mtp_hidden_size = 4
|
||||
|
||||
infos = {
|
||||
"req-e0": {"duplex": old, "request_id": "req-e0", "duplex_prompt_len": 19, "duplex_token_offset": 0},
|
||||
"req-e1": {"duplex": new, "request_id": "req-e1", "duplex_prompt_len": 18, "duplex_token_offset": 0},
|
||||
}
|
||||
talker.preprocess_batch(req_ids=list(infos), model_intermediate_buffer=infos, device=torch.device("cpu"))
|
||||
outs = {
|
||||
req: talker.preprocess(torch.zeros(1, dtype=torch.long), None, _omni_is_prefill=True, **info)
|
||||
for req, info in infos.items()
|
||||
}
|
||||
|
||||
_, stale_embeds, stale_info = outs["req-e0"]
|
||||
assert stale_info["duplex"] == {"stage0_stale": True}
|
||||
assert torch.count_nonzero(stale_embeds) == 0
|
||||
assert stale_info["pplex_depformer_audio_tokens"].numel() == 16
|
||||
assert outs["req-e1"][2]["duplex"]["epoch"] == 1
|
||||
assert list(runtime.sessions) == [("session", 1)]
|
||||
@@ -0,0 +1,55 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.models.personaplex.configuration_personaplex import (
|
||||
PersonaPlexDepformerConfig,
|
||||
)
|
||||
from vllm_omni.model_executor.models.personaplex.personaplex_depformer import PersonaPlexDepformer
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _tiny_depformer() -> PersonaPlexDepformer:
|
||||
torch.manual_seed(0)
|
||||
config = PersonaPlexDepformerConfig(
|
||||
hidden_size=32,
|
||||
num_hidden_layers=2,
|
||||
num_attention_heads=4,
|
||||
head_dim=8,
|
||||
num_key_value_heads=4,
|
||||
intermediate_size=48,
|
||||
dep_q=16,
|
||||
num_active_codebooks=8,
|
||||
card=64,
|
||||
)
|
||||
model = PersonaPlexDepformer(config, temporal_hidden_size=24, text_card=100)
|
||||
for param in model.parameters():
|
||||
torch.nn.init.normal_(param, std=0.2)
|
||||
return model.eval()
|
||||
|
||||
|
||||
def test_num_steps_matches_prefix_of_full_run_with_teacher_forcing():
|
||||
model = _tiny_depformer()
|
||||
text = torch.tensor([3, 7, 11])
|
||||
hidden = torch.randn(3, 1, 24)
|
||||
tokens = torch.randint(0, 64, (3, 16))
|
||||
provided = torch.zeros(3, 16, dtype=torch.bool)
|
||||
provided[0, 1:8] = True
|
||||
provided[:, 8:] = True
|
||||
|
||||
full, full_logits = model(text, hidden, tokens, provided, return_logits=True)
|
||||
head, head_logits = model(text, hidden, tokens, provided, return_logits=True, num_steps=8)
|
||||
|
||||
assert full.shape == (3, 16)
|
||||
assert head.shape == (3, 8)
|
||||
assert torch.equal(head, full[:, :8])
|
||||
assert torch.equal(head_logits, full_logits[:, :8])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("num_steps", [0, 17])
|
||||
def test_num_steps_out_of_range_is_rejected(num_steps):
|
||||
model = _tiny_depformer()
|
||||
with pytest.raises(ValueError, match="num_steps"):
|
||||
model(torch.tensor([1]), torch.randn(1, 1, 24), num_steps=num_steps)
|
||||
@@ -367,3 +367,30 @@ def test_mimi_conv_carries_preserve_inactive_rows(kind: str) -> None:
|
||||
else:
|
||||
assert torch.equal(_stream_carry(batched)[row], carry_before[row])
|
||||
assert torch.equal(batched._fresh[row], fresh_before[row])
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.cpu
|
||||
def test_recycled_mimi_transformer_row_matches_a_fresh_stream():
|
||||
from vllm_omni.model_executor.models.personaplex.personaplex_mimi import _MimiStreamingTransformer
|
||||
|
||||
torch.manual_seed(0)
|
||||
used = _MimiStreamingTransformer(num_layers=2, dim=16, num_heads=2, context=8)
|
||||
for param in used.parameters():
|
||||
torch.nn.init.normal_(param, std=0.1)
|
||||
fresh = _MimiStreamingTransformer(num_layers=2, dim=16, num_heads=2, context=8)
|
||||
fresh.load_state_dict(used.state_dict())
|
||||
used.streaming_init(2)
|
||||
fresh.streaming_init(2)
|
||||
both = torch.tensor([True, True])
|
||||
|
||||
# Wrap row 0's ring (6 frames x 2 positions > context 8) before recycling it.
|
||||
for _ in range(6):
|
||||
used.step(torch.randn(2, 2, 16), both)
|
||||
used.reset_slot(0)
|
||||
|
||||
frames = [torch.randn(2, 2, 16) for _ in range(5)]
|
||||
for x in frames:
|
||||
recycled = used.step(x, both)
|
||||
expected = fresh.step(x, both)
|
||||
torch.testing.assert_close(recycled[0], expected[0], rtol=0, atol=0)
|
||||
@@ -0,0 +1,30 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.models.output_templates import OmniOutput
|
||||
from vllm_omni.model_executor.models.personaplex.personaplex_talker import (
|
||||
PersonaPlexTalkerForConditionalGeneration,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_make_omni_output_keeps_token_rows_for_mixed_prefill_and_live_rows():
|
||||
# One live session carries one cached audio row; a newly admitted session
|
||||
# prefills 150 rows in the same step. The runner indexes the hidden states
|
||||
# with token-space logits indices, so the last prefill row must survive.
|
||||
hidden = torch.randn(151, 16)
|
||||
live_codes = torch.zeros(1, 8, dtype=torch.long)
|
||||
out = PersonaPlexTalkerForConditionalGeneration.make_omni_output(
|
||||
None,
|
||||
hidden,
|
||||
model_intermediate_buffer=[{"codes": {"audio": live_codes}}, {}],
|
||||
)
|
||||
|
||||
assert isinstance(out, OmniOutput)
|
||||
assert out.text_hidden_states.shape[0] == 151
|
||||
logits_indices = torch.tensor([0, 150])
|
||||
assert out.text_hidden_states[logits_indices].shape == (2, 16)
|
||||
assert torch.equal(out.multimodal_outputs["codes"]["audio"], live_codes)
|
||||
Reference in new issue
Block a user