Files
hao-ai-lab-FastVideo/fastvideo-kernel/wan22_vae_decode/wan22_vae_decode.py
T
William LinandClaude Opus 5.5 bec159cac0 [feat] PAN2: FlashAttention-4 on Blackwell by default, reference first-frame loading, per-process kernel builds
- Attention: on sm_100+ with flash_attn.cute installed and FASTVIDEO_FA4 unset, the PAN2 pipeline turns FA4 on
  before its modules load. One B300, 241 frames: 288 s instead of 670 s with the SDPA fallback. FASTVIDEO_FA4=0
  keeps the previous backend.
- First frame: an image_path is read as its stored pixels, without the EXIF orientation rotation, as the reference
  sampler reads it (load_image gains exif_transpose=True). All 11 sign-off inputs, one EXIF-rotated, now reach the
  VAE bit-identical to the reference.
- Wan2.2 VAE decode kernel: each process builds in a temporary directory of its own instead of a shared
  rank<LOCAL_RANK> directory, and finished builds go through the loader's keyed cache, added by atomic rename.
  Two processes building at once both succeed; a later one fetches the build in about 2 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:31:55 +00:00

153 lines
7.5 KiB
Python

"""The decoder of the Wan2.2 VAE (Wan2.2-TI2V-5B, 48 latent channels) on sm_100a (B200 / GB200), on one or several GPUs
of a node, built from solution/ on first use.
It decodes latents z [1, 48, T, 44, 78] (bf16) into the video [1, 3, 4T - 3, 704, 1248] (bf16, in [-1, 1]), as
diffusers' AutoencoderKLWan.decode(z).sample computes it (reference.py defines the result), for any T >= 1. Other
latent sizes and batches above one are refused: the kernels are specialised to 704 x 1248.
decoder = WanVAEDecoder(vae.state_dict(), device="cuda") # diffusers' AutoencoderKLWan, or its state dict
video = decoder.decode(latents) # [1, 3, 4T - 3, 704, 1248] bf16
Over several GPUs, every rank builds a decoder with the same process group and calls decode with the full latents;
rank 0 gets the video and the other ranks None. Without a process group, decode uses torch.distributed's default
group, and initializes a single-rank NCCL group when there is none (one GPU only). With local=True each process decodes
on its own GPU alone, whatever torch.distributed holds: one rank of one, so the kernels never communicate (comm.cu
returns before any exchange below two ranks) and no group has to be created collectively.
frames_per_pass sets how many latent frames each GPU carries through one pass of the decoder, which trades memory for
speed; the video is the same at any value. On one GB200 at 8 to 20 s of video: 2 frames peak at 14-16 GiB allocated
(x0.85 the speed of 16 frames), 4 (the default) at 21-22 GiB (x0.92), 8 at 34-35 GiB (x0.97), 16 at 60-61 GiB. The
allocator reserves more than it allocates; PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True keeps the difference small.
"""
import atexit
import functools
import importlib.util
import os
import shutil
import socket
import sys
import tempfile
import torch
import torch.distributed as dist
from torch import Tensor
HERE = os.path.dirname(os.path.abspath(__file__))
LATENT_CHANNELS, LATENT_HW, VIDEO_HW = 48, (44, 78), (704, 1248)
def _own(path, name):
"""The module at path, imported under a name of this kernel's own. Other kernels built the same way
keep tools and references of the same file names; a bare import would hand whichever loaded first to both."""
name = f"wan22_vae_decode_{name}"
module = sys.modules.get(name)
if module is None:
module_spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(module_spec)
sys.modules[name] = module
try:
module_spec.loader.exec_module(module)
except BaseException:
del sys.modules[name]
raise
return module
@functools.lru_cache(maxsize=None)
def _tools():
"""tools/loader.py and tools/spec.py."""
return (_own(os.path.join(HERE, "tools", "loader.py"), "loader"),
_own(os.path.join(HERE, "tools", "spec.py"), "spec"))
@functools.lru_cache(maxsize=None)
def _module():
"""The extension built from solution/, once per process, in a build directory of the process's own under
WAN22_VAE_DECODE_BUILD_DIR (default ~/.cache/fastvideo/wan22_vae_decode), removed when the process exits.
Processes that build at once each write only their own directory. Finished builds go to the cache beside them,
keyed by the sources, flags and toolchain and added by an atomic rename, so later processes copy one instead of
building again."""
loader, _ = _tools()
base = os.environ.get("WAN22_VAE_DECODE_BUILD_DIR", os.path.expanduser("~/.cache/fastvideo/wan22_vae_decode"))
cache = os.path.join(base, "cache")
os.makedirs(cache, exist_ok=True)
build_dir = tempfile.mkdtemp(prefix=f"build-{socket.gethostname()}-{os.getpid()}-", dir=base)
atexit.register(shutil.rmtree, build_dir, True)
return loader.load(os.path.join(HERE, "solution"), build_dir=build_dir, chdir=False, cache=cache).module
def weight_names() -> list[str]:
"""The 110 parameters the decoder takes, under diffusers' AutoencoderKLWan names, in the order it takes them."""
return list(_tools()[1].WEIGHT_NAMES)
def decoder_weights(state_dict, device=None) -> list[Tensor]:
"""The decoder's weights from an AutoencoderKLWan state dict (or the module itself): its post_quant_conv and
decoder parameters, in weight_names() order, as contiguous bf16 tensors on `device`."""
if isinstance(state_dict, torch.nn.Module):
state_dict = state_dict.state_dict()
_, spec = _tools()
weights = []
for name, shape in spec.WEIGHTS:
if name not in state_dict:
raise KeyError(f"the state dict has no {name}: is it the Wan2.2-TI2V-5B VAE's?")
w = state_dict[name]
if tuple(w.shape) != shape:
raise ValueError(f"{name} is {tuple(w.shape)}, expected {shape}")
weights.append(w.detach().to(device=device, dtype=torch.bfloat16).contiguous())
return weights
def _destroy_if_initialized():
# The group may be gone already (a caller destroyed it), and destroying it twice raises.
if dist.is_initialized():
dist.destroy_process_group()
def _default_group():
if not dist.is_initialized():
dist.init_process_group("nccl", store=dist.HashStore(), rank=0, world_size=1)
atexit.register(_destroy_if_initialized)
return dist.group.WORLD
class WanVAEDecoder:
"""The decoder with its weights prepared once (the layout and the Winograd transforms the kernels read)."""
def __init__(self, state_dict, device=None, pg=None, local=False, frames_per_pass=4):
if frames_per_pass < 1:
raise ValueError(f"frames_per_pass must be at least 1, not {frames_per_pass}")
self.frames_per_pass = int(frames_per_pass)
self.device = torch.device(device if device is not None else "cuda")
if self.device.index is None:
self.device = torch.device("cuda", torch.cuda.current_device())
with torch.cuda.device(self.device):
if local:
if pg is not None:
raise ValueError("local=True decodes on this GPU alone; it takes no process group")
# At world 1 the group is only carried, never used: any initialized group serves.
self.pg = dist.group.WORLD if dist.is_initialized() else _default_group()
self.rank, self.world = 0, 1
else:
self.pg = pg if pg is not None else _default_group()
self.rank, self.world = dist.get_rank(self.pg), dist.get_world_size(self.pg)
if self.world > 8:
raise ValueError(f"the decoder splits over at most 8 GPUs, not {self.world}")
self.weights = _module().prepare(decoder_weights(state_dict, self.device), self.rank, self.world,
self.pg)
@torch.no_grad()
def decode(self, z: Tensor) -> Tensor | None:
"""The video of latents z [1, 48, T, 44, 78] on rank 0, None on the other ranks."""
if z.dim() != 5 or z.shape[0] != 1 or z.shape[1] != LATENT_CHANNELS or tuple(z.shape[3:]) != LATENT_HW:
raise ValueError(f"latents {tuple(z.shape)}: expected [1, 48, T, 44, 78]")
z = z.to(device=self.device, dtype=torch.bfloat16).contiguous()
frames = 4 * z.shape[2] - 3
with torch.cuda.device(self.device):
out = torch.empty((1, 3, frames, *VIDEO_HW) if self.rank == 0 else (0,), dtype=torch.bfloat16,
device=self.device)
_module().run_with(z, self.weights, self.rank, self.world, self.pg, out, self.frames_per_pass)
return out if self.rank == 0 else None