feat(moss-tts): add CUDA Graph support for codec decoder (#4157)

Signed-off-by: leo.yang <leo.yang.engineer@gmail.com>
This commit is contained in:
yangyonggit authored and GitHub committed 2026-06-11 22:17:31 +00:00
1 parent 152343d5fb
commit a3834e32cf
5 files changed
+637 -18

No files matched your search

Whitespace-only changes.
@@ -0,0 +1,361 @@
"""Tests for MossTTSCUDAGraphCodecWrapper numerical equivalence.
Verifies that CUDA Graph-accelerated decoding produces results equivalent
to eager mode, with special attention to the two-argument _decode interface
(codes [NQ, 1, T] + lengths [1]) and the NQ-first input convention.
"""
import pytest
import torch
import torch.nn as nn
from vllm_omni.model_executor.models.moss_tts.audio_tokenizer import (
MossAudioTokenizerDecoderOutput,
)
from vllm_omni.model_executor.models.moss_tts.moss_codec_cudagraph import (
MossTTSCUDAGraphCodecWrapper,
)
pytestmark = [pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")]
DEVICE = torch.device("cuda:0")
NUM_QUANTIZERS = 8
DOWNSAMPLE_RATE = 4 # synthetic; real checkpoint uses 1920
# ---------------------------------------------------------------------------
# Synthetic codec model
# ---------------------------------------------------------------------------
class SyntheticCodecModel(nn.Module):
"""Minimal stand-in for MossAudioTokenizerModel.
Exposes the same two-argument _decode(codes, lengths) interface and
returns a MossAudioTokenizerDecoderOutput, mirroring the real model.
Input:
codes: [NQ, B, T] long — RVQ codes
lengths: [B] long — valid frame counts
Output:
MossAudioTokenizerDecoderOutput(audio=[B, 1, T*upsample], audio_lengths=[B])
"""
def __init__(
self,
num_quantizers: int = NUM_QUANTIZERS,
downsample_rate: int = DOWNSAMPLE_RATE,
):
super().__init__()
self.downsample_rate = downsample_rate
hidden = 32
# embed: treat NQ quantizers as input channels for Conv1d
self.embed = nn.Conv1d(num_quantizers, hidden, kernel_size=3, padding=1)
self.conv = nn.Conv1d(hidden, hidden, kernel_size=3, padding=1)
self.upsample = nn.ConvTranspose1d(hidden, 1, kernel_size=downsample_rate, stride=downsample_rate)
def _decode(self, codes: torch.Tensor, lengths: torch.Tensor) -> MossAudioTokenizerDecoderOutput:
"""codes: [NQ, B, T], lengths: [B] → audio [B, 1, T*upsample]."""
nq, b, t = codes.shape
# treat NQ as channel dim → [B, NQ, T] for Conv1d (expects [B, C, T])
x = codes.permute(1, 0, 2).to(dtype=self.embed.weight.dtype) # [B, NQ, T]
x = torch.relu(self.embed(x)) # [B, hidden, T]
x = torch.relu(self.conv(x)) # [B, hidden, T]
audio = self.upsample(x) # [B, 1, T * upsample]
audio_lengths = lengths * self.downsample_rate
return MossAudioTokenizerDecoderOutput(audio=audio, audio_lengths=audio_lengths)
def batch_decode(
self,
codes_list: list[torch.Tensor],
num_quantizers: int | None = None,
) -> MossAudioTokenizerDecoderOutput:
"""Mirrors MossAudioTokenizerModel.batch_decode (fallback path)."""
device = codes_list[0].device
nq = num_quantizers or codes_list[0].shape[0]
max_t = max(c.shape[-1] for c in codes_list)
codes = torch.zeros(nq, len(codes_list), max_t, device=device, dtype=torch.long)
lengths = torch.zeros(len(codes_list), device=device, dtype=torch.long)
for i, c in enumerate(codes_list):
codes[:nq, i, : c.shape[-1]] = c[:nq]
lengths[i] = c.shape[-1]
return self._decode(codes, lengths)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def model():
torch.manual_seed(0)
return SyntheticCodecModel().to(DEVICE).eval()
@pytest.fixture(scope="module")
def wrapper(model):
w = MossTTSCUDAGraphCodecWrapper(
model=model,
capture_sizes=[25, 50, 100],
num_quantizers=NUM_QUANTIZERS,
enabled=True,
)
w.warmup(DEVICE)
return w
def _random_codes(t: int, device: torch.device = DEVICE) -> torch.Tensor:
"""Return [NQ, T] long tensor (single-request convention)."""
return torch.randint(0, 64, (NUM_QUANTIZERS, t), dtype=torch.long, device=device)
def _eager_decode(model: SyntheticCodecModel, codes_nq_t: torch.Tensor) -> MossAudioTokenizerDecoderOutput:
"""Run the model directly in eager mode (reference output)."""
t = codes_nq_t.shape[-1]
codes_nq_1_t = codes_nq_t.unsqueeze(1) # [NQ, 1, T]
lengths = torch.tensor([t], dtype=torch.long, device=codes_nq_t.device)
with torch.no_grad():
return model._decode(codes_nq_1_t, lengths)
# ---------------------------------------------------------------------------
# 1. Exact-size inputs — must be bit-identical
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("t", [25, 50, 100])
def test_exact_size_bit_identical(model, wrapper, t):
codes = _random_codes(t)
ref = _eager_decode(model, codes)
with torch.no_grad():
out = wrapper.decode(codes)
torch.testing.assert_close(out.audio, ref.audio, atol=0, rtol=0)
# ---------------------------------------------------------------------------
# 2. Padded inputs — output trimmed to actual length
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("t", [10, 30, 47, 73, 99])
def test_padded_output_shape(model, wrapper, t):
codes = _random_codes(t)
ref = _eager_decode(model, codes)
with torch.no_grad():
out = wrapper.decode(codes)
expected_len = t * DOWNSAMPLE_RATE
assert out.audio.shape[-1] == expected_len, f"got {out.audio.shape[-1]}, want {expected_len}"
assert out.audio.shape == ref.audio.shape
@pytest.mark.parametrize("t", [10, 30, 47, 73, 99])
def test_padded_interior_positions_close(model, wrapper, t):
"""Positions well away from the zero-padding boundary must be numerically close."""
codes = _random_codes(t)
ref = _eager_decode(model, codes)
with torch.no_grad():
out = wrapper.decode(codes)
boundary = 2 * DOWNSAMPLE_RATE
if ref.audio.shape[-1] > boundary:
torch.testing.assert_close(
out.audio[..., :-boundary],
ref.audio[..., :-boundary],
atol=1e-5,
rtol=1e-5,
)
# ---------------------------------------------------------------------------
# 3. Fallback to eager (T > all capture sizes)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("t", [101, 150, 200])
def test_fallback_exact_match(model, wrapper, t):
codes = _random_codes(t)
ref = _eager_decode(model, codes)
with torch.no_grad():
out = wrapper.decode(codes)
torch.testing.assert_close(out.audio, ref.audio, atol=0, rtol=0)
# ---------------------------------------------------------------------------
# 4. Static buffer not aliased across calls
# ---------------------------------------------------------------------------
def test_output_not_aliased_after_later_replay(model, wrapper):
"""clone() in decode() must prevent later replays from overwriting the result."""
codes_a = _random_codes(50)
codes_b = _random_codes(50)
with torch.no_grad():
out_a = wrapper.decode(codes_a)
saved = out_a.audio.clone()
_ = wrapper.decode(codes_b) # replay overwrites static buffer
torch.testing.assert_close(out_a.audio, saved, atol=0, rtol=0)
def test_deterministic_across_calls(model, wrapper):
codes = _random_codes(30)
with torch.no_grad():
out1 = wrapper.decode(codes)
out2 = wrapper.decode(codes)
torch.testing.assert_close(out1.audio, out2.audio, atol=0, rtol=0)
# ---------------------------------------------------------------------------
# 5. Disabled wrapper falls back to eager
# ---------------------------------------------------------------------------
def test_disabled_falls_back_to_eager(model, wrapper):
codes = _random_codes(30)
ref = _eager_decode(model, codes)
wrapper.enabled = False
with torch.no_grad():
out = wrapper.decode(codes)
wrapper.enabled = True
torch.testing.assert_close(out.audio, ref.audio, atol=0, rtol=0)
# ---------------------------------------------------------------------------
# 6. audio_lengths is consistent with audio shape
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("t", [25, 30, 50, 99, 100])
def test_audio_lengths_consistent(model, wrapper, t):
codes = _random_codes(t)
with torch.no_grad():
out = wrapper.decode(codes)
assert out.audio_lengths is not None
assert int(out.audio_lengths[0].item()) == out.audio.shape[-1]
# ---------------------------------------------------------------------------
# 7. NQ-first input layout: codes_nq_t is [NQ, T], not [T, NQ]
# ---------------------------------------------------------------------------
def test_nq_first_layout_matches_eager(model, wrapper):
"""Verify that wrapper correctly interprets [NQ, T] (NQ-first) layout."""
t = 25
codes = _random_codes(t) # [NQ, T]
ref = _eager_decode(model, codes)
with torch.no_grad():
out = wrapper.decode(codes)
torch.testing.assert_close(out.audio, ref.audio, atol=0, rtol=0)
# ---------------------------------------------------------------------------
# 8. Non-contiguous input — copy_() must handle strided tensors correctly
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("t", [20, 47])
def test_noncontiguous_input_matches_contiguous(model, wrapper, t):
"""Padded path uses copy_() on a slice; must produce the same result
whether codes_nq_t is contiguous or a non-contiguous view (e.g. a column
slice of a larger buffer, which is the realistic production layout when the
caller slices out one request from a batched tensor)."""
codes_contiguous = _random_codes(t)
# Build a non-contiguous view: embed in a larger [NQ, 2, T] buffer and
# take column 0. The resulting tensor has stride (2*T, 2, 1) → non-contiguous.
buf = torch.zeros(NUM_QUANTIZERS, 2, t, dtype=torch.long, device=DEVICE)
buf[:, 0, :] = codes_contiguous
codes_noncontig = buf[:, 0, :] # [NQ, T], non-contiguous
assert not codes_noncontig.is_contiguous()
with torch.no_grad():
out_c = wrapper.decode(codes_contiguous)
out_nc = wrapper.decode(codes_noncontig)
torch.testing.assert_close(out_c.audio, out_nc.audio, atol=0, rtol=0)
# ---------------------------------------------------------------------------
# 9. Zero-padding uses token 0 — must be a valid codebook index (no OOB)
# ---------------------------------------------------------------------------
def test_zero_padding_no_cuda_error(model, wrapper):
"""Padded inputs fill unused frames with code 0. Verify that the zero-
padded region does not corrupt the output shape.
Note: SyntheticCodecModel uses Conv1d (not nn.Embedding), so it cannot
trigger a real CUDA index-out-of-bounds on large token ids. This test
validates shape correctness only; OOB safety on the real codec requires
an integration test with MossAudioTokenizerModel."""
t = 7 # well below the 25-frame bucket → large zero-padded region
codes = _random_codes(t)
with torch.no_grad():
out = wrapper.decode(codes)
torch.accelerator.synchronize() # surface any deferred CUDA errors
assert out.audio.shape[-1] == t * DOWNSAMPLE_RATE
# ---------------------------------------------------------------------------
# 10. Shape alignment: graph path and eager fallback return identical shapes
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("t", [25, 30, 101])
def test_graph_and_eager_shape_identical(model, wrapper, t):
"""Graph replay (t<=100) and eager fallback (t>100) must return tensors
with the same number of dimensions and the same batch/channel layout so
downstream consumers can treat both paths uniformly."""
codes = _random_codes(t)
ref = _eager_decode(model, codes)
with torch.no_grad():
out = wrapper.decode(codes)
assert out.audio.ndim == ref.audio.ndim, f"ndim mismatch: graph={out.audio.ndim} eager={ref.audio.ndim}"
assert out.audio.shape[:-1] == ref.audio.shape[:-1], (
f"batch/channel shape mismatch: graph={out.audio.shape} eager={ref.audio.shape}"
)
# ---------------------------------------------------------------------------
# 11. Half-precision weights: graph and eager must agree under fp16/bf16
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16])
def test_half_precision_graph_eager_agree(dtype):
"""CUDA Graph must produce outputs consistent with eager when model weights
are in half precision. The wrapper itself stores codes as long and lengths
as long; only the model's conv weights change dtype."""
torch.manual_seed(0)
model_hp = SyntheticCodecModel().to(device=DEVICE, dtype=dtype).eval()
wrapper_hp = MossTTSCUDAGraphCodecWrapper(
model=model_hp,
capture_sizes=[25, 50],
num_quantizers=NUM_QUANTIZERS,
enabled=True,
)
wrapper_hp.warmup(DEVICE)
codes = _random_codes(25)
ref = _eager_decode(model_hp, codes)
with torch.no_grad():
out = wrapper_hp.decode(codes)
# Half-precision accumulation introduces small numerical error; use
# tolerances that match typical fp16/bf16 rounding.
atol = 1e-2 if dtype == torch.float16 else 5e-2
torch.testing.assert_close(out.audio.float(), ref.audio.float(), atol=atol, rtol=1e-3)
# ---------------------------------------------------------------------------
# 12. Empty input (T=0): wrapper must not crash and return zero-length audio
# ---------------------------------------------------------------------------
def test_empty_input_t0(model, wrapper):
"""T=0 input must be handled gracefully: no exception, audio length == 0.
The outer MossTTSCodecDecoder guards against empty segments, but the
wrapper should also handle T=0 without crashing so it is safe to call
directly."""
codes = _random_codes(0) # [NQ, 0]
with torch.no_grad():
out = wrapper.decode(codes)
assert out.audio.shape[-1] == 0, f"expected empty audio, got shape {out.audio.shape}"
+2 -1
View File
@@ -14,6 +14,7 @@ connectors:
connector_get_max_wait: 300
codec_chunk_frames: 25
codec_left_context_frames: 0
decode_cudagraph_capture_sizes: [4, 8, 16, 25, 32, 50, 64, 100, 128, 200, 256]
stages:
- stage_id: 0
@@ -38,7 +39,7 @@ stages:
- stage_id: 1
max_num_seqs: 1
gpu_memory_utilization: 0.12
enforce_eager: true
enforce_eager: false
enable_prefix_caching: false
async_scheduling: true
# Talker can emit up to max_tokens=4096 frames × n_vq=32 = 131072 codec
@@ -20,6 +20,9 @@ from vllm_omni.model_executor.models.moss_tts.audio_tokenizer import (
MossAudioTokenizerConfig,
MossAudioTokenizerModel,
)
from vllm_omni.model_executor.models.moss_tts.moss_codec_cudagraph import (
MossTTSCUDAGraphCodecWrapper,
)
from vllm_omni.model_executor.models.output_templates import OmniOutput
logger = init_logger(__name__)
@@ -28,15 +31,16 @@ logger = init_logger(__name__)
class MossTTSCodecDecoder(nn.Module):
"""Stage-1 decoder for all MOSS-TTS variants.
Consumes ``(T, NQ)`` audio VQ codes emitted by Stage 0 and decodes them
Consumes ``(NQ, T)`` audio VQ codes emitted by Stage 0 and decodes them
to a 24 kHz mono waveform using the vendored
``MossAudioTokenizerModel``.
All five variants share the same codec checkpoint
``OpenMOSS-Team/MOSS-Audio-Tokenizer``. The number of quantizers
(``n_vq``) is passed as a ``num_quantizers`` argument to ``batch_decode``
so one codec instance handles both ``n_vq=32`` (MOSS-TTS) and ``n_vq=16``
(all other variants) without swapping weights.
(``n_vq``) is read from ``hf_config`` at construction time and fixed for
the lifetime of the instance; the same checkpoint can be configured as
``n_vq=32`` (MOSS-TTS) or ``n_vq=16`` (all other variants) without
swapping weights.
The codec checkpoint path defaults to the value stored in
``vllm_config.model_config.hf_config.codec_model_name_or_path`` but can
@@ -64,6 +68,7 @@ class MossTTSCodecDecoder(nn.Module):
# Resolved at load_weights() time
self._codec: MossAudioTokenizerModel | None = None
self._cuda_graph_wrapper: MossTTSCUDAGraphCodecWrapper | None = None
self._sr_tensor = torch.tensor(self._OUTPUT_SAMPLE_RATE, dtype=torch.int32)
# ------------------------------------------------------------------
@@ -100,10 +105,11 @@ class MossTTSCodecDecoder(nn.Module):
Stage 0 emits flat codebook-major ``[NQ * T_chunk]`` audio codes. The
chunk transfer adapter assigns those to ``request.prompt_token_ids``,
which arrives here as ``input_ids``. Per-request offsets are derived
from ``runtime_additional_information``-attached request metadata; meta
like ``left_context_size`` (overlap context for the causal decoder)
also lives there.
which arrives here as ``input_ids`` concatenated across all requests.
Per-request slice boundaries are computed from
``kwargs["num_scheduled_tokens"]`` (a list of token counts, one per
request). ``runtime_additional_information`` carries per-request
metadata such as ``left_context_size``.
Returns
-------
@@ -163,11 +169,10 @@ class MossTTSCodecDecoder(nn.Module):
continue
t_chunk = int(seg.numel() // self._n_vq)
codes_nq_t = seg.reshape(self._n_vq, t_chunk).to(device=device)
# Clamp out-of-range codes (the talker uses ``audio_pad_code``
# =``codebook_size`` for delay-pattern padding; passing it to the
# codec embedding lookup would be an OOB index. The downstream
# de-delay step is intended to drop those rows, but until that's
# wired we clamp so the kernel does not assert.)
# Clamp out-of-range codes: the talker uses ``audio_pad_code``
# (= ``codebook_size``) for delay-pattern padding. The stage input
# processor de-delays and drops pad rows before forwarding here, but
# clamp as a defensive guard against any edge-case leakage.
codebook_size = self._codec.config.codebook_size
codes_nq_t = codes_nq_t.clamp_(0, int(codebook_size) - 1)
@@ -183,7 +188,10 @@ class MossTTSCodecDecoder(nn.Module):
left_ctx = int(left_ctx.reshape(-1)[0].item()) if left_ctx.numel() else 0
left_ctx = int(left_ctx)
out = self._codec.batch_decode(codes_list=[codes_nq_t], num_quantizers=self._n_vq)
if self._cuda_graph_wrapper is not None:
out = self._cuda_graph_wrapper.decode(codes_nq_t)
else:
out = self._codec.batch_decode(codes_list=[codes_nq_t], num_quantizers=self._n_vq)
if out.audio is None:
continue
@@ -194,7 +202,13 @@ class MossTTSCodecDecoder(nn.Module):
# Trim left-context samples.
if left_ctx > 0:
trim = left_ctx * self._codec.downsample_rate
trim = min(left_ctx * self._codec.downsample_rate, wav.shape[0])
if trim < left_ctx * self._codec.downsample_rate:
logger.warning(
"left_ctx trim (%d samples) exceeds wav length (%d); returning empty audio.",
left_ctx * self._codec.downsample_rate,
wav.shape[0],
)
wav = wav[trim:]
audios[i] = wav
@@ -288,8 +302,7 @@ class MossTTSCodecDecoder(nn.Module):
codec_cfg.num_quantizers,
)
# Move sr_tensor to the same device
self._sr_tensor = self._sr_tensor.to(device=device)
self._maybe_enable_decoder_cudagraph(device)
# vLLM's track_weights_loading() compares the returned set against
# ``self.named_parameters()``. After ``self._codec = codec`` above,
@@ -297,5 +310,42 @@ class MossTTSCodecDecoder(nn.Module):
# mirror that here.
return {f"_codec.{name}" for name, _ in codec.named_parameters()}
def _maybe_enable_decoder_cudagraph(self, device: torch.device) -> None:
"""Capture CUDA Graphs for the codec decoder if enforce_eager is False."""
if getattr(self.vllm_config.model_config, "enforce_eager", True):
return
if self._codec is None:
return
# Read capture sizes from the connector's extra config (same convention
# as Qwen3-TTS), falling back to a sensible default covering common
# codec_chunk_frames values used in moss_tts.yaml.
capture_sizes: list[int] = [4, 8, 16, 25, 32, 50, 64, 100, 128, 200, 256]
model_cfg = getattr(self.vllm_config, "model_config", None)
connector_cfg = getattr(model_cfg, "stage_connector_config", None)
if isinstance(connector_cfg, dict):
extra_cfg: dict | None = connector_cfg.get("extra", connector_cfg)
else:
extra_cfg = getattr(connector_cfg, "extra", None)
if isinstance(extra_cfg, dict):
raw = extra_cfg.get("decode_cudagraph_capture_sizes")
if raw is not None:
if isinstance(raw, (list, tuple)):
parsed = sorted({int(v) for v in raw if int(v) > 0})
elif isinstance(raw, str):
parsed = sorted({int(v.strip()) for v in raw.split(",") if v.strip()})
else:
parsed = [int(raw)]
if parsed:
capture_sizes = parsed
self._cuda_graph_wrapper = MossTTSCUDAGraphCodecWrapper(
model=self._codec,
capture_sizes=capture_sizes,
num_quantizers=self._n_vq,
enabled=True,
)
self._cuda_graph_wrapper.warmup(device)
__all__ = ["MossTTSCodecDecoder"]
@@ -0,0 +1,207 @@
"""CUDA Graph acceleration for the MOSS Audio Tokenizer codec decoder.
Captures MossAudioTokenizerModel._decode for a set of fixed frame-count
bucket sizes, then replays the captured graph at inference time to eliminate
kernel-launch overhead. Inputs that exceed all captured sizes fall back to
eager execution transparently.
"""
from __future__ import annotations
import time
import torch
from torch.cuda import CUDAGraph
from vllm.logger import init_logger
from vllm.platforms import current_platform
from vllm_omni.model_executor.models.moss_tts.audio_tokenizer import (
MossAudioTokenizerDecoderOutput,
MossAudioTokenizerModel,
)
logger = init_logger(__name__)
class MossTTSCUDAGraphCodecWrapper:
"""CUDA Graph wrapper for MossAudioTokenizerModel._decode.
Graphs are keyed by padded_T (int). On each call the actual T is
bucket-matched to the smallest pre-captured size >= T. The static code
buffer [NQ, 1, padded_T] is filled left-aligned (right-zero-padded) and
the graph is replayed. The output audio is sliced to the correct length
by scaling from the captured audio shape (actual_T / padded_T * captured_len),
avoiding any assumption about downsample_rate vs effective decoder upsample.
The slice is cloned before returning so the static buffer can be reused.
Usage::
wrapper = MossTTSCUDAGraphCodecWrapper(codec_model, capture_sizes, nq)
wrapper.warmup(device)
# per-request decode:
out = wrapper.decode(codes_nq_t) # codes_nq_t: [NQ, T]
"""
def __init__(
self,
model: MossAudioTokenizerModel,
capture_sizes: list[int],
num_quantizers: int,
enabled: bool = True,
) -> None:
self.model = model
self.capture_sizes: list[int] = sorted(capture_sizes)
self.num_quantizers = num_quantizers
self.enabled = enabled
# All dicts keyed by padded_T.
self.graphs: dict[int, CUDAGraph] = {}
self.static_codes: dict[int, torch.Tensor] = {} # [NQ, 1, padded_T]
# static_lengths is kept alive here — the captured graph holds a
# reference to the underlying storage and must not be GC'd.
self.static_lengths: dict[int, torch.Tensor] = {} # [1]
self.static_audio: dict[int, torch.Tensor] = {} # [1, 1, padded_T * effective_upsample]
self._warmed_up = False
# ------------------------------------------------------------------
# Size helpers
# ------------------------------------------------------------------
def _get_padded_size(self, actual_t: int) -> int | None:
"""Return the smallest capture size >= actual_t, or None if too large."""
for s in self.capture_sizes:
if actual_t <= s:
return s
return None
# ------------------------------------------------------------------
# Warmup / capture
# ------------------------------------------------------------------
def warmup(self, device: torch.device) -> None:
"""Allocate static buffers and capture CUDA Graphs for all sizes."""
if device.type != "cuda" or not self.enabled or self._warmed_up:
return
nq = self.num_quantizers
logger.info(
"MOSS-TTS codec CUDA Graph warmup: nq=%d capture_sizes=%s",
nq,
self.capture_sizes,
)
t0 = time.perf_counter()
# One eager run per size to let cuDNN / CUDA allocate memory before
# the capture window (graph capture forbids new CUDA allocs during it).
for size in self.capture_sizes:
dummy_codes = torch.zeros(nq, 1, size, dtype=torch.long, device=device)
dummy_lengths = torch.tensor([size], dtype=torch.long, device=device)
with torch.no_grad():
_ = self.model._decode(dummy_codes, dummy_lengths)
torch.accelerator.synchronize(device)
for size in self.capture_sizes:
try:
self._capture(size, device)
logger.info(" Captured CUDA Graph for size=%d", size)
except Exception:
logger.warning(
" Failed to capture CUDA Graph for size=%d; this size will fall back to eager decode",
size,
exc_info=True,
)
self._warmed_up = True
elapsed_ms = (time.perf_counter() - t0) * 1000.0
logger.info(
"MOSS-TTS codec CUDA Graph warmup complete: %d/%d captured in %.1f ms",
len(self.graphs),
len(self.capture_sizes),
elapsed_ms,
)
def _capture(self, size: int, device: torch.device) -> None:
nq = self.num_quantizers
static_codes = torch.zeros(nq, 1, size, dtype=torch.long, device=device)
# lengths holds the number of valid code frames; set to full size at
# capture time so the decoder emits a full-size audio buffer.
static_lengths = torch.tensor([size], dtype=torch.long, device=device)
# Extra eager warmup inside capture to ensure all kernels are compiled.
with torch.no_grad():
_ = self.model._decode(static_codes, static_lengths)
torch.accelerator.synchronize(device)
graph = CUDAGraph()
with torch.no_grad():
with torch.cuda.graph(graph, pool=current_platform.get_global_graph_pool()):
static_out = self.model._decode(static_codes, static_lengths)
self.graphs[size] = graph
self.static_codes[size] = static_codes
self.static_lengths[size] = static_lengths
# static_out.audio is a static buffer reused every replay; hold a
# reference so it is not garbage-collected.
self.static_audio[size] = static_out.audio # [1, 1, size * effective_upsample]
# ------------------------------------------------------------------
# Inference
# ------------------------------------------------------------------
@torch.no_grad()
def decode(self, codes_nq_t: torch.Tensor) -> MossAudioTokenizerDecoderOutput:
"""Decode [NQ, T] codes to waveform using a CUDA Graph when possible.
Falls back to eager batch_decode when:
- CUDA Graph is disabled or not yet warmed up
- an outer CUDA stream capture is active (e.g. vLLM FULL graph mode)
- actual T exceeds all pre-captured sizes
"""
if not self.enabled or not self._warmed_up:
return self.model.batch_decode(codes_list=[codes_nq_t], num_quantizers=self.num_quantizers)
# Replaying a graph inside an active stream capture would corrupt the
# outer graph. Fall back to eager so the caller can complete its capture.
if torch.cuda.is_current_stream_capturing():
return self.model.batch_decode(codes_list=[codes_nq_t], num_quantizers=self.num_quantizers)
actual_t = int(codes_nq_t.shape[-1])
padded_size = self._get_padded_size(actual_t)
if padded_size is None or padded_size not in self.graphs:
return self.model.batch_decode(codes_list=[codes_nq_t], num_quantizers=self.num_quantizers)
# --- Fill static buffers then replay ---
static_codes = self.static_codes[padded_size] # [NQ, 1, padded_size]
if actual_t == padded_size:
# Exact fit: copy the whole buffer at once.
# codes_nq_t is [NQ, T]; unsqueeze(1) → [NQ, 1, T] matching static_codes.
static_codes.copy_(codes_nq_t.unsqueeze(1))
else:
# Smaller input: zero the buffer first (right-zero-pad), then fill
# left-aligned. static_codes[:, 0, :actual_t] is [NQ, actual_t]
# and codes_nq_t is [NQ, actual_t].
static_codes.zero_()
static_codes[:, 0, :actual_t].copy_(codes_nq_t)
self.graphs[padded_size].replay()
# static_audio[padded_size] is the live output buffer of the graph.
# Slice to the real audio length and clone before returning; without
# the clone the next replay would overwrite the caller's tensor.
# Derive the slice length from the captured audio shape rather than
# downsample_rate: the decoder's effective upsample (product of all
# PatchedPretransform patch sizes) can differ from the config attribute.
captured_len = self.static_audio[padded_size].shape[-1]
actual_wav_len = captured_len * actual_t // padded_size
audio = self.static_audio[padded_size][..., :actual_wav_len].clone()
audio_lengths = torch.tensor([actual_wav_len], dtype=torch.long, device=audio.device)
return MossAudioTokenizerDecoderOutput(audio=audio, audio_lengths=audio_lengths)
__all__ = ["MossTTSCUDAGraphCodecWrapper"]