Files
hao-ai-lab-FastVideo/fastvideo-kernel/wan22_vae_decode/reference.py
T
William LinandClaude Opus 5.5 500cf3b764 [perf] PAN2 sampling: Wan2.2 VAE decode kernel, split decode, fused DiT kernels
- fastvideo-kernel/wan22_vae_decode: the Wan2.2 (TI2V-5B) VAE decoder on sm_100a / sm_103a at 704x1248, built on
  first use against fastvideo-kernel's CUTLASS submodule. It frees intermediates early and carries 4 latent frames
  per pass: a 5 s video peaks at 20.3 GiB, down from 94.6 GiB in the kernel's first version, with the same output.
- WanVAEConfig.decode_kernel routes AutoencoderKLWan.decode through it where it applies (bf16 with fp32
  accumulation, about 7x faster than the fp32 decode on a B300, PSNR 61-63 dB, SSIM 0.998 against it);
  decode_kernel_parallel splits the decode over the sequence-parallel group (2, 4 or 8 GPUs) and broadcasts the
  video, with the ranks agreeing on whether every one of them could build the kernel.
- PAN2's DiT runs fused Triton kernels for Q/K RMSNorm + RoPE and LayerNorm + modulation (pan2_fusions,
  set_fused_kernels).
- PAN2PipelineConfig turns all three on (vae_decode_kernel, vae_decode_kernel_parallel, dit_fused_kernels).
- VideoGenerator copies a decoded RGB video's frames to the host once instead of once per frame.
- The PAN2 presets sample at guidance 2.5, the reference launcher's default since 2026-10-23.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 00:47:23 +00:00

252 lines
15 KiB
Python

