- 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>
153 lines
7.5 KiB
Python
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
|