Signed-off-by: liuqihao <liuqihao970610@gmail.com> Signed-off-by: Yueqian Lin <linyueqian@outlook.com> Co-authored-by: Yueqian Lin <linyueqian@outlook.com>
312 lines
14 KiB
Python
312 lines
14 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
|
"""CUDA graph replay for the AuK codec decode.
|
|
|
|
The decoder launches about a thousand small kernels per clip, so it is
|
|
bound by launch overhead rather than by the GPU. This wrapper replays the
|
|
whole decode as CUDA graphs, in three tiers:
|
|
|
|
Compiled bucket graphs are built at startup by warmup(): decode is passed
|
|
through torch.compile so Inductor fuses the elementwise chains, then one
|
|
graph is captured per bucket in compile_shapes. Shorter clips are
|
|
right-padded to their bucket with the normalized latent that decodes to
|
|
raw zero, which is what the eager decode's own convolution padding sees,
|
|
so the padding only reaches the last frames through the upsamplers'
|
|
lookahead. The fused kernels differ from eager in rounding order, so these
|
|
graphs are close to but not bit-identical with the eager decode.
|
|
|
|
Clips longer than tile_frames are decoded in tiles of that many frames
|
|
(by default the largest bucket, so every tile replays the same compiled
|
|
graph). Neighbouring tiles overlap by the decoder's receptive field, taken
|
|
from AuKVAE.decode_context_frames(), and each tile only contributes the
|
|
frames that lie outside that halo, so the stitched waveform equals the
|
|
whole-clip decode up to floating-point order. Memory and startup cost stay
|
|
constant however long the clip; the price is the halo recomputed per tile
|
|
(about an eighth of a 512-frame tile). decode_tiles() yields the tiles as
|
|
they finish so a caller can stream them.
|
|
|
|
Plain graphs are captured on demand, one per exact latent length, for
|
|
windows no compiled bucket serves (tiling off, or a bucket whose compile
|
|
failed). They replay the same kernels as eager and are bit-identical with
|
|
it. They share one wrapper-private graph pool, so they are never evicted
|
|
one at a time: when max_graphs is reached the whole generation is retired
|
|
and a fresh pool is started, and no surviving graph can hold an address
|
|
that a destroyed graph released.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections import OrderedDict
|
|
from collections.abc import Callable, Sequence
|
|
from dataclasses import dataclass
|
|
|
|
import torch
|
|
from vllm.logger import init_logger
|
|
from vllm.utils.math_utils import round_up
|
|
|
|
from vllm_omni.diffusion.models.auk.auk_vae import AuKVAE, SnakeBeta
|
|
from vllm_omni.platforms import current_omni_platform
|
|
|
|
logger = init_logger(__name__)
|
|
|
|
# Latent-frame buckets for the compiled graphs, at 50 Hz: 2.56, 5.12 and
|
|
# 10.24 s. Each bucket costs one Inductor compilation (tens of seconds) at
|
|
# startup and one private CUDA graph pool; longer clips are tiled over the
|
|
# largest one. Deployments override them with
|
|
# ``model_config.auk_vae_compile_shapes`` on the diffusion stage.
|
|
DEFAULT_COMPILE_SHAPES: tuple[int, ...] = (128, 256, 512)
|
|
|
|
|
|
def plan_tiles(
|
|
frames: int, tile: int, left: int, right: int, sizes: Sequence[int] = ()
|
|
) -> list[tuple[int, int, int, int]]:
|
|
"""Split a clip into decode windows: ``(window_start, window_frames, emit_start, emit_end)``.
|
|
|
|
Windows are ``tile`` frames wide. A window contributes the frames that
|
|
are at least ``left`` frames after its start and ``right`` frames before
|
|
its end, unless that edge is the clip's own edge, so each emitted frame
|
|
has its full decoder context inside its window. The last window is
|
|
pinned to the clip's end; it shrinks to the smallest of ``sizes`` (the
|
|
compiled buckets) that still holds the remaining frames plus the left
|
|
context, so a clip just past one tile does not pay for a second full
|
|
one. A clip that fits in one tile is a single window of its own length.
|
|
"""
|
|
if frames <= tile:
|
|
return [(0, frames, 0, frames)]
|
|
if tile <= left + right:
|
|
raise ValueError(f"tile_frames={tile} must exceed the decoder context {left} + {right}")
|
|
windows: list[tuple[int, int, int, int]] = []
|
|
emit = 0
|
|
while True:
|
|
start = 0 if emit == 0 else emit - left
|
|
if start + tile >= frames:
|
|
width = tile
|
|
for size in sorted(sizes):
|
|
if left + frames - emit <= size <= tile:
|
|
width = size
|
|
break
|
|
windows.append((frames - width, width, emit, frames))
|
|
return windows
|
|
end = start + tile - right
|
|
windows.append((start, tile, emit, end))
|
|
emit = end
|
|
|
|
|
|
@dataclass
|
|
class _GraphEntry:
|
|
graph: torch.cuda.CUDAGraph
|
|
static_latents: torch.Tensor
|
|
static_wav: torch.Tensor
|
|
|
|
|
|
class AuKVAEDecodeGraph:
|
|
"""Replay AuKVAE.decode for one clip per call."""
|
|
|
|
def __init__(
|
|
self,
|
|
vae: AuKVAE,
|
|
*,
|
|
enabled: bool = True,
|
|
frame_alignment: int = 1,
|
|
max_graphs: int = 32,
|
|
compile_shapes: Sequence[int] = DEFAULT_COMPILE_SHAPES,
|
|
tile_frames: int | None = None,
|
|
) -> None:
|
|
"""``tile_frames``: window for clips longer than it; None = the largest bucket, 0 = no tiling."""
|
|
self.vae = vae
|
|
self.enabled = bool(enabled)
|
|
self.frame_alignment = max(1, int(frame_alignment))
|
|
self.max_graphs = max(1, int(max_graphs))
|
|
self.compile_shapes = sorted({int(size) for size in compile_shapes if int(size) > 0})
|
|
self.context_frames = vae.decode_context_frames()
|
|
if tile_frames is None:
|
|
tile_frames = self.compile_shapes[-1] if self.compile_shapes else 0
|
|
if tile_frames <= sum(self.context_frames):
|
|
logger.warning(
|
|
"AuK codec decode: largest bucket %d does not exceed the decoder context %s; not tiling",
|
|
tile_frames,
|
|
self.context_frames,
|
|
)
|
|
tile_frames = 0
|
|
self.tile_frames = max(0, int(tile_frames))
|
|
if self.tile_frames and self.tile_frames <= sum(self.context_frames):
|
|
raise ValueError(f"tile_frames={self.tile_frames} must exceed the decoder context {self.context_frames}")
|
|
# Plain graphs keyed by latent length. They all live in _plain_pool and
|
|
# are retired together (see _retire_plain_graphs).
|
|
self._cache: OrderedDict[int, _GraphEntry] = OrderedDict()
|
|
self._plain_pool = None
|
|
# Normalized latent that decodes to raw zero, per device; pads short windows.
|
|
self._pad_latent: torch.Tensor | None = None
|
|
# Compiled graphs keyed by bucket, filled by warmup().
|
|
self._compiled: dict[int, _GraphEntry] = {}
|
|
self._compiled_decode: Callable[[torch.Tensor], torch.Tensor] | None = None
|
|
# Which path served the last call: "compiled", "graph" or "eager".
|
|
self.last_mode: str | None = None
|
|
|
|
@torch.no_grad()
|
|
def __call__(self, latents: torch.Tensor) -> torch.Tensor:
|
|
"""Decode [1, frames, latent_dim] latents into a [1, frames * hop] waveform."""
|
|
|
|
# Graphs are captured for one clip at a time. Anything else runs the
|
|
# plain eager decode, whole.
|
|
single_clip = latents.ndim == 3 and latents.shape[0] == 1
|
|
if not self.enabled or not single_clip:
|
|
self.last_mode = "eager"
|
|
return self.vae.decode(latents)
|
|
|
|
frames = int(latents.shape[1])
|
|
if not self.tile_frames or frames <= self.tile_frames:
|
|
return self._decode_window(latents).clone()
|
|
|
|
hop = self.vae.hop_size
|
|
wav = latents.new_empty((1, frames * hop))
|
|
for emit_start, chunk in self.decode_tiles(latents):
|
|
wav[:, emit_start * hop : emit_start * hop + chunk.shape[1]].copy_(chunk)
|
|
self.last_mode = "tiled"
|
|
return wav
|
|
|
|
def decode_tiles(self, latents: torch.Tensor):
|
|
"""Yield ``(emit_start_frame, waveform)`` per tile, in order, for a [1, frames, latent_dim] clip.
|
|
|
|
The waveforms are views into graph buffers where a graph served the
|
|
tile; copy before the next tile if they must outlive the iteration.
|
|
"""
|
|
frames = int(latents.shape[1])
|
|
tile = self.tile_frames or frames
|
|
left, right = self.context_frames
|
|
hop = self.vae.hop_size
|
|
for start, width, emit_start, emit_end in plan_tiles(frames, tile, left, right, self._compiled):
|
|
wav = self._decode_window(latents[:, start : start + width])
|
|
yield emit_start, wav[:, (emit_start - start) * hop : (emit_end - start) * hop]
|
|
|
|
def _decode_window(self, latents: torch.Tensor) -> torch.Tensor:
|
|
"""Decode one window through the best available tier; graph results are views into static buffers."""
|
|
|
|
# A call made while an outer graph is being captured, or off the
|
|
# accelerator, runs eager. The capture query is asked last because it
|
|
# needs a CUDA runtime.
|
|
if not latents.is_cuda or torch.cuda.is_current_stream_capturing():
|
|
self.last_mode = "eager"
|
|
return self.vae.decode(latents)
|
|
|
|
frames = int(latents.shape[1])
|
|
bucket = self.compiled_bucket(frames)
|
|
if bucket is not None:
|
|
entry = self._compiled[bucket]
|
|
self.last_mode = "compiled"
|
|
else:
|
|
bucket = round_up(frames, self.frame_alignment)
|
|
entry = self._cache.get(bucket)
|
|
if entry is None:
|
|
if len(self._cache) >= self.max_graphs:
|
|
self._retire_plain_graphs()
|
|
if self._plain_pool is None:
|
|
self._plain_pool = torch.cuda.graph_pool_handle()
|
|
entry = self._capture(bucket, latents.device, self.vae.decode, warm_iters=2, pool=self._plain_pool)
|
|
self._cache[bucket] = entry
|
|
self.last_mode = "graph"
|
|
|
|
if bucket == frames:
|
|
entry.static_latents.copy_(latents)
|
|
else:
|
|
entry.static_latents[:, :frames].copy_(latents)
|
|
entry.static_latents[:, frames:].copy_(self._pad_for(latents.device).expand(1, bucket - frames, -1))
|
|
entry.graph.replay()
|
|
return entry.static_wav[:, : frames * self.vae.hop_size]
|
|
|
|
def _retire_plain_graphs(self) -> None:
|
|
"""Drop every plain graph and their shared pool; the next capture starts a new one."""
|
|
logger.info("AuK codec decode: retiring %d plain CUDA graphs", len(self._cache))
|
|
self._cache.clear()
|
|
self._plain_pool = None
|
|
|
|
def _pad_for(self, device: torch.device) -> torch.Tensor:
|
|
"""The [latent_dim] normalized latent that AuKVAE.decode maps to raw zero."""
|
|
pad = self._pad_latent
|
|
if pad is None or pad.device != device:
|
|
mean = self.vae.global_mean.detach().float().to(device)
|
|
scale = torch.sqrt(self.vae.global_log_std.detach().float().to(device))
|
|
pad = -mean / scale
|
|
self._pad_latent = pad
|
|
return pad
|
|
|
|
def compiled_bucket(self, frames: int) -> int | None:
|
|
"""The smallest compiled bucket that holds frames latents, or None past the largest."""
|
|
for size in self.compile_shapes:
|
|
if frames <= size and size in self._compiled:
|
|
return size
|
|
return None
|
|
|
|
def warmup(self, device: torch.device | str) -> None:
|
|
"""Compile the decode and capture every bucket in compile_shapes.
|
|
|
|
Meant for service startup, since each bucket costs one Inductor
|
|
compilation. A failure only logs a warning and the bucket is served
|
|
by a plain graph instead.
|
|
"""
|
|
device = torch.device(device)
|
|
if not self.enabled or not self.compile_shapes or self._compiled:
|
|
return
|
|
on_accelerator = current_omni_platform.is_cuda_alike() and device.type == current_omni_platform.device_type
|
|
if not on_accelerator or torch.cuda.is_current_stream_capturing():
|
|
return
|
|
|
|
# The traced graph must read exp(alpha) from a buffer, not recompute it.
|
|
for module in self.vae.modules():
|
|
if isinstance(module, SnakeBeta):
|
|
module.precompute_exp_cache()
|
|
|
|
try:
|
|
self._compiled_decode = torch.compile(self.vae.decode, mode="default", fullgraph=False, dynamic=False)
|
|
except Exception:
|
|
logger.warning("torch.compile of the AuK codec decode failed; using plain CUDA graphs", exc_info=True)
|
|
self._compiled_decode = None
|
|
return
|
|
|
|
# Inductor fuses the plain Snake formula with the ops around it.
|
|
for size in self.compile_shapes:
|
|
try:
|
|
self._compiled[size] = self._capture(size, device, self._compiled_decode, warm_iters=5)
|
|
logger.info("Compiled and captured AuK codec decode: latent_frames=%d", size)
|
|
except Exception:
|
|
logger.warning(
|
|
"Compiled AuK codec decode failed for latent_frames=%d; falling back to plain CUDA graphs",
|
|
size,
|
|
exc_info=True,
|
|
)
|
|
logger.info(
|
|
"AuK codec decode compile warmup done: %d/%d buckets", len(self._compiled), len(self.compile_shapes)
|
|
)
|
|
|
|
def _capture(
|
|
self,
|
|
bucket: int,
|
|
device: torch.device,
|
|
decode: Callable[[torch.Tensor], torch.Tensor],
|
|
*,
|
|
warm_iters: int,
|
|
pool=None,
|
|
) -> _GraphEntry:
|
|
"""Capture one graph of decode on zero latents of bucket frames.
|
|
|
|
The warm iterations let cuDNN pick its algorithms and, for the
|
|
compiled decode, Inductor finish tracing and autotuning before
|
|
anything is recorded.
|
|
"""
|
|
static_latents = torch.zeros(1, bucket, self.vae.latent_dim, device=device, dtype=torch.float32)
|
|
with torch.inference_mode():
|
|
self.vae.decode(static_latents)
|
|
for _ in range(warm_iters):
|
|
decode(static_latents)
|
|
torch.accelerator.synchronize(device)
|
|
graph = torch.cuda.CUDAGraph()
|
|
if pool is None:
|
|
pool = current_omni_platform.get_global_graph_pool()
|
|
with torch.cuda.graph(graph, pool=pool):
|
|
static_wav = decode(static_latents)
|
|
logger.info("Captured AuK codec decode CUDA graph: latent_frames=%d", bucket)
|
|
return _GraphEntry(graph=graph, static_latents=static_latents, static_wav=static_wav)
|
|
|
|
|
|
__all__ = ["DEFAULT_COMPILE_SHAPES", "AuKVAEDecodeGraph", "plan_tiles"]
|