Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions examples/diffusers/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,17 @@ ARG TORCH_CUDA_ARCH_LIST="10.0a"
# Lower MAX_JOBS if the build OOMs (machines with <96GB RAM and many CPU cores).
ARG MAX_JOBS=4

# Persist arch list; CMAKE_ARGS pins fastvideo-kernel to sm_100 (Blackwell;
# CUDA 12.9 supports it) and disables the ThunderKittens kernel build (TK has
# crashed at worker startup on B200 in earlier revisions).
# Persist arch list; CMAKE_ARGS pins the fastvideo-kernel CUDA arch and disables
# the ThunderKittens kernel build (TK has crashed at worker startup on B200 in
# earlier revisions). CUDA_ARCH_CMAKE must match TORCH_CUDA_ARCH_LIST's target:
# - 100a (default) -> Blackwell B200/sm_100 (LTX-2.3 fleet)
# - 90a -> Hopper H100/sm_90 (FastWan-QAD fleet; build with
# --build-arg TORCH_CUDA_ARCH_LIST=9.0a --build-arg CUDA_ARCH_CMAKE=90a)
# It's a separate arg (not derived) because the cmake form drops the dot ("9.0a"
# -> "90a") and we keep that mapping explicit rather than sed-munging it.
ARG CUDA_ARCH_CMAKE=100a
ENV TORCH_CUDA_ARCH_LIST=${TORCH_CUDA_ARCH_LIST}
ENV CMAKE_ARGS="-DCMAKE_CUDA_ARCHITECTURES=100a -DFASTVIDEO_KERNEL_BUILD_TK=OFF"
ENV CMAKE_ARGS="-DCMAKE_CUDA_ARCHITECTURES=${CUDA_ARCH_CMAKE} -DFASTVIDEO_KERNEL_BUILD_TK=OFF"

# flash-attention FA4 from FastVideo's exact fork (XOR-op/flash-attention@
# fa4-compile -- the flash-attn-cute source in their pyproject). The cute
Expand Down Expand Up @@ -118,5 +124,21 @@ ENV FASTVIDEO_X264_PRESET=ultrafast
WORKDIR /opt/app
COPY . /opt/app/

# FastWan decodes with the TAEHV tiny autoencoder (the ~2.4s path) instead of
# the full Wan VAE (~5.8s). Bake the single-file taehv module + the Wan2.1
# checkpoint into the fastwan family dir; fastwan.factory loads them. Gated so
# LTX images (which use the full VAE) don't carry it. Build the FastWan image
# with --build-arg INCLUDE_FASTWAN_TAEHV=1.
ARG INCLUDE_FASTWAN_TAEHV=0
RUN if [ "$INCLUDE_FASTWAN_TAEHV" = "1" ]; then \
. /opt/dynamo/venv/bin/activate && \
uv pip install imageio imageio-ffmpeg && \
curl -fsSL https://raw.githubusercontent.com/madebyollin/taehv/main/taehv.py \
-o /opt/app/fastwan/taehv.py && \
curl -fsSL https://github.com/madebyollin/taehv/raw/main/taew2_1.pth \
-o /opt/app/fastwan/taew2_1.pth && \
test -s /opt/app/fastwan/taew2_1.pth ; \
fi

# Page-cache warming + LD_LIBRARY_PATH unset happen in entrypoint.sh.
ENTRYPOINT ["/opt/app/entrypoint.sh"]
9 changes: 9 additions & 0 deletions examples/diffusers/fastwan/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""FastWan-QAD-FP8 video-pipeline integration.

Model-specific glue (factory, shape menu, warmup) lives here. Generic
infrastructure (pool, backend, metrics, models, menu-hash) lives in the
sibling ``lib`` package; shared operational docs live in ``ltx23/``
(RUNBOOK, CACHING, ARCHITECTURE) since the machinery is common.
"""
169 changes: 169 additions & 0 deletions examples/diffusers/fastwan/factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""FastWan-QAD-FP8 model factory (TAEHV tiny-decoder path).

``load_model`` is the SINGLE source of truth for how the FastWan-QAD
``VideoGenerator`` is constructed, used by both the in-process path
(``lib.backend``) and the pool path (``lib.pool`` resolves it via
``--model-factory fastwan.factory:load_model``). Sharing it keeps the
torch.compile cache keys byte-identical between warmup-bake and serving.

FastWan-QAD-FP8-1.3B is a Wan2.1-T2V-1.3B distillation. vs the LTX-2.3 factory:
* single-stage pipeline -- no refine/upsampler;
* FP8 e4m3 per-tensor linear quant, ALWAYS on (the QAD checkpoint's native
format -- baked scale_weight params), not gated on enable_optimizations;
* text-to-video only;
* 3 denoise steps, no CFG (per-request);
* **TAEHV tiny autoencoder for decode** instead of the full Wan VAE. This is
what makes it the fast model: ~2.4s end-to-end vs ~5.8s with the full VAE
(the DiT denoise is identical; only the decode differs). The generator is
built with ``output_type="latent"`` so the heavy VAE is bypassed, and the
latents are decoded by TAEHV (``taew2_1.pth``, baked into the image). This
mirrors FastVideo's own ``fp8_wan2_1_1_3b.py --taehv-checkpoint`` recipe.

Heavy imports live inside the function so the dispatcher's argv-parse + dynamic
import path stays cheap for pool subprocesses.
"""

