Files
vllm-omni/benchmarks/diffusion/bench_wan_vae_decode.py
T
2026-09-22 11:37:49 -07:00

325 lines
13 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Benchmark and exactness check for the Wan VAE decoder fast path.
Loads a diffusers Wan VAE (by default the Cosmos3 one), decodes seeded latents
for the requested video size at each ``--fast-path`` level and reports wall
time, peak memory and, against the ``off`` level, bitwise equality, max abs
difference and PSNR. ``--profile`` adds a torch.profiler CUDA kernel table with
the convolution / layout-transpose share.
Single GPU::
python benchmarks/diffusion/bench_wan_vae_decode.py --model nvidia/Cosmos3-Nano
python benchmarks/diffusion/bench_wan_vae_decode.py --model nvidia/Cosmos3-Nano \
--fast-path off,lossless --frames 33 --profile
python benchmarks/diffusion/bench_wan_vae_decode.py --tiny --frames 9 --size 256x256
Multi-GPU VAE parallel decode (launch one process per GPU with ``torchrun``; the
decode is timed across all ranks and only rank 0 prints)::
torchrun --nproc-per-node 2 benchmarks/diffusion/bench_wan_vae_decode.py --model nvidia/Cosmos3-Nano \
--vae-patch-parallel-size 2 --vae-parallel-mode tile
``tile`` distributes the VAE's own spatial tiles over the ranks (it needs the
video to exceed the tile size, 256x256 pixels by default) and ``spatial_shard_*``
shards every decoder feature map along height or width with halo exchange. Both
return the full video on rank 0 only, so equality and PSNR are computed there.
"""
from __future__ import annotations
import argparse
import math
import os
import time
from collections.abc import Iterable
import torch
import torch.distributed as dist
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_wan import (
DistributedAutoencoderKLWan,
OmniAutoencoderKLWan,
)
from vllm_omni.diffusion.distributed.autoencoders.wan_vae_fastpath import (
VAE_FAST_PATH_LEVELS,
install_wan_vae_fastpath,
)
TINY_CONFIG = dict(
base_dim=32,
decoder_base_dim=64,
z_dim=48,
dim_mult=[1, 2, 4, 4],
num_res_blocks=2,
temperal_downsample=[False, True, True],
is_residual=True,
patch_size=2,
in_channels=12,
out_channels=12,
scale_factor_temporal=4,
scale_factor_spatial=16,
)
DTYPES = {"bf16": torch.bfloat16, "fp16": torch.float16, "fp32": torch.float32}
VAE_PARALLEL_MODES = ("tile", "spatial_shard_height", "spatial_shard_width")
_CONV_TAGS = ("conv", "cudnn", "gemm", "cutlass", "xmma")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--model", default="nvidia/Cosmos3-Nano", help="HF id or local path of the pipeline")
parser.add_argument("--subfolder", default="vae")
parser.add_argument("--tiny", action="store_true", help="Use a small random VAE instead of loading weights")
parser.add_argument("--size", default="1280x720", help="Output video WxH")
parser.add_argument("--frames", type=int, default=189, help="Output frame count (1 + 4k)")
parser.add_argument("--dtype", choices=sorted(DTYPES), default="bf16")
parser.add_argument("--fast-path", default=",".join(VAE_FAST_PATH_LEVELS), help="Comma-separated levels")
parser.add_argument("--warmup", type=int, default=1)
parser.add_argument("--iters", type=int, default=2)
parser.add_argument("--seed", type=int, default=0)
parser.add_argument("--profile", action="store_true", help="Print a torch.profiler CUDA kernel table")
parser.add_argument("--profile-rows", type=int, default=25)
parser.add_argument("--save-output", default=None, help="Save the `off` decode output to this .pt path")
parser.add_argument(
"--vae-patch-parallel-size",
type=int,
default=1,
help="VAE parallel decode over this many ranks (run under torchrun with as many processes)",
)
parser.add_argument(
"--vae-parallel-mode",
choices=VAE_PARALLEL_MODES,
default="tile",
help="tile: distribute the VAE's spatial tiles; spatial_shard_*: shard feature maps with halo exchange",
)
return parser.parse_args()
def init_distributed() -> tuple[int, int]:
"""Join the torchrun process group (if any) and return ``(rank, world_size)``."""
world_size = int(os.environ.get("WORLD_SIZE", "1"))
if world_size <= 1:
return 0, 1
from vllm_omni.diffusion.distributed.parallel_state import (
init_distributed_environment,
initialize_model_parallel,
)
rank = int(os.environ["RANK"])
local_rank = int(os.environ.get("LOCAL_RANK", rank))
init_distributed_environment(world_size=world_size, rank=rank, local_rank=local_rank)
initialize_model_parallel(sequence_parallel_size=world_size, ulysses_degree=world_size)
return rank, world_size
def load_vae(
args: argparse.Namespace, dtype: torch.dtype, device: torch.device, parallel: bool
) -> OmniAutoencoderKLWan:
cls = DistributedAutoencoderKLWan if parallel else OmniAutoencoderKLWan
if args.tiny:
torch.manual_seed(args.seed)
vae = cls(**TINY_CONFIG).to(device=device, dtype=dtype).eval()
if parallel:
vae.init_distributed()
else:
vae = cls.from_pretrained(args.model, subfolder=args.subfolder, torch_dtype=dtype).to(device).eval()
if parallel:
# Same setup as the registry: parallel decode needs tiling on.
vae.use_tiling = True
vae.set_parallel_size(args.vae_patch_parallel_size, mode=args.vae_parallel_mode)
return vae
def make_latents(vae: OmniAutoencoderKLWan, args: argparse.Namespace, dtype: torch.dtype, device: torch.device):
width, height = (int(v) for v in args.size.lower().split("x"))
spatial = int(getattr(vae.config, "scale_factor_spatial", 16))
temporal = int(getattr(vae.config, "scale_factor_temporal", 4))
if width % spatial or height % spatial or (args.frames - 1) % temporal:
raise SystemExit(f"size must be a multiple of {spatial} and frames must be 1 + {temporal}k")
shape = (1, vae.config.z_dim, (args.frames - 1) // temporal + 1, height // spatial, width // spatial)
generator = torch.Generator(device="cpu").manual_seed(args.seed)
latents = torch.randn(shape, generator=generator).to(device=device, dtype=dtype)
mean = getattr(vae.config, "latents_mean", None)
std = getattr(vae.config, "latents_std", None)
if mean is not None and std is not None:
mean = torch.as_tensor(mean, device=device, dtype=dtype).view(1, -1, 1, 1, 1)
std = torch.as_tensor(std, device=device, dtype=dtype).view(1, -1, 1, 1, 1)
latents = latents * std + mean
return latents
@torch.inference_mode()
def decode(vae: OmniAutoencoderKLWan, latents: torch.Tensor) -> torch.Tensor:
return vae.decode(latents, return_dict=False)[0]
def sync() -> None:
"""Wait for this rank's GPU and, under torchrun, for every other rank."""
torch.accelerator.synchronize()
if dist.is_initialized():
dist.barrier()
def psnr(a: torch.Tensor, b: torch.Tensor) -> float:
mse = torch.mean((a.float() - b.float()) ** 2).item()
return math.inf if mse == 0 else 10 * math.log10(4.0 / mse) # outputs live in [-1, 1]
def bits_equal(a: torch.Tensor, b: torch.Tensor) -> bool:
if a.shape != b.shape or a.dtype != b.dtype:
return False
int_dtype = torch.int16 if a.element_size() == 2 else torch.int32
return bool((a.contiguous().view(int_dtype) == b.contiguous().view(int_dtype)).all())
def profile_decode(vae: OmniAutoencoderKLWan, latents: torch.Tensor, rows: int, verbose: bool) -> None:
from torch.profiler import ProfilerActivity, profile
with profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA], record_shapes=False) as prof:
decode(vae, latents)
sync()
if not verbose:
return
events = [e for e in prof.key_averages() if e.device_time_total > 0]
events.sort(key=lambda e: e.device_time_total, reverse=True)
total = sum(e.device_time_total for e in events if e.device_type.name == "CUDA")
if total == 0:
total = sum(e.device_time_total for e in events)
conv = sum(
e.device_time_total
for e in events
if e.device_type.name == "CUDA" and any(tag in e.key.lower() for tag in _CONV_TAGS)
)
transpose = sum(e.device_time_total for e in events if e.device_type.name == "CUDA" and "nhwc" in e.key.lower())
print(
f" GPU kernel time: {total / 1e3:.1f} ms conv-like: {100 * conv / total:.1f}% "
f"nchw<->nhwc transposes: {100 * transpose / total:.1f}%"
)
print(f" {'kernel':90s} {'ms':>9s} {'%':>6s} {'calls':>7s}")
shown = 0
for e in events:
if e.device_type.name != "CUDA":
continue
share = 100 * e.device_time_total / total
print(f" {e.key[:90]:90s} {e.device_time_total / 1e3:9.2f} {share:6.1f} {e.count:7d}")
shown += 1
if shown >= rows:
break
def run_level(
args: argparse.Namespace,
level: str,
dtype: torch.dtype,
device: torch.device,
*,
rank: int,
parallel: bool,
) -> tuple[torch.Tensor, dict]:
vae = load_vae(args, dtype, device, parallel)
report = install_wan_vae_fastpath(vae, level=level)
latents = make_latents(vae, args, dtype, device)
for _ in range(args.warmup):
decode(vae, latents)
sync()
torch.accelerator.reset_peak_memory_stats()
timings = []
output = None
for _ in range(args.iters):
sync()
start = time.perf_counter()
output = decode(vae, latents)
sync()
timings.append(time.perf_counter() - start)
peak = torch.tensor(torch.accelerator.max_memory_allocated() / 2**30, device=device)
if dist.is_initialized():
dist.all_reduce(peak, op=dist.ReduceOp.MAX) # the largest rank decides whether the config fits
stats = {
"installed": report.installed,
"reason": report.reason,
"fused_silu": report.fused_silu_dtypes,
"patched": dict(report.patched),
"time_s": min(timings),
"peak_gib": peak.item(),
}
if args.profile:
if rank == 0:
print(f"[{level}] profile")
profile_decode(vae, latents, args.profile_rows, verbose=rank == 0)
assert output is not None
del vae
torch.accelerator.empty_cache()
return output.detach(), stats
def main() -> None:
args = parse_args()
if not torch.cuda.is_available():
raise SystemExit("CUDA device required")
rank, world_size = init_distributed()
parallel = args.vae_patch_parallel_size > 1
if parallel and world_size < args.vae_patch_parallel_size:
raise SystemExit(
f"--vae-patch-parallel-size {args.vae_patch_parallel_size} needs torchrun with at least that many "
f"processes (WORLD_SIZE={world_size})"
)
device = torch.device("cuda", torch.accelerator.current_device_index()) if world_size > 1 else torch.device("cuda")
dtype = DTYPES[args.dtype]
levels: Iterable[str] = [level.strip() for level in args.fast_path.split(",") if level.strip()]
for level in levels:
if level not in VAE_FAST_PATH_LEVELS:
raise SystemExit(f"unknown fast-path level {level!r}; choose from {VAE_FAST_PATH_LEVELS}")
if "off" not in levels:
levels = ["off", *levels]
if rank == 0:
parallel_desc = f" vae_parallel={args.vae_parallel_mode}x{args.vae_patch_parallel_size}" if parallel else ""
print(
f"model={'tiny' if args.tiny else args.model} size={args.size} frames={args.frames} "
f"dtype={args.dtype} world_size={world_size}{parallel_desc}"
)
golden = None
baseline_time = None
try:
for level in levels:
output, stats = run_level(args, level, dtype, device, rank=rank, parallel=parallel)
if level == "off":
golden = output
baseline_time = stats["time_s"]
if args.save_output and rank == 0:
torch.save(output.cpu(), args.save_output)
assert golden is not None
if rank != 0:
# Parallel decode assembles the video on rank 0 only; the other
# ranks just keep the collectives in lockstep.
del output
continue
speedup = baseline_time / stats["time_s"] if baseline_time else float("nan")
line = (
f"[{level:13s}] decode {stats['time_s'] * 1e3:9.1f} ms speedup {speedup:5.2f}x "
f"peak {stats['peak_gib']:5.2f} GiB installed={stats['installed']}"
)
if level != "off":
equal = torch.equal(output, golden)
line += (
f" torch_equal={equal} bits_equal={bits_equal(output, golden)} "
f"max_abs_diff={(output.float() - golden.float()).abs().max().item():.3e} "
f"psnr={psnr(output, golden):.2f} dB"
)
if stats["installed"]:
line += f" fused_silu={stats['fused_silu'] or 'off'}"
else:
line += f" reason={stats['reason']}"
print(line)
del output
torch.accelerator.empty_cache()
finally:
if dist.is_initialized():
dist.destroy_process_group()
if __name__ == "__main__":
main()