"""Wan2.2 VAE decoder: a standalone PyTorch reference of diffusers' AutoencoderKLWan._decode (Wan2.2-TI2V-5B VAE).
decode(z, weights, dtype) maps latents z [B, 48, T, H/16, W/16] to a video [B, 3, 4T-3, H, W] with values in [-1, 1],
as AutoencoderKLWan.decode(z).sample does with tiling and slicing off (its defaults). It needs torch and nothing else:
`weights` is a plain dict name -> tensor of the decoder's and post_quant_conv's parameters under diffusers' names
(the 110 of wan22_vae_decode.weight_names()), such as the VAE's state dict:
weights = {k: v.cuda().float() for k, v in vae.state_dict().items()}
video = decode(latents.cuda(), weights) # fp32
video = decode(latents.cuda(), weights, torch.bfloat16) # a pipeline's bf16 decode
What is computed
----------------
The decoder of the TI2V-5B VAE (z_dim 48, decoder_base_dim 256, dim_mult [1, 2, 4, 4], num_res_blocks 2,
is_residual, patch_size 2, temporal upsampling in the first two up blocks, as the VAE's config.json says):
1. post_quant_conv: a 1x1x1 conv 48 -> 48, over all T latent frames at once.
2. The decoder, run on one latent frame at a time, in order (chunk i is latent frame i). A chunk sees the earlier
ones only through the causal caches (below). With h x w = H/16 x W/16:
conv_in causal 3x3x3 conv 48 -> 1024 at h x w
mid block residual block 1024, attention 1024, residual block 1024 at h x w
up block 0 3 residual blocks 1024; upsample3d 1024 (t x2, h w x2); + DupUp3D 1024 -> 1024 to 2h x 2w
up block 1 3 residual blocks 1024; upsample3d 1024 (t x2, h w x2); + DupUp3D 1024 -> 1024 to 4h x 4w
up block 2 residual blocks 1024 -> 512, 512, 512; upsample2d 512 (h w x2); + DupUp3D 1024 -> 512 to 8h x 8w
up block 3 residual blocks 512 -> 256, 256, 256; no resampler, no shortcut at 8h x 8w
norm_out RMS norm 256, then SiLU
conv_out causal 3x3x3 conv 256 -> 12 at 8h x 8w
Up blocks 0 to 2 add their shortcut, DupUp3D of the block's input, to the output of their resampler.
The chunks' outputs are concatenated along time.
3. unpatchify, [B, 12, F, 8h, 8w] -> [B, 3, F, H, W]: the 12 channels are (colour k, column offset j, row offset i)
of 2 x 2 pixel patches, video[b, k, f, 2y + i, 2x + j] = out[b, 4k + 2j + i, f, y, x].
4. clamp to [-1, 1].
The parts, with diffusers' class names:
- causal conv (WanCausalConv3d), kernel kt x kh x kw, stride 1: zero padding of kh//2 and kw//2 on both sides in
space; in time, 2 * (kt//2) frames before the chunk and none after: the frames of the conv's cache, and zeros for
those the cache lacks.
- RMS norm (WanRMS_norm) over channels: F.normalize, x / max(||x||_2, 1e-12), times sqrt(C), times gamma.
- residual block (WanResidualBlock), in -> out channels: x1 = conv1(SiLU(norm1(x))), x2 = conv2(SiLU(norm2(x1))),
causal 3x3x3 convs; it returns x2 + shortcut, the shortcut being x, or a 1x1x1 conv in -> out of x when in != out.
(Dropout is 0, and diffusers decodes in eval mode.)
- attention (WanAttentionBlock), per frame: n = RMS norm of x; q, k and v are the channels [0, C), [C, 2C) and
[2C, 3C) of a 1x1 conv C -> 3C of n; single-head attention softmax(q k^T / sqrt(C)) v over the h*w positions of the
frame (head dim 1024, no mask: diffusers' docstring calls it causal, it is not); a 1x1 conv C -> C; plus x.
- upsample3d (WanResample "upsample3d"): the time conv, a causal 3x1x1 conv C -> 2C whose output channel c becomes
frame 2t and channel C + c frame 2t + 1, then upsample2d. upsample2d (WanResample "upsample2d"): per frame, nearest
x2 in h and w (mode nearest-exact, WanUpsample) and a 3x3 conv2d C -> C with zero padding 1.
- DupUp3D, in -> out channels, factor ft in time (2 in up blocks 0 and 1, 1 in up block 2) and 2 in h and w: each
input channel repeated r = out * ft * 4 / in times, the copies laid out over the (ft, 2, 2) sub-positions:
dup[c, ft*t + a, 2y + i, 2x + j] = x[(4*ft*c + 4a + 2i + j) // r, t, y, x]. In up blocks 0 and 1 (r = 8) that is
nearest x2 in t, h and w; in up block 2 (r = 2) it is dup[c, t, 2y + i, 2x + j] = x[2c + i, t, y, x]: channel pairs
become row pairs, each value repeated in two columns.
The first latent frame yields one frame
---------------------------------------
On the first chunk (diffusers' first_chunk):
- upsample3d skips the time conv: the chunk's frame passes through as one frame (diffusers marks the conv's cache
"Rep"). The time conv's input sequence therefore begins at the second chunk, after zeros: it never sees latent
frame 0. (Diffusers runs the second chunk's time conv without history and caches a zero frame in front of that
chunk's frame; giving it no history, as here, and zero padding yields the same frames.)
- DupUp3D keeps only the last of its ft copies in time (x[:, :, ft - 1:]): one frame, as on the main path.
So chunk 0 gives one video frame and every later chunk four (each upsample3d doubles them): 1 + 4(T - 1) = 4T - 3.
Causal caches
-------------
Each causal conv with a 3-frame kernel (conv_in, conv1 and conv2 of every residual block, the two time convs,
conv_out) keeps the last two frames of its input, the activations it convolves; after a one-frame chunk, the previous
chunk's last frame and that one. The next chunk's conv sees them before its own frames, zero padding standing in for
what is missing (the first chunk has no history, a one-frame history lacks a frame). The loop thus computes, chunk by
chunk, what one causal conv over the whole sequence would, with zeros before the first frame. The 1x1x1 convs
(post_quant_conv, the residual shortcuts) need no cache.
Over the whole sequence at once, then, the decoder runs every causal conv over all the frames of its level, two zero
frames before the first; upsample3d's time conv over the frames after the first only, the first passing through; and
DupUp3D doubling every frame but the first. That form equals the frame loop up to rounding, not bit for bit, its convs
having other shapes (fp32: max |diff| 1.7e-5, mean 6e-7 on scripts/check_reference.py's latents).
Precision
---------
dtype=torch.float32 computes everything in fp32, as diffusers does in fp32 (both under the same torch.backends
settings; scripts/check_reference.py turns TF32 off).
dtype=torch.bfloat16 computes what diffusers does with the VAE in bf16 under torch.autocast("cuda", dtype=bfloat16),
which is how a video pipeline decodes with this VAE in bf16: the weights and the latents are rounded to bf16 (autocast
casts the latents at post_quant_conv); every conv and the attention take bf16 operands and return bf16 (autocast's
lower-precision ops; the kernels accumulate in fp32; for head dim 1024 PyTorch runs SDPA's memory-efficient kernel);
the RMS norm normalizes in fp32 and rounds to bf16 before the products with sqrt(C) and gamma, each of which rounds
again (diffusers' upcast for 16-bit inputs); the nearest upsampling runs in fp32 (WanUpsample's upcast; it only copies
values); SiLU, the sums, DupUp3D, unpatchify and clamp run in bf16. The output is bf16. decode turns autocast off
itself: the casts are explicit.
The operations and their order are diffusers', so that the same kernels see the same operands: on a GB200 with torch
2.12 and cuDNN 9.20, scripts/check_reference.py finds the reference bit-exact against diffusers in both modes. (The
bf16 decode itself is max 0.07, mean 0.003 from the fp32 one there.)
Credits
-------
Derived from the decoder of diffusers' AutoencoderKLWan (src/diffusers/models/autoencoders/autoencoder_kl_wan.py,
diffusers 0.39.0), Copyright 2025 The Wan Team and The HuggingFace Team, licensed under the Apache License, Version 2.0
(http://www.apache.org/licenses/LICENSE-2.0); diffusers' port of the Wan2.2 VAE of Wan-AI. Its structure, parameter
names and caching rules are theirs. Changes: the modules are rewritten as functions of a weight dict, the cache is
keyed by parameter name instead of a running index, the "Rep" marker is expressed as the time conv starting at the
second chunk, and the encoder, tiling and slicing are left out.
"""
import torch
import torch.nn.functional as F
CACHE_T = 2 # frames of history kept by a causal conv with a 3-frame kernel
PATCH_SIZE = 2
RESNETS_PER_UP_BLOCK = 3 # num_res_blocks + 1
# decoder.up_blocks.{i}: (in channels, out channels, temporal upsampling, spatial upsampling)
UP_BLOCKS = ((1024, 1024, True, True), (1024, 1024, True, True), (1024, 512, False, True), (512, 256, False, False))
PREFIXES = ("decoder.", "post_quant_conv.")
@torch.no_grad()
def decode(z, weights, dtype=torch.float32):
"""Decodes latents z [B, 48, T, H/16, W/16] into a video [B, 3, 4T-3, H, W] in [-1, 1], of `dtype`.
`weights` maps diffusers' parameter names to tensors; those of the decoder and post_quant_conv are cast to `dtype`
on z's device (no copy when they already are). dtype is torch.float32 or torch.bfloat16 (see the module docstring).
"""
if dtype not in (torch.float32, torch.bfloat16):
raise ValueError(f"dtype must be torch.float32 or torch.bfloat16, not {dtype}")
weights = {name: t.to(z.device, dtype) for name, t in weights.items() if name.startswith(PREFIXES)}
with torch.autocast(z.device.type, enabled=False):
x = causal_conv(z.to(dtype), weights, "post_quant_conv")
cache = {}
chunks = [decode_chunk(x[:, :, i : i + 1], weights, cache, first=i == 0) for i in range(x.shape[2])]
return unpatchify(torch.cat(chunks, dim=2), PATCH_SIZE).clamp(-1.0, 1.0)
def decode_chunk(x, weights, cache, first):
"""The decoder on one latent frame x [B, 48, 1, h, w]: [B, 12, 1, 8h, 8w] on the first chunk, [B, 12, 4, 8h, 8w]
on the others. `cache` holds the causal convs' history, by conv name, and is updated."""
x = cached_conv(x, weights, "decoder.conv_in", cache)
x = residual_block(x, weights, "decoder.mid_block.resnets.0", cache)
x = attention(x, weights, "decoder.mid_block.attentions.0")
x = residual_block(x, weights, "decoder.mid_block.resnets.1", cache)
for i in range(len(UP_BLOCKS)):
x = up_block(x, weights, i, cache, first)
x = F.silu(rms_norm(x, weights["decoder.norm_out.gamma"]))
return cached_conv(x, weights, "decoder.conv_out", cache)
def up_block(x, weights, i, cache, first):
"""WanResidualUpBlock i: the residual blocks, then (up blocks 0 to 2) the resampler plus DupUp3D of x."""
_, out_channels, temporal, spatial = UP_BLOCKS[i]
name = f"decoder.up_blocks.{i}"
block_input = x
for j in range(RESNETS_PER_UP_BLOCK):
x = residual_block(x, weights, f"{name}.resnets.{j}", cache)
if not spatial:
return x
# The first chunk skips the time conv, whose history therefore starts empty at the second chunk.
if temporal and not first:
x = time_upsample(x, weights, f"{name}.upsampler.time_conv", cache)
x = spatial_upsample(x, weights, f"{name}.upsampler.resample.1")
return x + dup_up(block_input, out_channels, 2 if temporal else 1, 2, first)
def residual_block(x, weights, name, cache):
"""WanResidualBlock: conv2(SiLU(norm2(conv1(SiLU(norm1(x)))))) plus x, or plus a 1x1x1 conv of x."""
shortcut = causal_conv(x, weights, f"{name}.conv_shortcut") if f"{name}.conv_shortcut.weight" in weights else x
x = cached_conv(F.silu(rms_norm(x, weights[f"{name}.norm1.gamma"])), weights, f"{name}.conv1", cache)
x = cached_conv(F.silu(rms_norm(x, weights[f"{name}.norm2.gamma"])), weights, f"{name}.conv2", cache)
return x + shortcut
def attention(x, weights, name):
"""WanAttentionBlock: single-head attention over the h*w positions of each frame, plus x."""
identity = x
b, c, t, h, w = x.shape
x = x.permute(0, 2, 1, 3, 4).reshape(b * t, c, h, w)
x = rms_norm(x, weights[f"{name}.norm.gamma"])
qkv = F.conv2d(x, weights[f"{name}.to_qkv.weight"], weights[f"{name}.to_qkv.bias"])
qkv = qkv.reshape(b * t, 1, c * 3, -1).permute(0, 1, 3, 2).contiguous() # [b*t, 1 head, h*w, 3C]
q, k, v = qkv.chunk(3, dim=-1)
x = F.scaled_dot_product_attention(q, k, v)
x = x.squeeze(1).permute(0, 2, 1).reshape(b * t, c, h, w)
x = F.conv2d(x, weights[f"{name}.proj.weight"], weights[f"{name}.proj.bias"])
return x.view(b, t, c, h, w).permute(0, 2, 1, 3, 4) + identity
def time_upsample(x, weights, name, cache):
"""upsample3d's time conv: a causal 3x1x1 conv C -> 2C; channel c becomes frame 2t, channel C + c frame 2t + 1."""
b, c, t, h, w = x.shape
x = cached_conv(x, weights, name, cache)
x = x.reshape(b, 2, c, t, h, w)
x = torch.stack((x[:, 0], x[:, 1]), 3)
return x.reshape(b, c, t * 2, h, w)
def spatial_upsample(x, weights, name):
"""Per frame: nearest x2 in h and w (in fp32, as WanUpsample does; it only copies values), then a 3x3 conv2d."""
b, c, t, h, w = x.shape
x = x.permute(0, 2, 1, 3, 4).reshape(b * t, c, h, w)
x = F.interpolate(x.float(), scale_factor=(2.0, 2.0), mode="nearest-exact").type_as(x)
x = F.conv2d(x, weights[f"{name}.weight"], weights[f"{name}.bias"], padding=1)
return x.view(b, t, x.size(1), x.size(2), x.size(3)).permute(0, 2, 1, 3, 4)
def dup_up(x, out_channels, factor_t, factor_s, first):
"""DupUp3D: each channel repeated r = out * ft * fs * fs / in times, the copies spread over the (ft, fs, fs)
sub-positions of time, rows and columns; on the first chunk only the last of the ft copies in time is kept."""
b, c, t, h, w = x.shape
x = x.repeat_interleave(out_channels * factor_t * factor_s * factor_s // c, dim=1)
x = x.view(b, out_channels, factor_t, factor_s, factor_s, t, h, w)
x = x.permute(0, 1, 5, 2, 6, 3, 7, 4).contiguous()
x = x.view(b, out_channels, t * factor_t, h * factor_s, w * factor_s)
return x[:, :, factor_t - 1 :] if first else x
def rms_norm(x, gamma):
"""WanRMS_norm over channels (dim 1): F.normalize, in fp32 for a 16-bit x and rounded back, times sqrt(C), times
gamma."""
x_hat = F.normalize(x.float() if x.dtype in (torch.float16, torch.bfloat16) else x, dim=1).to(x.dtype)
return x_hat * x.shape[1] ** 0.5 * gamma
def cached_conv(x, weights, name, cache):
"""causal_conv of one chunk with the history in cache[name], which it replaces by the last CACHE_T frames of its
input: after a one-frame chunk, the previous chunk's last frame and this one."""
history = cache.get(name)
keep = x[:, :, -CACHE_T:].clone()
if keep.shape[2] < CACHE_T and history is not None:
keep = torch.cat([history[:, :, -1:], keep], dim=2)
cache[name] = keep
return causal_conv(x, weights, name, history)
def causal_conv(x, weights, name, history=None):
"""WanCausalConv3d `name` on x [B, C, t, h, w]: zero padding of kh//2 and kw//2 on both sides in space and, in
time, 2 * (kt//2) frames before x: `history` (the frames before x, at most that many), then zeros."""
weight, bias = weights[f"{name}.weight"], weights[f"{name}.bias"]
kt, kh, kw = weight.shape[2:]
pad = [kw // 2, kw // 2, kh // 2, kh // 2, 2 * (kt // 2), 0]
if history is not None and pad[4] > 0:
x = torch.cat([history, x], dim=2)
pad[4] -= history.shape[2]
return F.conv3d(F.pad(x, pad), weight, bias)
def unpatchify(x, p):
"""[B, 3*p*p, F, h, w] -> [B, 3, F, p*h, p*w]: channel (k*p + j)*p + i is colour k at row offset i, column j."""
b, cp, f, h, w = x.shape
x = x.view(b, cp // (p * p), p, p, f, h, w)
x = x.permute(0, 1, 4, 5, 3, 6, 2).contiguous()
return x.view(b, cp // (p * p), f, h * p, w * p)