import logging
import os
import sys
from typing import Any

logger = logging.getLogger(__name__)

# TAEHV (Wan2.1 tiny autoencoder) checkpoint, baked into the image (Dockerfile).
TAEHV_CKPT = os.environ.get("FASTWAN_TAEHV_CKPT", "/opt/app/fastwan/taew2_1.pth")


def _load_taehv():
import torch

repo_dir = os.path.dirname(TAEHV_CKPT)
if repo_dir and repo_dir not in sys.path:
sys.path.insert(0, repo_dir)
from taehv import TAEHV

return TAEHV(checkpoint_path=TAEHV_CKPT).to("cuda", torch.float16)


def _decode_with_taehv(taehv_model, latents):
import torch

with torch.no_grad():
latents = latents.permute(0, 2, 1, 3, 4)
latents = latents.to(
device=next(taehv_model.parameters()).device,
dtype=next(taehv_model.parameters()).dtype,
)
decoded = taehv_model.decode_video(
latents, parallel=False, show_progress_bar=False
)
return [
(f.clamp(0, 1) * 255).byte().cpu().permute(1, 2, 0).numpy()
for f in decoded[0]
]


class _TaehvVideoGenerator:
"""Adapter exposing the FastVideo ``generate_video(**kwargs)`` contract that
``lib.backend``/``lib.pool`` call, but decoding via TAEHV. The underlying
generator is built with ``output_type="latent"`` (VAE bypassed), so
``generate()`` returns latents in ``result.samples``; we TAEHV-decode them
and write the mp4 at the requested fps. width/height/num_frames ARE plumbed
into the latent request (via SamplingParam), so multi-shape serving renders
the requested dimensions -- e.g. portrait 480x832 vs landscape 832x480 --
rather than the model's default shape."""

def __init__(self, gen: Any, taehv: Any) -> None:
self._gen = gen
self._taehv = taehv

def generate_video(
self,
prompt: str,
output_path: str | None = None,
fps: int = 16,
num_inference_steps: int = 3,
guidance_scale: float = 1.0,
seed: int | None = None,
negative_prompt: str | None = None,
width: int | None = None,
height: int | None = None,
num_frames: int | None = None,
save_video: bool = True,
return_frames: bool = False,
**_ignored: Any,
) -> Any:
import imageio

sampling: dict[str, Any] = {
"num_inference_steps": num_inference_steps,
"guidance_scale": guidance_scale,
}
# Plumb the requested shape onto SamplingParam.{width,height,num_frames}
# so each pool subprocess renders (and compiles for) its actual shape.
# Without this the generator falls back to the model default (832x480)
# and portrait 480x832 silently comes out landscape.
if width is not None:
sampling["width"] = width
if height is not None:
sampling["height"] = height
if num_frames is not None:
sampling["num_frames"] = num_frames
if seed is not None:
sampling["seed"] = seed
request: dict[str, Any] = {
"prompt": prompt,
"sampling": sampling,
"output": {"save_video": False},
}
if negative_prompt:
request["negative_prompt"] = negative_prompt
result = self._gen.generate(request=request)
frames = _decode_with_taehv(self._taehv, result.samples)
if output_path:
imageio.mimsave(output_path, frames, fps=fps, format="mp4")
return result


def load_model(
model_path: str,
num_gpus: int,
enable_optimizations: bool,
) -> Any:
"""Build the FastWan-QAD-FP8 generator (FP8 + TAEHV decode).

``enable_optimizations`` is accepted for the shared pool/warmup factory
signature but intentionally unused (FP8 is always on; see module docstring).
"""
from fastvideo import VideoGenerator
from fastvideo.layers.quantization import get_quantization_config

del enable_optimizations
quant_kwargs: dict[str, Any] = {
"transformer_quant": get_quantization_config("FP8")(granularity="tensor"),
}
logger.info(
"FastWan-QAD: FP8 e4m3 per-tensor linear quant (always on); "
"attention_backend=%s",
os.environ.get("FASTVIDEO_ATTENTION_BACKEND", "<default>"),
)

