[Perf] [OmniVoice] Triton kernel fusion + CUDA Graph acceleration (#3336)

Signed-off-by: univa-HARRY <harry@univa.co.kr>
Signed-off-by: Harry <harry@univa.co.kr>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-05-22 13:12:47 +00:00
1 parent d714b4ce04
commit e7644daa7f
7 files changed
+1129 -9

No files matched your search

Whitespace-only changes.
@@ -0,0 +1,263 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
Tests for OmniVoice CUDA Graph generator wrapper numerical equivalence.
Verifies that _OmniVoiceCUDAGraphForward produces results equivalent to
eager mode across three scenarios:
- Exact-size inputs (no padding) → bit-identical
- Padded inputs (padded to nearest bucket) → correct slicing, exact match
for position-independent models
- Oversized inputs (fallback to eager) → bit-identical
Uses a lightweight synthetic generator to keep warmup fast and avoid
loading actual model weights.
"""
from __future__ import annotations
import pytest
import torch
import torch.nn as nn
pytestmark = [
pytest.mark.core_model,
pytest.mark.cuda,
pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required"),
]
DEVICE = torch.device("cuda:0")
NUM_CB = 8
VOCAB = 1025
HIDDEN = 64
CAPTURE_SIZES = [32, 64, 128]
# ---------------------------------------------------------------------------
# Synthetic generator: matches _OmniVoiceCUDAGraphForward's interface
# ---------------------------------------------------------------------------
class _SyntheticGenerator:
"""Position-independent Embedding+Linear replacing OmniVoiceGenerator's 28-layer transformer.
Each token position is processed independently (no attention), so padded
runs produce bit-identical results to non-padded runs on the same positions.
This makes the padding/slicing logic easy to verify exactly.
"""
class _Cfg:
num_audio_codebook = NUM_CB
def __init__(self, device: torch.device):
self.config = self._Cfg()
self.text_embedding = nn.Embedding(1000, HIDDEN).to(device).eval()
self._linear = nn.Linear(HIDDEN, NUM_CB * VOCAB, bias=False).to(device).eval()
self._rope_cos: torch.Tensor | None = None
self._rope_sin: torch.Tensor | None = None
def _ensure_rope(self, seq_len: int, device: torch.device) -> None:
if self._rope_cos is None or self._rope_cos.shape[0] < seq_len:
max_len = max(seq_len, 512)
self._rope_cos = torch.zeros(max_len, 64, device=device)
self._rope_sin = torch.zeros(max_len, 64, device=device)
def _step_forward(
self,
input_ids: torch.Tensor,
audio_mask: torch.Tensor,
attention_mask: torch.Tensor | None,
cos: torch.Tensor,
sin: torch.Tensor,
) -> torch.Tensor:
two_b, _, S = input_ids.shape
x = self.text_embedding(input_ids[:, 0, :].clamp(0, 999)) # [two_b, S, H]
logits = self._linear(x) # [two_b, S, 8*1025]
return logits.view(two_b, S, NUM_CB, VOCAB).permute(0, 2, 1, 3) # [two_b, 8, S, 1025]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _eager(gen: _SyntheticGenerator, ids: torch.Tensor, mask: torch.Tensor, attn: torch.Tensor) -> torch.Tensor:
"""Run _step_forward with zero RoPE tensors (synthetic gen ignores them)."""
S = ids.shape[-1]
cos = torch.zeros(S, 64, device=ids.device)
sin = torch.zeros(S, 64, device=ids.device)
return gen._step_forward(ids, mask, attn, cos, sin)
def _make_inputs(seq_len: int, device: torch.device = DEVICE):
two_b = 2
ids = torch.randint(0, 100, (two_b, NUM_CB, seq_len), dtype=torch.long, device=device)
mask = torch.ones(two_b, seq_len, dtype=torch.bool, device=device)
attn = torch.ones(two_b, 1, seq_len, seq_len, dtype=torch.bool, device=device)
return ids, mask, attn
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def gen():
torch.manual_seed(42)
return _SyntheticGenerator(DEVICE)
@pytest.fixture(scope="module")
def wrapper(gen):
from vllm_omni.model_executor.models.omnivoice.omnivoice_generator import (
_OmniVoiceCUDAGraphForward,
)
w = _OmniVoiceCUDAGraphForward(gen, capture_sizes=CAPTURE_SIZES)
w.warmup(DEVICE)
return w
# ---------------------------------------------------------------------------
# 1. Exact-size inputs → bit-identical
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("seq_len", CAPTURE_SIZES)
def test_exact_size_bit_identical(gen, wrapper, seq_len):
"""When input exactly matches a captured bucket, output must be bit-identical to eager."""
ids, mask, attn = _make_inputs(seq_len)
with torch.no_grad():
eager_out = _eager(gen, ids, mask, attn)
graph_out = wrapper(ids, mask, attn)
torch.testing.assert_close(graph_out, eager_out, atol=0, rtol=0)
# ---------------------------------------------------------------------------
# 2. Padded inputs → correct shape, values match at actual positions
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("seq_len", [1, 15, 33, 60, 100])
def test_padded_output_shape(gen, wrapper, seq_len):
"""Graph output must be sliced back to actual seq_len, not the bucket size."""
ids, mask, attn = _make_inputs(seq_len)
with torch.no_grad():
graph_out = wrapper(ids, mask, attn)
assert graph_out.shape == (2, NUM_CB, seq_len, VOCAB)
@pytest.mark.parametrize("seq_len", [15, 33, 60, 100])
def test_padded_output_matches_eager(gen, wrapper, seq_len):
"""Padded graph output must equal eager output at actual positions.
The synthetic model has no attention across positions, so zero-padding
does not affect non-padded positions — exact match is expected.
"""
ids, mask, attn = _make_inputs(seq_len)
with torch.no_grad():
eager_out = _eager(gen, ids, mask, attn)
graph_out = wrapper(ids, mask, attn)
torch.testing.assert_close(graph_out, eager_out, atol=0, rtol=0)
# ---------------------------------------------------------------------------
# 3. Oversized inputs → fallback to eager (lazy capture), bit-identical
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("seq_len", [129, 200, 256])
def test_fallback_eager_bit_identical(gen, wrapper, seq_len):
"""Sequences exceeding the largest bucket fall back to lazy capture → bit-identical."""
ids, mask, attn = _make_inputs(seq_len)
with torch.no_grad():
eager_out = _eager(gen, ids, mask, attn)
graph_out = wrapper(ids, mask, attn)
torch.testing.assert_close(graph_out, eager_out, atol=0, rtol=0)
# ---------------------------------------------------------------------------
# 4. Determinism
# ---------------------------------------------------------------------------
def test_deterministic_across_calls(wrapper):
"""Same input must produce identical output on repeated CUDA graph replays."""
ids, mask, attn = _make_inputs(32)
with torch.no_grad():
out1 = wrapper(ids, mask, attn)
out2 = wrapper(ids, mask, attn)
torch.testing.assert_close(out1, out2, atol=0, rtol=0)
# ---------------------------------------------------------------------------
# 5. _find_bucket logic (CPU, no CUDA graph)
# ---------------------------------------------------------------------------
def test_find_bucket_returns_nearest_bucket():
"""_find_bucket must return the smallest bucket >= seq_len, or None if all are smaller."""
from vllm_omni.model_executor.models.omnivoice.omnivoice_generator import (
_OmniVoiceCUDAGraphForward,
)
w = _OmniVoiceCUDAGraphForward.__new__(_OmniVoiceCUDAGraphForward)
w._capture_sizes = [32, 64, 128]
w._graphs = {}
assert w._find_bucket(1) == 32
assert w._find_bucket(32) == 32
assert w._find_bucket(33) == 64
assert w._find_bucket(64) == 64
assert w._find_bucket(128) == 128
assert w._find_bucket(129) is None
assert w._find_bucket(1000) is None
# ---------------------------------------------------------------------------
# 6. enable_cuda_graph=False produces same tokens as enable_cuda_graph=True
# ---------------------------------------------------------------------------
def test_cuda_graph_disabled_matches_eager_generator():
"""OmniVoiceGenerator with enable_cuda_graph=False must produce the same
_step_forward output as one with enable_cuda_graph=True (after warmup).
Uses a 2-layer config so warmup completes quickly without loading weights.
"""
from vllm_omni.model_executor.models.omnivoice.omnivoice_generator import OmniVoiceGenerator
from vllm_omni.transformers_utils.configs.omnivoice import OmniVoiceConfig
cfg = OmniVoiceConfig()
cfg.llm_num_hidden_layers = 2
cfg.num_hidden_layers = 2
torch.manual_seed(0)
gen_eager = OmniVoiceGenerator(cfg).to(DEVICE).eval()
gen_eager.config.enable_cuda_graph = False
gen_eager._cuda_graph_fwd = None
torch.manual_seed(0)
gen_graph = OmniVoiceGenerator(cfg).to(DEVICE).eval()
gen_graph.config.cuda_graph_capture_sizes = [64]
from vllm_omni.model_executor.models.omnivoice.omnivoice_generator import _OmniVoiceCUDAGraphForward
gen_graph._cuda_graph_fwd = _OmniVoiceCUDAGraphForward(gen_graph, capture_sizes=[64])
gen_graph._cuda_graph_fwd.warmup(DEVICE)
seq_len = 64
ids = torch.zeros(2, cfg.num_audio_codebook, seq_len, dtype=torch.long, device=DEVICE)
mask = torch.ones(2, seq_len, dtype=torch.bool, device=DEVICE)
attn = torch.ones(2, 1, seq_len, seq_len, dtype=torch.bool, device=DEVICE)
gen_eager._ensure_rope(seq_len, DEVICE)
dtype = gen_eager.text_embedding.weight.dtype
cos = gen_eager._rope_cos[:seq_len].to(device=DEVICE, dtype=dtype)
sin = gen_eager._rope_sin[:seq_len].to(device=DEVICE, dtype=dtype)
with torch.no_grad():
eager_logits = gen_eager._step_forward(ids, mask, attn, cos, sin)
graph_logits = gen_graph._cuda_graph_fwd(ids, mask, attn)
torch.testing.assert_close(graph_logits, eager_logits, atol=0, rtol=0)
@@ -0,0 +1,214 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
Correctness and regression tests for OmniVoiceDecoder.
Covers three areas:
1. NaN regression — verifies the fp16 overflow fix:
acoustic decoder must operate in float32 even when fc2 weights are fp16.
Demonstrates that the pre-fix code path (no .float() after fc2) produces
NaN/Inf with large-scale weights, while the fixed path does not.
2. Output shape / value sanity — correct [B, 1, T*upsample] shape, finite values.
3. Acoustic decoder float32 invariant — weights must be float32 after build.
"""
from __future__ import annotations
import pytest
import torch
import torch.nn as nn
from vllm_omni.model_executor.models.omnivoice.omnivoice_decoder import (
HiggsAudioRVQ,
OmniVoiceDecoder,
)
from vllm_omni.transformers_utils.configs.omnivoice import OmniVoiceConfig
pytestmark = [
pytest.mark.core_model,
pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required"),
]
DEVICE = torch.device("cuda:0")
UPSAMPLE = 4 # synthetic acoustic decoder stride (real DAC uses 960)
T_FRAMES = 25 # number of audio token frames to decode
# ---------------------------------------------------------------------------
# Tiny acoustic decoder: ConvTranspose1d upsample by UPSAMPLE
# ---------------------------------------------------------------------------
class _TinyAcousticDecoder(nn.Module):
def __init__(self, in_channels: int = 256, upsample: int = UPSAMPLE):
super().__init__()
self.conv = nn.ConvTranspose1d(in_channels, 1, kernel_size=upsample, stride=upsample)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.conv(x) # [B, 1, T*upsample]
# ---------------------------------------------------------------------------
# Builder
# ---------------------------------------------------------------------------
def _build_decoder(
*,
fc2_dtype: torch.dtype = torch.float32,
acoustic_dtype: torch.dtype = torch.float32,
weight_scale: float = 1.0,
) -> OmniVoiceDecoder:
"""Build an OmniVoiceDecoder with synthetic components (no model checkpoint required).
Args:
fc2_dtype: dtype for the fc2 linear layer weights.
acoustic_dtype: dtype for the acoustic decoder weights.
weight_scale: constant weight value for overflow testing.
"""
config = OmniVoiceConfig()
decoder = OmniVoiceDecoder(config)
decoder.quantizer = HiggsAudioRVQ(num_quantizers=8, codebook_size=1024, codebook_dim=64, hidden_size=1024).to(
DEVICE
)
decoder.fc2 = nn.Linear(1024, 256).to(DEVICE).to(fc2_dtype)
acoustic = _TinyAcousticDecoder(in_channels=256, upsample=UPSAMPLE).to(DEVICE).to(acoustic_dtype)
if weight_scale != 1.0:
with torch.no_grad():
nn.init.constant_(acoustic.conv.weight, weight_scale)
nn.init.constant_(acoustic.conv.bias, weight_scale)
decoder.acoustic_decoder = acoustic
decoder._loaded = True
return decoder
# ---------------------------------------------------------------------------
# 1. NaN regression
# ---------------------------------------------------------------------------
def test_no_nan_fp16_fc2_float32_acoustic():
"""fp16 fc2 + float32 acoustic decoder (the fixed path) must not produce NaN or Inf."""
decoder = _build_decoder(fc2_dtype=torch.float16, acoustic_dtype=torch.float32)
tokens = torch.randint(0, 1024, (1, 8, T_FRAMES), device=DEVICE)
audio = decoder(tokens)
assert not torch.isnan(audio).any(), "NaN in output — fp16 overflow may have reached acoustic decoder"
assert not torch.isinf(audio).any(), "Inf in output"
def test_fp16_acoustic_decoder_produces_nan_without_fix():
"""Pre-fix scenario: fp16 acoustic decoder with large weights overflows to NaN/Inf.
This test documents the original bug and will fail (raising AssertionError)
if the underlying hardware / Triton version somehow avoids overflow, but
with weights scaled to 300.0 the ConvTranspose1d intermediate activations
reliably exceed fp16 max (~65504) in practice.
"""
decoder = _build_decoder(fc2_dtype=torch.float16, acoustic_dtype=torch.float16, weight_scale=300.0)
with torch.no_grad():
nn.init.constant_(decoder.fc2.weight, 300.0)
nn.init.constant_(decoder.fc2.bias, 300.0)
for q in decoder.quantizer.quantizers:
nn.init.constant_(q.codebook.weight, 300.0)
tokens = torch.randint(0, 1024, (1, 8, T_FRAMES), device=DEVICE)
# Simulate pre-fix forward: no .float() after fc2
codes = tokens.transpose(0, 1).long()
quantized = decoder.quantizer.decode(codes)
quantized_fp16 = decoder.fc2(quantized.transpose(1, 2).to(decoder.fc2.weight.dtype)).transpose(
1, 2
) # stays fp16, no .float()
audio_broken = decoder.acoustic_decoder(quantized_fp16)
assert torch.isnan(audio_broken).any() or torch.isinf(audio_broken).any(), (
"Expected NaN/Inf in pre-fix fp16 path with large weights — test setup may need a higher weight_scale"
)
def test_acoustic_decoder_receives_float32_input():
"""The .float() cast after fc2 must ensure acoustic decoder always gets float32 input,
regardless of fc2 weight dtype."""
decoder = _build_decoder(fc2_dtype=torch.float16, acoustic_dtype=torch.float32)
captured_dtypes: list[torch.dtype] = []
original_fwd = decoder.acoustic_decoder.forward
def _hook(x: torch.Tensor) -> torch.Tensor:
captured_dtypes.append(x.dtype)
return original_fwd(x)
decoder.acoustic_decoder.forward = _hook # type: ignore[method-assign]
tokens = torch.randint(0, 1024, (1, 8, T_FRAMES), device=DEVICE)
decoder(tokens)
assert captured_dtypes, "acoustic_decoder.forward was never called"
assert captured_dtypes[0] == torch.float32, (
f"Acoustic decoder received {captured_dtypes[0]}, expected torch.float32 — "
"the .float() upcast after fc2 may be missing"
)
# ---------------------------------------------------------------------------
# 2. Output shape and value sanity
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("batch,t_frames", [(1, 10), (1, 50), (2, 25)])
def test_output_shape(batch, t_frames):
"""Decoder must return [B, 1, T*upsample] waveform tensor."""
decoder = _build_decoder()
tokens = torch.randint(0, 1024, (batch, 8, t_frames), device=DEVICE)
audio = decoder(tokens)
assert audio.shape == (batch, 1, t_frames * UPSAMPLE), (
f"Expected ({batch}, 1, {t_frames * UPSAMPLE}), got {audio.shape}"
)
def test_no_nan_float32_baseline():
"""Float32 fc2 + float32 acoustic decoder must always produce finite output."""
decoder = _build_decoder(fc2_dtype=torch.float32, acoustic_dtype=torch.float32)
tokens = torch.randint(0, 1024, (1, 8, T_FRAMES), device=DEVICE)
audio = decoder(tokens)
assert not torch.isnan(audio).any()
assert not torch.isinf(audio).any()
def test_output_is_3d():
"""Output must be 3-D: [B, 1, samples]. OmniVoiceDecoder.forward unsqueezes if needed."""
decoder = _build_decoder()
tokens = torch.randint(0, 1024, (1, 8, 10), device=DEVICE)
audio = decoder(tokens)
assert audio.dim() == 3, f"Expected 3-D output, got {audio.dim()}-D"
assert audio.shape[1] == 1, f"Expected channel dim=1, got {audio.shape[1]}"
# ---------------------------------------------------------------------------
# 3. Acoustic decoder float32 invariant
# ---------------------------------------------------------------------------
def test_acoustic_decoder_weights_are_float32():
"""Acoustic decoder weights must be float32 — any fp16 parameter is a regression indicator.
In production, load_weights() calls self.acoustic_decoder.float() to enforce this.
The synthetic builder mirrors that invariant.
"""
decoder = _build_decoder(fc2_dtype=torch.float16, acoustic_dtype=torch.float32)
for name, param in decoder.acoustic_decoder.named_parameters():
assert param.dtype == torch.float32, (
f"acoustic_decoder.{name} is {param.dtype} — must be float32 (see load_weights fix)"
)
@@ -0,0 +1,200 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
Correctness tests for OmniVoice Triton kernels.
Verifies that RMSNorm, SwiGLU, and fused-add+RMSNorm Triton kernels
produce results numerically equivalent to the PyTorch reference.
All computations follow the kernel's internal float32 promotion rules.
"""
from __future__ import annotations
import pytest
import torch
pytestmark = [
pytest.mark.core_model,
pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required"),
]
DEVICE = torch.device("cuda:0")
EPS = 1e-6
try:
from vllm_omni.model_executor.models.omnivoice.omnivoice_generator import (
_TRITON_AVAILABLE,
triton_fused_add_rms_norm,
triton_rms_norm,
triton_swiglu,
)
except ImportError:
_TRITON_AVAILABLE = False
triton_available = pytest.mark.skipif(not _TRITON_AVAILABLE, reason="Triton not available on this platform")
# ---------------------------------------------------------------------------
# PyTorch reference implementations (mirror the kernel's internal arithmetic)
# ---------------------------------------------------------------------------
def _ref_rms_norm(x: torch.Tensor, weight: torch.Tensor, eps: float) -> torch.Tensor:
# Kernel promotes x to float32, computes RMS, casts back to x.dtype, then mul weight
x_f32 = x.float()
ms = x_f32.pow(2).mean(-1, keepdim=True)
normed = (x_f32 * torch.rsqrt(ms + eps)).to(x.dtype)
return normed * weight.to(normed.dtype)
def _ref_swiglu(gate: torch.Tensor, up: torch.Tensor) -> torch.Tensor:
# Kernel: gate → float32, silu, cast to up.dtype, mul up
gate_f32 = gate.float()
silu = gate_f32 * torch.sigmoid(gate_f32)
return silu.to(up.dtype) * up
def _ref_fused_add_rms_norm(
x: torch.Tensor, residual: torch.Tensor, weight: torch.Tensor, eps: float
) -> tuple[torch.Tensor, torch.Tensor]:
# Kernel: s_f32 = x+r in float32, s stored as x.dtype; y = norm(s_f32)*weight
s_f32 = x.float() + residual.float()
s = s_f32.to(x.dtype)
ms = s_f32.pow(2).mean(-1, keepdim=True)
normed = (s_f32 * torch.rsqrt(ms + eps)).to(x.dtype)
y = normed * weight.to(normed.dtype)
return y, s
# ---------------------------------------------------------------------------
# RMSNorm
# ---------------------------------------------------------------------------
@triton_available
@pytest.mark.parametrize("rows,cols", [(1, 64), (4, 512), (16, 1024), (32, 3072)])
def test_rms_norm_float32(rows, cols):
torch.manual_seed(0)
x = torch.randn(rows, cols, device=DEVICE)
w = torch.ones(cols, device=DEVICE)
ref = _ref_rms_norm(x, w, EPS)
out = triton_rms_norm(x, w, EPS)
torch.testing.assert_close(out, ref, atol=1e-5, rtol=1e-5)
@triton_available
@pytest.mark.parametrize("shape", [(2, 16, 64), (4, 32, 512)])
def test_rms_norm_3d_input(shape):
"""Triton wrapper must handle 3-D [B, S, H] inputs by flattening and restoring shape."""
torch.manual_seed(1)
hidden = shape[-1]
x = torch.randn(*shape, device=DEVICE)
w = torch.ones(hidden, device=DEVICE)
ref = _ref_rms_norm(x, w, EPS)
out = triton_rms_norm(x, w, EPS)
torch.testing.assert_close(out, ref, atol=1e-5, rtol=1e-5)
assert out.shape == x.shape
@triton_available
def test_rms_norm_no_nan_on_zero_input():
"""RMSNorm of the zero vector must not produce NaN (eps prevents divide-by-zero)."""
x = torch.zeros(4, 256, device=DEVICE)
w = torch.ones(256, device=DEVICE)
out = triton_rms_norm(x, w, EPS)
assert not torch.isnan(out).any()
# ---------------------------------------------------------------------------
# SwiGLU
# ---------------------------------------------------------------------------
@triton_available
@pytest.mark.parametrize("rows,cols", [(1, 64), (4, 512), (16, 3072)])
def test_swiglu_float32(rows, cols):
torch.manual_seed(2)
gate = torch.randn(rows, cols, device=DEVICE)
up = torch.randn(rows, cols, device=DEVICE)
ref = _ref_swiglu(gate, up)
out = triton_swiglu(gate, up)
torch.testing.assert_close(out, ref, atol=1e-5, rtol=1e-5)
@triton_available
def test_swiglu_negative_gate():
"""silu(x) = x*sigmoid(x) is defined for negative x; result must not be NaN."""
gate = torch.full((4, 64), -10.0, device=DEVICE)
up = torch.ones(4, 64, device=DEVICE)
ref = _ref_swiglu(gate, up)
out = triton_swiglu(gate, up)
torch.testing.assert_close(out, ref, atol=1e-5, rtol=1e-5)
assert not torch.isnan(out).any()
@triton_available
def test_swiglu_output_shape_preserved():
gate = torch.randn(3, 8, 512, device=DEVICE)
up = torch.randn(3, 8, 512, device=DEVICE)
out = triton_swiglu(gate, up)
assert out.shape == gate.shape
# ---------------------------------------------------------------------------
# Fused Add + RMSNorm
# ---------------------------------------------------------------------------
@triton_available
@pytest.mark.parametrize("rows,cols", [(1, 64), (4, 512), (16, 1024)])
def test_fused_add_rms_norm_float32(rows, cols):
torch.manual_seed(3)
x = torch.randn(rows, cols, device=DEVICE)
residual = torch.randn(rows, cols, device=DEVICE)
weight = torch.ones(cols, device=DEVICE)
ref_y, ref_s = _ref_fused_add_rms_norm(x, residual, weight, EPS)
out_y, out_s = triton_fused_add_rms_norm(x, residual, weight, EPS)
torch.testing.assert_close(out_y, ref_y, atol=1e-5, rtol=1e-5)
# Sum output s = x + r must be bit-exact (no numerics involved, just addition)
torch.testing.assert_close(out_s, ref_s, atol=0, rtol=0)
@triton_available
def test_fused_add_rms_norm_sum_is_bitexact():
"""The residual sum s = x + r must be bit-exact between Triton and PyTorch."""
torch.manual_seed(4)
x = torch.randn(8, 512, device=DEVICE)
r = torch.randn(8, 512, device=DEVICE)
w = torch.ones(512, device=DEVICE)
_, ref_s = _ref_fused_add_rms_norm(x, r, w, EPS)
_, out_s = triton_fused_add_rms_norm(x, r, w, EPS)
torch.testing.assert_close(out_s, ref_s, atol=0, rtol=0)
@triton_available
def test_fused_add_rms_norm_returns_two_tensors():
"""Wrapper must return exactly two tensors: (normed_output, sum)."""
x = torch.randn(4, 256, device=DEVICE)
r = torch.randn(4, 256, device=DEVICE)
w = torch.ones(256, device=DEVICE)
result = triton_fused_add_rms_norm(x, r, w, EPS)
assert len(result) == 2
y, s = result
assert y.shape == x.shape
assert s.shape == x.shape
@@ -108,7 +108,10 @@ class OmniVoiceDecoder(nn.Module):
quantized = self.quantizer.decode(codes)
# Project: [B, 1024, T] → fc2 → [B, 256, T]
quantized = self.fc2(quantized.transpose(1, 2)).transpose(1, 2)
# Cast to fc2 weight dtype (may be fp16 when checkpoint stores weights as fp16),
# then upcast back to float32 — acoustic decoder ConvTranspose1d upsampling
# produces intermediate values that exceed the fp16 range (~65504), causing NaN.
quantized = self.fc2(quantized.transpose(1, 2).to(self.fc2.weight.dtype)).transpose(1, 2).float()
# Acoustic decoder: [B, 256, T] → [B, 1, T*960]
audio = self.acoustic_decoder(quantized)
@@ -192,6 +195,11 @@ class OmniVoiceDecoder(nn.Module):
param.data.copy_(state_dict[higgs_name])
loaded += 1
# Checkpoint weights may be fp16; force float32 so ConvTranspose1d
# upsampling doesn't overflow (fp16 max ~65504 is too small for
# intermediate activations in the DAC upsampling chain).
self.acoustic_decoder.float()
# Apply HiggsAudioV2 output padding adjustment
self._adjust_output_padding(self.acoustic_decoder)
@@ -13,6 +13,8 @@ via FlashAttention/SageAttention/SDPA backends.
from __future__ import annotations
import math
import threading
from collections import OrderedDict
import torch
import torch.nn as nn
@@ -24,6 +26,184 @@ from vllm_omni.transformers_utils.configs.omnivoice import OmniVoiceConfig
logger = init_logger(__name__)
# ---------------------------------------------------------------------------
# Triton kernels (inference-only; graceful fallback when triton is absent)
# ---------------------------------------------------------------------------
_TRITON_AVAILABLE = False
try:
import triton
import triton.language as tl
try:
from triton.language.extra.libdevice import rsqrt # noqa: F401
except ModuleNotFoundError:
from triton.language.extra.cuda.libdevice import rsqrt # noqa: F401
def _calculate_settings(n: int) -> tuple[int, int]:
MAX_FUSED_SIZE = 65536
BLOCK_SIZE = triton.next_power_of_2(n)
if BLOCK_SIZE > MAX_FUSED_SIZE:
raise RuntimeError(f"n={n} exceeds max Triton block size {MAX_FUSED_SIZE}")
num_warps = 4
if BLOCK_SIZE >= 32768:
num_warps = 32
elif BLOCK_SIZE >= 8192:
num_warps = 16
elif BLOCK_SIZE >= 2048:
num_warps = 8
return BLOCK_SIZE, num_warps
@triton.jit
def _rms_norm_fwd_kernel(
Y_ptr, # noqa: N803
Y_stride, # noqa: N803
X_ptr, # noqa: N803
X_stride, # noqa: N803
W_ptr, # noqa: N803
n_cols,
eps,
BLOCK_SIZE: tl.constexpr, # noqa: N803
):
row = tl.program_id(0).to(tl.int64)
cols = tl.arange(0, BLOCK_SIZE)
mask = cols < n_cols
x = tl.load(X_ptr + row * X_stride + cols, mask=mask, other=0.0)
w = tl.load(W_ptr + cols, mask=mask, other=0.0)
x_f32 = x.to(tl.float32)
ms = tl.sum(x_f32 * x_f32, axis=0) / n_cols
rstd = rsqrt(ms + eps)
y = (x_f32 * rstd).to(x.dtype) * w
tl.store(Y_ptr + row * Y_stride + cols, y, mask=mask)
def triton_rms_norm(x: torch.Tensor, weight: torch.Tensor, eps: float) -> torch.Tensor:
shape = x.shape
n_cols = shape[-1]
x2d = x.contiguous().view(-1, n_cols)
w = weight.contiguous()
BLOCK_SIZE, num_warps = _calculate_settings(n_cols)
y = torch.empty_like(x2d)
_rms_norm_fwd_kernel[(x2d.shape[0],)](
y,
y.stride(0),
x2d,
x2d.stride(0),
w,
n_cols,
eps,
BLOCK_SIZE=BLOCK_SIZE,
num_warps=num_warps,
)
return y.view(*shape)
@triton.jit
def _swiglu_fwd_kernel(
gate_ptr,
up_ptr,
out_ptr,
stride,
n_cols: tl.constexpr,
BLOCK_SIZE: tl.constexpr, # noqa: N803
):
pid = tl.program_id(0).to(tl.int64)
gate_ptr += pid * stride
up_ptr += pid * stride
out_ptr += pid * stride
cols = tl.arange(0, BLOCK_SIZE)
mask = cols < n_cols
gate = tl.load(gate_ptr + cols, mask=mask, other=0).to(tl.float32)
up = tl.load(up_ptr + cols, mask=mask, other=0)
silu_gate = gate * tl.sigmoid(gate)
out = silu_gate.cast(up.dtype) * up
tl.store(out_ptr + cols, out, mask=mask)
def triton_swiglu(gate: torch.Tensor, up: torch.Tensor) -> torch.Tensor:
gate = gate.contiguous()
up = up.contiguous()
shape = gate.shape
n_cols = shape[-1]
g2d = gate.view(-1, n_cols)
u2d = up.view(-1, n_cols)
out = torch.empty_like(g2d)
BLOCK_SIZE, num_warps = _calculate_settings(n_cols)
_swiglu_fwd_kernel[(g2d.shape[0],)](
g2d,
u2d,
out,
out.stride(0),
n_cols=n_cols,
BLOCK_SIZE=BLOCK_SIZE,
num_warps=num_warps,
)
return out.view(*shape)
@triton.jit
def _fused_add_rms_norm_fwd_kernel(
Y_ptr, # noqa: N803
Y_stride, # noqa: N803
S_ptr, # noqa: N803
S_stride, # noqa: N803
X_ptr, # noqa: N803
X_stride, # noqa: N803
R_ptr, # noqa: N803
R_stride, # noqa: N803
W_ptr, # noqa: N803
n_cols,
eps,
BLOCK_SIZE: tl.constexpr, # noqa: N803
):
row = tl.program_id(0).to(tl.int64)
cols = tl.arange(0, BLOCK_SIZE)
mask = cols < n_cols
x = tl.load(X_ptr + row * X_stride + cols, mask=mask, other=0.0)
r = tl.load(R_ptr + row * R_stride + cols, mask=mask, other=0.0)
dtype = x.dtype
s_f32 = x.to(tl.float32) + r.to(tl.float32)
s = s_f32.to(dtype)
tl.store(S_ptr + row * S_stride + cols, s, mask=mask)
w = tl.load(W_ptr + cols, mask=mask, other=0.0)
ms = tl.sum(s_f32 * s_f32, axis=0) / n_cols
rstd = rsqrt(ms + eps)
y = (s_f32 * rstd).to(dtype) * w
tl.store(Y_ptr + row * Y_stride + cols, y, mask=mask)
def triton_fused_add_rms_norm(
x: torch.Tensor,
residual: torch.Tensor,
weight: torch.Tensor,
eps: float,
) -> tuple[torch.Tensor, torch.Tensor]:
shape = x.shape
n_cols = shape[-1]
x2d = x.contiguous().view(-1, n_cols)
r2d = residual.contiguous().view(-1, n_cols)
w = weight.contiguous()
BLOCK_SIZE, num_warps = _calculate_settings(n_cols)
y = torch.empty_like(x2d)
s = torch.empty_like(x2d)
_fused_add_rms_norm_fwd_kernel[(x2d.shape[0],)](
y,
y.stride(0),
s,
s.stride(0),
x2d,
x2d.stride(0),
r2d,
r2d.stride(0),
w,
n_cols,
eps,
BLOCK_SIZE=BLOCK_SIZE,
num_warps=num_warps,
)
return y.view(*shape), s.view(*shape)
_TRITON_AVAILABLE = True
logger.debug("OmniVoice Triton kernels loaded")
except Exception:
logger.debug("Triton not available; using PyTorch fallback for OmniVoice kernels")
# ---------------------------------------------------------------------------
# Unmasking schedule helpers
# ---------------------------------------------------------------------------
@@ -63,6 +243,8 @@ class OmniVoiceRMSNorm(nn.Module):
self.eps = eps
def forward(self, x: torch.Tensor) -> torch.Tensor:
if _TRITON_AVAILABLE:
return triton_rms_norm(x, self.weight, self.eps)
variance = x.to(torch.float32).pow(2).mean(-1, keepdim=True)
x = x * torch.rsqrt(variance + self.eps)
return self.weight * x.to(self.weight.dtype)
@@ -158,6 +340,8 @@ class OmniVoiceMLP(nn.Module):
self.down_proj = nn.Linear(config.llm_intermediate_size, config.llm_hidden_size, bias=False)
def forward(self, x: torch.Tensor) -> torch.Tensor:
if _TRITON_AVAILABLE:
return self.down_proj(triton_swiglu(self.gate_proj(x), self.up_proj(x)))
return self.down_proj(F.silu(self.gate_proj(x)) * self.up_proj(x))
@@ -181,13 +365,22 @@ class OmniVoiceTransformerBlock(nn.Module):
residual = hidden_states
hidden_states = self.input_layernorm(hidden_states)
hidden_states = self.self_attn(hidden_states, attention_mask=attention_mask, cos=cos, sin=sin)
hidden_states = residual + hidden_states
residual = hidden_states
hidden_states = self.post_attention_layernorm(hidden_states)
if _TRITON_AVAILABLE:
# Fused: (attn_out + residual) + RMSNorm in one kernel
hidden_states, residual = triton_fused_add_rms_norm(
hidden_states,
residual,
self.post_attention_layernorm.weight,
self.post_attention_layernorm.eps,
)
else:
hidden_states = residual + hidden_states
residual = hidden_states
hidden_states = self.post_attention_layernorm(hidden_states)
hidden_states = self.mlp(hidden_states)
hidden_states = residual + hidden_states
return hidden_states
@@ -222,6 +415,214 @@ def _apply_rotary_pos_emb(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor)
return x * torch.cat([cos, cos], dim=-1) + rotated * torch.cat([sin, sin], dim=-1)
# ---------------------------------------------------------------------------
# CUDA Graph wrapper
# ---------------------------------------------------------------------------
class _OmniVoiceCUDAGraphForward:
"""Pre-captures CUDA graphs for predefined sequence-length buckets.
Memory layout: all graphs share a single per-instance pool handle, which
isolates OmniVoice CUDA memory from other vllm modules. Sequential replay
(one step at a time) means pool sharing is safe.
"""
# Default bucket count is 10; 16 gives modest headroom for edge cases
# (seq_len > max bucket or non-CFG batch) without unbounded GPU growth.
_MAX_LAZY_GRAPHS: int = 16
def __init__(self, generator: OmniVoiceGenerator, capture_sizes: list[int]) -> None:
self._gen = generator
self._capture_sizes = sorted(capture_sizes)
# Pre-warmed graphs keyed by (two_b, bucket); fixed set, never evicted.
self._graphs: dict[tuple[int, int], dict] = {}
# Lazy-captured graphs for oversized / non-CFG shapes; capped via LRU.
self._lazy_graphs: OrderedDict[tuple[int, int], dict] = OrderedDict()
self._lock = threading.Lock()
# Per-instance pool handle: isolates OmniVoice CUDA memory from other
# vllm modules while still allowing safe re-use across sequential replays.
self._pool_handle: int | None = None
def _find_bucket(self, seq_len: int) -> int | None:
for bucket in self._capture_sizes:
if bucket >= seq_len:
return bucket
return None
def _pad_inputs(
self,
input_ids: torch.Tensor,
audio_mask: torch.Tensor,
attention_mask: torch.Tensor | None,
bucket: int,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor | None]:
S = input_ids.shape[-1]
if S == bucket:
return input_ids, audio_mask, attention_mask
two_b = input_ids.shape[0]
num_cb = input_ids.shape[1]
ids_padded = torch.zeros(two_b, num_cb, bucket, dtype=input_ids.dtype, device=input_ids.device)
ids_padded[:, :, :S] = input_ids
mask_padded = torch.zeros(two_b, bucket, dtype=torch.bool, device=audio_mask.device)
mask_padded[:, :S] = audio_mask
if attention_mask is not None:
attn_padded = torch.zeros(
two_b,
1,
bucket,
bucket,
dtype=attention_mask.dtype,
device=attention_mask.device,
)
attn_padded[:, :, :S, :S] = attention_mask
else:
attn_padded = None
return ids_padded, mask_padded, attn_padded
def _capture_for_key(
self,
key: tuple[int, int],
input_ids: torch.Tensor,
audio_mask: torch.Tensor,
attention_mask: torch.Tensor | None,
) -> dict:
_, bucket = key
device = input_ids.device
self._gen._ensure_rope(bucket, device)
model_dtype = self._gen.text_embedding.weight.dtype
static_cos = self._gen._rope_cos[:bucket].to(device=device, dtype=model_dtype).contiguous()
static_sin = self._gen._rope_sin[:bucket].to(device=device, dtype=model_dtype).contiguous()
static_input_ids = input_ids.clone()
static_audio_mask = audio_mask.clone()
static_attn_mask = attention_mask.clone() if attention_mask is not None else None
with torch.no_grad():
_ = self._gen._step_forward(
static_input_ids,
static_audio_mask,
static_attn_mask,
static_cos,
static_sin,
)
torch.accelerator.synchronize(device)
# Lazy-init per-instance pool handle: isolates OmniVoice CUDA Graph
# memory from other vllm modules (unlike get_global_graph_pool which
# shares a single pool across all captured graphs and can cause memory
# aliasing when two graphs replay concurrently).
if self._pool_handle is None:
self._pool_handle = torch.cuda.graph_pool_handle()
graph = torch.cuda.CUDAGraph()
with torch.no_grad():
with torch.cuda.graph(graph, pool=self._pool_handle):
static_output = self._gen._step_forward(
static_input_ids,
static_audio_mask,
static_attn_mask,
static_cos,
static_sin,
)
entry = {
"graph": graph,
"static_input_ids": static_input_ids,
"static_audio_mask": static_audio_mask,
"static_attn_mask": static_attn_mask,
"static_cos": static_cos,
"static_sin": static_sin,
"static_output": static_output,
}
logger.info("OmniVoice CUDA Graph captured for key %s", key)
return entry
def warmup(self, device: torch.device) -> None:
"""Pre-capture graphs for all bucket sizes with B=1 (two_b=2 for CFG)."""
if not torch.cuda.is_available():
return
logger.info(
"OmniVoice CUDA Graph warmup: capturing %d bucket sizes %s",
len(self._capture_sizes),
self._capture_sizes,
)
two_b = 2
num_cb = self._gen.config.num_audio_codebook
for bucket in self._capture_sizes:
key = (two_b, bucket)
dummy_ids = torch.zeros(two_b, num_cb, bucket, dtype=torch.long, device=device)
dummy_mask = torch.zeros(two_b, bucket, dtype=torch.bool, device=device)
dummy_attn = torch.ones(two_b, 1, bucket, bucket, dtype=torch.bool, device=device)
self._graphs[key] = self._capture_for_key(key, dummy_ids, dummy_mask, dummy_attn)
logger.info("OmniVoice CUDA Graph warmup complete (%d graphs)", len(self._graphs))
def __call__(
self,
input_ids: torch.Tensor,
audio_mask: torch.Tensor,
attention_mask: torch.Tensor | None,
) -> torch.Tensor:
if torch.cuda.is_current_stream_capturing():
seq_len = input_ids.shape[-1]
self._gen._ensure_rope(seq_len, input_ids.device)
dtype = self._gen.text_embedding.weight.dtype
cos = self._gen._rope_cos[:seq_len].to(device=input_ids.device, dtype=dtype)
sin = self._gen._rope_sin[:seq_len].to(device=input_ids.device, dtype=dtype)
return self._gen._step_forward(input_ids, audio_mask, attention_mask, cos, sin)
seq_len = input_ids.shape[-1]
two_b = input_ids.shape[0]
bucket = self._find_bucket(seq_len) if two_b == 2 else None
if bucket is None:
# Lazy capture: oversized sequence or non-unit batch (no pre-warmed bucket).
# Lock prevents concurrent threads from double-capturing the same key.
# _lazy_graphs is capped at _MAX_LAZY_GRAPHS with LRU eviction to
# prevent unbounded GPU memory growth when seq_len varies widely.
key = (two_b, seq_len)
ids_in, mask_in, attn_in = input_ids, audio_mask, attention_mask
with self._lock:
entry = self._lazy_graphs.get(key)
if entry is None:
entry = self._capture_for_key(key, ids_in, mask_in, attn_in)
if len(self._lazy_graphs) >= self._MAX_LAZY_GRAPHS:
evicted_key, _ = self._lazy_graphs.popitem(last=False)
logger.warning("OmniVoice CUDA Graph lazy cache full; evicted key %s", evicted_key)
self._lazy_graphs[key] = entry
else:
key = (two_b, bucket)
ids_in, mask_in, attn_in = self._pad_inputs(input_ids, audio_mask, attention_mask, bucket)
with self._lock:
entry = self._graphs.get(key)
if entry is None:
entry = self._capture_for_key(key, ids_in, mask_in, attn_in)
self._graphs[key] = entry
entry["static_input_ids"].copy_(ids_in)
entry["static_audio_mask"].copy_(mask_in)
if attn_in is not None and entry["static_attn_mask"] is not None:
entry["static_attn_mask"].copy_(attn_in)
entry["graph"].replay()
output = entry["static_output"]
if bucket is not None and bucket != seq_len:
output = output[:, :, :seq_len, :]
return output
def clear(self) -> None:
with self._lock:
self._graphs.clear()
self._lazy_graphs.clear()
# ---------------------------------------------------------------------------
# Generator model
# ---------------------------------------------------------------------------
@@ -278,6 +679,11 @@ class OmniVoiceGenerator(nn.Module):
self._rope_cos = None
self._rope_sin = None
# CUDA Graph (bucket-size pre-capture; lazy fallback for oversized shapes)
self._cuda_graph_fwd: _OmniVoiceCUDAGraphForward | None = (
_OmniVoiceCUDAGraphForward(self, config.cuda_graph_capture_sizes) if config.enable_cuda_graph else None
)
def _ensure_rope(self, seq_len: int, device: torch.device) -> None:
"""Lazily compute RoPE cos/sin if needed."""
if self._rope_cos is None or self._rope_cos.shape[0] < seq_len:
@@ -364,6 +770,20 @@ class OmniVoiceGenerator(nn.Module):
self.config.audio_vocab_size,
).permute(0, 2, 1, 3) # [B, 8, S, 1025]
def _step_forward(
self,
input_ids: torch.Tensor,
audio_mask: torch.Tensor,
attention_mask: torch.Tensor | None,
cos: torch.Tensor,
sin: torch.Tensor,
) -> torch.Tensor:
"""Single unmasking-step forward using pre-cast RoPE tensors (CUDA graph safe)."""
hidden_states = self._prepare_embeddings(input_ids, audio_mask)
for layer in self.layers:
hidden_states = layer(hidden_states, attention_mask=attention_mask, cos=cos, sin=sin)
return self._get_logits(self.norm(hidden_states))
@torch.inference_mode()
def forward(
self,
@@ -440,10 +860,12 @@ class OmniVoiceGenerator(nn.Module):
# Main iterative loop
for step in range(num_step):
# Prepare embeddings and run transformer
inputs_embeds = self._prepare_embeddings(input_ids, audio_mask)
hidden_states = self._transformer_forward(inputs_embeds, attention_mask)
batch_logits = self._get_logits(hidden_states).to(torch.float32)
if self._cuda_graph_fwd is not None and input_ids.is_cuda:
batch_logits = self._cuda_graph_fwd(input_ids, audio_mask, attention_mask).to(torch.float32)
else:
inputs_embeds = self._prepare_embeddings(input_ids, audio_mask)
hidden_states = self._transformer_forward(inputs_embeds, attention_mask)
batch_logits = self._get_logits(hidden_states).to(torch.float32)
# batch_logits: [2*B, 8, S, 1025]
for i in range(B):
@@ -586,3 +1008,6 @@ class OmniVoiceGenerator(nn.Module):
)
else:
logger.info("Generator: all %d weights loaded", len(loaded_keys))
if self._cuda_graph_fwd is not None:
self._cuda_graph_fwd.warmup(device)
@@ -2,6 +2,8 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""OmniVoice configuration for vLLM-Omni two-stage pipeline."""
import os
from transformers import AutoConfig
from transformers.configuration_utils import PretrainedConfig
@@ -80,6 +82,14 @@ class OmniVoiceConfig(PretrainedConfig):
# Serving
self.speculative_config = None
# Precedence: config.json > OMNIVOICE_CUDA_GRAPH env var > default (enabled).
# Read once at init; changing OMNIVOICE_CUDA_GRAPH at runtime has no effect.
self.enable_cuda_graph = getattr(self, "enable_cuda_graph", os.environ.get("OMNIVOICE_CUDA_GRAPH", "1") != "0")
self.cuda_graph_capture_sizes = getattr(
self,
"cuda_graph_capture_sizes",
[128, 192, 256, 320, 384, 448, 512, 640, 768, 1024],
)
AutoConfig.register("omnivoice", OmniVoiceConfig)