Files
hao-ai-lab-FastVideo/fastvideo-kernel/wan22_vae_decode/notes/optimization.md
T
William Lin 86ef6bfadf [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.
2026-11-03 20:31:55 +00:00

100 lines
7.8 KiB
Markdown

# How the decoder got here
The solution is the main of a multi-agent optimization run (bifrost, 2026-10-25/26): 32 DeepSeek V4.1 Flash peers
and two GPT-6 Astra architects on GB200, starting from a first solution written before the run. Every merge passed the
run's CI (correct on every workload and short clip at worlds 1, 2 and 4, and at least 1 % faster than main by the
geomean in the same job, unless waived) and an independent review. The comments in `solution/` cite the run's pull
requests (PRn), issues (In) and jobs (Jn).
## The design
The decoder runs layer by layer, as kernels of its own and CUTLASS kernels instantiated in `solution/` (the
headers are fastvideo-kernel's CUTLASS submodule). There is no megakernel: see "What did not pay" below.
- **Frames in passes.** A pass carries several latent frames per GPU instead of the reference's one (4 by default,
`frames_per_pass`; the run measured its speed at 16, see "Memory"), so its GEMMs are large, and the causal history
(the last two frames of each 3 x 3 x 3 convolution's input) is carried from pass to pass as an offset, never by
concatenation.
- **The 18 convolutions of up blocks 1-3** (most of the FLOPs) are F(4x4, 3x3) Winograd in space, keeping the causal
3-tap convolution in time, in three steps:
1. `wino_in.cu`: the input transform, two blocks per tile;
2. 36 component products, each a CUTLASS SM100 implicit-GEMM 3 x 1 x 1 convolution (`conv.cu`: 2-CTA 256 x 256 x 64
tiles, TMA im2col, `tcgen05` MMA, fp32 accumulation);
3. `wino_epilogue.cu`: the inverse transform with the bias and the residual add, and the *next* convolution's RMS
norm, SiLU and causal padding, so that convolution reads its input ready-made.
- **The other convolutions** at 44 x 78 are direct CUTLASS implicit GEMMs; a few small ones still go through cuDNN
and cuBLAS, under 1 % of the GPU time.
- **The upsamplers** never materialize the upsampled tensor: nearest x2 followed by a 3 x 3 convolution is four 2 x 2
phase convolutions at the low resolution, and `resample.cu` interleaves them with the bias and the DupUp3D shortcut
in one pass. The temporal upsampler (`timeconv.cu`) writes its two frames straight into place.
- **Pointwise and the rest:** `norm.cu` (RMS norm + SiLU + causal pad in one pass, where the epilogue does not already
apply it), `attn.cu` (the mid block's single-head attention over each frame's 3,432 positions), and `head.cu` (the
output head: `conv_out` as a 3 x 1 x 1 convolution to 112 channels, then a 9-tap stencil with the bias, the clamp
and the unpatchify write).
- **Over GPUs** (`comm.cu`, `prefix.cu`): the decoder's prefix (post_quant_conv, conv_in, the mid block's first
residual block and its attention) is split by time, each rank computing its frames with exact causal context from
the full latents, then transposed to bands of rows (`prefix.cu`). From there each rank owns 44 / world latent rows;
every 3 x 3 convolution exchanges one halo row with the neighbours only (NCCL send/recv in one group), and rank 0
receives the other ranks' pixels per chunk.
## The merges
| Merge | Change | CI speedup | Geomean after |
|---|---|---|---|
| kernel#0 | the first solution: Winograd up blocks, phase upsamplers, fused pointwise, row bands | | 1043 ms |
| PR3 | 1 x 1 channel shortcuts as own kernels, their bias folded into the residual | x1.017 | 1025 ms |
| PR24 | mid-block attention as own kernels instead of ATen SDPA (8.5 -> 2.6 ms of f121x1) | x1.013 | 1012 ms |
| PR15 | RMS norm + SiLU + pad in one pass | x1.110 | 913 ms |
| PR204 | halo exchange with the neighbours only, one coalesced group | x1.014 | 901 ms |
| PR392 | the fused Winograd input transform and the fused inverse epilogue (with the next norm) | x1.189 | 757 ms |
| PR90 | output head as a temporal convolution and a 9-tap stencil (waived at parity) | x1.003 | 755 ms |
| PR438 | the prefix split by time before the row bands | x1.019 | 740 ms |
| PR452 | world 8 uses all-gather, not the ring (worlds 1/2/4 unchanged; waived) | x1.001 | 740 ms |
| PR488 | the upsampler's phases interleaved in one pass, and the temporal upsampler in place | x1.017 | 728 ms |
| PR513 | chunks of min(16 x world, 61) latent frames instead of 8 x world | x1.023 | 711 ms |
| PR490 | a check that no fused input leaks across chunks (waived) | x0.999 | 712 ms |
| PR592 | no dead store of y at each residual block's first convolution | x1.010 | 704 ms |
| PR517 | the input transform at two blocks per tile (waived at x1.007) | x1.007 | 699 ms |
## Memory
The run optimized time alone, and its main decoded 16 latent frames per GPU per pass at a peak of 100-102 GiB on one
GPU, against 13-16 GiB for the release's decode, which carries one latent frame. After the run:
- **Tensors are freed at their last use.** At the peak, up block 3's first convolution (512 -> 256 channels at
352 x 624) held its padded input after the Winograd transform had read it, the transform (2.25x the input) after
every product was enqueued, and the block's input after its shortcut and first norm had read it. Releasing each of
them then takes the peak from 101 to 60 GiB at 16 frames per pass, at the same speed, with a bit-identical video.
- **The pass is 4 latent frames per GPU by default** (`frames_per_pass`), since memory grows with it at 3.5-4.2 GiB per
latent frame at 704 x 1248: 21-22 GiB on one GPU and 18-20 GiB per GPU at 2 and 4 GPUs, for 8-9 % more time than 16
frames per pass. The time a smaller pass costs is in the low resolutions (44 x 78 and 88 x 156), whose GEMMs do not
fill the GPU with few frames, while the memory is in the two high ones (176 x 312 and 352 x 624). Passes of 16 frames
through up block 1 and of 2-4 through up blocks 2 and 3 should keep most of the speed at this memory; it is not built.
## What did not pay
- **Megakernels.** Before the run, a persistent kernel per residual block measured 4.1-7.5x slower than two fused
per-convolution kernels. During the run, a cooperative kernel for one whole Winograd convolution (forward transform,
products and inverse with the next norm, in one launch of 152 CTAs in 2-CTA clusters) was correct but took
6.2-7.2 ms at T 8, 352 x 624, 256 channels, against 3.3 ms for the same work in three launches (0.46-0.53x). The gaps
between the kernels of a decode are about 5 us each, so fewer launches buy little; what a single kernel loses is
the occupancy and the tiling each stage picks for itself.
- **FP8.** e4m3 operands in the Winograd component products cut the products from 262 to 162 ms at f121x1 (10-11 %
of the decode), but fail the error budget on all three clips (RMS at 2.3-3.3x the allowance, even with a power-of-two
scale per component): the transformed operands need about one more mantissa bit. Before the run, FP8 in the direct
convolutions passed in up blocks 1-3 but bought only 1 % of the decode, since quantization was a pass of its own.
- **Splitting by time throughout.** A chunk needs 38 latent frames of causal context, so recomputing it is a poor fit;
only the prefix is split by time (PR438).
## Where to go next
- **The products** are about 60 % of the GPU time (59.6 % at f121x1: 1,383 CUTLASS launches, 384 ms of 644), at
65-68 % of the tensor cores' peak, bound by operand fetch. The transforms and the epilogues are another 30 %. A grouped products kernel (all 36 components of a convolution, persistent, in
2-CTA clusters) passed correctness during the run; the step after it is to compute the input transform inside the
products' operand producer, which removes a write and a read of the transformed tensor (2.25x the activation) per
convolution.
- **Eight GPUs across two trays:** the code accepts up to 8 ranks (PR452 fixed world 8's all-gather), but world 8
was never in CI. A frame pipeline that passes each
layer's two-frame context beat row bands at eight GPUs before the run (1.40-1.55 s against 2.12 s on f241, with
library convolutions).