Files
vllm-omni/tests/model_executor/models/personaplex/test_mimi_cudagraph.py
T
2026-10-07 08:03:03 -07:00

614 lines
24 KiB
Python

# 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.platforms import current_platform
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)]
def _assert_waveform_matches(actual: torch.Tensor, expected: torch.Tensor) -> None:
if not current_platform.is_rocm():
assert torch.equal(actual, expected)
return
# ROCm convolution kernels can accumulate low-order FP32 differences
# between eager execution and CUDA graph replay. Keep token outputs exact,
# and bound both the peak and aggregate waveform error here.
error = (actual - expected).abs()
rms = error.square().mean().sqrt().item()
print(f"PersonaPlex Mimi ROCm graph parity: max_abs={error.max().item():.9g}, rms={rms:.9g}")
torch.testing.assert_close(actual, expected, atol=1e-3, rtol=1e-4)
assert rms <= 1e-4, rms
@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_waveform_matches(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_waveform_matches(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_waveform_matches(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_waveform_matches(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