Compare commits

...
1 Commits
Author SHA1 Message Date
SolitaryThinker 26719dcfe7 [new-model] Add PAN2 text-to-video and image-to-video
Port of vllm-project/vllm-omni#8602. PAN2 is a 29B mixture-of-transformers
video DiT (separate video and text streams joined in one attention per
block) with a Qwen3.5 text encoder and the Wan2.2 VAE. One pipeline serves
text-to-video and first-frame image-to-video.

- Native PAN2 DiT; per-stream Q/K/V fused at load time, Ulysses SP with
  replicated text tokens through DistributedAttention.
- Native Qwen3.5 text encoder (Gated DeltaNet + gated attention) with a
  PyTorch chunked delta rule.
- Reuses the Wan2.2 VAE and the flow-match Euler scheduler.
- PAN2 stages: chat-template prompt crop at hidden_states[-3], first-frame
  conditioning, uniform sigmas with shift 7, fp32 latents. Adds a
  DenoisingStage.prepare_scheduler_model_output hook for the last one.
- Registry entry for the Modular Diffusers PAN2ModularPipeline manifest,
  pan2_t2v / pan2_i2v presets, example, docs, and local parity tests
  against a diffusers/transformers reference on the public tiny checkpoint.
2026-10-15 03:44:35 +00:00
28 changed files with 3481 additions and 3 deletions
@@ -283,6 +283,15 @@ surfaces:
- fastvideo.configs.pipelines.hunyuan15.Hunyuan15T2V720PConfig
- fastvideo.configs.pipelines.hyworld.HYWorldConfig
- fastvideo.configs.pipelines.hyworld.Hunyuan15T2V480PConfig
text_system_prompt:
sources:
- fastvideo.configs.pipelines.pan2.PAN2PipelineConfig
text_encoder_skip_layers:
sources:
- fastvideo.configs.pipelines.pan2.PAN2PipelineConfig
text_max_sequence_length:
sources:
- fastvideo.configs.pipelines.pan2.PAN2PipelineConfig
text_encoder_max_lengths:
sources:
- fastvideo.configs.pipelines.hunyuan15.Hunyuan15I2V480PStepDistilledConfig
+63
View File
@@ -0,0 +1,63 @@
# PAN2 Text/Image to Video
PAN2 is a 29B mixture-of-transformers video DiT with separate video and text streams, a Qwen3.5 text encoder and the
Wan2.2 VAE. One pipeline serves text-to-video and image-to-video: pass `image_path` to condition on a first frame and
leave it out for text only.
## Model
| Hub repo | Pipeline | Workloads | Example |
|---|---|---|---|
| `IFM/PAN2` | `PAN2ModularPipeline` | T2V, I2V | [`basic_pan2.py`](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_pan2.py) |
PAN2 checkpoints use the Modular Diffusers layout (`modular_model_index.json`). Any repo id or local directory whose
name contains `pan2` (or whose manifest declares `PAN2ModularPipeline`) resolves to this pipeline. The defaults come
from the `pan2_t2v` and `pan2_i2v` presets:
| Setting | Default |
|---|---|
| Resolution | 704x1248 (height and width must be multiples of 32) |
| Frames | 313, 13 s at 24 fps (`num_frames - 1` must be a multiple of 4) |
| Steps | 50, uniform sigmas from 1 to 0 with flow shift 7 |
| Guidance | 3.0, classifier-free guidance with an empty negative prompt |
PAN2 is trained on the structured captions its prompt rewriter produces, serialized as one-line JSON. Short raw
prompts work, with weaker results.
## Usage
```bash
python examples/inference/basic/basic_pan2.py
```
```python
from fastvideo import VideoGenerator
generator = VideoGenerator.from_pretrained(
"IFM/PAN2",
num_gpus=1,
dit_cpu_offload=False,
text_encoder_cpu_offload=True,
)
generator.generate_video(
"A red fox trots through fresh snow in a pine forest at dawn.",
image_path=None, # or the path of a first frame for image-to-video
output_path="video_samples_pan2",
)
```
For image-to-video, the first frame is scaled to cover the requested resolution and center cropped, then encoded by the
VAE into the first latent frame of the conditioning input.
## Parallelism
With `num_gpus > 1`, FastVideo runs Ulysses sequence parallelism: the video tokens are sharded across ranks and the
text tokens are replicated. The number of GPUs must divide the 32 attention heads. Sequence-parallel padding of the
video tokens is trimmed before attention, so no attention mask is needed.
## Differences from the vLLM-Omni integration
- The optional prompt and video guardrails (`pan2-guardrail`) are not included.
- The Cache-DiT `quality` policy and CFG-parallel execution are vLLM-Omni serving features and are not ported.
- The text encoder is a FastVideo-native Qwen3.5 port. Its Gated DeltaNet layers use a PyTorch chunked delta rule
instead of the `fla` kernels.
+1
View File
@@ -74,6 +74,7 @@ column links a runnable script in `examples/inference/basic/` where one exists.
| matrixgame | `FastVideo/Matrix-Game-2.0-Base-Distilled-Diffusers`<br>`FastVideo/Matrix-Game-2.0-GTA-Distilled-Diffusers`<br>`FastVideo/Matrix-Game-2.0-TempleRun-Distilled-Diffusers`<br>`FastVideo/Matrix-Game-2.0-Base-Diffusers`<br>`FastVideo/Matrix-Game-2.0-GTA-Diffusers`<br>`FastVideo/Matrix-Game-2.0-TempleRun-Diffusers`<br>`mignonjia/mg_longtuning_distilled_zelda`<br>`mignonjia/mg_sf_distilled_zelda_1k_steps`<br>`mignonjia/mg_sf_distilled_zelda`<br>`mignonjia/mg_causal_zelda`<br>`mignonjia/mg_bidirectional_zelda` | I2V | [basic_matrixgame2.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_matrixgame2.py) |
| matrixgame | `FastVideo/Matrix-Game-3.0-Base-Distilled-Diffusers` | I2V | [basic_matrixgame3.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_matrixgame3.py) |
| minimax_h3 | `MiniMaxAI/MiniMax-H3` | T2V, I2V | [T2VA](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_minimax_h3_t2v.py)<br>[FL2VA](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_minimax_h3_fl2va.py)<br>[Ref2VA](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_minimax_h3_ref2va.py) |
| pan2 | `IFM/PAN2` | T2V, I2V | [basic_pan2.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_pan2.py) |
| sd35 | `stabilityai/stable-diffusion-3.5-medium` | T2I | [basic_sd35_t2i.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_sd35_t2i.py) |
| stable_audio | `FastVideo/stable-audio-open-1.0-Diffusers` | T2V | [basic_stable_audio.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_stable_audio.py) |
| stable_audio | `FastVideo/stable-audio-open-small-Diffusers` | T2V | [basic_stable_audio_small.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_stable_audio_small.py) |
+40
View File
@@ -0,0 +1,40 @@
import os
from fastvideo import VideoGenerator
# PAN2 is trained on the structured captions its prompt rewriter produces (serialized as one-line JSON). Plain
# prompts like this one work, with weaker results.
PROMPT = ("A red fox trots through fresh snow in a pine forest at dawn, its breath visible in the cold air. "
"The camera tracks it from the side at ground level as low golden light filters through the trees.")
OUTPUT_PATH = "video_samples_pan2"
MODEL_PATH = os.environ.get("PAN2_MODEL_PATH", "IFM/PAN2")
# Set to a first frame (e.g. "assets/images/girl.png") for image-to-video; the same pipeline runs both modes.
IMAGE_PATH = None
def main():
# The 29B DiT runs in bf16; with more GPUs, num_gpus enables Ulysses sequence parallelism (the 32 attention
# heads must be divisible by it).
generator = VideoGenerator.from_pretrained(
MODEL_PATH,
num_gpus=int(os.environ.get("PAN2_NUM_GPUS", "1")),
use_fsdp_inference=False,
dit_cpu_offload=False,
vae_cpu_offload=False,
text_encoder_cpu_offload=True,
pin_cpu_memory=True,
)
# Defaults come from the pan2 preset: 704x1248, 313 frames (13 s at 24 fps), 50 steps, guidance 3.0.
generator.generate_video(
PROMPT,
image_path=IMAGE_PATH,
output_path=OUTPUT_PATH,
save_video=True,
)
if __name__ == "__main__":
main()
+3 -2
View File
@@ -12,6 +12,7 @@ from fastvideo.configs.models.dits.longcat import LongCatVideoConfig
from fastvideo.configs.models.dits.ltx2 import LTX2VideoConfig
from fastvideo.configs.models.dits.magi_human import MagiHumanVideoConfig
from fastvideo.configs.models.dits.minimax_h3 import MiniMaxH3Config
from fastvideo.configs.models.dits.pan2 import PAN2VideoConfig
from fastvideo.configs.models.dits.mmaudio import (
MMAUDIO_44K_TRAINING_VARIANTS,
MMAUDIO_TRAINING_VARIANTS,
@@ -37,7 +38,7 @@ __all__ = [
"LongCatVideoConfig", "LTX2VideoConfig", "HYWorldConfig", "Kandinsky5VideoConfig", "Kandinsky6ArchConfig",
"Kandinsky6VideoAudioConfig", "Kandinsky6SRArchConfig", "Kandinsky6SRConfig", "MagiHumanVideoConfig",
"StableAudioConfig", "GlmImageDiTConfig", "HeliosConfig", "LingBotWorld2CausalFastVideoConfig",
"LingBotWorldFastVideoConfig", "LingBotVideoConfig", "MiniMaxH3Config", "ZImageDiTConfig", "MMAudioArchConfig",
"MMAudioTransformerConfig", "MMAUDIO_VARIANT_ARCHITECTURES", "MMAUDIO_TRAINING_VARIANTS",
"LingBotWorldFastVideoConfig", "LingBotVideoConfig", "MiniMaxH3Config", "PAN2VideoConfig", "ZImageDiTConfig",
"MMAudioArchConfig", "MMAudioTransformerConfig", "MMAUDIO_VARIANT_ARCHITECTURES", "MMAUDIO_TRAINING_VARIANTS",
"MMAUDIO_44K_TRAINING_VARIANTS", "get_mmaudio_transformer_config"
]
+86
View File
@@ -0,0 +1,86 @@
# SPDX-License-Identifier: Apache-2.0
from dataclasses import dataclass, field
from fastvideo.configs.models.dits.base import DiTArchConfig, DiTConfig
from fastvideo.platforms import AttentionBackendEnum
def is_transformer_block(n: str, m) -> bool:
return "transformer_blocks" in n and str.isdigit(n.split(".")[-1])
def is_refiner_block(n: str, m) -> bool:
return "context_refiner" in n and str.isdigit(n.split(".")[-1])
@dataclass
class PAN2ArchConfig(DiTArchConfig):
_fsdp_shard_conditions: list = field(default_factory=lambda: [is_transformer_block, is_refiner_block])
_supported_attention_backends: tuple[AttentionBackendEnum, ...] = (
AttentionBackendEnum.FLASH_ATTN,
AttentionBackendEnum.TORCH_SDPA,
)
# Diffusers PAN2Transformer3DModel keys -> native keys. The per-stream Q/K/V projections are fused on load.
param_names_mapping: dict = field(
default_factory=lambda: {
r"^x_embedder\.(.*)$": r"x_embedder.proj.\1",
r"^time_embedder\.linear_1\.(.*)$": r"time_embedder.mlp.fc_in.\1",
r"^time_embedder\.linear_2\.(.*)$": r"time_embedder.mlp.fc_out.\1",
r"^context_embedder\.0\.(.*)$": r"context_norm.\1",
r"^context_embedder\.1\.(.*)$": r"context_proj.\1",
r"^context_refiner\.(\d+)\.attn\.to_q\.(.*)$": (r"context_refiner.\1.attn.to_qkv.\2", 0, 3),
r"^context_refiner\.(\d+)\.attn\.to_k\.(.*)$": (r"context_refiner.\1.attn.to_qkv.\2", 1, 3),
r"^context_refiner\.(\d+)\.attn\.to_v\.(.*)$": (r"context_refiner.\1.attn.to_qkv.\2", 2, 3),
r"^context_refiner\.(\d+)\.attn\.to_out\.0\.(.*)$": r"context_refiner.\1.attn.to_out.\2",
r"^context_refiner\.(\d+)\.ff\.net\.0\.proj\.(.*)$": r"context_refiner.\1.ff.fc_in.\2",
r"^context_refiner\.(\d+)\.ff\.net\.2\.(.*)$": r"context_refiner.\1.ff.fc_out.\2",
r"^transformer_blocks\.(\d+)\.attn\.to_q\.(.*)$": (r"transformer_blocks.\1.attn.to_qkv.\2", 0, 3),
r"^transformer_blocks\.(\d+)\.attn\.to_k\.(.*)$": (r"transformer_blocks.\1.attn.to_qkv.\2", 1, 3),
r"^transformer_blocks\.(\d+)\.attn\.to_v\.(.*)$": (r"transformer_blocks.\1.attn.to_qkv.\2", 2, 3),
r"^transformer_blocks\.(\d+)\.attn\.add_q_proj\.(.*)$":
(r"transformer_blocks.\1.attn.add_qkv_proj.\2", 0, 3),
r"^transformer_blocks\.(\d+)\.attn\.add_k_proj\.(.*)$":
(r"transformer_blocks.\1.attn.add_qkv_proj.\2", 1, 3),
r"^transformer_blocks\.(\d+)\.attn\.add_v_proj\.(.*)$":
(r"transformer_blocks.\1.attn.add_qkv_proj.\2", 2, 3),
r"^transformer_blocks\.(\d+)\.attn\.to_out\.0\.(.*)$": r"transformer_blocks.\1.attn.to_out.\2",
r"^transformer_blocks\.(\d+)\.(ff|ff_context)\.net\.0\.proj\.(.*)$": r"transformer_blocks.\1.\2.fc_in.\3",
r"^transformer_blocks\.(\d+)\.(ff|ff_context)\.net\.2\.(.*)$": r"transformer_blocks.\1.\2.fc_out.\3",
})
reverse_param_names_mapping: dict = field(default_factory=lambda: {})
patch_size: tuple[int, ...] = (1, 2, 2)
in_channels: int = 97
out_channels: int = 48
num_attention_heads: int = 32
attention_head_dim: int = 128
num_layers: int = 44
num_refiner_layers: int = 4
mlp_ratio: float = 8.0
context_mlp_ratio: float = 4.0
refiner_mlp_ratio: float = 4.0
text_embed_dim: int = 4096
rope_axes_dim: tuple[int, ...] = (16, 56, 56)
rope_theta: float = 256.0
# Frame positions are rescaled to the frame rate the rotary embedding was trained at (16 fps of 24 fps).
rope_temporal_scale: float = 16 / 24
# PAN2 runs its text stream in the DiT dtype.
cast_prompt_embeds_to_dit_dtype: bool = True
exclude_lora_layers: list[str] = field(
default_factory=lambda: ["x_embedder", "time_embedder", "context_norm", "context_proj", "context_refiner"])
def __post_init__(self):
super().__post_init__()
self.patch_size = tuple(self.patch_size)
self.rope_axes_dim = tuple(self.rope_axes_dim)
self.hidden_size: int = self.attention_head_dim * self.num_attention_heads
self.num_channels_latents: int = self.out_channels
@dataclass
class PAN2VideoConfig(DiTConfig):
arch_config: DiTArchConfig = field(default_factory=PAN2ArchConfig)
prefix: str = "PAN2"
@@ -15,6 +15,7 @@ from fastvideo.configs.models.encoders.gemma import LTX2GemmaConfig
from fastvideo.configs.models.encoders.mistral3 import Mistral3TextConfig
from fastvideo.configs.models.encoders.minimax_h3_qwen3_vl import (MiniMaxH3Qwen3VLArchConfig, MiniMaxH3Qwen3VLConfig)
from fastvideo.configs.models.encoders.qwen3 import Qwen3TextConfig
from fastvideo.configs.models.encoders.qwen3_5 import Qwen3_5TextArchConfig, Qwen3_5TextConfig
from fastvideo.configs.models.encoders.lingbot_video import LingBotVideoQwen3VLTextConfig
from fastvideo.configs.models.encoders.stable_audio_conditioner import (
StableAudioConditionerArchConfig,
@@ -49,6 +50,8 @@ __all__ = [
"StableAudioConditionerConfig",
"T5GemmaEncoderConfig",
"Qwen3TextConfig",
"Qwen3_5TextArchConfig",
"Qwen3_5TextConfig",
"Mistral3TextConfig",
"LingBotWorld2UMT5ArchConfig",
"LingBotWorld2UMT5Config",
@@ -0,0 +1,119 @@
# SPDX-License-Identifier: Apache-2.0
"""Qwen3.5 text encoder configuration (hybrid Gated DeltaNet + gated attention)."""
from dataclasses import dataclass, field
from typing import Any
from fastvideo.configs.models.encoders.base import (
TextEncoderArchConfig,
TextEncoderConfig,
)
_LEGACY_LAYER_TYPES = {
"mamba": "linear_attention",
"conv": "linear_attention",
"attention": "full_attention",
}
_DEFAULT_ROPE_THETA = 10000.0
_DEFAULT_PARTIAL_ROTARY_FACTOR = 0.25
def _is_transformer_layer(n: str, m: Any) -> bool:
return "layers" in n and str.isdigit(n.split(".")[-1])
def _is_embeddings(n: str, m: Any) -> bool:
return n.endswith("embed_tokens")
def _is_final_norm(n: str, m: Any) -> bool:
return n == "norm"
@dataclass
class Qwen3_5TextArchConfig(TextEncoderArchConfig):
"""Architecture config for the Qwen3.5 text model (``Qwen3_5TextModel``).
Layers alternate between Gated DeltaNet linear attention and gated full
attention according to ``layer_types``. The defaults describe the 9B text
tower; checkpoint ``config.json`` values override them at load time.
"""
vocab_size: int = 248320
hidden_size: int = 4096
intermediate_size: int = 12288
num_hidden_layers: int = 32
num_attention_heads: int = 16
num_key_value_heads: int = 4
head_dim: int = 256
hidden_act: str = "silu"
max_position_embeddings: int = 32768
initializer_range: float = 0.02
rms_norm_eps: float = 1e-6
use_cache: bool = True
tie_word_embeddings: bool = False
attention_bias: bool = False
attention_dropout: float = 0.0
linear_conv_kernel_dim: int = 4
linear_key_head_dim: int = 128
linear_value_head_dim: int = 128
linear_num_key_heads: int = 16
linear_num_value_heads: int = 32
layer_types: list[str] | None = None
full_attention_interval: int = 4
# RoPE settings as written by Transformers 5 (``rope_parameters``) or by
# older configs (top-level ``rope_theta`` / ``partial_rotary_factor`` /
# ``rope_scaling``). Values inside ``rope_parameters`` take precedence.
rope_parameters: dict[str, Any] | None = None
rope_scaling: dict[str, Any] | None = None
rope_theta: float | None = None
partial_rotary_factor: float | None = None
pad_token_id: int | None = None
bos_token_id: int | None = None
eos_token_id: int | list[int] | None = None
_fsdp_shard_conditions: list = field(
default_factory=lambda: [_is_transformer_layer, _is_embeddings, _is_final_norm])
def __post_init__(self) -> None:
super().__post_init__()
# Validate eagerly; the resolved views are recomputed on demand because
# ``update_model_arch`` re-runs this hook after replacing raw fields.
self.resolved_layer_types()
self.resolved_rope_parameters()
def resolved_layer_types(self) -> list[str]:
"""Return one ``linear_attention`` / ``full_attention`` entry per layer."""
if self.layer_types is None:
interval = self.full_attention_interval
layer_types = [
"linear_attention" if (index + 1) % interval else "full_attention"
for index in range(self.num_hidden_layers)
]
else:
layer_types = [_LEGACY_LAYER_TYPES.get(kind, kind) for kind in self.layer_types]
if len(layer_types) < self.num_hidden_layers:
raise ValueError(f"Qwen3.5 layer_types has {len(layer_types)} entries for "
f"{self.num_hidden_layers} layers")
unknown = set(layer_types) - {"linear_attention", "full_attention"}
if unknown:
raise ValueError(f"Unsupported Qwen3.5 layer types: {sorted(unknown)}")
return layer_types[:self.num_hidden_layers]
def resolved_rope_parameters(self) -> dict[str, Any]:
"""Return the effective text RoPE settings with Transformers' precedence."""
rope = dict(self.rope_scaling or self.rope_parameters or {})
rope.setdefault("rope_type", rope.get("type", "default"))
rope.setdefault("rope_theta", self.rope_theta if self.rope_theta is not None else _DEFAULT_ROPE_THETA)
rope.setdefault(
"partial_rotary_factor",
self.partial_rotary_factor if self.partial_rotary_factor is not None else _DEFAULT_PARTIAL_ROTARY_FACTOR,
)
return rope
@dataclass
class Qwen3_5TextConfig(TextEncoderConfig):
"""Top-level config for the Qwen3.5 text encoder."""
arch_config: TextEncoderArchConfig = field(default_factory=Qwen3_5TextArchConfig)
prefix: str = "qwen3_5"
+2 -1
View File
@@ -16,6 +16,7 @@ from fastvideo.configs.pipelines.lingbot_video import LingBotVideoT2VConfig
from fastvideo.configs.pipelines.matrixgame2 import MatrixGame2I2V480PConfig
from fastvideo.configs.pipelines.matrixgame3 import MatrixGame3I2V720PConfig
from fastvideo.configs.pipelines.mmaudio import MMAudioV2AConfig
from fastvideo.configs.pipelines.pan2 import PAN2PipelineConfig
from fastvideo.pipelines.basic.ltx2.pipeline_configs import LTX2T2VConfig
from fastvideo.registry import get_pipeline_config_cls_from_name
from fastvideo.models.wan.pipeline_config import (LucyEditDevConfig, SelfForcingWanT2V480PConfig, WanI2V480PConfig,
@@ -29,5 +30,5 @@ __all__ = [
"Kandinsky5T2VConfig", "Kandinsky5I2VConfig", "Kandinsky5DMDConfig", "Kandinsky6TI2VAConfig",
"Kandinsky6SRPipelineConfig", "LingBotWorld2CausalFastI2V480PConfig", "LingBotWorldFastI2V480PConfig",
"LingBotVideoT2VConfig", "MatrixGame2I2V480PConfig", "MatrixGame3I2V720PConfig", "MMAudioV2AConfig",
"get_pipeline_config_cls_from_name"
"PAN2PipelineConfig", "get_pipeline_config_cls_from_name"
]
+41
View File
@@ -0,0 +1,41 @@
# SPDX-License-Identifier: Apache-2.0
"""PAN2 pipeline configuration: text-to-video, and image-to-video from a first frame."""
from dataclasses import dataclass, field
from fastvideo.configs.models import DiTConfig, EncoderConfig, VAEConfig
from fastvideo.configs.models.dits import PAN2VideoConfig
from fastvideo.configs.models.encoders import Qwen3_5TextConfig
from fastvideo.configs.pipelines.base import PipelineConfig
from fastvideo.models.wan.vae_config import WanVAEConfig
PAN2_SYSTEM_PROMPT = "You are a helpful assistant. Generate the video with the following instructions."
@dataclass
class PAN2PipelineConfig(PipelineConfig):
"""One configuration serves text-to-video and, given a first frame, image-to-video."""
dit_config: DiTConfig = field(default_factory=PAN2VideoConfig)
# The Wan2.2 48-channel VAE; its architecture and latent statistics come from vae/config.json at load time.
vae_config: VAEConfig = field(default_factory=WanVAEConfig)
text_encoder_configs: tuple[EncoderConfig, ...] = field(default_factory=lambda: (Qwen3_5TextConfig(), ))
text_encoder_precisions: tuple[str, ...] = field(default_factory=lambda: ("bf16", ))
dit_precision: str = "bf16"
vae_precision: str = "fp32"
flow_shift: float | None = 7.0
# PAN2 samples on fp32 latents; keep the Euler update out of autocast.
scheduler_step_in_fp32: bool = True
# Prompt encoding: PAN2 conditions on the tokens after the chat template's user header, taken from the text
# encoder's hidden states with its last two layers skipped (``hidden_states[-3]``).
text_system_prompt: str = PAN2_SYSTEM_PROMPT
text_encoder_skip_layers: int = 2
text_max_sequence_length: int = 1536
def __post_init__(self) -> None:
# The encoder is needed for the image-to-video first frame.
self.vae_config.load_encoder = True
self.vae_config.load_decoder = True
self.text_encoder_configs[0].arch_config.output_hidden_states = True
+397
View File
@@ -0,0 +1,397 @@
# SPDX-License-Identifier: Apache-2.0
"""PAN2 video transformer.
A mixture-of-transformers DiT: the video and text streams keep separate weights and meet only in one joint
attention per block, over ``[video; text]``. The text stream is modulated by a learned, timestep-free table.
Under sequence parallelism the video tokens are sharded and the text tokens are replicated on every rank.
"""
from typing import Any
import torch
import torch.nn as nn
import torch.nn.functional as F
from fastvideo.attention import DistributedAttention, LocalAttention
from fastvideo.configs.models.dits.pan2 import PAN2VideoConfig
from fastvideo.distributed.communication_op import (sequence_model_parallel_all_gather_with_unpad,
sequence_model_parallel_shard)
from fastvideo.distributed.parallel_state import get_sp_world_size
from fastvideo.layers.layernorm import RMSNorm
from fastvideo.layers.linear import ReplicatedLinear
from fastvideo.layers.mlp import MLP
from fastvideo.layers.rotary_embedding import get_1d_rotary_pos_embed
from fastvideo.layers.visual_embedding import PatchEmbed, TimestepEmbedder
from fastvideo.models.dits.base import BaseDiT
from fastvideo.platforms import AttentionBackendEnum
class PAN2SwiGLU(nn.Module):
"""SwiGLU MLP of the text refiner; the fused input projection stores ``[value; gate]``."""
def __init__(self, dim: int, inner_dim: int, prefix: str = ""):
super().__init__()
self.fc_in = ReplicatedLinear(dim, 2 * inner_dim, bias=False, prefix=f"{prefix}.fc_in")
self.fc_out = ReplicatedLinear(inner_dim, dim, bias=False, prefix=f"{prefix}.fc_out")
def forward(self, x: torch.Tensor) -> torch.Tensor:
x, _ = self.fc_in(x)
value, gate = x.chunk(2, dim=-1)
x, _ = self.fc_out(value * F.silu(gate))
return x
class PAN2RefinerAttention(nn.Module):
"""Text-only self-attention of the refiner; the text is replicated on every sequence-parallel rank."""
def __init__(self,
dim: int,
num_heads: int,
head_dim: int,
eps: float,
supported_attention_backends: tuple[AttentionBackendEnum, ...] | None = None,
prefix: str = ""):
super().__init__()
self.num_heads = num_heads
self.to_qkv = ReplicatedLinear(dim, 3 * num_heads * head_dim, bias=False, prefix=f"{prefix}.to_qkv")
self.norm_q = RMSNorm(head_dim, eps=eps)
self.norm_k = RMSNorm(head_dim, eps=eps)
self.to_out = ReplicatedLinear(num_heads * head_dim, dim, bias=False, prefix=f"{prefix}.to_out")
self.attn = LocalAttention(num_heads=num_heads,
head_size=head_dim,
causal=False,
supported_attention_backends=supported_attention_backends)
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
qkv, _ = self.to_qkv(hidden_states)
query, key, value = qkv.unflatten(-1, (3, self.num_heads, -1)).unbind(2)
query = self.norm_q(query).to(value.dtype)
key = self.norm_k(key).to(value.dtype)
hidden_states = self.attn(query, key, value)
hidden_states, _ = self.to_out(hidden_states.flatten(2))
return hidden_states
class PAN2TokenRefinerBlock(nn.Module):
def __init__(self,
dim: int,
num_heads: int,
head_dim: int,
mlp_ratio: float,
eps: float,
supported_attention_backends: tuple[AttentionBackendEnum, ...] | None = None,
prefix: str = ""):
super().__init__()
self.norm1 = RMSNorm(dim, eps=eps)
self.attn = PAN2RefinerAttention(dim,
num_heads,
head_dim,
eps,
supported_attention_backends=supported_attention_backends,
prefix=f"{prefix}.attn")
self.norm2 = RMSNorm(dim, eps=eps)
self.ff = PAN2SwiGLU(dim, int(dim * mlp_ratio), prefix=f"{prefix}.ff")
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
hidden_states = hidden_states + self.attn(self.norm1(hidden_states))
hidden_states = hidden_states + self.ff(self.norm2(hidden_states))
return hidden_states
class PAN2AdaLayerNormZero(nn.Module):
"""Timestep modulation of the video stream: modulated attention input plus the gate/shift/scale for the rest."""
def __init__(self, dim: int, eps: float = 1e-6, prefix: str = ""):
super().__init__()
self.linear = ReplicatedLinear(dim, 6 * dim, bias=True, prefix=f"{prefix}.linear")
self.norm = nn.LayerNorm(dim, elementwise_affine=False, eps=eps)
def forward(self, x: torch.Tensor,
temb: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
emb, _ = self.linear(F.silu(temb))
shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = emb.chunk(6, dim=-1)
x = self.norm(x) * (1 + scale_msa[:, None]) + shift_msa[:, None]
return x, gate_msa, shift_mlp, scale_mlp, gate_mlp
class PAN2AdaLayerNormOut(nn.Module):
def __init__(self, dim: int, eps: float = 1e-6, prefix: str = ""):
super().__init__()
self.linear = ReplicatedLinear(dim, 2 * dim, bias=True, prefix=f"{prefix}.linear")
self.norm = nn.LayerNorm(dim, elementwise_affine=False, eps=eps)
def forward(self, x: torch.Tensor, temb: torch.Tensor) -> torch.Tensor:
emb, _ = self.linear(F.silu(temb).to(x.dtype))
scale, shift = emb.chunk(2, dim=-1)
return self.norm(x) * (1 + scale)[:, None, :] + shift[:, None, :]
class PAN2Attention(nn.Module):
"""Joint attention over ``[video; text]`` with per-stream projections and Q/K RMSNorm.
RoPE applies to the video tokens only; ``DistributedAttention`` applies it after its all-to-all, where each rank
holds the full (unpadded) video sequence for its heads.
"""
def __init__(self,
dim: int,
num_heads: int,
head_dim: int,
eps: float,
supported_attention_backends: tuple[AttentionBackendEnum, ...] | None = None,
prefix: str = ""):
super().__init__()
self.num_heads = num_heads
inner_dim = num_heads * head_dim
self.to_qkv = ReplicatedLinear(dim, 3 * inner_dim, bias=True, prefix=f"{prefix}.to_qkv")
self.norm_q = RMSNorm(head_dim, eps=eps)
self.norm_k = RMSNorm(head_dim, eps=eps)
self.to_out = ReplicatedLinear(inner_dim, dim, bias=True, prefix=f"{prefix}.to_out")
self.add_qkv_proj = ReplicatedLinear(dim, 3 * inner_dim, bias=True, prefix=f"{prefix}.add_qkv_proj")
self.norm_added_q = RMSNorm(head_dim, eps=eps)
self.norm_added_k = RMSNorm(head_dim, eps=eps)
self.to_add_out = ReplicatedLinear(inner_dim, dim, bias=True, prefix=f"{prefix}.to_add_out")
self.attn = DistributedAttention(num_heads=num_heads,
head_size=head_dim,
causal=False,
supported_attention_backends=supported_attention_backends,
prefix=f"{prefix}.attn")
def _project(self, projection: ReplicatedLinear,
hidden_states: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
qkv, _ = projection(hidden_states)
query, key, value = qkv.unflatten(-1, (3, self.num_heads, -1)).unbind(2)
return query, key, value
def forward(
self,
hidden_states: torch.Tensor,
encoder_hidden_states: torch.Tensor,
freqs_cis: tuple[torch.Tensor, torch.Tensor],
original_seq_len: int,
) -> tuple[torch.Tensor, torch.Tensor]:
query, key, value = self._project(self.to_qkv, hidden_states)
query = self.norm_q(query).to(value.dtype)
key = self.norm_k(key).to(value.dtype)
encoder_query, encoder_key, encoder_value = self._project(self.add_qkv_proj, encoder_hidden_states)
encoder_query = self.norm_added_q(encoder_query).to(encoder_value.dtype)
encoder_key = self.norm_added_k(encoder_key).to(encoder_value.dtype)
hidden_states, encoder_hidden_states = self.attn(query,
key,
value,
original_seq_len,
replicated_q=encoder_query,
replicated_k=encoder_key,
replicated_v=encoder_value,
freqs_cis=freqs_cis)
hidden_states, _ = self.to_out(hidden_states.flatten(2))
encoder_hidden_states, _ = self.to_add_out(encoder_hidden_states.flatten(2))
return hidden_states, encoder_hidden_states
class PAN2TransformerBlock(nn.Module):
def __init__(self,
dim: int,
num_heads: int,
head_dim: int,
mlp_ratio: float,
context_mlp_ratio: float,
eps: float = 1e-6,
supported_attention_backends: tuple[AttentionBackendEnum, ...] | None = None,
prefix: str = ""):
super().__init__()
self.norm1 = PAN2AdaLayerNormZero(dim, eps=eps, prefix=f"{prefix}.norm1")
self.norm1_context = nn.LayerNorm(dim, elementwise_affine=False, eps=eps)
self.scale_shift_table_context = nn.Parameter(torch.zeros(6 * dim))
self.attn = PAN2Attention(dim,
num_heads,
head_dim,
eps,
supported_attention_backends=supported_attention_backends,
prefix=f"{prefix}.attn")
self.norm2 = nn.LayerNorm(dim, elementwise_affine=False, eps=eps)
self.ff = MLP(dim, int(dim * mlp_ratio), act_type="gelu_pytorch_tanh", prefix=f"{prefix}.ff")
self.norm2_context = nn.LayerNorm(dim, elementwise_affine=False, eps=eps)
self.ff_context = MLP(dim,
int(dim * context_mlp_ratio),
act_type="gelu_pytorch_tanh",
prefix=f"{prefix}.ff_context")
def forward(
self,
hidden_states: torch.Tensor,
encoder_hidden_states: torch.Tensor,
temb: torch.Tensor,
freqs_cis: tuple[torch.Tensor, torch.Tensor],
original_seq_len: int,
) -> tuple[torch.Tensor, torch.Tensor]:
norm_hidden_states, gate_msa, shift_mlp, scale_mlp, gate_mlp = self.norm1(hidden_states, temb)
(
context_shift_msa,
context_scale_msa,
context_gate_msa,
context_shift_mlp,
context_scale_mlp,
context_gate_mlp,
) = self.scale_shift_table_context.to(temb.dtype).chunk(6, dim=-1)
norm_encoder_hidden_states = (self.norm1_context(encoder_hidden_states) *
(1 + context_scale_msa) + context_shift_msa)
attn_output, context_attn_output = self.attn(norm_hidden_states, norm_encoder_hidden_states, freqs_cis,
original_seq_len)
hidden_states = hidden_states + attn_output * gate_msa.unsqueeze(1)
encoder_hidden_states = encoder_hidden_states + context_attn_output * context_gate_msa
norm_hidden_states = self.norm2(hidden_states) * (1 + scale_mlp[:, None]) + shift_mlp[:, None]
norm_encoder_hidden_states = (self.norm2_context(encoder_hidden_states) *
(1 + context_scale_mlp) + context_shift_mlp)
hidden_states = hidden_states + self.ff(norm_hidden_states) * gate_mlp.unsqueeze(1)
encoder_hidden_states = encoder_hidden_states + self.ff_context(norm_encoder_hidden_states) * context_gate_mlp
return hidden_states, encoder_hidden_states
class PAN2Transformer3DModel(BaseDiT):
"""PAN2 DiT. The input stacks the noisy latents, the conditioning latents and a one-channel conditioning mask
along channels, so one model serves text-to-video and first-frame image-to-video."""
_fsdp_shard_conditions = PAN2VideoConfig()._fsdp_shard_conditions
_compile_conditions = PAN2VideoConfig()._compile_conditions
_supported_attention_backends = PAN2VideoConfig()._supported_attention_backends
param_names_mapping = PAN2VideoConfig().param_names_mapping
reverse_param_names_mapping = PAN2VideoConfig().reverse_param_names_mapping
lora_param_names_mapping = PAN2VideoConfig().lora_param_names_mapping
def __init__(self, config: PAN2VideoConfig, hf_config: dict[str, Any], **kwargs) -> None:
super().__init__(config=config, hf_config=hf_config)
inner_dim = config.num_attention_heads * config.attention_head_dim
self.hidden_size = inner_dim
self.num_attention_heads = config.num_attention_heads
self.num_channels_latents = config.num_channels_latents
self.out_channels = config.out_channels
self.patch_size = tuple(config.patch_size)
self.rope_axes_dim = tuple(config.rope_axes_dim)
if sum(self.rope_axes_dim) != config.attention_head_dim:
raise ValueError(f"rope_axes_dim {self.rope_axes_dim} must sum to the head dim {config.attention_head_dim}")
if config.num_attention_heads % get_sp_world_size() != 0:
raise ValueError(f"The number of attention heads ({config.num_attention_heads}) must be divisible by the "
f"sequence parallel size ({get_sp_world_size()})")
self.x_embedder = PatchEmbed(self.patch_size,
config.in_channels,
inner_dim,
prefix=f"{config.prefix}.x_embedder")
self.time_embedder = TimestepEmbedder(inner_dim, act_layer="silu", frequency_embedding_size=256)
self.context_norm = RMSNorm(config.text_embed_dim, eps=1e-5)
self.context_proj = ReplicatedLinear(config.text_embed_dim,
inner_dim,
bias=True,
prefix=f"{config.prefix}.context_proj")
self.context_refiner = nn.ModuleList([
PAN2TokenRefinerBlock(inner_dim,
config.num_attention_heads,
config.attention_head_dim,
config.refiner_mlp_ratio,
eps=1e-5,
supported_attention_backends=self._supported_attention_backends,
prefix=f"{config.prefix}.context_refiner.{i}")
for i in range(config.num_refiner_layers)
])
self.context_type_embedding = nn.Parameter(torch.zeros(inner_dim))
self.transformer_blocks = nn.ModuleList([
PAN2TransformerBlock(inner_dim,
config.num_attention_heads,
config.attention_head_dim,
config.mlp_ratio,
config.context_mlp_ratio,
supported_attention_backends=self._supported_attention_backends,
prefix=f"{config.prefix}.transformer_blocks.{i}") for i in range(config.num_layers)
])
self.norm_out = PAN2AdaLayerNormOut(inner_dim, eps=1e-6, prefix=f"{config.prefix}.norm_out")
self.proj_out = ReplicatedLinear(inner_dim,
self.patch_size[0] * self.patch_size[1] * self.patch_size[2] *
self.out_channels,
bias=True,
prefix=f"{config.prefix}.proj_out")
self.gradient_checkpointing = False
self.__post_init__()
def _rotary_pos_embed(self, num_frames: int, height: int, width: int,
device: torch.device) -> tuple[torch.Tensor, torch.Tensor]:
"""Half-dim cos/sin of the 3-axis ``(t, h, w)`` RoPE over the full (unsharded) video token grid."""
axes = [torch.arange(size, device=device, dtype=torch.float32) for size in (num_frames, height, width)]
grid = torch.stack(torch.meshgrid(*axes, indexing="ij"), dim=0)
grid[0] = grid[0] * self.config.rope_temporal_scale
cos_sin = [
get_1d_rotary_pos_embed(dim, grid[i].reshape(-1), theta=self.config.rope_theta, use_real=False)
for i, dim in enumerate(self.rope_axes_dim)
]
freqs_cos = torch.cat([cos for cos, _ in cos_sin], dim=1).float()
freqs_sin = torch.cat([sin for _, sin in cos_sin], dim=1).float()
return freqs_cos, freqs_sin
def forward(self,
hidden_states: torch.Tensor,
encoder_hidden_states: torch.Tensor | list[torch.Tensor],
timestep: torch.LongTensor,
encoder_hidden_states_image: torch.Tensor | list[torch.Tensor] | None = None,
guidance=None,
**kwargs) -> torch.Tensor:
if not isinstance(encoder_hidden_states, torch.Tensor):
encoder_hidden_states = encoder_hidden_states[0]
batch_size, _, num_frames, height, width = hidden_states.shape
p_t, p_h, p_w = self.patch_size
post_patch_num_frames = num_frames // p_t
post_patch_height = height // p_h
post_patch_width = width // p_w
freqs_cis = self._rotary_pos_embed(post_patch_num_frames, post_patch_height, post_patch_width,
hidden_states.device)
hidden_states = self.x_embedder(hidden_states)
hidden_states, original_seq_len = sequence_model_parallel_shard(hidden_states, dim=1)
if timestep.dim() == 0:
timestep = timestep.expand(batch_size)
temb = self.time_embedder(timestep).to(hidden_states.dtype)
encoder_hidden_states, _ = self.context_proj(self.context_norm(encoder_hidden_states))
for refiner_block in self.context_refiner:
encoder_hidden_states = refiner_block(encoder_hidden_states)
encoder_hidden_states = encoder_hidden_states + self.context_type_embedding
for block in self.transformer_blocks:
if torch.is_grad_enabled() and self.gradient_checkpointing:
hidden_states, encoder_hidden_states = self._gradient_checkpointing_func(
block, hidden_states, encoder_hidden_states, temb, freqs_cis, original_seq_len)
else:
hidden_states, encoder_hidden_states = block(hidden_states, encoder_hidden_states, temb, freqs_cis,
original_seq_len)
hidden_states = self.norm_out(hidden_states, temb)
hidden_states = sequence_model_parallel_all_gather_with_unpad(hidden_states, original_seq_len, dim=1)
hidden_states, _ = self.proj_out(hidden_states)
# proj_out emits channels before the patch offsets: (C, p_t, p_h, p_w).
hidden_states = hidden_states.reshape(batch_size, post_patch_num_frames, post_patch_height, post_patch_width,
-1, p_t, p_h, p_w)
hidden_states = hidden_states.permute(0, 4, 1, 5, 2, 6, 3, 7)
return hidden_states.flatten(6, 7).flatten(4, 5).flatten(2, 3)
EntryClass = PAN2Transformer3DModel
+574
View File
@@ -0,0 +1,574 @@
# SPDX-License-Identifier: Apache-2.0
"""Qwen3.5 text encoder: hybrid Gated DeltaNet linear attention + gated full attention.
The encoder runs replicated (no tensor or sequence parallelism) and
inference-only, so the linear-attention layers use a pure-PyTorch chunked gated
delta rule instead of the fused ``fla`` / ``causal_conv1d`` kernels. Full
attention calls ``scaled_dot_product_attention`` directly with head-major
``[B, H, L, D]`` inputs: that is the layout the reference text model feeds SDPA,
and keeping it makes bf16 hidden states reproducible against that reference.
"""
from collections.abc import Iterable
from typing import Any
import torch
import torch.nn.functional as F
from torch import nn
from fastvideo.configs.models.encoders import BaseEncoderOutput
from fastvideo.configs.models.encoders.qwen3_5 import Qwen3_5TextArchConfig, Qwen3_5TextConfig
from fastvideo.layers.linear import ReplicatedLinear
from fastvideo.layers.quantization import QuantizationConfig
from fastvideo.layers.vocab_parallel_embedding import VocabParallelEmbedding
from fastvideo.logger import init_logger
from fastvideo.models.encoders.base import TextEncoder
from fastvideo.models.loader.weight_utils import default_weight_loader
logger = init_logger(__name__)
_GATED_DELTA_CHUNK_SIZE = 64
# Checkpoints saved from the multimodal wrapper nest the text tower.
_CHECKPOINT_PREFIXES = ("model.language_model.", "language_model.", "model.")
_SKIPPED_CHECKPOINT_PREFIXES = ("lm_head.", "mtp.", "visual.", "model.visual.")
class Qwen3_5RMSNorm(nn.Module):
"""Zero-centered RMSNorm: the stored weight is an offset from one."""
def __init__(self, hidden_size: int, eps: float = 1e-6) -> None:
super().__init__()
self.eps = eps
self.weight = nn.Parameter(torch.zeros(hidden_size))
def forward(self, x: torch.Tensor) -> torch.Tensor:
output = x.float()
output = output * torch.rsqrt(output.pow(2).mean(-1, keepdim=True) + self.eps)
# The scale is applied in float32 before the downcast.
output = output * (1.0 + self.weight.float())
return output.type_as(x)
class Qwen3_5RMSNormGated(nn.Module):
"""Per-head RMSNorm followed by a SiLU output gate (plain, not zero-centered, weight)."""
def __init__(self, hidden_size: int, eps: float = 1e-6) -> None:
super().__init__()
self.eps = eps
self.weight = nn.Parameter(torch.ones(hidden_size))
def forward(self, hidden_states: torch.Tensor, gate: torch.Tensor) -> torch.Tensor:
input_dtype = hidden_states.dtype
hidden_states = hidden_states.to(torch.float32)
variance = hidden_states.pow(2).mean(-1, keepdim=True)
hidden_states = hidden_states * torch.rsqrt(variance + self.eps)
hidden_states = self.weight * hidden_states.to(input_dtype)
hidden_states = hidden_states * F.silu(gate.to(torch.float32))
return hidden_states.to(input_dtype)
def _l2norm(x: torch.Tensor, eps: float = 1e-6) -> torch.Tensor:
return x * torch.rsqrt((x * x).sum(dim=-1, keepdim=True) + eps)
def chunk_gated_delta_rule(
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
g: torch.Tensor,
beta: torch.Tensor,
chunk_size: int = _GATED_DELTA_CHUNK_SIZE,
) -> torch.Tensor:
"""Chunked gated delta rule over a full prefix with a zero initial state.
Inputs are ``[batch, seq, heads, dim]`` (``g`` / ``beta`` are
``[batch, seq, heads]``); q/k must already be L2-normalized. The recurrence
runs in float32 and the output is returned in the query dtype.
"""
output_dtype = query.dtype
query, key, value, beta, g = (x.transpose(1, 2).contiguous().to(torch.float32)
for x in (query, key, value, beta, g))
batch_size, num_heads, sequence_length, k_head_dim = key.shape
v_head_dim = value.shape[-1]
pad_size = (chunk_size - sequence_length % chunk_size) % chunk_size
query = F.pad(query, (0, 0, 0, pad_size))
key = F.pad(key, (0, 0, 0, pad_size))
value = F.pad(value, (0, 0, 0, pad_size))
beta = F.pad(beta, (0, pad_size))
g = F.pad(g, (0, pad_size))
total_sequence_length = sequence_length + pad_size
query = query * (1 / (query.shape[-1]**0.5))
v_beta = value * beta.unsqueeze(-1)
k_beta = key * beta.unsqueeze(-1)
query, key, value, k_beta, v_beta = (x.reshape(x.shape[0], x.shape[1], -1, chunk_size, x.shape[-1])
for x in (query, key, value, k_beta, v_beta))
g = g.reshape(g.shape[0], g.shape[1], -1, chunk_size)
upper_with_diagonal = torch.triu(torch.ones(chunk_size, chunk_size, dtype=torch.bool, device=query.device),
diagonal=0)
# Intra-chunk decay and the (I + strictly-lower)^-1 WY transform, solved by
# forward substitution row by row.
g = g.cumsum(dim=-1)
decay_mask = ((g.unsqueeze(-1) - g.unsqueeze(-2)).tril().exp().float()).tril()
attn = -((k_beta @ key.transpose(-1, -2)) * decay_mask).masked_fill(upper_with_diagonal, 0)
for i in range(1, chunk_size):
row = attn[..., i, :i].clone()
sub = attn[..., :i, :i].clone()
attn[..., i, :i] = row + (row.unsqueeze(-1) * sub).sum(-2)
attn = attn + torch.eye(chunk_size, dtype=attn.dtype, device=attn.device)
value = attn @ v_beta
k_cumdecay = attn @ (k_beta * g.exp().unsqueeze(-1))
recurrent_state = torch.zeros(batch_size,
num_heads,
k_head_dim,
v_head_dim,
dtype=value.dtype,
device=value.device)
core_attn_out = torch.zeros_like(value)
for i in range(total_sequence_length // chunk_size):
q_i, k_i, v_i = query[:, :, i], key[:, :, i], value[:, :, i]
# decay_mask is already zero above the diagonal, which keeps this causal.
attn = q_i @ k_i.transpose(-1, -2) * decay_mask[:, :, i]
v_prime = k_cumdecay[:, :, i] @ recurrent_state
v_new = v_i - v_prime
attn_inter = (q_i * g[:, :, i, :, None].exp()) @ recurrent_state
core_attn_out[:, :, i] = attn_inter + attn @ v_new
recurrent_state = (recurrent_state * g[:, :, i, -1, None, None].exp() +
(k_i * (g[:, :, i, -1, None] - g[:, :, i]).exp()[..., None]).transpose(-1, -2) @ v_new)
core_attn_out = core_attn_out.reshape(core_attn_out.shape[0], core_attn_out.shape[1], -1, core_attn_out.shape[-1])
core_attn_out = core_attn_out[:, :, :sequence_length]
return core_attn_out.transpose(1, 2).contiguous().to(output_dtype)
class Qwen3_5GatedDeltaNet(nn.Module):
"""Gated DeltaNet token mixer used by the ``linear_attention`` layers."""
def __init__(
self,
config: Qwen3_5TextArchConfig,
quant_config: QuantizationConfig | None = None,
prefix: str = "",
) -> None:
super().__init__()
self.hidden_size = config.hidden_size
self.num_v_heads = config.linear_num_value_heads
self.num_k_heads = config.linear_num_key_heads
if self.num_v_heads % self.num_k_heads:
raise ValueError(f"Qwen3.5 linear value heads ({self.num_v_heads}) must be a multiple of key heads "
f"({self.num_k_heads})")
self.head_k_dim = config.linear_key_head_dim
self.head_v_dim = config.linear_value_head_dim
self.key_dim = self.head_k_dim * self.num_k_heads
self.value_dim = self.head_v_dim * self.num_v_heads
self.conv_kernel_size = config.linear_conv_kernel_dim
if config.hidden_act != "silu":
raise ValueError(f"Unsupported Qwen3.5 activation: {config.hidden_act}")
self.conv_dim = self.key_dim * 2 + self.value_dim
self.conv1d = nn.Conv1d(
in_channels=self.conv_dim,
out_channels=self.conv_dim,
bias=False,
kernel_size=self.conv_kernel_size,
groups=self.conv_dim,
padding=self.conv_kernel_size - 1,
)
self.dt_bias = nn.Parameter(torch.ones(self.num_v_heads))
self.A_log = nn.Parameter(torch.zeros(self.num_v_heads))
self.norm = Qwen3_5RMSNormGated(self.head_v_dim, eps=config.rms_norm_eps)
self.out_proj = ReplicatedLinear(self.value_dim,
self.hidden_size,
bias=False,
quant_config=quant_config,
prefix=f"{prefix}.out_proj")
self.in_proj_qkv = ReplicatedLinear(self.hidden_size,
self.conv_dim,
bias=False,
quant_config=quant_config,
prefix=f"{prefix}.in_proj_qkv")
self.in_proj_z = ReplicatedLinear(self.hidden_size,
self.value_dim,
bias=False,
quant_config=quant_config,
prefix=f"{prefix}.in_proj_z")
self.in_proj_b = ReplicatedLinear(self.hidden_size,
self.num_v_heads,
bias=False,
quant_config=quant_config,
prefix=f"{prefix}.in_proj_b")
self.in_proj_a = ReplicatedLinear(self.hidden_size,
self.num_v_heads,
bias=False,
quant_config=quant_config,
prefix=f"{prefix}.in_proj_a")
def forward(self, hidden_states: torch.Tensor, padding_mask: torch.Tensor | None = None) -> torch.Tensor:
if padding_mask is not None:
# Zero padded tokens so they cannot leak into the recurrent state.
hidden_states = (hidden_states * padding_mask[:, :, None]).to(hidden_states.dtype)
batch_size, seq_len, _ = hidden_states.shape
mixed_qkv, _ = self.in_proj_qkv(hidden_states)
z, _ = self.in_proj_z(hidden_states)
b, _ = self.in_proj_b(hidden_states)
a, _ = self.in_proj_a(hidden_states)
# Causal depthwise conv: left padding of kernel-1, keep the first seq_len outputs.
mixed_qkv = mixed_qkv.transpose(1, 2)
mixed_qkv = F.silu(self.conv1d(mixed_qkv.to(self.conv1d.weight.dtype))[:, :, :seq_len]).to(mixed_qkv.dtype)
mixed_qkv = mixed_qkv.transpose(1, 2)
query, key, value = torch.split(mixed_qkv, [self.key_dim, self.key_dim, self.value_dim], dim=-1)
query = query.reshape(batch_size, seq_len, -1, self.head_k_dim)
key = key.reshape(batch_size, seq_len, -1, self.head_k_dim)
value = value.reshape(batch_size, seq_len, -1, self.head_v_dim)
beta = b.sigmoid()
# float32 keeps exp(A_log) finite for half-precision weights.
g = -self.A_log.float().exp() * F.softplus(a.float() + self.dt_bias)
if self.num_v_heads > self.num_k_heads:
repeats = self.num_v_heads // self.num_k_heads
query = query.repeat_interleave(repeats, dim=2)
key = key.repeat_interleave(repeats, dim=2)
core_attn_out = chunk_gated_delta_rule(_l2norm(query), _l2norm(key), value, g=g, beta=beta)
core_attn_out = self.norm(core_attn_out.reshape(-1, self.head_v_dim), z.reshape(-1, self.head_v_dim))
core_attn_out = core_attn_out.reshape(batch_size, seq_len, -1)
output, _ = self.out_proj(core_attn_out)
return output
class Qwen3_5RotaryEmbedding(nn.Module):
"""Text-only partial RoPE.
Qwen3.5's interleaved M-RoPE collapses to standard RoPE when the temporal,
height and width positions are identical, which holds for text tokens.
"""
def __init__(self, config: Qwen3_5TextArchConfig) -> None:
super().__init__()
rope = config.resolved_rope_parameters()
if rope["rope_type"] != "default":
raise NotImplementedError(f"Qwen3.5 rope_type {rope['rope_type']!r} is not supported")
rotary_dim = int(config.head_dim * rope["partial_rotary_factor"])
inv_freq = 1.0 / (rope["rope_theta"]**(
torch.arange(0, rotary_dim, 2, dtype=torch.float32, device="cpu") / rotary_dim))
self.register_buffer("inv_freq", inv_freq, persistent=False)
@torch.no_grad()
def forward(self, hidden_states: torch.Tensor, position_ids: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
inv_freq = self.inv_freq.to(device=hidden_states.device, dtype=torch.float32)
device_type = hidden_states.device.type if hidden_states.device.type != "mps" else "cpu"
with torch.autocast(device_type=device_type, enabled=False):
inv_freq = inv_freq[None, :, None].expand(position_ids.shape[0], -1, 1)
freqs = (inv_freq @ position_ids[:, None, :].float()).transpose(1, 2)
emb = torch.cat((freqs, freqs), dim=-1)
cos = emb.cos()
sin = emb.sin()
return cos.to(hidden_states.dtype), sin.to(hidden_states.dtype)
def _rotate_half(x: torch.Tensor) -> torch.Tensor:
x1, x2 = x.chunk(2, dim=-1)
return torch.cat((-x2, x1), dim=-1)
def _repeat_kv(x: torch.Tensor, groups: int) -> torch.Tensor:
batch_size, num_kv_heads, seq_len, head_dim = x.shape
x = x[:, :, None].expand(batch_size, num_kv_heads, groups, seq_len, head_dim)
return x.reshape(batch_size, num_kv_heads * groups, seq_len, head_dim)
def _apply_partial_rotary(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
"""Rotate the leading ``cos.shape[-1]`` channels of ``[B, H, L, D]`` and pass the rest through."""
rotary_dim = cos.shape[-1]
cos = cos.unsqueeze(1)
sin = sin.unsqueeze(1)
x_rot, x_pass = x[..., :rotary_dim], x[..., rotary_dim:]
x_rot = x_rot * cos + _rotate_half(x_rot) * sin
return torch.cat([x_rot, x_pass], dim=-1)
class Qwen3_5Attention(nn.Module):
"""Gated full attention with per-head QK-norm, partial RoPE and GQA."""
def __init__(
self,
config: Qwen3_5TextConfig,
quant_config: QuantizationConfig | None = None,
prefix: str = "",
) -> None:
super().__init__()
arch = config.arch_config
self.num_heads = arch.num_attention_heads
self.num_kv_heads = arch.num_key_value_heads
if self.num_heads % self.num_kv_heads:
raise ValueError(f"Qwen3.5 attention heads ({self.num_heads}) must be a multiple of KV heads "
f"({self.num_kv_heads})")
self.head_dim = arch.head_dim
self.scaling = self.head_dim**-0.5
# q_proj packs, per head, the query followed by its output gate.
self.q_proj = ReplicatedLinear(arch.hidden_size,
self.num_heads * self.head_dim * 2,
bias=arch.attention_bias,
quant_config=quant_config,
prefix=f"{prefix}.q_proj")
self.k_proj = ReplicatedLinear(arch.hidden_size,
self.num_kv_heads * self.head_dim,
bias=arch.attention_bias,
quant_config=quant_config,
prefix=f"{prefix}.k_proj")
self.v_proj = ReplicatedLinear(arch.hidden_size,
self.num_kv_heads * self.head_dim,
bias=arch.attention_bias,
quant_config=quant_config,
prefix=f"{prefix}.v_proj")
self.o_proj = ReplicatedLinear(self.num_heads * self.head_dim,
arch.hidden_size,
bias=arch.attention_bias,
quant_config=quant_config,
prefix=f"{prefix}.o_proj")
self.q_norm = Qwen3_5RMSNorm(self.head_dim, eps=arch.rms_norm_eps)
self.k_norm = Qwen3_5RMSNorm(self.head_dim, eps=arch.rms_norm_eps)
def forward(
self,
hidden_states: torch.Tensor,
position_embeddings: tuple[torch.Tensor, torch.Tensor],
padding_mask: torch.Tensor | None = None,
) -> torch.Tensor:
batch_size, seq_len, _ = hidden_states.shape
packed, _ = self.q_proj(hidden_states)
query, gate = torch.chunk(packed.view(batch_size, seq_len, -1, self.head_dim * 2), 2, dim=-1)
gate = gate.reshape(batch_size, seq_len, -1)
key, _ = self.k_proj(hidden_states)
value, _ = self.v_proj(hidden_states)
query = self.q_norm(query).transpose(1, 2)
key = self.k_norm(key.view(batch_size, seq_len, -1, self.head_dim)).transpose(1, 2)
value = value.view(batch_size, seq_len, -1, self.head_dim).transpose(1, 2)
cos, sin = position_embeddings
query = _apply_partial_rotary(query, cos, sin)
key = _apply_partial_rotary(key, cos, sin)
groups = self.num_heads // self.num_kv_heads
# Native GQA is only used where SDPA keeps a fused kernel (no mask, head_dim <= 256).
native_gqa = padding_mask is None and self.head_dim <= 256
if groups > 1 and not native_gqa:
key = _repeat_kv(key, groups)
value = _repeat_kv(value, groups)
if padding_mask is None:
attn_output = F.scaled_dot_product_attention(
query,
key,
value,
dropout_p=0.0,
is_causal=seq_len > 1,
scale=self.scaling,
enable_gqa=groups > 1 and native_gqa,
)
else:
causal = torch.ones(seq_len, seq_len, device=query.device, dtype=torch.bool).tril()
mask = causal[None, None] & padding_mask.to(device=query.device, dtype=torch.bool)[:, None, None, :]
attn_output = F.scaled_dot_product_attention(
query,
key,
value,
attn_mask=mask,
dropout_p=0.0,
is_causal=False,
scale=self.scaling,
)
attn_output = attn_output.transpose(1, 2).reshape(batch_size, seq_len, -1)
attn_output = attn_output * torch.sigmoid(gate)
output, _ = self.o_proj(attn_output)
return output
class Qwen3_5MLP(nn.Module):
def __init__(
self,
config: Qwen3_5TextArchConfig,
quant_config: QuantizationConfig | None = None,
prefix: str = "",
) -> None:
super().__init__()
if config.hidden_act != "silu":
raise ValueError(f"Unsupported Qwen3.5 activation: {config.hidden_act}")
self.gate_proj = ReplicatedLinear(config.hidden_size,
config.intermediate_size,
bias=False,
quant_config=quant_config,
prefix=f"{prefix}.gate_proj")
self.up_proj = ReplicatedLinear(config.hidden_size,
config.intermediate_size,
bias=False,
quant_config=quant_config,
prefix=f"{prefix}.up_proj")
self.down_proj = ReplicatedLinear(config.intermediate_size,
config.hidden_size,
bias=False,
quant_config=quant_config,
prefix=f"{prefix}.down_proj")
def forward(self, x: torch.Tensor) -> torch.Tensor:
gate, _ = self.gate_proj(x)
up, _ = self.up_proj(x)
output, _ = self.down_proj(F.silu(gate) * up)
return output
class Qwen3_5DecoderLayer(nn.Module):
def __init__(
self,
config: Qwen3_5TextConfig,
layer_type: str,
quant_config: QuantizationConfig | None = None,
prefix: str = "",
) -> None:
super().__init__()
arch = config.arch_config
self.layer_type = layer_type
if layer_type == "linear_attention":
self.linear_attn = Qwen3_5GatedDeltaNet(arch, quant_config=quant_config, prefix=f"{prefix}.linear_attn")
else:
self.self_attn = Qwen3_5Attention(config, quant_config=quant_config, prefix=f"{prefix}.self_attn")
self.mlp = Qwen3_5MLP(arch, quant_config=quant_config, prefix=f"{prefix}.mlp")
self.input_layernorm = Qwen3_5RMSNorm(arch.hidden_size, eps=arch.rms_norm_eps)
self.post_attention_layernorm = Qwen3_5RMSNorm(arch.hidden_size, eps=arch.rms_norm_eps)
def forward(
self,
hidden_states: torch.Tensor,
position_embeddings: tuple[torch.Tensor, torch.Tensor],
padding_mask: torch.Tensor | None = None,
) -> torch.Tensor:
residual = hidden_states
hidden_states = self.input_layernorm(hidden_states)
if self.layer_type == "linear_attention":
hidden_states = self.linear_attn(hidden_states, padding_mask)
else:
hidden_states = self.self_attn(hidden_states, position_embeddings, padding_mask)
hidden_states = residual + hidden_states
residual = hidden_states
hidden_states = self.post_attention_layernorm(hidden_states)
hidden_states = self.mlp(hidden_states)
return residual + hidden_states
class Qwen3_5TextModel(TextEncoder):
"""Qwen3.5 text tower used as a diffusion text encoder (e.g. PAN2).
``hidden_states`` follows the Transformers contract: entry 0 is the token
embeddings, entry ``i`` is the output of layer ``i - 1``, and the last entry
is the final-normed output (it replaces the raw last-layer output).
"""
def __init__(self, config: Qwen3_5TextConfig) -> None:
super().__init__(config)
self.config = config
arch = config.arch_config
self.quant_config = getattr(config, "quant_config", None)
self.layer_types = arch.resolved_layer_types()
self.embed_tokens = VocabParallelEmbedding(
arch.vocab_size,
arch.hidden_size,
org_num_embeddings=arch.vocab_size,
quant_config=self.quant_config,
)
self.layers = nn.ModuleList([
Qwen3_5DecoderLayer(
config,
layer_type,
quant_config=self.quant_config,
prefix=f"{config.prefix}.layers.{index}",
) for index, layer_type in enumerate(self.layer_types)
])
self.norm = Qwen3_5RMSNorm(arch.hidden_size, eps=arch.rms_norm_eps)
self.rotary_emb = Qwen3_5RotaryEmbedding(arch)
def get_input_embeddings(self, input_ids: torch.Tensor) -> torch.Tensor:
return self.embed_tokens(input_ids)
def forward(
self,
input_ids: torch.Tensor | None = None,
position_ids: torch.Tensor | None = None,
attention_mask: torch.Tensor | None = None,
inputs_embeds: torch.Tensor | None = None,
output_hidden_states: bool | None = None,
**kwargs: Any,
) -> BaseEncoderOutput:
output_hidden_states = (output_hidden_states
if output_hidden_states is not None else self.config.arch_config.output_hidden_states)
if (input_ids is None) == (inputs_embeds is None):
raise ValueError("Specify exactly one of input_ids or inputs_embeds")
if inputs_embeds is None:
assert input_ids is not None
if input_ids.ndim == 1:
input_ids = input_ids.unsqueeze(0)
inputs_embeds = self.get_input_embeddings(input_ids)
hidden_states = inputs_embeds
batch_size, seq_len = hidden_states.shape[:2]
if position_ids is None:
position_ids = torch.arange(seq_len, device=hidden_states.device).unsqueeze(0).expand(batch_size, -1)
elif position_ids.ndim == 1:
position_ids = position_ids.unsqueeze(0).expand(batch_size, -1)
elif position_ids.ndim != 2:
raise ValueError("Qwen3.5 text encoder expects text position_ids shaped [batch, seq]")
# An all-ones mask is equivalent to no mask; only real padding takes the masked path.
padding_mask = None
if attention_mask is not None:
if attention_mask.ndim != 2:
raise ValueError("Qwen3.5 text encoder expects a 2D [batch, seq] padding mask")
if not bool(attention_mask.to(torch.bool).all()):
padding_mask = attention_mask.to(hidden_states.device)
position_embeddings = self.rotary_emb(hidden_states, position_ids)
all_hidden_states: tuple[torch.Tensor, ...] | None = () if output_hidden_states else None
for layer in self.layers:
if all_hidden_states is not None:
all_hidden_states += (hidden_states, )
hidden_states = layer(hidden_states, position_embeddings, padding_mask)
hidden_states = self.norm(hidden_states)
if all_hidden_states is not None:
all_hidden_states += (hidden_states, )
return BaseEncoderOutput(
last_hidden_state=hidden_states,
hidden_states=all_hidden_states,
attention_mask=attention_mask,
)
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
params_dict = dict(self.named_parameters())
loaded_params: set[str] = set()
for name, loaded_weight in weights:
if name.startswith(_SKIPPED_CHECKPOINT_PREFIXES) or "rotary_emb.inv_freq" in name:
continue
for checkpoint_prefix in _CHECKPOINT_PREFIXES:
if name.startswith(checkpoint_prefix):
name = name[len(checkpoint_prefix):]
break
if name not in params_dict:
logger.debug("Skipping unexpected Qwen3.5 checkpoint tensor %s", name)
continue
param = params_dict[name]
weight_loader = getattr(param, "weight_loader", default_weight_loader)
weight_loader(param, loaded_weight)
loaded_params.add(name)
return loaded_params
EntryClass = Qwen3_5TextModel
+2
View File
@@ -50,6 +50,7 @@ _TEXT_TO_VIDEO_DIT_MODELS = {
"Kandinsky5Transformer3DModel": ("dits", "kandinsky5", "Kandinsky5Transformer3DModel"),
"Kandinsky6Transformer3DModel": ("dits", "kandinsky6", "Kandinsky6Transformer3DModel"),
"Flux2Transformer2DModel": ("dits", "flux_2", "Flux2Transformer2DModel"),
"PAN2Transformer3DModel": ("dits", "pan2", "PAN2Transformer3DModel"),
}
_IMAGE_TO_VIDEO_DIT_MODELS = {
@@ -103,6 +104,7 @@ _TEXT_ENCODER_MODELS = {
"LTX2GemmaTextEncoderModel": ("encoders", "gemma", "LTX2GemmaTextEncoderModel"),
"Qwen3ForCausalLM": ("encoders", "qwen3", "Qwen3ForCausalLM"),
"Mistral3ForConditionalGeneration": ("encoders", "mistral3", "Mistral3ForConditionalGeneration"),
"Qwen3_5TextModel": ("encoders", "qwen3_5", "Qwen3_5TextModel"),
}
_IMAGE_ENCODER_MODELS: dict[str, tuple] = {
@@ -0,0 +1 @@
# SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,46 @@
# SPDX-License-Identifier: Apache-2.0
"""PAN2 video generation: text-to-video, and image-to-video from a first frame."""
from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.pipelines.basic.pan2.stages import (
PAN2ConditioningStage,
PAN2DenoisingStage,
PAN2InputValidationStage,
PAN2TextEncodingStage,
PAN2TimestepPreparationStage,
)
from fastvideo.pipelines.composed_pipeline_base import ComposedPipelineBase
from fastvideo.pipelines.stages import DecodingStage, LatentPreparationStage
class PAN2Pipeline(ComposedPipelineBase):
"""One stage chain serves both modes: a request with ``image_path`` (or a PIL ``pil_image``) runs image-to-video
from that first frame, and one without runs text-to-video."""
_required_config_modules = ["text_encoder", "tokenizer", "vae", "transformer", "scheduler"]
def create_pipeline_stages(self, fastvideo_args: FastVideoArgs) -> None:
self.add_stage(stage_name="input_validation_stage", stage=PAN2InputValidationStage())
self.add_stage(stage_name="prompt_encoding_stage",
stage=PAN2TextEncodingStage(
text_encoders=[self.get_module("text_encoder")],
tokenizers=[self.get_module("tokenizer")],
))
self.add_stage(stage_name="timestep_preparation_stage",
stage=PAN2TimestepPreparationStage(scheduler=self.get_module("scheduler")))
self.add_stage(stage_name="latent_preparation_stage",
stage=LatentPreparationStage(scheduler=self.get_module("scheduler"),
transformer=self.get_module("transformer")))
self.add_stage(stage_name="conditioning_stage", stage=PAN2ConditioningStage(vae=self.get_module("vae")))
self.add_stage(stage_name="denoising_stage",
stage=PAN2DenoisingStage(transformer=self.get_module("transformer"),
scheduler=self.get_module("scheduler"),
pipeline=self))
self.add_stage(stage_name="decoding_stage", stage=DecodingStage(vae=self.get_module("vae"), pipeline=self))
class PAN2ModularPipeline(PAN2Pipeline):
"""The pipeline class PAN2 checkpoints declare in their ``modular_model_index.json``."""
EntryClass = [PAN2Pipeline, PAN2ModularPipeline]
+48
View File
@@ -0,0 +1,48 @@
# SPDX-License-Identifier: Apache-2.0
"""Inference presets for PAN2 text-to-video and image-to-video."""
from fastvideo.api.presets import InferencePreset, PresetStageSpec
_DENOISE_STAGE = PresetStageSpec(
name="denoise",
kind="denoising",
description="PAN2 flow-matching denoising",
allowed_overrides=frozenset({"num_inference_steps", "guidance_scale"}),
)
# 704x1248, 313 frames (13 s at 24 fps), 50 steps, CFG 3. PAN2 has no default negative prompt; an empty one is
# encoded as a single space.
_SHARED_DEFAULTS = {
"height": 704,
"width": 1248,
"num_frames": 313,
"fps": 24,
"num_inference_steps": 50,
"guidance_scale": 3.0,
"negative_prompt": "",
}
PAN2_T2V = InferencePreset(
name="pan2_t2v",
version=1,
model_family="pan2",
description="PAN2 text-to-video",
workload_type="t2v",
stage_schemas=(_DENOISE_STAGE, ),
defaults=dict(_SHARED_DEFAULTS),
)
# The same pipeline runs image-to-video when the request carries a first frame (image_path).
PAN2_I2V = InferencePreset(
name="pan2_i2v",
version=1,
model_family="pan2",
description="PAN2 image-to-video from a first frame",
workload_type="i2v",
stage_schemas=(_DENOISE_STAGE, ),
defaults=dict(_SHARED_DEFAULTS),
)
ALL_PRESETS = (PAN2_T2V, PAN2_I2V)
__all__ = ["ALL_PRESETS", "PAN2_I2V", "PAN2_T2V"]
+202
View File
@@ -0,0 +1,202 @@
# SPDX-License-Identifier: Apache-2.0
"""PAN2-specific pipeline stages: prompt encoding, first-frame conditioning, sigma schedule and fp32 sampling."""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
import numpy as np
import PIL.Image
import torch
import torchvision.transforms.functional as TF
from torch.distributed.tensor import DTensor
from fastvideo.distributed import get_local_torch_device
from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.forward_context import set_forward_context
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
from fastvideo.pipelines.stages.base import PipelineStage
from fastvideo.pipelines.stages.denoising import DenoisingStage
from fastvideo.pipelines.stages.input_validation import InputValidationStage
from fastvideo.pipelines.stages.text_encoding import TextEncodingStage
from fastvideo.pipelines.stages.timestep_preparation import TimestepPreparationStage
from fastvideo.pipelines.stages.validators import StageValidators as V
from fastvideo.pipelines.stages.validators import VerificationResult
from fastvideo.utils import PRECISION_TO_TYPE
_USER_HEADER = "<|im_start|>user\n"
class PAN2InputValidationStage(InputValidationStage):
"""Rejects geometry the PAN2 VAE and patch embedding cannot represent."""
def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch:
batch = super().forward(batch, fastvideo_args)
pipeline_config = fastvideo_args.pipeline_config
vae_arch = pipeline_config.vae_config.arch_config
spatial_multiple = vae_arch.spatial_compression_ratio * pipeline_config.dit_config.arch_config.patch_size[1]
if batch.height % spatial_multiple != 0 or batch.width % spatial_multiple != 0:
raise ValueError(f"PAN2 height and width must be divisible by {spatial_multiple}, "
f"got {batch.height} and {batch.width}.")
if (batch.num_frames - 1) % vae_arch.temporal_compression_ratio != 0:
raise ValueError(f"PAN2 num_frames - 1 must be divisible by {vae_arch.temporal_compression_ratio}, "
f"got {batch.num_frames}.")
if batch.pil_image is not None and not isinstance(batch.pil_image, PIL.Image.Image):
raise ValueError("PAN2 image-to-video expects the first frame as a PIL image or an image_path.")
return batch
class PAN2TextEncodingStage(TextEncodingStage):
"""Encodes a prompt with PAN2's chat template.
PAN2 conditions on the tokens after the ``<|im_start|>user\\n`` header (the prompt and the assistant header),
read from the text encoder's hidden states with its last ``text_encoder_skip_layers`` layers skipped.
"""
@staticmethod
def tokenize(tokenizer: Any, prompt: str, system_prompt: str, max_length: int) -> tuple[torch.Tensor, int]:
"""Token ids truncated to ``max_length`` tokens after the user header, and where that header ends."""
# An empty prompt is encoded as a single space, as in training.
messages = [{"role": "system", "content": system_prompt}, {"role": "user", "content": prompt or " "}]
input_ids = tokenizer.apply_chat_template(messages, add_generation_prompt=True, tokenize=True,
return_dict=True)["input_ids"]
if isinstance(input_ids, torch.Tensor):
input_ids = input_ids.tolist()
if input_ids and isinstance(input_ids[0], list):
input_ids = input_ids[0]
header_ids = tokenizer(_USER_HEADER, add_special_tokens=False)["input_ids"]
crop_start = next((i + len(header_ids)
for i in range(len(input_ids)) if input_ids[i:i + len(header_ids)] == header_ids), None)
if crop_start is None:
raise ValueError(f"The PAN2 tokenizer's chat template has no {_USER_HEADER!r} header.")
return torch.tensor([input_ids[:crop_start + max_length]], dtype=torch.long), crop_start
@torch.no_grad()
def encode_text(
self,
text: str | list[str],
fastvideo_args: FastVideoArgs,
encoder_index: int | list[int] | None = None,
return_attention_mask: bool = False,
return_type: str = "list",
device: torch.device | str | None = None,
dtype: torch.dtype | None = None,
max_length: int | Sequence[int | None] | None = None,
truncation: bool | None = None,
padding: bool | str | None = None,
):
pipeline_config = fastvideo_args.pipeline_config
texts = [text] if isinstance(text, str) else list(text)
if len(texts) != 1:
raise ValueError(f"PAN2 encodes one prompt per request, got {len(texts)}.")
if isinstance(max_length, Sequence):
max_length = max_length[0]
max_length = max_length or pipeline_config.text_max_sequence_length
tokenizer = self.tokenizers[0]
text_encoder = self.text_encoders[0]
target_device = torch.device(device) if device is not None else get_local_torch_device()
# A module parked on CPU by text_encoder_cpu_offload computes on the target device and goes back afterwards;
# FSDP-wrapped (DTensor) encoders move their own shards.
first_param = next(text_encoder.parameters(), None)
input_device = first_param.device if first_param is not None else target_device
moved_for_forward = False
if (first_param is not None and not isinstance(first_param, DTensor)
and input_device.type != target_device.type):
text_encoder = text_encoder.to(target_device)
input_device = target_device
moved_for_forward = True
input_ids, crop_start = self.tokenize(tokenizer, texts[0], pipeline_config.text_system_prompt, max_length)
with set_forward_context(current_timestep=0, attn_metadata=None):
outputs = text_encoder(input_ids=input_ids.to(input_device), output_hidden_states=True)
prompt_embeds = outputs.hidden_states[-(pipeline_config.text_encoder_skip_layers + 1)][:, crop_start:]
# fp32 is exact for the bf16 encoder output. The shared latent stage draws the initial noise in this dtype,
# and PAN2 samples on fp32 latents; the denoiser casts the embeddings to the DiT dtype.
prompt_embeds = prompt_embeds.to(device=target_device, dtype=dtype or torch.float32, copy=True)
attention_mask = torch.ones(prompt_embeds.shape[:2], dtype=torch.long, device=target_device)
if moved_for_forward and fastvideo_args.text_encoder_cpu_offload:
text_encoder.to("cpu")
return self.return_embeds([prompt_embeds], [attention_mask], return_type, return_attention_mask, [0])
class PAN2TimestepPreparationStage(TimestepPreparationStage):
"""PAN2 samples on uniformly spaced sigmas from 1 down to (excluding) 0; the scheduler applies the shift."""
def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch:
if batch.timesteps is None and batch.sigmas is None:
batch.sigmas = np.linspace(1.0, 0.0, batch.num_inference_steps + 1)[:-1].tolist()
return super().forward(batch, fastvideo_args)
class PAN2ConditioningStage(PipelineStage):
"""Builds the conditioning latents and mask that PAN2 stacks after the noisy latents along channels.
Text-to-video conditions on zeros. Image-to-video fills the first latent frame with the normalized VAE latents
of the first frame, scaled to cover the output and center cropped, and sets that frame's mask channel to one.
"""
def __init__(self, vae) -> None:
super().__init__()
self.vae = vae
@staticmethod
def preprocess_image(image: PIL.Image.Image, height: int, width: int) -> torch.Tensor:
"""``[1, 3, 1, H, W]`` pixels in ``[-1, 1]``: scale to cover the canvas, round the size, center crop."""
image = image.convert("RGB")
scale = max(width / image.width, height / image.height)
image = image.resize((round(image.width * scale), round(image.height * scale)), resample=PIL.Image.LANCZOS)
left, top = round((image.width - width) / 2), round((image.height - height) / 2)
image = image.crop((left, top, left + width, top + height))
return (TF.to_tensor(image) * 2.0 - 1.0)[None, :, None]
def _encode_first_frame(self, image: PIL.Image.Image, batch: ForwardBatch,
fastvideo_args: FastVideoArgs) -> torch.Tensor:
device = get_local_torch_device()
vae_dtype = PRECISION_TO_TYPE[fastvideo_args.pipeline_config.vae_precision]
self.vae = self.vae.to(device)
pixels = self.preprocess_image(image, batch.height, batch.width).to(device=device, dtype=vae_dtype)
latents = self.vae.encode(pixels).mode().float()
shift = self.vae.shift_factor.to(latents.device, latents.dtype)
scale = self.vae.scaling_factor.to(latents.device, latents.dtype)
if fastvideo_args.vae_cpu_offload:
self.vae.to("cpu")
return (latents - shift) * scale
@torch.no_grad()
def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch:
latents = batch.latents
batch_size, num_channels, num_frames, height, width = latents.shape
condition = torch.zeros(batch_size,
num_channels + 1,
num_frames,
height,
width,
device=latents.device,
dtype=torch.float32)
if batch.pil_image is not None:
image_latents = self._encode_first_frame(batch.pil_image, batch, fastvideo_args)
condition[:, :num_channels, :1] = image_latents.to(condition.device)
condition[:, num_channels:, :1] = 1.0
batch.image_latent = condition
return batch
def verify_input(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> VerificationResult:
result = VerificationResult()
result.add_check("latents", batch.latents, [V.is_tensor, V.with_dims(5)])
result.add_check("height", batch.height, V.positive_int)
result.add_check("width", batch.width, V.positive_int)
return result
def verify_output(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> VerificationResult:
result = VerificationResult()
result.add_check("image_latent", batch.image_latent, [V.is_tensor, V.with_dims(5)])
return result
class PAN2DenoisingStage(DenoisingStage):
"""Keeps the latents in fp32 across the loop; they are cast to the DiT dtype for each forward."""
def prepare_scheduler_model_output(self, noise_pred, state) -> torch.Tensor:
return noise_pred.float()
+5
View File
@@ -496,6 +496,7 @@ class DenoisingStage(PipelineStage):
noise_pred_text,
guidance_rescale=batch.guidance_rescale,
)
noise_pred = self.prepare_scheduler_model_output(noise_pred, state)
if scheduler_fp32:
# Diffusers-style: fp32 Euler update outside autocast avoids BF16 drift.
latents = self.scheduler.step(noise_pred, t, latents, **extra_step_kwargs, return_dict=False)[0]
@@ -629,6 +630,10 @@ class DenoisingStage(PipelineStage):
def prepare_timestep(self, timestep, latent_model_input, batch, state) -> torch.Tensor:
return timestep.repeat(latent_model_input.shape[0])
def prepare_scheduler_model_output(self, noise_pred, state) -> torch.Tensor:
"""The model output handed to the scheduler step, which returns the next latents in its dtype."""
return noise_pred
def finish_step(self, latents, state) -> torch.Tensor:
return latents
+21
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import dataclasses
import os
import re
from collections.abc import Callable
from functools import lru_cache
from typing import TYPE_CHECKING, Any
@@ -45,6 +46,7 @@ from fastvideo.configs.pipelines.flux_2 import (
from fastvideo.configs.pipelines.matrixgame2 import MatrixGame2I2V480PConfig
from fastvideo.configs.pipelines.matrixgame3 import MatrixGame3I2V720PConfig
from fastvideo.configs.pipelines.minimax_h3 import MiniMaxH3PipelineConfig
from fastvideo.configs.pipelines.pan2 import PAN2PipelineConfig
from fastvideo.configs.pipelines.mmaudio import (
MMAudioLarge44kV2PipelineConfig,
MMAudioLarge44kV2AConfig,
@@ -1295,6 +1297,22 @@ def _register_configs() -> None:
pipeline_cls_name="MiniMaxH3ModularPipeline",
)
# PAN2: one pipeline runs text-to-video, and image-to-video when the request carries a first frame.
def _is_pan2(name: str) -> bool:
name = name.lower()
return "pan2modularpipeline" in name or re.search(r"(^|[/_-])pan-?2([/_.-]|$)", name) is not None
register_configs(
sampling_param_cls=None,
pipeline_config_cls=PAN2PipelineConfig,
workload_types=(WorkloadType.T2V, WorkloadType.I2V),
hf_model_paths=["IFM/PAN2"],
model_detectors=[_is_pan2],
model_family="pan2",
default_preset="pan2_t2v",
pipeline_cls_name="PAN2ModularPipeline",
)
# SD3.5
register_configs(
sampling_param_cls=None,
@@ -1487,6 +1505,8 @@ def _register_presets() -> None:
ALL_PRESETS as MINIMAX_H3_PRESETS, )
from fastvideo.pipelines.basic.mmaudio.presets import (
ALL_PRESETS as MMAUDIO_PRESETS, )
from fastvideo.pipelines.basic.pan2.presets import (
ALL_PRESETS as PAN2_PRESETS, )
from fastvideo.pipelines.basic.sd35.presets import (
ALL_PRESETS as SD35_PRESETS, )
from fastvideo.pipelines.basic.stable_audio.presets import (
@@ -1524,6 +1544,7 @@ def _register_presets() -> None:
MATRIXGAME3_PRESETS,
MINIMAX_H3_PRESETS,
MMAUDIO_PRESETS,
PAN2_PRESETS,
SD35_PRESETS,
STABLE_AUDIO_PRESETS,
TURBODIFFUSION_PRESETS,
+1
View File
@@ -204,6 +204,7 @@ nav:
- LTX-2.5: inference/ltx2_5.md
- Kandinsky 6 T2IVA: inference/kandinsky6.md
- Kandinsky 6 Video SR: inference/kandinsky6_sr.md
- PAN2: inference/pan2.md
- CLI: inference/cli.md
- Add Pipeline: inference/add_pipeline.md
- Examples:
@@ -0,0 +1,339 @@
# SPDX-License-Identifier: Apache-2.0
"""Qwen3.5 text-encoder parity for PAN2 against Transformers' ``Qwen3_5TextModel``.
Coverage scope: both. The checkpoint test loads the public tiny PAN2 text
encoder through FastVideo's production ``TextEncoderLoader`` and feeds both
models the exact PAN2 chat-templated token ids. The random-config tests use a
larger hybrid stack (GQA, partial RoPE, more value than key heads in the Gated
DeltaNet, sequences that cross several 64-token delta-rule chunks) and also
cover bf16 and padded batches.
Everything runs on CPU. Set ``PAN2_TINY_MODEL_DIR`` to a local copy of
``wuqing157/tiny-pan2-modular-pipe``; otherwise the HF cache is used and the
checkpoint test skips when it is absent.
"""
from __future__ import annotations
import copy
import os
import socket
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pytest
import torch
from torch.testing import assert_close
from fastvideo.configs.models.encoders.qwen3_5 import Qwen3_5TextConfig
from fastvideo.forward_context import set_forward_context
from fastvideo.models.loader.utils import set_default_torch_dtype
PARITY_SCOPE = "both"
TINY_REPO_ID = "wuqing157/tiny-pan2-modular-pipe"
PAN2_SYSTEM_MESSAGE = "You are a helpful assistant. Generate the video with the following instructions."
PAN2_TEXT_ENCODER_LAYERS_TO_SKIP = 2
PAN2_PROMPTS = (
"A red fox crosses fresh snow at sunrise.",
"A slow dolly shot glides through a rain-soaked neon market at night. Steam rises from food stalls while "
"vendors in bright raincoats shout over the hum of generators. Reflections of pink, teal and amber signs ripple "
"across puddles as a cyclist weaves between crowds, her umbrella catching droplets that sparkle in the light. "
"In the background, a tram rattles past an old clock tower, its windows glowing warm yellow. The camera rises to "
"reveal rooftops crowded with antennas and laundry lines, then tilts toward a thunderstorm rolling in over the "
"harbor, lightning briefly silhouetting cranes and container ships. Cinematic lighting, shallow depth of field, "
"35mm film grain, realistic motion blur.",
)
FP32_TOLERANCE = {"atol": 1e-5, "rtol": 1e-5}
BF16_TOLERANCE = {"atol": 2e-2, "rtol": 2e-2}
def _hf_qwen3_5() -> tuple[Any, Any]:
module = pytest.importorskip("transformers.models.qwen3_5", reason="transformers without Qwen3.5 support")
return module.Qwen3_5TextConfig, module.Qwen3_5TextModel
def _tiny_model_dir() -> Path | None:
local = os.getenv("PAN2_TINY_MODEL_DIR")
if local:
return Path(local)
try:
from huggingface_hub import snapshot_download
return Path(snapshot_download(TINY_REPO_ID, local_files_only=True))
except Exception:
return None
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
@pytest.fixture(scope="module")
def cpu_single_rank():
"""Single-rank CPU runtime; the loader would otherwise target ``mps`` on CPU-only Linux hosts."""
import fastvideo.distributed.parallel_state as parallel_state
import fastvideo.models.loader.component_loader as component_loader
from fastvideo.distributed import cleanup_dist_env_and_memory, maybe_init_distributed_environment_and_model_parallel
with pytest.MonkeyPatch.context() as patch:
patch.setattr(parallel_state, "get_local_torch_device", lambda: torch.device("cpu"))
patch.setattr(component_loader, "get_local_torch_device", lambda: torch.device("cpu"))
for name, value in (("MASTER_ADDR", "127.0.0.1"), ("MASTER_PORT", str(_free_port())), ("RANK", "0"),
("WORLD_SIZE", "1"), ("LOCAL_RANK", "0")):
patch.setenv(name, value)
maybe_init_distributed_environment_and_model_parallel(1, 1)
yield
cleanup_dist_env_and_memory()
def _loader_args(config: Qwen3_5TextConfig, precision: str = "fp32") -> SimpleNamespace:
return SimpleNamespace(
pipeline_config=SimpleNamespace(text_encoder_configs=(config, ), text_encoder_precisions=(precision, )),
text_encoder_cpu_offload=False,
override_text_encoder_quant=None,
override_text_encoder_safetensors=None,
pin_cpu_memory=False,
disable_offload_on_unified_memory=lambda device_id, offload_flag=None: False,
)
def _pan2_input_ids(tokenizer: Any, prompt: str) -> tuple[torch.Tensor, int]:
"""Tokenize exactly like PAN2's ``encode_prompt`` and return ids plus the user-header crop."""
messages = [{"role": "system", "content": PAN2_SYSTEM_MESSAGE}, {"role": "user", "content": prompt or " "}]
input_ids = tokenizer.apply_chat_template(messages, add_generation_prompt=True, tokenize=True,
return_dict=True).input_ids
header_ids = tokenizer("<|im_start|>user\n", add_special_tokens=False).input_ids
crop_start = next(
(i + len(header_ids) for i in range(len(input_ids)) if input_ids[i:i + len(header_ids)] == header_ids),
None,
)
assert crop_start is not None, "PAN2 chat template has no user header"
return torch.tensor([input_ids]), crop_start
def _run_both(reference: torch.nn.Module, native: torch.nn.Module, **inputs: Any) -> tuple[Any, Any]:
with torch.inference_mode():
expected = reference(**inputs, output_hidden_states=True)
with set_forward_context(current_timestep=0, attn_metadata=None):
actual = native(**inputs, output_hidden_states=True)
return expected, actual
def _assert_hidden_states_close(
expected: tuple[torch.Tensor, ...],
actual: tuple[torch.Tensor, ...] | None,
label: str,
tolerance: dict[str, float],
valid: torch.Tensor | None = None,
) -> float:
assert actual is not None, f"{label}: native encoder returned no hidden_states"
assert len(actual) == len(expected), f"{label}: {len(actual)} hidden states, reference has {len(expected)}"
worst = 0.0
for index, (reference_state, native_state) in enumerate(zip(expected, actual, strict=True)):
assert native_state.shape == reference_state.shape, f"{label} hidden_states[{index}] shape"
assert torch.isfinite(native_state).all(), f"{label} hidden_states[{index}] has non-finite values"
if valid is not None:
reference_state = reference_state[valid]
native_state = native_state[valid]
drift = (native_state.float() - reference_state.float()).abs().max().item()
worst = max(worst, drift)
print(f"{label} hidden_states[{index}]: max_abs={drift:.3e}", flush=True)
assert_close(native_state, reference_state, **tolerance, msg=lambda m: f"{label} hidden_states[{index}]: {m}")
return worst
def test_pan2_qwen3_5_arch_config_matches_transformers_rope_and_layer_types() -> None:
"""Config.json fields resolve to the same RoPE settings and layer schedule as Transformers."""
hf_config_cls, _ = _hf_qwen3_5()
cases = (
{},
{
"rope_theta": 1_000_000.0
},
{
"partial_rotary_factor": 0.5,
"rope_parameters": {
"rope_type": "default",
"rope_theta": 5_000_000.0
}
},
{
"partial_rotary_factor": 0.5,
"rope_parameters": {
"rope_type": "default",
"rope_theta": 5_000_000.0,
"partial_rotary_factor": 0.25
}
},
{
"num_hidden_layers": 6,
"full_attention_interval": 3
},
{
"num_hidden_layers": 3,
"layer_types": ["mamba", "attention", "linear_attention"]
},
)
for case in cases:
reference = hf_config_cls.from_dict(dict(case))
config = Qwen3_5TextConfig()
config.update_model_arch(dict(case))
rope = config.arch_config.resolved_rope_parameters()
for key in ("rope_type", "rope_theta", "partial_rotary_factor"):
assert rope[key] == reference.rope_parameters[key], f"{case}: {key}"
assert config.arch_config.resolved_layer_types() == list(reference.layer_types), case
def test_pan2_qwen3_5_tiny_checkpoint_loader_parity(cpu_single_rank) -> None:
"""Production-loader encoder matches every HF hidden state on real PAN2 prompts (fp32 CPU)."""
_, hf_model_cls = _hf_qwen3_5()
model_dir = _tiny_model_dir()
if model_dir is None or not (model_dir / "text_encoder" / "config.json").is_file():
pytest.skip(f"{TINY_REPO_ID} is not available locally; set PAN2_TINY_MODEL_DIR")
if not (model_dir / "tokenizer").is_dir():
pytest.skip(f"{TINY_REPO_ID} tokenizer is not available locally")
from transformers import AutoTokenizer
from fastvideo.models.encoders.qwen3_5 import Qwen3_5TextModel
from fastvideo.models.loader.component_loader import TextEncoderLoader
text_encoder_dir = model_dir / "text_encoder"
tokenizer = AutoTokenizer.from_pretrained(model_dir / "tokenizer", local_files_only=True)
reference, loading_info = hf_model_cls.from_pretrained(
text_encoder_dir,
local_files_only=True,
dtype=torch.float32,
output_loading_info=True,
)
load_errors = {
name: loading_info.get(name)
for name in ("missing_keys", "unexpected_keys", "mismatched_keys", "error_msgs") if loading_info.get(name)
}
assert not load_errors, f"Reference Qwen3.5 checkpoint did not load strictly: {load_errors}"
reference = reference.eval()
native = TextEncoderLoader().load(str(text_encoder_dir), _loader_args(Qwen3_5TextConfig()))
assert isinstance(native, Qwen3_5TextModel)
assert native.layer_types == list(reference.config.layer_types)
assert next(native.parameters()).dtype == torch.float32
assert_close(native.rotary_emb.inv_freq, reference.rotary_emb.inv_freq.float(), atol=0.0, rtol=0.0)
for prompt_index, prompt in enumerate(PAN2_PROMPTS):
input_ids, crop_start = _pan2_input_ids(tokenizer, prompt)
label = f"tiny prompt{prompt_index} (L={input_ids.shape[1]})"
expected, actual = _run_both(reference, native, input_ids=input_ids)
worst = _assert_hidden_states_close(expected.hidden_states, actual.hidden_states, label, FP32_TOLERANCE)
assert_close(actual.last_hidden_state, expected.last_hidden_state, **FP32_TOLERANCE)
skip = PAN2_TEXT_ENCODER_LAYERS_TO_SKIP + 1
reference_embeds = expected.hidden_states[-skip][:, crop_start:]
native_embeds = actual.hidden_states[-skip][:, crop_start:]
assert_close(native_embeds, reference_embeds, **FP32_TOLERANCE)
print(
f"{label} PAN2 prompt_embeds hidden_states[-{skip}][:, {crop_start}:] "
f"max_abs={(native_embeds - reference_embeds).abs().max().item():.3e} all_layers_max_abs={worst:.3e}",
flush=True,
)
@pytest.fixture(scope="module")
def random_pair(cpu_single_rank):
"""A randomly initialized 8-layer hybrid reference and the native model loaded from its state dict."""
hf_config_cls, hf_model_cls = _hf_qwen3_5()
from fastvideo.models.encoders.qwen3_5 import Qwen3_5TextModel
torch.manual_seed(0)
hf_config = hf_config_cls(
vocab_size=1024,
hidden_size=128,
intermediate_size=256,
num_hidden_layers=8,
num_attention_heads=4,
num_key_value_heads=2,
head_dim=32,
linear_conv_kernel_dim=4,
linear_key_head_dim=16,
linear_value_head_dim=24,
linear_num_key_heads=2,
linear_num_value_heads=4,
layer_types=[
"linear_attention",
"linear_attention",
"full_attention",
"linear_attention",
"full_attention",
"linear_attention",
"linear_attention",
"full_attention",
],
rope_parameters={
"rope_type": "default",
"rope_theta": 1_000_000.0,
"partial_rotary_factor": 0.25
},
initializer_range=0.08,
)
reference = hf_model_cls(hf_config).eval()
with torch.no_grad():
# Move the zero-centered norms, gates and decay parameters off their
# init values so every term contributes.
for name, parameter in reference.named_parameters():
if name.endswith("norm.weight") or name.endswith(("A_log", "dt_bias")):
parameter.add_(0.2 * torch.randn_like(parameter))
config = Qwen3_5TextConfig()
config.update_model_arch(hf_config.to_dict())
with set_default_torch_dtype(torch.float32):
native = Qwen3_5TextModel(config).eval()
loaded = native.load_weights(reference.state_dict().items())
expected_names = {name for name, _ in native.named_parameters()}
assert loaded == expected_names, f"unloaded native parameters: {sorted(expected_names - loaded)}"
assert set(reference.state_dict()) == expected_names
return reference, native
def test_pan2_qwen3_5_random_config_fp32_parity(random_pair) -> None:
"""Multi-chunk delta rule (L=301), GQA and partial RoPE match HF in fp32."""
reference, native = random_pair
generator = torch.Generator().manual_seed(1)
input_ids = torch.randint(0, reference.config.vocab_size, (2, 301), generator=generator)
expected, actual = _run_both(reference, native, input_ids=input_ids)
worst = _assert_hidden_states_close(expected.hidden_states, actual.hidden_states, "random fp32", FP32_TOLERANCE)
print(f"random fp32 all_layers_max_abs={worst:.3e}", flush=True)
# Each layer must move the residual stream, or the comparison is vacuous.
for index in range(1, len(expected.hidden_states) - 1):
change = (expected.hidden_states[index] - expected.hidden_states[index - 1]).abs().max().item()
assert change > 0.1, f"layer {index - 1} barely changes the hidden state ({change:.3e})"
def test_pan2_qwen3_5_random_config_padding_parity(random_pair) -> None:
"""Right- and left-padded rows match HF on every non-padded position."""
reference, native = random_pair
generator = torch.Generator().manual_seed(2)
input_ids = torch.randint(0, reference.config.vocab_size, (2, 200), generator=generator)
attention_mask = torch.ones_like(input_ids)
attention_mask[0, 150:] = 0
attention_mask[1, :37] = 0
expected, actual = _run_both(reference, native, input_ids=input_ids, attention_mask=attention_mask)
worst = _assert_hidden_states_close(
expected.hidden_states,
actual.hidden_states,
"random padded",
FP32_TOLERANCE,
valid=attention_mask.bool(),
)
print(f"random padded all_layers_max_abs={worst:.3e}", flush=True)
def test_pan2_qwen3_5_random_config_bf16_parity(random_pair) -> None:
"""Half-precision dtype handling (fp32 norms, gates and delta rule) follows HF."""
reference, native = (copy.deepcopy(model).to(torch.bfloat16) for model in random_pair)
generator = torch.Generator().manual_seed(3)
input_ids = torch.randint(0, reference.config.vocab_size, (1, 173), generator=generator)
expected, actual = _run_both(reference, native, input_ids=input_ids)
assert actual.hidden_states is not None and actual.hidden_states[-1].dtype == torch.bfloat16
worst = _assert_hidden_states_close(expected.hidden_states, actual.hidden_states, "random bf16", BF16_TOLERANCE)
print(f"random bf16 all_layers_max_abs={worst:.3e}", flush=True)
+71
View File
@@ -0,0 +1,71 @@
# PAN2 Port Status
## Summary
- model_family: pan2
- workload_types: T2V, I2V (one pipeline; I2V when the request carries a first frame)
- official_ref: vllm-project/vllm-omni#8602 at `59dd8c9a` (the diffusers integration is private)
- official_ref_dir: none; rebuilt in `tests/local_tests/pan2/reference.py`
- hf_weights_path: `IFM/PAN2` (not public); tests use `wuqing157/tiny-pan2-modular-pipe@3ff3130b`
- local_weights_dir: HF cache, or `PAN2_TINY_MODEL_DIR`
- source_layout: Modular Diffusers (`modular_model_index.json`), diffusers component subfolders
- local_tests_readme: `tests/local_tests/pan2/README.md`
## Current Phase
- phase: 11 (post-parity review and handoff)
- status: in_progress (GPU and real-weight validation pending, see I001 and I002)
- owner: orchestrator
- last_updated: 2026-10-15
## Component Matrix
| Component | Type | Reuse/Port | Official Definition | Official Instantiation | FastVideo Target | Prototype | Conversion | Parity | Open Issues |
|---|---|---|---|---|---|---|---|---|---|
| transformer | DiT | port | vLLM-Omni `pan2_transformer.py` (diffusers `PAN2Transformer3DModel`) | `transformer/config.json` | `fastvideo/models/dits/pan2.py`, `fastvideo/configs/models/dits/pan2.py` | done | load-time key mapping (Q/K/V fused) | non_skip_pass | I002 |
| text_encoder | encoder | port | transformers `Qwen3_5TextModel` | `text_encoder/config.json` | `fastvideo/models/encoders/qwen3_5.py`, `fastvideo/configs/models/encoders/qwen3_5.py` | done | passthrough | non_skip_pass | I002 |
| tokenizer | tokenizer | reuse | `Qwen2Tokenizer` | `tokenizer/` | AutoTokenizer | n/a | passthrough | covered by pipeline parity | — |
| vae | VAE | reuse | diffusers `AutoencoderKLWan` (Wan2.2, z_dim 48) | `vae/config.json` | `fastvideo/models/wan/vae.py` | done | passthrough | non_skip_pass | — |
| scheduler | scheduler | reuse | diffusers `FlowMatchEulerDiscreteScheduler` | `scheduler/scheduler_config.json` (shift 7) | `fastvideo/models/schedulers/scheduling_flow_match_euler_discrete.py` | done | passthrough | non_skip_pass | — |
## Conversion State
- conversion_script: none; the transformer maps diffusers keys at load time (`PAN2ArchConfig.param_names_mapping`)
- converted_weights_dir: n/a
- source_layout: diffusers
- strict_load_status: tiny checkpoint loads strictly (no missing or unexpected keys)
- passthrough_components: text_encoder, tokenizer, vae, scheduler
- retry_history: none
## Parity Commands
| Scope | Command | Last Result | Notes |
|---|---|---|---|
| DiT | `pytest tests/local_tests/transformers/test_pan2_transformer_parity.py` | 12 passed (CPU) | includes SP=2 vs one rank over gloo |
| Text encoder | `pytest tests/local_tests/encoders/test_pan2_qwen3_5_parity.py` | 5 passed (CPU) | exact |
| VAE | `pytest tests/local_tests/vaes/test_pan2_vae_parity.py` | passed (CPU) | exact |
| Scheduler | `pytest tests/local_tests/schedulers/test_pan2_scheduler_parity.py` | passed (CPU) | exact |
| Pipeline | `pytest tests/local_tests/pipelines/test_pan2_pipeline_parity.py` | 3 passed (CPU) | T2V and I2V latents exact |
## Open Questions
| ID | Question | Owner | Needed By Phase | Status | Resolution |
|---|---|---|---|---|---|
| Q001 | Which reference, with the diffusers integration private? | orchestrator | 2 | resolved | vLLM-Omni #8602, rebuilt from diffusers/transformers blocks in `reference.py` |
| Q002 | Port the `pan2-guardrail` prompt/video guardrails? | orchestrator | 7 | resolved | Not ported: the package is not public and FastVideo has no guardrail stage. |
| Q003 | Port Cache-DiT `quality` and CFG-parallel? | orchestrator | 7 | resolved | Not ported: vLLM-Omni serving features. Ulysses SP is supported through `DistributedAttention`. |
## Issues And Blockers
| ID | Phase | Component | Severity | Issue | Evidence | Owner | Status | Resolution |
|---|---|---|---|---|---|---|---|---|
| I001 | 9-10 | all | medium | No real-weight parity or SSIM regression | `IFM/PAN2` is not public | orchestrator | open | Add `fastvideo/tests/ssim/test_pan2_similarity.py` once weights are public. |
| I002 | 9 | transformer, text_encoder | medium | No GPU run yet (bf16 + FlashAttention, NCCL SP, 9B encoder speed) | All tests ran on a CPU login node | orchestrator | open | Run the same tests on a GPU node. |
| I003 | 7 | pipeline | low | The shared denoiser casts latents to the model-output dtype in the scheduler step; PAN2 samples on fp32 latents | pipeline parity fails (8e-2) without the fix | orchestrator | resolved | `DenoisingStage.prepare_scheduler_model_output` hook; `PAN2DenoisingStage` returns `noise_pred.float()`. |
## Escape Hatches
| ID | Phase | Decision Type | Question | Recommended Option | Status | Resolution |
|---|---|---|---|---|---|---|
## Decisions
| Date | Decision | Rationale | Impact |
|---|---|---|---|
| 2026-10-15 | Native Qwen3.5 encoder with a PyTorch chunked gated delta rule | No `fla`/`causal_conv1d` dependency; encoder runs once per prompt | Slower than fused kernels for long prompts |
| 2026-10-15 | Fuse the per-stream Q/K/V projections at load time | One GEMM per stream instead of three | Mapping in `PAN2ArchConfig.param_names_mapping` |
| 2026-10-15 | Text embeddings kept in fp32 after encoding | The shared latent stage draws noise in the embedding dtype; PAN2 uses fp32 noise and latents | Denoiser casts embeddings to the DiT dtype (`cast_prompt_embeds_to_dit_dtype`) |
## Handoff Notes
- The remaining work is I001 and I002. Both need hardware or weights, not code changes.
+54
View File
@@ -0,0 +1,54 @@
# PAN2 Local Tests
Runbook for validating the PAN2 port (text-to-video and first-frame image-to-video). These tests run manually and are
not part of the default CI suite.
## Reference
PAN2's diffusers integration (`PAN2Transformer3DModel`, `PAN2ModularPipeline`) is not public yet, and the full
`IFM/PAN2` weights are not released. The reference is the vLLM-Omni port,
[vllm-project/vllm-omni#8602](https://github.com/vllm-project/vllm-omni/pull/8602) (head `59dd8c9a`), which reports
parity with the diffusers implementation. `reference.py` rebuilds it from diffusers and transformers building blocks:
the DiT from `AdaLayerNormZero`, `AdaLayerNormContinuous`, `FeedForward` (gelu-approximate and swiglu), `Timesteps`,
`TimestepEmbedding` and the diffusers RoPE helpers, using the checkpoint's module names so the checkpoint loads
strictly. The pipeline is `reference_generate`, a step-by-step copy of the vLLM-Omni `PAN2Pipeline.forward`
using `Qwen3_5TextModel`, `AutoencoderKLWan` and `FlowMatchEulerDiscreteScheduler`.
The checkpoint is the public random-weight [`wuqing157/tiny-pan2-modular-pipe`](https://huggingface.co/wuqing157/tiny-pan2-modular-pipe)
(revision `3ff3130b`), the same one vLLM-Omni tests against. It has two DiT blocks, a four-layer Qwen3.5 text model
(three Gated DeltaNet layers and one full-attention layer) and a small Wan2.2-layout VAE.
## Setup
```bash
huggingface-cli download wuqing157/tiny-pan2-modular-pipe --revision 3ff3130b4527f61aaab7bde01465c9884dc84cdd
# or point the tests at a local copy:
export PAN2_TINY_MODEL_DIR=/path/to/tiny-pan2-modular-pipe
```
The tests need `diffusers` with `AutoencoderKLWan` and `transformers` with `Qwen3_5TextModel` (5.x). They run on CPU
when no GPU is present. On a GPU-less host, `import fastvideo` also needs the `fastvideo_kernel` stub described in
`fastvideo/tests/AGENTS.md`.
## Tests
| Area | Command | Result (CPU, fp32 unless noted) |
|---|---|---|
| DiT: tiny checkpoint, strict load + forward | `pytest tests/local_tests/transformers/test_pan2_transformer_parity.py -k tiny_checkpoint` | max abs 6.0e-7 |
| DiT: random weights, batch 1 and 2 | `pytest tests/local_tests/transformers/test_pan2_transformer_parity.py -k random_weights` | max abs 0 and 1.9e-5 (outputs ~16) |
| DiT: Ulysses SP=2 vs one rank, padded tokens | `pytest tests/local_tests/transformers/test_pan2_transformer_parity.py -k sp2` | max abs 5.7e-6 (gloo on CPU; NCCL on 2 GPUs) |
| Qwen3.5 text encoder | `pytest tests/local_tests/encoders/test_pan2_qwen3_5_parity.py` | all hidden states exact (fp32, bf16, padded batch) |
| Wan2.2 VAE encode/decode + normalization | `pytest tests/local_tests/vaes/test_pan2_vae_parity.py` | exact; normalization 2.4e-7 |
| Flow-match scheduler, PAN2 sigmas + shift | `pytest tests/local_tests/schedulers/test_pan2_scheduler_parity.py` | exact |
| Pipeline, T2V and I2V (bf16 DiT) | `pytest tests/local_tests/pipelines/test_pan2_pipeline_parity.py` | final latents and video exact |
The pipeline test builds the FastVideo pipeline through the registry and component loaders and compares the final
latents (relative L2 < 2e-2, cosine > 0.9995) and the decoded video (PSNR > 40 dB) with `reference_generate`. To
confirm the threshold catches real deviations, three injected changes were each run against it: conditioning on
`hidden_states[-2]`, letting the latents fall to bf16 between steps, and shifting the first-frame crop by one pixel.
Each moved the latents by about 8e-2 relative L2.
## Not covered
- Real-weight parity and an SSIM regression: `IFM/PAN2` is not public.
- GPU runs: everything above ran on CPU. The bf16 GPU path, FlashAttention, and SP over NCCL still need a GPU node.
+299
View File
@@ -0,0 +1,299 @@
# SPDX-License-Identifier: Apache-2.0
"""PAN2 reference implementation for local parity tests.
The diffusers ``PAN2Transformer3DModel`` and ``PAN2ModularPipeline`` are not public yet. The reference here is
assembled from diffusers' and transformers' own building blocks (``AdaLayerNormZero``, ``AdaLayerNormContinuous``,
``FeedForward`` with ``gelu-approximate``/``swiglu``, ``Timesteps``/``TimestepEmbedding``,
``get_1d_rotary_pos_embed``/``apply_rotary_emb``, ``Qwen3_5TextModel``, ``AutoencoderKLWan``,
``FlowMatchEulerDiscreteScheduler``) and follows the vLLM-Omni PAN2 port (vllm-project/vllm-omni#8602), which was
validated against the diffusers implementation. Module names are the checkpoint's, so checkpoints load strictly.
"""
from __future__ import annotations
import os
from pathlib import Path
import numpy as np
import PIL.Image
import torch
import torch.nn as nn
import torch.nn.functional as F
from diffusers.models.attention import FeedForward
from diffusers.models.embeddings import TimestepEmbedding, Timesteps, apply_rotary_emb, get_1d_rotary_pos_embed
from diffusers.models.normalization import AdaLayerNormContinuous, AdaLayerNormZero, RMSNorm
TINY_REPO_ID = "wuqing157/tiny-pan2-modular-pipe"
TINY_REVISION = "3ff3130b4527f61aaab7bde01465c9884dc84cdd"
SYSTEM_PROMPT = "You are a helpful assistant. Generate the video with the following instructions."
TEXT_ENCODER_LAYERS_TO_SKIP = 2
MAX_SEQUENCE_LENGTH = 1536
def tiny_model_dir() -> Path | None:
"""``PAN2_TINY_MODEL_DIR``, else the pinned tiny checkpoint in the local HF cache."""
local = os.getenv("PAN2_TINY_MODEL_DIR")
if local:
return Path(local)
try:
from huggingface_hub import snapshot_download
return Path(snapshot_download(TINY_REPO_ID, revision=TINY_REVISION, local_files_only=True))
except Exception:
return None
def _sdpa(query: torch.Tensor, key: torch.Tensor, value: torch.Tensor) -> torch.Tensor:
# [B, S, H, D] -> [B, S, H*D]
out = F.scaled_dot_product_attention(query.transpose(1, 2), key.transpose(1, 2), value.transpose(1, 2))
return out.transpose(1, 2).flatten(2)
class _RefRefinerAttention(nn.Module):
def __init__(self, dim: int, heads: int, dim_head: int, eps: float):
super().__init__()
self.heads = heads
self.to_q = nn.Linear(dim, heads * dim_head, bias=False)
self.to_k = nn.Linear(dim, heads * dim_head, bias=False)
self.to_v = nn.Linear(dim, heads * dim_head, bias=False)
self.norm_q = RMSNorm(dim_head, eps=eps)
self.norm_k = RMSNorm(dim_head, eps=eps)
self.to_out = nn.ModuleList([nn.Linear(heads * dim_head, dim, bias=False)])
def forward(self, x: torch.Tensor) -> torch.Tensor:
query = self.norm_q(self.to_q(x).unflatten(-1, (self.heads, -1)))
key = self.norm_k(self.to_k(x).unflatten(-1, (self.heads, -1)))
value = self.to_v(x).unflatten(-1, (self.heads, -1))
return self.to_out[0](_sdpa(query, key, value))
class _RefRefinerBlock(nn.Module):
def __init__(self, dim: int, heads: int, dim_head: int, mlp_ratio: float, eps: float):
super().__init__()
self.norm1 = RMSNorm(dim, eps=eps)
self.attn = _RefRefinerAttention(dim, heads, dim_head, eps)
self.norm2 = RMSNorm(dim, eps=eps)
self.ff = FeedForward(dim, inner_dim=int(dim * mlp_ratio), activation_fn="swiglu", bias=False)
def forward(self, x: torch.Tensor) -> torch.Tensor:
x = x + self.attn(self.norm1(x))
return x + self.ff(self.norm2(x))
class _RefJointAttention(nn.Module):
def __init__(self, dim: int, heads: int, dim_head: int, eps: float):
super().__init__()
self.heads = heads
self.to_q = nn.Linear(dim, heads * dim_head)
self.to_k = nn.Linear(dim, heads * dim_head)
self.to_v = nn.Linear(dim, heads * dim_head)
self.norm_q = RMSNorm(dim_head, eps=eps)
self.norm_k = RMSNorm(dim_head, eps=eps)
self.to_out = nn.ModuleList([nn.Linear(heads * dim_head, dim)])
self.add_q_proj = nn.Linear(dim, heads * dim_head)
self.add_k_proj = nn.Linear(dim, heads * dim_head)
self.add_v_proj = nn.Linear(dim, heads * dim_head)
self.norm_added_q = RMSNorm(dim_head, eps=eps)
self.norm_added_k = RMSNorm(dim_head, eps=eps)
self.to_add_out = nn.Linear(heads * dim_head, dim)
def forward(self, x: torch.Tensor, context: torch.Tensor,
rope: tuple[torch.Tensor, torch.Tensor]) -> tuple[torch.Tensor, torch.Tensor]:
query = self.norm_q(self.to_q(x).unflatten(-1, (self.heads, -1)))
key = self.norm_k(self.to_k(x).unflatten(-1, (self.heads, -1)))
value = self.to_v(x).unflatten(-1, (self.heads, -1))
# Interleaved (GPT-J) rotary embedding on the video tokens only.
query = apply_rotary_emb(query, rope, use_real=True, use_real_unbind_dim=-1, sequence_dim=1)
key = apply_rotary_emb(key, rope, use_real=True, use_real_unbind_dim=-1, sequence_dim=1)
context_query = self.norm_added_q(self.add_q_proj(context).unflatten(-1, (self.heads, -1)))
context_key = self.norm_added_k(self.add_k_proj(context).unflatten(-1, (self.heads, -1)))
context_value = self.add_v_proj(context).unflatten(-1, (self.heads, -1))
out = _sdpa(torch.cat([query, context_query], dim=1), torch.cat([key, context_key], dim=1),
torch.cat([value, context_value], dim=1))
out, context_out = out.split([x.shape[1], context.shape[1]], dim=1)
return self.to_out[0](out), self.to_add_out(context_out)
class _RefBlock(nn.Module):
def __init__(self, dim: int, heads: int, dim_head: int, mlp_ratio: float, context_mlp_ratio: float):
super().__init__()
eps = 1e-6
self.norm1 = AdaLayerNormZero(dim)
self.norm1_context = nn.LayerNorm(dim, elementwise_affine=False, eps=eps)
self.scale_shift_table_context = nn.Parameter(torch.zeros(6 * dim))
self.attn = _RefJointAttention(dim, heads, dim_head, eps)
self.norm2 = nn.LayerNorm(dim, elementwise_affine=False, eps=eps)
self.ff = FeedForward(dim, inner_dim=int(dim * mlp_ratio), activation_fn="gelu-approximate")
self.norm2_context = nn.LayerNorm(dim, elementwise_affine=False, eps=eps)
self.ff_context = FeedForward(dim, inner_dim=int(dim * context_mlp_ratio), activation_fn="gelu-approximate")
def forward(self, x, context, temb, rope):
norm_x, gate_msa, shift_mlp, scale_mlp, gate_mlp = self.norm1(x, emb=temb)
c_shift_msa, c_scale_msa, c_gate_msa, c_shift_mlp, c_scale_mlp, c_gate_mlp = (
self.scale_shift_table_context.chunk(6, dim=-1))
norm_context = self.norm1_context(context) * (1 + c_scale_msa) + c_shift_msa
attn_out, context_attn_out = self.attn(norm_x, norm_context, rope)
x = x + attn_out * gate_msa.unsqueeze(1)
context = context + context_attn_out * c_gate_msa
norm_x = self.norm2(x) * (1 + scale_mlp[:, None]) + shift_mlp[:, None]
norm_context = self.norm2_context(context) * (1 + c_scale_mlp) + c_shift_mlp
x = x + self.ff(norm_x) * gate_mlp.unsqueeze(1)
context = context + self.ff_context(norm_context) * c_gate_mlp
return x, context
class ReferencePAN2Transformer(nn.Module):
def __init__(self, patch_size=(1, 2, 2), in_channels=97, out_channels=48, num_attention_heads=32,
attention_head_dim=128, num_layers=44, num_refiner_layers=4, mlp_ratio=8.0, context_mlp_ratio=4.0,
refiner_mlp_ratio=4.0, text_embed_dim=4096, rope_axes_dim=(16, 56, 56), rope_theta=256.0,
rope_temporal_scale=16 / 24, **_):
super().__init__()
self.patch_size = tuple(patch_size)
self.rope_axes_dim = tuple(rope_axes_dim)
self.rope_theta = rope_theta
self.rope_temporal_scale = rope_temporal_scale
inner_dim = num_attention_heads * attention_head_dim
self.x_embedder = nn.Conv3d(in_channels, inner_dim, kernel_size=self.patch_size, stride=self.patch_size)
self.time_proj = Timesteps(num_channels=256, flip_sin_to_cos=True, downscale_freq_shift=0)
self.time_embedder = TimestepEmbedding(in_channels=256, time_embed_dim=inner_dim)
self.context_embedder = nn.Sequential(RMSNorm(text_embed_dim, eps=1e-5), nn.Linear(text_embed_dim, inner_dim))
self.context_refiner = nn.ModuleList([
_RefRefinerBlock(inner_dim, num_attention_heads, attention_head_dim, refiner_mlp_ratio, eps=1e-5)
for _ in range(num_refiner_layers)
])
self.context_type_embedding = nn.Parameter(torch.zeros(inner_dim))
self.transformer_blocks = nn.ModuleList([
_RefBlock(inner_dim, num_attention_heads, attention_head_dim, mlp_ratio, context_mlp_ratio)
for _ in range(num_layers)
])
self.norm_out = AdaLayerNormContinuous(inner_dim, inner_dim, elementwise_affine=False, eps=1e-6)
self.proj_out = nn.Linear(inner_dim, self.patch_size[0] * self.patch_size[1] * self.patch_size[2] * out_channels)
def _rope(self, num_frames: int, height: int, width: int, device) -> tuple[torch.Tensor, torch.Tensor]:
axes = [torch.arange(size, device=device, dtype=torch.float32) for size in (num_frames, height, width)]
grid = torch.stack(torch.meshgrid(*axes, indexing="ij"), dim=0)
grid[0] = grid[0] * self.rope_temporal_scale
cos, sin = zip(*[
get_1d_rotary_pos_embed(dim, grid[i].reshape(-1), self.rope_theta, use_real=True,
repeat_interleave_real=True) for i, dim in enumerate(self.rope_axes_dim)
])
return torch.cat(cos, dim=1).float(), torch.cat(sin, dim=1).float()
def forward(self, hidden_states, timestep, encoder_hidden_states):
batch_size, _, num_frames, height, width = hidden_states.shape
p_t, p_h, p_w = self.patch_size
t, h, w = num_frames // p_t, height // p_h, width // p_w
rope = self._rope(t, h, w, hidden_states.device)
x = self.x_embedder(hidden_states).flatten(2).transpose(1, 2)
temb = self.time_embedder(self.time_proj(timestep).to(x.dtype))
context = self.context_embedder(encoder_hidden_states)
for block in self.context_refiner:
context = block(context)
context = context + self.context_type_embedding
for block in self.transformer_blocks:
x, context = block(x, context, temb, rope)
x = self.proj_out(self.norm_out(x, temb))
x = x.reshape(batch_size, t, h, w, -1, p_t, p_h, p_w).permute(0, 4, 1, 5, 2, 6, 3, 7)
return x.flatten(6, 7).flatten(4, 5).flatten(2, 3)
# --------------------------------------------------------------------------------------------------------------------
# Reference pipeline (vLLM-Omni ``PAN2Pipeline.forward``)
# --------------------------------------------------------------------------------------------------------------------
def reference_encode_prompt(tokenizer, text_encoder, prompt: str, device) -> torch.Tensor:
messages = [{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": prompt or " "}]
input_ids = tokenizer.apply_chat_template(messages, add_generation_prompt=True, tokenize=True,
return_dict=True).input_ids
header_ids = tokenizer("<|im_start|>user\n", add_special_tokens=False).input_ids
crop_start = next(i + len(header_ids) for i in range(len(input_ids))
if input_ids[i:i + len(header_ids)] == header_ids)
input_ids = torch.tensor([input_ids[:crop_start + MAX_SEQUENCE_LENGTH]], device=device)
hidden_states = text_encoder(input_ids=input_ids, output_hidden_states=True).hidden_states
return hidden_states[-(TEXT_ENCODER_LAYERS_TO_SKIP + 1)][:, crop_start:]
def reference_condition_latents(vae, latents: torch.Tensor, image: PIL.Image.Image | None, height: int,
width: int) -> torch.Tensor:
batch_size, num_channels, num_frames, latent_height, latent_width = latents.shape
condition = latents.new_zeros(batch_size, num_channels + 1, num_frames, latent_height, latent_width)
if image is None:
return condition
from diffusers.video_processor import VideoProcessor
image = image.convert("RGB")
scale = max(width / image.width, height / image.height)
image = image.resize((round(image.width * scale), round(image.height * scale)), resample=PIL.Image.LANCZOS)
left, top = round((image.width - width) / 2), round((image.height - height) / 2)
image = image.crop((left, top, left + width, top + height))
pixels = VideoProcessor(vae_scale_factor=16).preprocess(image, height=height, width=width)
pixels = pixels.unsqueeze(2).to(device=latents.device, dtype=vae.dtype)
image_latents = vae.encode(pixels).latent_dist.mode()
mean = torch.tensor(vae.config.latents_mean).view(1, -1, 1, 1, 1).to(image_latents)
std = torch.tensor(vae.config.latents_std).view(1, -1, 1, 1, 1).to(image_latents)
condition[:, :num_channels, :1] = (image_latents - mean) / std
condition[:, num_channels:, :1] = 1.0
return condition
@torch.no_grad()
def reference_generate(components: dict, *, prompt: str, negative_prompt: str, image: PIL.Image.Image | None,
height: int, width: int, num_frames: int, num_inference_steps: int, guidance_scale: float,
seed: int, dit_dtype: torch.dtype, device="cpu") -> tuple[torch.Tensor, torch.Tensor]:
"""Final latents and the decoded video in ``[0, 1]``, following the vLLM-Omni pipeline step by step."""
from diffusers.utils.torch_utils import randn_tensor
tokenizer, text_encoder = components["tokenizer"], components["text_encoder"]
transformer, vae, scheduler = components["transformer"], components["vae"], components["scheduler"]
do_cfg = guidance_scale > 1.0
prompt_embeds = reference_encode_prompt(tokenizer, text_encoder, prompt, device).to(dit_dtype)
negative_embeds = (reference_encode_prompt(tokenizer, text_encoder, negative_prompt, device).to(dit_dtype)
if do_cfg else None)
shape = (1, vae.config.z_dim, (num_frames - 1) // vae.config.scale_factor_temporal + 1,
height // vae.config.scale_factor_spatial, width // vae.config.scale_factor_spatial)
generator = [torch.Generator("cpu").manual_seed(seed)]
latents = randn_tensor(shape, generator=generator, device=torch.device(device), dtype=torch.float32)
condition = reference_condition_latents(vae, latents, image, height, width).to(dit_dtype)
scheduler.set_timesteps(sigmas=np.linspace(1.0, 0.0, num_inference_steps + 1)[:-1], device=device)
for t in scheduler.timesteps:
model_input = torch.cat([latents.to(dit_dtype), condition], dim=1)
timestep = t.expand(model_input.shape[0])
noise_pred = transformer(model_input, timestep, prompt_embeds)
if do_cfg:
noise_uncond = transformer(model_input, timestep, negative_embeds)
noise_pred = noise_uncond + guidance_scale * (noise_pred - noise_uncond)
latents = scheduler.step(noise_pred.float(), t, latents, return_dict=False)[0]
mean = torch.tensor(vae.config.latents_mean).view(1, -1, 1, 1, 1).to(latents)
std = torch.tensor(vae.config.latents_std).view(1, -1, 1, 1, 1).to(latents)
video = vae.decode((latents * std + mean).to(vae.dtype), return_dict=False)[0]
return latents, (video.float() / 2 + 0.5).clamp(0, 1)
def load_reference_components(model_dir: Path, dit_dtype: torch.dtype) -> dict:
import json
from diffusers import AutoencoderKLWan, FlowMatchEulerDiscreteScheduler
from safetensors.torch import load_file
from transformers import AutoTokenizer, Qwen3_5TextModel
transformer_dir = model_dir / "transformer"
transformer = ReferencePAN2Transformer(**json.loads((transformer_dir / "config.json").read_text()))
transformer.load_state_dict(load_file(str(transformer_dir / "diffusion_pytorch_model.safetensors")), strict=True)
return {
"tokenizer": AutoTokenizer.from_pretrained(model_dir / "tokenizer"),
"text_encoder": Qwen3_5TextModel.from_pretrained(model_dir / "text_encoder", torch_dtype=torch.float32).eval(),
"transformer": transformer.to(dit_dtype).eval(),
"vae": AutoencoderKLWan.from_pretrained(model_dir / "vae", torch_dtype=torch.float32).eval(),
"scheduler": FlowMatchEulerDiscreteScheduler.from_pretrained(model_dir / "scheduler"),
}
@@ -0,0 +1,156 @@
# SPDX-License-Identifier: Apache-2.0
"""PAN2 end-to-end pipeline parity on the public tiny checkpoint.
The FastVideo pipeline (built through the real registry and component loaders) is compared against
``tests/local_tests/pan2/reference.py::reference_generate``, which follows the vLLM-Omni ``PAN2Pipeline.forward``
step by step with transformers/diffusers components: prompt template and crop, fp32 noise, first-frame
conditioning, the uniform sigma schedule with shift 7, sequential CFG, fp32 Euler steps and the VAE decode.
Both sides run the DiT in bf16 (FastVideo's denoiser always feeds it bf16), so the comparison allows bf16 rounding.
Runs on CPU when no GPU is present. Set ``PAN2_TINY_MODEL_DIR`` to a local copy of the tiny checkpoint.
"""
from __future__ import annotations
import os
import socket
import PIL.Image
import pytest
import torch
os.environ.setdefault("FASTVIDEO_ATTENTION_BACKEND", "TORCH_SDPA")
from tests.local_tests.pan2.reference import load_reference_components, reference_generate, tiny_model_dir # noqa: E402
HEIGHT, WIDTH, NUM_FRAMES = 64, 96, 9
NUM_INFERENCE_STEPS = 3
# A large scale makes the guided steps visible through the tiny random-weight VAE.
GUIDANCE_SCALE = 30.0
SEED = 42
PROMPT = "a cat walks on the grass"
NEGATIVE_PROMPT = "blurry"
# Not the output aspect ratio, so the cover-resize and center crop are exercised.
CONDITION_IMAGE = PIL.Image.radial_gradient("L").resize((120, 64)).convert("RGB")
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
@pytest.fixture(scope="module")
def pan2_runtime():
model_dir = tiny_model_dir()
if model_dir is None or not (model_dir / "modular_model_index.json").is_file():
pytest.skip("wuqing157/tiny-pan2-modular-pipe is not available locally; set PAN2_TINY_MODEL_DIR")
from fastvideo.configs.pipelines.pan2 import PAN2PipelineConfig
from fastvideo.distributed import cleanup_dist_env_and_memory, maybe_init_distributed_environment_and_model_parallel
import fastvideo.distributed.parallel_state as parallel_state
from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.pipelines import build_pipeline
monkeypatch = pytest.MonkeyPatch()
if not torch.cuda.is_available():
# On a GPU-less Linux host fastvideo resolves the local device to mps; run on CPU instead. Swapping the code
# object reaches every module that imported the function by name.
def _cpu_device() -> torch.device:
return torch.device("cpu")
monkeypatch.setattr(parallel_state.get_local_torch_device, "__code__", _cpu_device.__code__)
for name, value in (("MASTER_ADDR", "127.0.0.1"), ("MASTER_PORT", str(_free_port())), ("RANK", "0"),
("WORLD_SIZE", "1"), ("LOCAL_RANK", "0")):
monkeypatch.setenv(name, os.environ.get(name, value))
maybe_init_distributed_environment_and_model_parallel(1, 1)
pipeline_config = PAN2PipelineConfig(dit_precision="bf16",
vae_precision="fp32",
text_encoder_precisions=("fp32", ))
args = FastVideoArgs(model_path=str(model_dir),
num_gpus=1,
pipeline_config=pipeline_config,
dit_cpu_offload=False,
text_encoder_cpu_offload=False,
vae_cpu_offload=False,
use_fsdp_inference=False)
pipeline = build_pipeline(args)
device = parallel_state.get_local_torch_device()
try:
yield pipeline, args, model_dir, device
finally:
del pipeline
cleanup_dist_env_and_memory()
monkeypatch.undo()
def _fastvideo_generate(pipeline, args, image, guidance_scale: float = GUIDANCE_SCALE):
from fastvideo.pipelines import ForwardBatch
batch = ForwardBatch(data_type="video",
prompt=PROMPT,
negative_prompt=NEGATIVE_PROMPT,
seed=SEED,
height=HEIGHT,
width=WIDTH,
num_frames=NUM_FRAMES,
num_inference_steps=NUM_INFERENCE_STEPS,
guidance_scale=guidance_scale,
pil_image=image)
with torch.inference_mode():
batch = pipeline.forward(batch, args)
return batch.latents.float().cpu(), batch.output.float().cpu()
def _psnr(a: torch.Tensor, b: torch.Tensor) -> float:
mse = (a - b).pow(2).mean().clamp_min(1e-12)
return float(10 * torch.log10(1.0 / mse))
@pytest.mark.parametrize("image", [None, CONDITION_IMAGE], ids=["t2v", "i2v"])
def test_pan2_pipeline_matches_reference(pan2_runtime, image):
pipeline, args, model_dir, device = pan2_runtime
latents, video = _fastvideo_generate(pipeline, args, image)
components = load_reference_components(model_dir, torch.bfloat16)
components = {
name: module.to(device) if isinstance(module, torch.nn.Module) else module
for name, module in components.items()
}
ref_latents, ref_video = reference_generate(components,
prompt=PROMPT,
negative_prompt=NEGATIVE_PROMPT,
image=image,
height=HEIGHT,
width=WIDTH,
num_frames=NUM_FRAMES,
num_inference_steps=NUM_INFERENCE_STEPS,
guidance_scale=GUIDANCE_SCALE,
seed=SEED,
dit_dtype=torch.bfloat16,
device=device)
ref_latents, ref_video = ref_latents.float().cpu(), ref_video.float().cpu()
assert latents.dtype == torch.float32
assert latents.shape == ref_latents.shape == (1, 48, 3, HEIGHT // 16, WIDTH // 16)
assert video.shape == ref_video.shape == (1, 3, NUM_FRAMES, HEIGHT, WIDTH)
latent_rel = float((latents - ref_latents).norm() / ref_latents.norm())
cosine = float(torch.nn.functional.cosine_similarity(latents.flatten(), ref_latents.flatten(), dim=0))
psnr = _psnr(video, ref_video)
print(f"latents rel_l2={latent_rel:.3e} cosine={cosine:.6f}; video psnr={psnr:.1f} dB")
assert latent_rel < 2e-2
assert cosine > 0.9995
assert psnr > 40.0
def test_pan2_image_conditioning_and_guidance_change_every_frame(pan2_runtime):
pipeline, args, _, _ = pan2_runtime
_, t2v = _fastvideo_generate(pipeline, args, None)
_, i2v = _fastvideo_generate(pipeline, args, CONDITION_IMAGE)
_, unguided = _fastvideo_generate(pipeline, args, None, guidance_scale=1.0)
def per_frame_max_diff(a, b):
return (a - b).abs().amax(dim=(0, 1, 3, 4))
assert per_frame_max_diff(t2v, i2v).max() > 1e-3
assert per_frame_max_diff(t2v, unguided).min() > 1e-3
@@ -0,0 +1,200 @@
# SPDX-License-Identifier: Apache-2.0
"""PAN2 flow-matching Euler schedule on FastVideo's FlowMatchEulerDiscreteScheduler.
Coverage scope: both. PAN2 loads Diffusers' ``FlowMatchEulerDiscreteScheduler``
from ``<checkpoint>/scheduler`` (shift 7.0, no dynamic shifting), optionally
overrides the shift with ``flow_shift``, calls
``set_timesteps(sigmas=np.linspace(1.0, 0.0, N + 1)[:-1])`` and steps float32
latents. The FastVideo side is loaded by the production ``SchedulerLoader`` and
its schedule is built by the stock ``TimestepPreparationStage`` from
``batch.sigmas``. Timesteps, sigmas, and every Euler update must be bit-exact.
The default asset is the tiny PAN2 checkpoint (config only), so this runs on a
CPU-only host::
pytest tests/local_tests/schedulers/test_pan2_scheduler_parity.py -v -s
Set ``PAN2_MODEL_PATH`` to a local PAN2 root containing ``scheduler/`` to use
another checkpoint.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from types import SimpleNamespace
import numpy as np
import pytest
import torch
from torch.testing import assert_close
try:
from diffusers import FlowMatchEulerDiscreteScheduler as DiffusersFlowMatchEulerDiscreteScheduler
except ImportError: # pragma: no cover - environment dependent
pytest.skip("diffusers with FlowMatchEulerDiscreteScheduler is required", allow_module_level=True)
from fastvideo.models.loader.component_loader import SchedulerLoader
from fastvideo.models.schedulers.scheduling_flow_match_euler_discrete import (
FlowMatchEulerDiscreteScheduler as FastVideoFlowMatchEulerDiscreteScheduler, )
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
from fastvideo.pipelines.stages.timestep_preparation import TimestepPreparationStage
PARITY_SCOPE = "both"
TINY_PAN2_REPO = "wuqing157/tiny-pan2-modular-pipe"
TINY_PAN2_REVISION = "3ff3130b4527f61aaab7bde01465c9884dc84cdd"
MODEL_PATH_ENV = "PAN2_MODEL_PATH"
LATENT_SHAPE = (1, 48, 3, 4, 6)
def _pan2_root(subfolder: str) -> Path:
"""Resolve a PAN2 root that contains ``subfolder``; skip when unavailable."""
override = os.getenv(MODEL_PATH_ENV)
if override:
root = Path(override)
else:
try:
from huggingface_hub import snapshot_download
except ImportError:
pytest.skip("huggingface_hub is required to resolve the tiny PAN2 checkpoint")
root = None
last_error: Exception | None = None
for local_files_only in (True, False):
try:
root = Path(
snapshot_download(
TINY_PAN2_REPO,
revision=TINY_PAN2_REVISION,
allow_patterns=[f"{subfolder}/*"],
local_files_only=local_files_only,
))
break
except Exception as exc: # noqa: BLE001 - offline/missing cache must skip.
last_error = exc
if root is None:
pytest.skip(f"Tiny PAN2 checkpoint unavailable: {last_error}")
if not (root / subfolder).is_dir():
pytest.skip(f"PAN2 {subfolder}/ missing under {root}")
return root
@pytest.fixture(scope="module")
def pan2_root() -> Path:
root = _pan2_root("scheduler")
if not (root / "scheduler" / "scheduler_config.json").is_file():
pytest.skip(f"PAN2 scheduler config missing under {root}")
return root
@pytest.fixture
def cpu_runtime(monkeypatch: pytest.MonkeyPatch) -> None:
"""Pin FastVideo's device helper to CPU; it reports ``mps`` on CPU-only Linux."""
monkeypatch.setattr("fastvideo.pipelines.stages.timestep_preparation.get_local_torch_device",
lambda: torch.device("cpu"))
def _pan2_sigmas(num_steps: int) -> np.ndarray:
"""PAN2's pre-shift grid: uniform from 1 down to, but excluding, 0."""
return np.linspace(1.0, 0.0, num_steps + 1)[:-1]
def _reference(root: Path, flow_shift: float | None = None) -> DiffusersFlowMatchEulerDiscreteScheduler:
"""Mirror the upstream PAN2 pipeline's scheduler construction."""
scheduler = DiffusersFlowMatchEulerDiscreteScheduler.from_pretrained(str(root),
subfolder="scheduler",
local_files_only=True)
if flow_shift is not None:
scheduler._shift = flow_shift
return scheduler
def _fastvideo(root: Path, flow_shift: float | None = None) -> FastVideoFlowMatchEulerDiscreteScheduler:
"""Load through the production SchedulerLoader, which applies ``flow_shift``."""
fastvideo_args = SimpleNamespace(pipeline_config=SimpleNamespace(flow_shift=flow_shift))
scheduler = SchedulerLoader().load(str(root / "scheduler"), fastvideo_args)
assert type(scheduler) is FastVideoFlowMatchEulerDiscreteScheduler
return scheduler
def _prepare_fastvideo_timesteps(scheduler: FastVideoFlowMatchEulerDiscreteScheduler,
num_steps: int) -> ForwardBatch:
"""Drive the stock TimestepPreparationStage with PAN2's custom sigmas."""
batch = ForwardBatch(data_type="video", num_inference_steps=num_steps, sigmas=_pan2_sigmas(num_steps).tolist())
stage = TimestepPreparationStage(scheduler)
assert stage.verify_input(batch, SimpleNamespace()).is_valid()
batch = stage.forward(batch, SimpleNamespace())
assert batch.timesteps is scheduler.timesteps
return batch
def test_pan2_scheduler_loader_keeps_checkpoint_config(pan2_root: Path) -> None:
"""Without ``flow_shift`` the loader keeps every released config value, including shift 7.0."""
raw = json.loads((pan2_root / "scheduler" / "scheduler_config.json").read_text())
assert raw["_class_name"] == "FlowMatchEulerDiscreteScheduler"
scheduler = _fastvideo(pan2_root)
for key, value in raw.items():
if not key.startswith("_"):
assert scheduler.config[key] == value, key
assert scheduler.shift == raw["shift"] == 7.0
assert not scheduler.config.use_dynamic_shifting
@pytest.mark.parametrize("num_steps", [1, 4, 8, 30, 50])
def test_pan2_scheduler_schedule_matches_diffusers(num_steps: int, pan2_root: Path, cpu_runtime: None) -> None:
"""Timesteps and sigmas from ``batch.sigmas`` are bit-exact for short and long schedules."""
reference = _reference(pan2_root)
reference.set_timesteps(sigmas=_pan2_sigmas(num_steps), device="cpu")
fastvideo = _fastvideo(pan2_root)
batch = _prepare_fastvideo_timesteps(fastvideo, num_steps)
print(f"[pan2-scheduler] N={num_steps} timesteps[:4]={reference.timesteps[:4].tolist()} "
f"sigmas[-3:]={reference.sigmas[-3:].tolist()}")
assert len(batch.timesteps) == num_steps
assert_close(batch.timesteps, reference.timesteps, atol=0, rtol=0)
assert_close(fastvideo.sigmas, reference.sigmas, atol=0, rtol=0)
assert fastvideo.sigmas[0].item() == 1.0 and fastvideo.sigmas[-1].item() == 0.0
@pytest.mark.parametrize("flow_shift", [None, 5.0], ids=["checkpoint_shift", "flow_shift_override"])
def test_pan2_scheduler_steps_match_diffusers(flow_shift: float | None, pan2_root: Path, cpu_runtime: None) -> None:
"""Five float32 Euler updates are bit-exact, with and without a shift override."""
num_steps = 5
reference = _reference(pan2_root, flow_shift)
reference.set_timesteps(sigmas=_pan2_sigmas(num_steps), device="cpu")
fastvideo = _fastvideo(pan2_root, flow_shift)
_prepare_fastvideo_timesteps(fastvideo, num_steps)
assert fastvideo.shift == reference.shift == (flow_shift or 7.0)
assert_close(fastvideo.timesteps, reference.timesteps, atol=0, rtol=0)
generator = torch.Generator().manual_seed(2026)
reference_latents = torch.randn(LATENT_SHAPE, generator=generator)
fastvideo_latents = reference_latents.clone()
max_diff = 0.0
for index in range(num_steps):
noise_pred = torch.randn(LATENT_SHAPE, generator=generator)
reference_latents = reference.step(noise_pred, reference.timesteps[index], reference_latents).prev_sample
fastvideo_latents = fastvideo.step(noise_pred, fastvideo.timesteps[index], fastvideo_latents).prev_sample
assert fastvideo_latents.dtype == torch.float32
max_diff = max(max_diff, (fastvideo_latents - reference_latents).abs().max().item())
assert_close(fastvideo_latents, reference_latents, atol=0, rtol=0)
print(f"[pan2-scheduler] flow_shift={flow_shift} steps={num_steps} max_abs={max_diff:.3e}")
assert fastvideo.step_index == reference.step_index == num_steps
def test_pan2_scheduler_step_returns_model_output_dtype(pan2_root: Path, cpu_runtime: None) -> None:
"""Both implementations cast ``prev_sample`` to the model output dtype.
PAN2 keeps float32 latents by stepping with ``noise_pred.float()``; a bf16
noise prediction would silently drop the latents to bf16 on either side.
"""
reference = _reference(pan2_root)
reference.set_timesteps(sigmas=_pan2_sigmas(2), device="cpu")
fastvideo = _fastvideo(pan2_root)
_prepare_fastvideo_timesteps(fastvideo, 2)
latents = torch.randn(LATENT_SHAPE, generator=torch.Generator().manual_seed(5))
noise_pred = torch.randn(LATENT_SHAPE, generator=torch.Generator().manual_seed(6)).to(torch.bfloat16)
expected = reference.step(noise_pred, reference.timesteps[0], latents).prev_sample
actual = fastvideo.step(noise_pred, fastvideo.timesteps[0], latents).prev_sample
assert expected.dtype == actual.dtype == torch.bfloat16
assert_close(actual, expected, atol=0, rtol=0)
@@ -0,0 +1,311 @@
# SPDX-License-Identifier: Apache-2.0
"""PAN2 transformer weight-schema and forward parity.
The reference is ``tests/local_tests/pan2/reference.py``: diffusers building blocks arranged as the (not yet public)
diffusers ``PAN2Transformer3DModel``, following the vLLM-Omni port. Its module names are the checkpoint's, so the
public tiny checkpoint loads into it strictly.
Set ``PAN2_TINY_MODEL_DIR`` to a local copy of ``wuqing157/tiny-pan2-modular-pipe``; otherwise the HF cache is
used and the checkpoint test skips when it is absent.
"""
from __future__ import annotations
import argparse
import json
import os
import socket
import subprocess
import sys
from dataclasses import fields
from pathlib import Path
import pytest
import torch
import torch.nn as nn
from torch.testing import assert_close
os.environ.setdefault("DISABLE_SP", "1")
os.environ.setdefault("FASTVIDEO_ATTENTION_BACKEND", "TORCH_SDPA")
from fastvideo.forward_context import set_forward_context # noqa: E402
from fastvideo.models.loader.fsdp_load import load_model_from_full_model_state_dict # noqa: E402
from fastvideo.models.loader.utils import get_param_names_mapping # noqa: E402
from tests.local_tests.pan2.reference import TINY_REPO_ID, ReferencePAN2Transformer, tiny_model_dir # noqa: E402
REPO_ROOT = Path(__file__).resolve().parents[3]
SP_WORLD_SIZE = 2
# --------------------------------------------------------------------------------------------------------------------
# Helpers
# --------------------------------------------------------------------------------------------------------------------
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
def _small_kwargs() -> dict:
"""Larger than the tiny checkpoint: several heads, asymmetric RoPE axes, more blocks, distinct MLP ratios."""
return {
"patch_size": (1, 2, 2),
"in_channels": 9,
"out_channels": 4,
"num_attention_heads": 4,
"attention_head_dim": 32,
"num_layers": 3,
"num_refiner_layers": 2,
"mlp_ratio": 3.0,
"context_mlp_ratio": 2.0,
"refiner_mlp_ratio": 1.5,
"text_embed_dim": 48,
"rope_axes_dim": (8, 12, 12),
"rope_theta": 256.0,
"rope_temporal_scale": 16 / 24,
}
@pytest.fixture
def single_rank_dist():
from fastvideo.distributed import cleanup_dist_env_and_memory, maybe_init_distributed_environment_and_model_parallel
os.environ.setdefault("MASTER_ADDR", "127.0.0.1")
os.environ.setdefault("MASTER_PORT", str(_free_port()))
os.environ.setdefault("RANK", "0")
os.environ.setdefault("WORLD_SIZE", "1")
os.environ.setdefault("LOCAL_RANK", "0")
maybe_init_distributed_environment_and_model_parallel(1, 1)
yield
cleanup_dist_env_and_memory()
def _build_native(arch_kwargs: dict):
from fastvideo.configs.models.dits.pan2 import PAN2VideoConfig
from fastvideo.models.dits.pan2 import PAN2Transformer3DModel
config = PAN2VideoConfig()
config.update_model_arch(arch_kwargs)
return PAN2Transformer3DModel(config=config, hf_config=arch_kwargs).float().eval()
def _load_into_native(native, weights) -> None:
incompatible = load_model_from_full_model_state_dict(
native,
iter(weights),
device=torch.device("cpu"),
param_dtype=torch.float32,
strict=True,
param_names_mapping=get_param_names_mapping(native.param_names_mapping),
training_mode=False,
)
assert incompatible.missing_keys == []
assert incompatible.unexpected_keys == []
def _randomize(module: nn.Module, seed: int) -> None:
generator = torch.Generator().manual_seed(seed)
with torch.no_grad():
for param in module.parameters():
param.copy_(torch.randn(param.shape, generator=generator) * 0.2)
def _make_inputs(in_channels: int, text_dim: int, batch_size: int = 2, seed: int = 0) -> dict[str, torch.Tensor]:
generator = torch.Generator().manual_seed(seed)
return {
# 9 frames at 64x96 in latent space: 3 latent frames of 4x6.
"hidden_states": torch.randn(batch_size, in_channels, 3, 4, 6, generator=generator),
"timestep": torch.tensor([700.0, 125.5][:batch_size]),
"encoder_hidden_states": torch.randn(batch_size, 13, text_dim, generator=generator),
}
def _compare(native, reference, inputs: dict[str, torch.Tensor], scope: str) -> None:
with torch.inference_mode():
expected = reference(**inputs)
with set_forward_context(current_timestep=0, attn_metadata=None):
actual = native(**inputs)
assert actual.shape == expected.shape
diff = (actual - expected).abs()
print(f"{scope}: shape={tuple(actual.shape)} ref_abs_mean={expected.abs().mean().item():.3e} "
f"diff_max={diff.max().item():.3e} diff_mean={diff.mean().item():.3e}")
assert torch.isfinite(expected).all() and expected.abs().mean() > 1e-3
assert_close(actual, expected, atol=2e-5, rtol=2e-5)
# --------------------------------------------------------------------------------------------------------------------
# Tests
# --------------------------------------------------------------------------------------------------------------------
def test_pan2_transformer_registry_resolves_native_class():
from fastvideo.models.dits.pan2 import PAN2Transformer3DModel
from fastvideo.models.registry import ModelRegistry
model_cls, architecture = ModelRegistry.resolve_model_cls("PAN2Transformer3DModel")
assert model_cls is PAN2Transformer3DModel
assert architecture == "PAN2Transformer3DModel"
@pytest.mark.parametrize(("module_name", "expected"), [
("transformer_blocks.0", True),
("transformer_blocks.43", True),
("context_refiner.3", True),
("transformer_blocks.0.attn.to_qkv", False),
("transformer_blocks.0.ff.fc_in", False),
])
def test_pan2_fsdp_shard_conditions_match_only_blocks(module_name: str, expected: bool):
from fastvideo.configs.models.dits.pan2 import PAN2ArchConfig
conditions = PAN2ArchConfig()._fsdp_shard_conditions
assert any(condition(module_name, None) for condition in conditions) is expected
def test_pan2_default_arch_config_is_the_29b_model():
from fastvideo.configs.models.dits.pan2 import PAN2ArchConfig
config = PAN2ArchConfig()
assert config.hidden_size == 4096
assert config.num_channels_latents == 48
assert config.in_channels == 2 * 48 + 1
assert sum(config.rope_axes_dim) == config.attention_head_dim
def test_pan2_tiny_checkpoint_config_fields_are_arch_fields():
model_dir = tiny_model_dir()
if model_dir is None or not (model_dir / "transformer" / "config.json").is_file():
pytest.skip(f"{TINY_REPO_ID} is not available locally; set PAN2_TINY_MODEL_DIR")
from fastvideo.configs.models.dits.pan2 import PAN2ArchConfig
checkpoint_config = json.loads((model_dir / "transformer" / "config.json").read_text())
assert checkpoint_config.pop("_class_name") == "PAN2Transformer3DModel"
checkpoint_config.pop("_diffusers_version", None)
assert set(checkpoint_config) <= {field.name for field in fields(PAN2ArchConfig)}
def test_pan2_tiny_checkpoint_strict_load_and_forward_parity(single_rank_dist):
model_dir = tiny_model_dir()
if model_dir is None or not (model_dir / "transformer").is_dir():
pytest.skip(f"{TINY_REPO_ID} is not available locally; set PAN2_TINY_MODEL_DIR")
from safetensors.torch import load_file
transformer_dir = model_dir / "transformer"
arch_kwargs = json.loads((transformer_dir / "config.json").read_text())
state_dict = load_file(str(transformer_dir / "diffusion_pytorch_model.safetensors"))
reference = ReferencePAN2Transformer(**arch_kwargs).float().eval()
reference.load_state_dict(state_dict, strict=True)
native = _build_native(arch_kwargs)
_load_into_native(native, state_dict.items())
inputs = _make_inputs(arch_kwargs["in_channels"], arch_kwargs["text_embed_dim"])
_compare(native, reference, inputs, "tiny checkpoint")
@pytest.mark.parametrize("batch_size", [1, 2])
def test_pan2_random_weights_forward_parity(single_rank_dist, batch_size: int):
kwargs = _small_kwargs()
reference = ReferencePAN2Transformer(**kwargs).float().eval()
# Zero-initialized tables (text modulation, type embedding) must be exercised too.
_randomize(reference, seed=1234)
native = _build_native(kwargs)
_load_into_native(native, reference.state_dict().items())
inputs = _make_inputs(kwargs["in_channels"], kwargs["text_embed_dim"], batch_size=batch_size, seed=batch_size)
_compare(native, reference, inputs, f"random weights, batch {batch_size}")
# --------------------------------------------------------------------------------------------------------------------
# Sequence parallel: SP=2 must match one rank (video tokens are padded to a multiple of the SP size)
# --------------------------------------------------------------------------------------------------------------------
def _run_sp_worker(mode: str, output_path: Path) -> None:
from fastvideo.configs.models.dits.pan2 import PAN2VideoConfig
from fastvideo.distributed import cleanup_dist_env_and_memory, maybe_init_distributed_environment_and_model_parallel
from fastvideo.models.dits.pan2 import PAN2Transformer3DModel
import torch.distributed as dist
sp_size = 1 if mode == "single" else SP_WORLD_SIZE
if torch.cuda.is_available():
device = torch.device(f"cuda:{int(os.environ.get('LOCAL_RANK', '0'))}")
torch.cuda.set_device(device)
torch.backends.cuda.matmul.allow_tf32 = False
torch.backends.cudnn.allow_tf32 = False
else:
# GPU-less host: gloo ranks on CPU. fastvideo resolves the local device to mps there; swapping the code
# object reaches every module that imported get_local_torch_device by name.
import fastvideo.distributed.parallel_state as parallel_state
def _cpu_device() -> torch.device:
return torch.device("cpu")
parallel_state.get_local_torch_device.__code__ = _cpu_device.__code__
device = torch.device("cpu")
try:
maybe_init_distributed_environment_and_model_parallel(1, sp_size)
kwargs = _small_kwargs()
reference = ReferencePAN2Transformer(**kwargs).float()
_randomize(reference, seed=1234)
config = PAN2VideoConfig()
config.update_model_arch(kwargs)
native = PAN2Transformer3DModel(config=config, hf_config=kwargs).float().eval()
_load_into_native(native, reference.state_dict().items())
native = native.to(device)
# 5x2x6 latents patchify to 15 video tokens, which SP=2 pads to 16.
inputs = _make_inputs(kwargs["in_channels"], kwargs["text_embed_dim"])
inputs["hidden_states"] = torch.randn(2, kwargs["in_channels"], 5, 2, 6, generator=torch.Generator().manual_seed(5))
inputs = {name: value.to(device) for name, value in inputs.items()}
with torch.inference_mode(), set_forward_context(current_timestep=0, attn_metadata=None):
output = native(**inputs)
if int(os.environ.get("RANK", "0")) == 0:
torch.save({"output": output.detach().cpu()}, output_path)
dist.barrier()
finally:
cleanup_dist_env_and_memory()
def _run_torchrun(mode: str, nproc_per_node: int, output_path: Path) -> None:
command = [
sys.executable, "-m", "torch.distributed.run", "--nnodes", "1", "--nproc_per_node",
str(nproc_per_node), "--master_port",
str(_free_port()),
str(Path(__file__).resolve()), "--pan2-sp-worker", "--mode", mode, "--output",
str(output_path)
]
environment = os.environ.copy()
environment["PYTHONPATH"] = os.pathsep.join(filter(None, [str(REPO_ROOT), environment.get("PYTHONPATH")]))
environment["DISABLE_SP"] = "0"
environment["FASTVIDEO_ATTENTION_BACKEND"] = "TORCH_SDPA"
process = subprocess.run(command, capture_output=True, text=True, env=environment)
if process.returncode != 0:
raise RuntimeError(f"{mode} worker failed with code {process.returncode}\n"
f"STDOUT:\n{process.stdout}\nSTDERR:\n{process.stderr}")
def test_pan2_sp2_matches_single_rank(tmp_path: Path):
"""Ulysses SP=2 on an odd video token count (padded to the SP size) must match one rank. Runs on CPU with gloo
when no GPU is present."""
if torch.cuda.is_available() and torch.cuda.device_count() < SP_WORLD_SIZE:
pytest.skip(f"PAN2 SP parity requires {SP_WORLD_SIZE} CUDA devices.")
single_path, sp_path = tmp_path / "single.pt", tmp_path / "sp2.pt"
_run_torchrun("single", 1, single_path)
_run_torchrun("sp", SP_WORLD_SIZE, sp_path)
single = torch.load(single_path, map_location="cpu", weights_only=True)["output"]
sp = torch.load(sp_path, map_location="cpu", weights_only=True)["output"]
diff = (sp - single).abs()
print(f"sp2-vs-single diff_max={diff.max().item():.3e}")
assert_close(sp, single, atol=1e-5, rtol=1e-5)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--pan2-sp-worker", action="store_true")
parser.add_argument("--mode", choices=["single", "sp"], required=True)
parser.add_argument("--output", type=str, required=True)
args = parser.parse_args()
if not args.pan2_sp_worker:
raise SystemExit("This module is intended to be run by pytest.")
_run_sp_worker(args.mode, Path(args.output))
@@ -0,0 +1,387 @@
# SPDX-License-Identifier: Apache-2.0
"""PAN2 reuse of FastVideo's Wan2.2 (48-channel) VAE.
Coverage scope: both. PAN2 ships a Diffusers ``AutoencoderKLWan`` in the
Wan2.2 layout (z_dim 48, residual blocks, 2x2 patchify, 16x spatial and 4x
temporal compression). The FastVideo side loads it through ``VAELoader``, the
path a pipeline uses, and is compared on CPU fp32 against
``diffusers.AutoencoderKLWan.from_pretrained``:
* posterior mode/logvar for a single first frame (PAN2's I2V conditioning),
* posterior mode/logvar for a 9-frame clip (4x temporal downsampling + cache),
* decode of 3 latent frames (first-chunk handling + temporal upsampling cache).
The normalization tests use a copy of the checkpoint with non-trivial
``latents_mean``/``latents_std`` (the tiny checkpoint ships 0/1, which would
hide a missing or doubled normalization) and pin where it happens: the raw
``encode``/``decode`` never normalize, ``PAN2ConditioningStage`` applies
PAN2's ``(z - mean) / std`` once, and ``DecodingStage`` applies
``z * std + mean`` once.
The default asset is the random-weight tiny PAN2 checkpoint, so this runs on a
CPU-only host::
pytest tests/local_tests/vaes/test_pan2_vae_parity.py -v -s
Set ``PAN2_MODEL_PATH`` to a local PAN2 root containing ``vae/`` to use other
weights.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from types import SimpleNamespace
import pytest
import torch
from safetensors.torch import load_file
from torch.testing import assert_close
try:
from diffusers import AutoencoderKLWan as DiffusersAutoencoderKLWan
except ImportError: # pragma: no cover - environment dependent
pytest.skip("diffusers with AutoencoderKLWan is required", allow_module_level=True)
from fastvideo.models.loader.component_loader import VAELoader
from fastvideo.models.wan.vae import AutoencoderKLWan as FastVideoAutoencoderKLWan
from fastvideo.models.wan.vae_config import WanVAEConfig
PARITY_SCOPE = "both"
TINY_PAN2_REPO = "wuqing157/tiny-pan2-modular-pipe"
TINY_PAN2_REVISION = "3ff3130b4527f61aaab7bde01465c9884dc84cdd"
MODEL_PATH_ENV = "PAN2_MODEL_PATH"
# H and W must be multiples of 32 (16x spatial compression times the 2x2
# patchify inside the VAE).
HEIGHT, WIDTH = 64, 96
VIDEO_FRAMES = 9
LATENT_FRAMES = (VIDEO_FRAMES - 1) // 4 + 1
ATOL = RTOL = 1e-5
# Structural fields of Wan-AI/Wan2.2-TI2V-5B-Diffusers vae/config.json. The
# statistics do not affect parameter shapes, so placeholders keep this inline.
WAN22_TI2V_VAE_CONFIG = {
"attn_scales": [],
"base_dim": 160,
"clip_output": False,
"decoder_base_dim": 256,
"dim_mult": [1, 2, 4, 4],
"dropout": 0.0,
"in_channels": 12,
"is_residual": True,
"latents_mean": [0.0] * 48,
"latents_std": [1.0] * 48,
"num_res_blocks": 2,
"out_channels": 12,
"patch_size": 2,
"scale_factor_spatial": 16,
"scale_factor_temporal": 4,
"temperal_downsample": [False, True, True],
"z_dim": 48,
}
def _pan2_root(subfolder: str) -> Path:
"""Resolve a PAN2 root that contains ``subfolder``; skip when unavailable."""
override = os.getenv(MODEL_PATH_ENV)
if override:
root = Path(override)
else:
try:
from huggingface_hub import snapshot_download
except ImportError:
pytest.skip("huggingface_hub is required to resolve the tiny PAN2 checkpoint")
root = None
last_error: Exception | None = None
for local_files_only in (True, False):
try:
root = Path(
snapshot_download(
TINY_PAN2_REPO,
revision=TINY_PAN2_REVISION,
allow_patterns=[f"{subfolder}/*"],
local_files_only=local_files_only,
))
break
except Exception as exc: # noqa: BLE001 - offline/missing cache must skip.
last_error = exc
if root is None:
pytest.skip(f"Tiny PAN2 checkpoint unavailable: {last_error}")
if not (root / subfolder).is_dir():
pytest.skip(f"PAN2 {subfolder}/ missing under {root}")
return root
@pytest.fixture(scope="module")
def vae_dir() -> Path:
path = _pan2_root("vae") / "vae"
for name in ("config.json", "diffusion_pytorch_model.safetensors"):
if not (path / name).is_file():
pytest.skip(f"PAN2 VAE asset missing: {path / name}")
return path
@pytest.fixture
def cpu_runtime(monkeypatch: pytest.MonkeyPatch) -> None:
"""Pin FastVideo's device helper to CPU; it reports ``mps`` on CPU-only Linux."""
for module in (
"fastvideo.models.loader.component_loader",
"fastvideo.pipelines.stages.decoding",
"fastvideo.pipelines.basic.pan2.stages",
):
monkeypatch.setattr(f"{module}.get_local_torch_device", lambda: torch.device("cpu"))
def _pan2_vae_config() -> WanVAEConfig:
"""The VAE config a PAN2 pipeline config would carry (arch filled by the loader)."""
config = WanVAEConfig()
config.load_encoder = True
config.load_decoder = True
return config
def _load_fastvideo(vae_dir: Path) -> FastVideoAutoencoderKLWan:
"""Load through the production VAELoader, exactly as a pipeline does."""
fastvideo_args = SimpleNamespace(
model_paths={},
pipeline_config=SimpleNamespace(vae_config=_pan2_vae_config(), vae_precision="fp32"),
vae_cpu_offload=False,
)
vae = VAELoader().load(str(vae_dir), fastvideo_args)
assert isinstance(vae, FastVideoAutoencoderKLWan)
assert fastvideo_args.model_paths["vae"] == str(vae_dir)
assert all(param.dtype == torch.float32 and param.device.type == "cpu" for param in vae.parameters())
return vae
def _load_diffusers(vae_dir: Path) -> DiffusersAutoencoderKLWan:
model, info = DiffusersAutoencoderKLWan.from_pretrained(
str(vae_dir),
torch_dtype=torch.float32,
local_files_only=True,
output_loading_info=True,
)
problems = {key: value for key, value in info.items() if value}
assert not problems, f"Diffusers reference did not load strictly: {problems}"
return model.eval()
def _pixels(num_frames: int, seed: int) -> torch.Tensor:
generator = torch.Generator().manual_seed(seed)
return torch.rand((1, 3, num_frames, HEIGHT, WIDTH), generator=generator) * 2.0 - 1.0
def _latents(seed: int) -> torch.Tensor:
generator = torch.Generator().manual_seed(seed)
return torch.randn((1, 48, LATENT_FRAMES, HEIGHT // 16, WIDTH // 16), generator=generator)
def _report(name: str, actual: torch.Tensor, expected: torch.Tensor, clamp_bound: float | None = None) -> None:
assert actual.shape == expected.shape, f"{name}: {tuple(actual.shape)} != {tuple(expected.shape)}"
diff = (actual.float() - expected.float()).abs()
message = (f"[pan2-vae] {name}: shape={tuple(actual.shape)} max_abs={diff.max().item():.3e} "
f"mean_abs={diff.mean().item():.3e} ref_absmax={expected.abs().max().item():.3e}")
if clamp_bound is not None:
# Clamped pixels compare trivially; report how many there are.
message += f" clamped={(expected.abs() >= clamp_bound).float().mean().item():.2%}"
print(message)
def _nontrivial_stats() -> tuple[list[float], list[float]]:
mean = [round(0.05 * (index - 24), 4) for index in range(48)]
std = [round(0.4 + 0.03 * index, 4) for index in range(48)]
return mean, std
@pytest.fixture
def stats_vae_dir(vae_dir: Path, tmp_path: Path) -> Path:
"""Checkpoint copy with non-trivial latent statistics (weights symlinked)."""
config = json.loads((vae_dir / "config.json").read_text())
config["latents_mean"], config["latents_std"] = _nontrivial_stats()
target = tmp_path / "vae"
target.mkdir()
(target / "config.json").write_text(json.dumps(config))
(target / "diffusion_pytorch_model.safetensors").symlink_to(
(vae_dir / "diffusion_pytorch_model.safetensors").resolve())
return target
def _diffusers_stats(model: DiffusersAutoencoderKLWan, like: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
mean = torch.tensor(model.config.latents_mean).view(1, -1, 1, 1, 1).to(like)
std = torch.tensor(model.config.latents_std).view(1, -1, 1, 1, 1).to(like)
return mean, std
def test_pan2_vae_production_loader_matches_checkpoint(vae_dir: Path, cpu_runtime: None) -> None:
"""The loader fills the tiny Wan2.2 arch from config.json and covers every weight.
``VAELoader`` loads ``AutoencoderKLWan`` with ``strict=False``, so key
coverage is checked here instead of trusting the loader.
"""
raw = json.loads((vae_dir / "config.json").read_text())
vae = _load_fastvideo(vae_dir)
arch = vae.config.arch_config
for key in ("base_dim", "decoder_base_dim", "z_dim", "num_res_blocks", "in_channels", "out_channels",
"is_residual", "patch_size", "scale_factor_spatial", "scale_factor_temporal", "dropout"):
assert getattr(arch, key) == raw[key], key
for key in ("dim_mult", "attn_scales", "temperal_downsample", "latents_mean", "latents_std"):
assert list(getattr(arch, key)) == list(raw[key]), key
assert vae.config.use_feature_cache and not vae.config.use_tiling
assert arch.spatial_compression_ratio == 16 and arch.temporal_compression_ratio == 4
assert tuple(vae.scaling_factor.shape) == tuple(vae.shift_factor.shape) == (1, 48, 1, 1, 1)
assert_close(vae.scaling_factor.flatten(), 1.0 / torch.tensor(raw["latents_std"]))
assert_close(vae.shift_factor.flatten(), torch.tensor(raw["latents_mean"]))
checkpoint = load_file(str(vae_dir / "diffusion_pytorch_model.safetensors"))
state = vae.state_dict()
assert set(state) == set(checkpoint), (sorted(set(checkpoint) - set(state)), sorted(set(state) - set(checkpoint)))
for key, tensor in checkpoint.items():
assert torch.equal(state[key], tensor.float()), key
@pytest.mark.parametrize("variant", ["wan22_ti2v_5b", "tiny_pan2"])
def test_pan2_vae_config_shapes_match_diffusers(variant: str, request: pytest.FixtureRequest) -> None:
"""``WanVAEConfig`` expresses both the real Wan2.2 and the tiny PAN2 VAE configs.
Both models are built on the meta device, so the full-size config costs no
memory; parameter names and shapes must match one-to-one.
"""
if variant == "tiny_pan2":
raw = json.loads((request.getfixturevalue("vae_dir") / "config.json").read_text())
config = {key: value for key, value in raw.items() if not key.startswith("_")}
else:
config = WAN22_TI2V_VAE_CONFIG
fastvideo_config = _pan2_vae_config()
fastvideo_config.update_model_arch(config)
with torch.device("meta"):
fastvideo = FastVideoAutoencoderKLWan(fastvideo_config)
reference = DiffusersAutoencoderKLWan.from_config(config)
fastvideo_shapes = {key: tuple(value.shape) for key, value in fastvideo.state_dict().items()}
reference_shapes = {key: tuple(value.shape) for key, value in reference.state_dict().items()}
assert fastvideo_shapes == reference_shapes
def test_pan2_vae_wan22_topology_random_weight_parity() -> None:
"""Numerically cover the Wan2.2 paths the tiny config skips.
The tiny checkpoint uses ``dim_mult`` [1, 1, 1, 1] and no separate decoder
width, so channel-changing shortcuts and resamplers never run. This narrow
random-weight model keeps the real topology (``dim_mult`` [1, 2, 4, 4], two
res blocks, ``decoder_base_dim`` > ``base_dim``).
"""
config = dict(WAN22_TI2V_VAE_CONFIG, base_dim=8, decoder_base_dim=16)
torch.manual_seed(0)
reference = DiffusersAutoencoderKLWan.from_config(config).eval()
fastvideo_config = _pan2_vae_config()
fastvideo_config.update_model_arch(config)
fastvideo = FastVideoAutoencoderKLWan(fastvideo_config).eval()
fastvideo.load_state_dict(reference.state_dict(), strict=True)
video = _pixels(VIDEO_FRAMES, seed=11)
latents = _latents(seed=12)
with torch.inference_mode():
expected_mode = reference.encode(video).latent_dist.mode()
actual_mode = fastvideo.encode(video).mode()
expected_video = reference.decode(latents).sample
actual_video = fastvideo.decode(latents)
_report("wan22-topology encode mode", actual_mode, expected_mode)
_report("wan22-topology decode", actual_video, expected_video, clamp_bound=1.0)
assert_close(actual_mode, expected_mode, atol=ATOL, rtol=RTOL)
assert_close(actual_video, expected_video, atol=ATOL, rtol=RTOL)
@pytest.mark.parametrize("num_frames", [1, VIDEO_FRAMES], ids=["first_frame", "video_9f"])
def test_pan2_vae_encode_matches_diffusers(num_frames: int, vae_dir: Path, cpu_runtime: None) -> None:
"""Posterior mode (PAN2 uses ``latent_dist.mode()``) and logvar match Diffusers.
FastVideo's ``encode`` returns the ``DiagonalGaussianDistribution`` itself,
so the port calls ``vae.encode(x).mode()`` instead of ``.latent_dist.mode()``.
"""
reference = _load_diffusers(vae_dir)
fastvideo = _load_fastvideo(vae_dir)
pixels = _pixels(num_frames, seed=100 + num_frames)
with torch.inference_mode():
expected = reference.encode(pixels).latent_dist
actual = fastvideo.encode(pixels)
assert tuple(actual.mode().shape) == (1, 48, (num_frames - 1) // 4 + 1, HEIGHT // 16, WIDTH // 16)
_report(f"encode mode ({num_frames}f)", actual.mode(), expected.mode())
_report(f"encode logvar ({num_frames}f)", actual.logvar, expected.logvar)
assert_close(actual.mode(), expected.mode(), atol=ATOL, rtol=RTOL)
assert_close(actual.logvar, expected.logvar, atol=ATOL, rtol=RTOL)
def test_pan2_vae_decode_matches_diffusers(vae_dir: Path, cpu_runtime: None) -> None:
"""Decode three latent frames so first-chunk and temporal cache paths run."""
reference = _load_diffusers(vae_dir)
fastvideo = _load_fastvideo(vae_dir)
latents = _latents(seed=7)
with torch.inference_mode():
expected = reference.decode(latents).sample
actual = fastvideo.decode(latents)
assert tuple(actual.shape) == (1, 3, VIDEO_FRAMES, HEIGHT, WIDTH)
_report("decode", actual, expected, clamp_bound=1.0)
assert_close(actual, expected, atol=ATOL, rtol=RTOL)
def test_pan2_conditioning_stage_matches_reference(stats_vae_dir: Path, cpu_runtime: None) -> None:
"""``PAN2ConditioningStage`` builds the reference I2V condition: cover-resize and center crop the first frame,
encode it, apply ``(z - mean) / std`` once, and set the mask channel on the first latent frame only.
The raw ``encode`` is unnormalized, so the stage is the only place the statistics apply.
"""
import PIL.Image
from fastvideo.pipelines.basic.pan2.stages import PAN2ConditioningStage
from tests.local_tests.pan2.reference import reference_condition_latents
reference = _load_diffusers(stats_vae_dir)
fastvideo = _load_fastvideo(stats_vae_dir)
# Not the output aspect ratio, so the resize and crop are exercised.
image = PIL.Image.radial_gradient("L").resize((120, 64)).convert("RGB")
latents = torch.zeros(1, 48, LATENT_FRAMES, HEIGHT // 16, WIDTH // 16)
with torch.inference_mode():
pixels = PAN2ConditioningStage.preprocess_image(image, HEIGHT, WIDTH)
raw = reference.encode(pixels).latent_dist.mode()
assert_close(fastvideo.encode(pixels).mode(), raw, atol=ATOL, rtol=RTOL)
expected = reference_condition_latents(reference, latents, image, HEIGHT, WIDTH)
batch = SimpleNamespace(latents=latents, pil_image=image, height=HEIGHT, width=WIDTH, image_latent=None)
stage_args = SimpleNamespace(pipeline_config=SimpleNamespace(vae_precision="fp32"), vae_cpu_offload=False)
actual = PAN2ConditioningStage(fastvideo).forward(batch, stage_args).image_latent
_report("PAN2 I2V condition", actual, expected)
assert actual.shape == (1, 49, LATENT_FRAMES, HEIGHT // 16, WIDTH // 16)
assert_close(actual, expected, atol=ATOL, rtol=RTOL)
assert torch.equal(actual[:, 48, 0], torch.ones_like(actual[:, 48, 0]))
assert not actual[:, :, 1:].any()
assert not torch.allclose(raw, expected[:, :48, :1], atol=1e-2), "statistics too close to identity"
def test_pan2_decoding_stage_denormalizes_once(stats_vae_dir: Path, cpu_runtime: None) -> None:
"""``DecodingStage`` performs PAN2's ``z * std + mean`` itself.
The PAN2 denoising loop must hand normalized latents to ``DecodingStage``.
Denormalizing beforehand (as the upstream pipeline does inline) would apply
the statistics twice, which the negative check below detects.
"""
from fastvideo.pipelines.stages.decoding import DecodingStage
reference = _load_diffusers(stats_vae_dir)
fastvideo = _load_fastvideo(stats_vae_dir)
latents = _latents(seed=31)
# vae_tiling=True is the PipelineConfig default; the feature-cache decode ignores tiling, so it must not matter.
decode_args = SimpleNamespace(
pipeline_config=SimpleNamespace(vae_precision="fp32", vae_decode_precision="fp32", vae_tiling=True),
disable_autocast=False,
)
stage = DecodingStage(fastvideo)
with torch.inference_mode():
mean, std = _diffusers_stats(reference, latents)
expected = reference.decode(latents * std + mean).sample
expected = (expected * 0.5 + 0.5).clamp(0, 1)
actual = stage.decode(latents, decode_args)
doubled = stage.decode(latents * std + mean, decode_args)
_report("DecodingStage output", actual, expected)
assert_close(actual, expected, atol=ATOL, rtol=RTOL)
assert (doubled - expected).abs().max().item() > 1e-3, "double denormalization went undetected"