[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:
1 parent
e61cd4290f
commit
ed6bc5ae0c
14 files changed
+1640
-116
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user