Files
hao-ai-lab-FastVideo/fastvideo/tests/transformers/test_pan2_fusions.py
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

293 lines
15 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""PAN2's fused Q/K RMSNorm + RoPE and LayerNorm + modulation ops (``fastvideo.models.dits.pan2_fusions``).
The PyTorch references must reproduce the eager arithmetic they replace, their backward formulas must be the true
gradients (fp64 gradcheck), the Triton kernels must reproduce the references, and a PAN2 model with fused kernels must
match the eager model forward and backward. On a GPU-less host the Triton kernels run under the Triton interpreter in
a subprocess, in FP32: the interpreter truncates FP32 to BF16 instead of rounding to nearest even, so the BF16 kernel
comparison needs a GPU.
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
import pytest
import torch
import fastvideo.envs as envs
from fastvideo.configs.models.dits.pan2 import PAN2ArchConfig, PAN2VideoConfig
from fastvideo.forward_context import set_forward_context
from fastvideo.layers.layernorm import RMSNorm
from fastvideo.layers.rotary_embedding import _apply_rotary_emb, get_1d_rotary_pos_embed
from fastvideo.models.dits import pan2 as pan2_module
from fastvideo.models.dits import pan2_fusions
from fastvideo.models.dits.pan2_fusions import layer_norm_modulate, qk_rmsnorm_rope
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
REPO_ROOT = Path(__file__).resolve().parents[3]
LATENT_CHANNELS = 4
TINY_ARCH = dict(
patch_size=(1, 2, 2),
in_channels=2 * LATENT_CHANNELS + 1,
out_channels=LATENT_CHANNELS,
num_attention_heads=4,
attention_head_dim=16,
num_layers=2,
num_refiner_layers=1,
mlp_ratio=2.0,
context_mlp_ratio=2.0,
refiner_mlp_ratio=2.0,
text_embed_dim=16,
rope_axes_dim=(4, 6, 6),
)
@pytest.fixture()
def cpu_attention(env_overrides):
env_overrides.enter_context(envs.FASTVIDEO_ATTENTION_BACKEND.override("TORCH_SDPA"))
@pytest.fixture()
def single_process_group(env_overrides):
"""A 1x1 sequence/tensor-parallel group, which DistributedAttention needs even on one CPU process."""
from fastvideo.distributed import cleanup_dist_env_and_memory, maybe_init_distributed_environment_and_model_parallel
env_overrides.enter_context(envs.override_external("MASTER_ADDR", os.environ.get("MASTER_ADDR") or "127.0.0.1"))
env_overrides.enter_context(envs.override_external("MASTER_PORT", os.environ.get("MASTER_PORT") or "29593"))
env_overrides.enter_context(envs.override_external("RANK", "0"))
env_overrides.enter_context(envs.override_external("WORLD_SIZE", "1"))
env_overrides.enter_context(envs.override_external("LOCAL_RANK", "0"))
maybe_init_distributed_environment_and_model_parallel(1, 1)
try:
yield
finally:
cleanup_dist_env_and_memory()
def _model() -> pan2_module.PAN2Transformer3DModel:
return pan2_module.PAN2Transformer3DModel(PAN2VideoConfig(arch_config=PAN2ArchConfig(**TINY_ARCH)), hf_config={})
def _inputs(seed: int = 0) -> dict[str, torch.Tensor]:
generator = torch.Generator().manual_seed(seed)
return dict(
hidden_states=torch.randn(1, TINY_ARCH["in_channels"], 3, 4, 6, generator=generator),
encoder_hidden_states=torch.randn(1, 5, TINY_ARCH["text_embed_dim"], generator=generator),
timestep=torch.tensor([600.0]),
)
def _randomize(model: torch.nn.Module, seed: int) -> None:
generator = torch.Generator().manual_seed(seed)
with torch.no_grad():
for parameter in model.parameters():
parameter.copy_(torch.randn(parameter.shape, generator=generator) * 0.1)
def _qkv(batch: int, seq: int, heads: int, dim: int, dtype: torch.dtype, seed: int = 0):
generator = torch.Generator().manual_seed(seed)
qkv = torch.randn(batch, seq, 3 * heads * dim, generator=generator).to(dtype)
return qkv.unflatten(-1, (3, heads, -1)).unbind(2)
def _rope_tables(seq: int, dim: int) -> tuple[torch.Tensor, torch.Tensor]:
positions = torch.arange(seq, dtype=torch.float32) * (16 / 24)
return get_1d_rotary_pos_embed(dim, positions, theta=256.0, use_real=False)
def _eager_qk(x: torch.Tensor, weight: torch.Tensor, eps: float, rope: tuple[torch.Tensor, torch.Tensor] | None):
norm = RMSNorm(x.shape[-1], eps=eps)
with torch.no_grad():
norm.weight.copy_(weight)
y = norm.forward_native(x).to(x.dtype)
if rope is None:
return y
rows = y.flatten(0, 1)
cos, sin = (table.repeat(x.shape[0], 1) for table in rope)
return _apply_rotary_emb(rows, cos, sin, is_neox_style=False).unflatten(0, x.shape[:2])
# ---- references against the eager arithmetic -----------------------------------------------------------------------
@pytest.mark.parametrize("with_rope", [False, True])
def test_qk_reference_is_the_eager_chain_bit_for_bit(with_rope: bool) -> None:
q, k, _ = _qkv(2, 13, 3, 16, torch.bfloat16)
weights = torch.rand(16) + 0.5, torch.rand(16) + 0.5
rope = _rope_tables(13, 16) if with_rope else None
fused_q, fused_k = qk_rmsnorm_rope(q, k, *weights, 1e-6, *(rope or (None, None)))
assert torch.equal(fused_q, _eager_qk(q, weights[0], 1e-6, rope))
assert torch.equal(fused_k, _eager_qk(k, weights[1], 1e-6, rope))
assert fused_q.is_contiguous() and fused_q.dtype == torch.bfloat16
def test_layer_norm_modulate_reference_is_the_autocast_arithmetic() -> None:
generator = torch.Generator().manual_seed(1)
x = (torch.randn(2, 11, 48, generator=generator) * 4 + 2).to(torch.bfloat16)
scale = (1 + torch.randn(2, 48, generator=generator)).to(torch.bfloat16)
shift = torch.randn(48, generator=generator).to(torch.bfloat16)
norm = torch.nn.LayerNorm(48, elementwise_affine=False, eps=1e-6)
# Under autocast the eager LayerNorm and modulation run in FP32 and the next linear rounds the result once.
expected = (norm(x.float()) * scale.float()[:, None] + shift.float()).to(torch.bfloat16)
actual = layer_norm_modulate(x, scale, shift, 1e-6)
assert actual.dtype == torch.bfloat16
torch.testing.assert_close(actual, expected, atol=0, rtol=2**-7)
def test_fused_ops_have_true_gradients() -> None:
q, k, _ = _qkv(2, 5, 2, 8, torch.float64, seed=2)
q, k = q.detach().requires_grad_(), k.detach().requires_grad_()
weights = [(torch.rand(8, dtype=torch.float64) + 0.5).requires_grad_() for _ in range(2)]
cos, sin = (table.double() for table in _rope_tables(5, 8))
assert torch.autograd.gradcheck(lambda *args: qk_rmsnorm_rope(*args, 1e-6, cos, sin), (q, k, *weights))
assert torch.autograd.gradcheck(lambda *args: qk_rmsnorm_rope(*args, 1e-6), (q, k, *weights))
x = torch.randn(2, 4, 12, dtype=torch.float64, requires_grad=True)
scale = torch.randn(2, 12, dtype=torch.float64, requires_grad=True)
shift = torch.randn(12, dtype=torch.float64, requires_grad=True) # shared by the batch, as PAN2's text tables
assert torch.autograd.gradcheck(lambda *args: layer_norm_modulate(*args, 1e-6), (x, scale, shift))
@pytest.mark.gpu
def test_fused_ops_compile_with_their_backwards_on_gpu() -> None:
"""Inside a fullgraph compiled region, as regional compile captures PAN2's blocks, both fused ops and their
backwards are opaque ops: tracing never launches a Triton kernel, and the values and gradients are eager's."""
if not torch.cuda.is_available() or not pan2_fusions.HAVE_TRITON:
pytest.skip("needs CUDA and Triton")
q, k, _ = (t.cuda() for t in _qkv(2, 21, 3, 16, torch.bfloat16, seed=6))
weights = [(torch.rand(16) + 0.5).cuda() for _ in range(2)]
cos, sin = (table.cuda() for table in _rope_tables(21, 16))
generator = torch.Generator().manual_seed(7)
x = torch.randn(2, 9, 40, generator=generator).cuda().to(torch.bfloat16)
scale = (1 + torch.randn(2, 40, generator=generator)).cuda()
shift = torch.randn(40, generator=generator).cuda()
def fn(q, k, q_weight, k_weight, x, scale, shift):
q_out, k_out = qk_rmsnorm_rope(q, k, q_weight, k_weight, 1e-6, cos, sin)
return (q_out.float().square().sum() + k_out.float().square().sum() +
layer_norm_modulate(x, scale, shift, 1e-6).float().square().sum())
def run(function):
leaves = [t.detach().clone().requires_grad_() for t in (q, k, *weights, x, scale, shift)]
loss = function(*leaves)
loss.backward()
return loss.detach(), [leaf.grad for leaf in leaves]
eager_loss, eager_grads = run(fn)
loss, grads = run(torch.compile(fn, fullgraph=True, dynamic=True))
torch.testing.assert_close(loss, eager_loss, atol=0, rtol=1e-5)
for actual, expected in zip(grads, eager_grads, strict=True):
torch.testing.assert_close(actual, expected, atol=1e-5, rtol=1e-5)
# ---- the Triton kernels against the references ----------------------------------------------------------------------
def check_triton_kernels(device: str, dtypes: tuple[torch.dtype, ...]) -> None:
"""Every Triton kernel against its reference, forward and backward; raises on a mismatch."""
for dtype in dtypes:
tol = dict(atol=1e-5, rtol=1e-5) if dtype == torch.float32 else dict(atol=0, rtol=2**-6)
# 21 rows: a partial row block. q and k are strided views of one projection, as in the model.
q, k, _ = (t.to(device) for t in _qkv(2, 21, 3, 16, dtype, seed=3))
weight = (torch.rand(16) + 0.5).to(device)
cos, sin = (table.to(device) for table in _rope_tables(21, 16))
grad = torch.randn(2, 21, 3, 16, generator=torch.Generator().manual_seed(4)).to(device=device, dtype=dtype)
for tables in ((cos, sin), (None, None)):
expected, expected_rstd = pan2_fusions._qk_forward_reference(q, weight, *tables, 1e-6)
actual, rstd = pan2_fusions._qk_forward_triton(q, weight, *tables, 1e-6)
torch.testing.assert_close(actual.float(), expected.float(), **tol)
torch.testing.assert_close(rstd, expected_rstd, atol=1e-6, rtol=1e-6)
expected_grads = pan2_fusions._qk_backward_reference(grad, q, weight, *tables, expected_rstd)
actual_grads = pan2_fusions._qk_backward_triton(grad, q, weight, *tables, rstd)
for a, e in zip(actual_grads, expected_grads, strict=True):
torch.testing.assert_close(a.float(), e.float(), atol=1e-4, rtol=1e-3 if dtype != torch.float32 else 1e-5)
generator = torch.Generator().manual_seed(5)
x = (torch.randn(2, 9, 40, generator=generator) * 3 + 1).to(device=device, dtype=dtype)
scale = (1 + torch.randn(2, 40, generator=generator)).to(device=device, dtype=dtype)
shift = torch.randn(40, generator=generator).to(device=device, dtype=dtype)
grad = torch.randn(2, 9, 40, generator=generator).to(device=device, dtype=dtype)
expected = pan2_fusions._ln_forward_reference(x, scale, shift, 1e-6)
actual = pan2_fusions._ln_forward_triton(x, scale, shift, 1e-6)
torch.testing.assert_close(actual[0].float(), expected[0].float(), **tol)
torch.testing.assert_close(actual[1:], expected[1:], atol=1e-5, rtol=1e-5)
expected_grads = pan2_fusions._ln_backward_reference(grad, x, scale, expected[1], expected[2])
actual_grads = pan2_fusions._ln_backward_triton(grad, x, scale, actual[1], actual[2])
for a, e in zip(actual_grads, expected_grads, strict=True):
torch.testing.assert_close(a.float(), e.float(), atol=1e-4, rtol=1e-3 if dtype != torch.float32 else 1e-5)
def test_triton_kernels_match_their_references(env_overrides) -> None:
if not pan2_fusions.HAVE_TRITON:
pytest.skip("Triton is not installed")
if torch.cuda.is_available():
check_triton_kernels("cuda", (torch.float32, torch.bfloat16))
return
code = (f"import sys; sys.path.insert(0, {str(REPO_ROOT)!r}); "
"import fastvideo.models.dits.pan2_fusions as fusions; fusions._FORCE_TRITON = True; "
"import torch; from fastvideo.tests.transformers.test_pan2_fusions import check_triton_kernels; "
"check_triton_kernels('cpu', (torch.float32, ))")
# The child inherits this environment with the Triton interpreter switched on.
env_overrides.enter_context(envs.override_external("TRITON_INTERPRET", "1"))
process = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=600)
assert process.returncode == 0, f"Triton interpreter check failed:\n{process.stdout}\n{process.stderr}"
# ---- the fused model against the eager model -----------------------------------------------------------------------
def _forward_backward(model: torch.nn.Module, inputs: dict[str, torch.Tensor]):
model.zero_grad(set_to_none=True)
with set_forward_context(current_timestep=0, attn_metadata=None, forward_batch=ForwardBatch(data_type="dummy")):
output = model(**inputs)
weight = torch.randn(output.shape, generator=torch.Generator().manual_seed(6), dtype=output.dtype)
(output * weight).sum().backward()
return output.detach(), {name: p.grad.detach().clone() for name, p in model.named_parameters() if p.grad is not None}
def test_fused_model_matches_the_eager_model(cpu_attention, single_process_group) -> None:
model = _model()
_randomize(model, seed=7)
inputs = _inputs(seed=8)
eager_out, eager_grads = _forward_backward(model, inputs)
model.set_fused_kernels(True)
assert all(block.fused_kernels and block.attn.fused_kernels for block in model.transformer_blocks)
fused_out, fused_grads = _forward_backward(model, inputs)
torch.testing.assert_close(fused_out, eager_out, atol=1e-5, rtol=1e-5)
assert fused_grads.keys() == eager_grads.keys()
for name, grad in eager_grads.items():
torch.testing.assert_close(fused_grads[name], grad, atol=1e-5, rtol=1e-4, msg=name)
@pytest.mark.gpu
def test_fused_model_matches_the_eager_model_in_bf16_on_gpu(cpu_attention, single_process_group) -> None:
"""The sampling numerics: BF16 activations under autocast, Triton kernels, on one GPU."""
if not torch.cuda.is_available():
pytest.skip("needs CUDA")
model = _model().cuda()
_randomize(model, seed=7)
inputs = {name: value.cuda() for name, value in _inputs(seed=8).items()}
def run():
model.zero_grad(set_to_none=True)
with torch.autocast("cuda", dtype=torch.bfloat16), set_forward_context(
current_timestep=0, attn_metadata=None, forward_batch=ForwardBatch(data_type="dummy")):
output = model(**inputs)
output.float().square().mean().backward()
return output.float(), {name: p.grad.float() for name, p in model.named_parameters() if p.grad is not None}
eager_out, eager_grads = run()
model.set_fused_kernels(True)
fused_out, fused_grads = run()
scale = eager_out.abs().mean()
assert float((fused_out - eager_out).abs().mean() / scale) < 1e-2
for name, grad in eager_grads.items():
error = float((fused_grads[name] - grad).norm() / grad.norm().clamp_min(1e-12))
assert error < 5e-2, f"{name}: relative gradient error {error:.3e}"