gen = VideoGenerator.from_pretrained(
model_path,
num_gpus=num_gpus,
use_fsdp_inference=False,
dit_cpu_offload=False,
# output_type="latent" bypasses the full Wan VAE (TAEHV decodes instead),
# so the heavy VAE can be CPU-offloaded to save GPU VRAM.
vae_cpu_offload=True,
text_encoder_cpu_offload=False,
pin_cpu_memory=False,
enable_torch_compile=True,
output_type="latent",
**quant_kwargs,
)
taehv = _load_taehv()
logger.info("FastWan-QAD: TAEHV tiny decoder loaded from %s", TAEHV_CKPT)
return _TaehvVideoGenerator(gen, taehv)
117 changes: 117 additions & 0 deletions examples/diffusers/fastwan/preflight_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""
Standalone smoke-test of GenericVideoBackend.preflight() without spinning
up the Dynamo distributed runtime. Loads the model, runs the preflight
loop, exits. Logs per-shape timings and total wall time.

Used to verify that a candidate ship image's preflight will:
- Find fastwan/shapes.json
- Successfully warm every shape's in-memory cache
- Complete in a reasonable wall-clock budget for pod startup
"""

from __future__ import annotations

import argparse
import asyncio
import logging
import os
import sys
import time

# Put examples/diffusers/ on sys.path so the lib / fastwan packages
# resolve. This script lives at examples/diffusers/fastwan/preflight_test.py;
# parent dir = examples/diffusers/.
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))


def _parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument(
"--model",
default="FastVideo/FastWan-QAD-FP8-1.3B",
help="HuggingFace model identifier",
)
p.add_argument(
"--gpu-uuid",
required=True,
help="GPU UUID to pin to (sets CUDA_VISIBLE_DEVICES before torch import)",
)
return p.parse_args()


async def _amain(model: str) -> int:
from fastwan.factory import load_model
from lib.backend import GenericVideoBackend

backend_args = argparse.Namespace(
model=model,
served_model_name=None,
num_gpus=1,
enable_optimizations=False,
attention_backend="FLASH_ATTN",
)

backend = GenericVideoBackend(
args=backend_args,
model_factory_callable=load_model,
model_factory_dotted="fastwan.factory:load_model",
model_label="fastwan-qad-fp8",
)

# Match production: default the shapes-JSON path so preflight can
# find the menu without an explicit env var.
if "WARMUP_SHAPES_JSON_PATH" not in os.environ:
os.environ["WARMUP_SHAPES_JSON_PATH"] = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "shapes.json"
)

t_init = time.perf_counter()
print("[preflight-test] initialize_model() ...", flush=True)
await backend.initialize_model()
print(
"[preflight-test] initialize_model() done in %.1fs"
% (time.perf_counter() - t_init),
flush=True,
)

t_pre = time.perf_counter()
print("[preflight-test] preflight() ...", flush=True)
await backend.preflight()
print(
"[preflight-test] preflight() done in %.1fs" % (time.perf_counter() - t_pre),
flush=True,
)

print(
"[preflight-test] TOTAL boot-equivalent time: %.1fs"
% (time.perf_counter() - t_init),
flush=True,
)
return 0


def main() -> int:
args = _parse_args()

# Pin GPU before any torch import (the factory imports torch).
os.environ["CUDA_VISIBLE_DEVICES"] = args.gpu_uuid

# Match worker.py's logging setup so preflight's log lines look the same.
logging.basicConfig(
level=(
logging.DEBUG
if os.environ.get("FASTVIDEO_LOG_LEVEL") == "DEBUG"
else logging.INFO
),
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
force=True,
)

return asyncio.run(_amain(args.model))


if __name__ == "__main__":
sys.exit(main())
20 changes: 20 additions & 0 deletions examples/diffusers/fastwan/shapes.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"_comment": "FastWan-QAD-FP8 v1 shape menu (deepinfra). TWO 480p/5s shapes: landscape 832x480 and portrait 480x832, both @ 81 frames, 3 denoise steps, guidance 1.0 (no CFG) -- the model's native QAD recipe (Wan2.1-T2V-1.3B distillation). Text-to-video ONLY (no i2v), so unlike ltx23 there is no 'warm_i2v' (the model has no image-conditioning graph to warm). Both shapes are kept boot-warm by the K=2 subprocess pool (VIDEO_POOL_MODE=1, VIDEO_POOL_MAX_SIZE=2) -- one resident shape-pinned process each with its own baked compile cache, so customers never hit cold-spawn churn switching orientation. Dims are multiples of 16 (Wan VAE spatial stride); 81 = 4*20+1 (Wan VAE temporal stride 4k+1). Adding/removing shapes changes IMAGE_SHAPE_HASH and requires a new image tag + a matching per-model menu in backend ttv.py _FASTWAN_SIZE_TABLE/FastWanQADFP8In (CI-checked). Compile-cache / boot-warm strategy: reuses lib/ + warmup.py (same machinery as ltx23/CACHING.md).",
"model": "/data/default",
"fps": 16,
"num_inference_steps": 3,
"guidance_scale": 1.0,
"seed": 42,
"shapes": [
{
"width": 832,
"height": 480,
"num_frames": 81
},
{
"width": 480,
"height": 832,
"num_frames": 81
}
]
}
Loading
Loading