Compare commits

...
Author SHA1 Message Date
Davids048 f031fc576c update parity test. 2026-07-22 01:41:08 +00:00
Davids048 451f521679 add lingbot ti2v support 2026-07-21 05:33:52 +00:00
28 changed files with 2370 additions and 155 deletions
+4 -1
View File
@@ -353,11 +353,14 @@ def request_to_sampling_param(
request: GenerationRequest,
*,
model_path: str,
workload_type: Any | None = None,
) -> SamplingParam:
"""Translate one request using the preset for its model and workload."""
if request.plan is not None:
raise NotImplementedError("GenerationRequest.plan is not wired into VideoGenerator yet")
sampling_param = SamplingParam.from_pretrained(model_path)
sampling_param = (SamplingParam.from_pretrained(model_path)
if workload_type is None else SamplingParam.from_pretrained(model_path, workload_type))
if request.state is not None:
_validate_continuation_state(request.state)
sampling_param.continuation_state = request.state
+9 -4
View File
@@ -187,7 +187,7 @@ class SamplingParam:
def __post_init__(self) -> None:
self.data_type = "video" if self.num_frames > 1 else "image"
def check_sampling_param(self):
def check_sampling_param(self) -> None:
if self.prompt_path and not self.prompt_path.endswith(".txt"):
raise ValueError("prompt_path must be a txt file")
@@ -206,8 +206,9 @@ class SamplingParam:
self.__post_init__()
@classmethod
def from_pretrained(cls, model_path: str) -> SamplingParam:
sampling_param = cls._from_preset(model_path)
def from_pretrained(cls, model_path: str, workload_type: Any | None = None) -> SamplingParam:
"""Build model/workload preset defaults, or fall back to generic defaults."""
sampling_param = cls._from_preset(model_path, workload_type)
if sampling_param is not None:
return sampling_param
@@ -222,6 +223,7 @@ class SamplingParam:
def _from_preset(
cls,
model_path: str,
workload_type: Any | None = None,
) -> SamplingParam | None:
"""Build a SamplingParam from preset defaults.
@@ -232,7 +234,10 @@ class SamplingParam:
from fastvideo.registry import get_preset_selection
try:
preset_name, model_family = get_preset_selection(model_path)
if workload_type is None:
preset_name, model_family = get_preset_selection(model_path)
else:
preset_name, model_family = get_preset_selection(model_path, workload_type)
except (ValueError, RuntimeError):
return None
if preset_name is None or model_family is None:
@@ -10,7 +10,7 @@ from fastvideo.configs.models.encoders.reason1 import Reason1ArchConfig, Reason1
from fastvideo.configs.models.encoders.gemma import LTX2GemmaConfig
from fastvideo.configs.models.encoders.mistral3 import Mistral3TextConfig
from fastvideo.configs.models.encoders.qwen3 import Qwen3TextConfig
from fastvideo.configs.models.encoders.lingbot_video import LingBotVideoQwen3VLTextConfig
from fastvideo.configs.models.encoders.lingbot_video import LingBotVideoQwen3VLConfig, LingBotVideoQwen3VLTextConfig
from fastvideo.configs.models.encoders.stable_audio_conditioner import (StableAudioConditionerArchConfig,
StableAudioConditionerConfig)
from fastvideo.configs.models.encoders.t5gemma import T5GemmaEncoderConfig
@@ -20,5 +20,6 @@ __all__ = [
"CLIPVisionConfig", "WAN2_1ControlCLIPVisionConfig", "LlamaConfig", "T5Config", "T5LargeConfig", "Qwen2_5_VLConfig",
"Reason1ArchConfig", "Reason1Config", "LTX2GemmaConfig", "SiglipVisionConfig", "StableAudioConditionerArchConfig",
"StableAudioConditionerConfig", "T5GemmaEncoderConfig", "Qwen3TextConfig", "Mistral3TextConfig",
"LingBotWorld2UMT5ArchConfig", "LingBotWorld2UMT5Config", "LingBotVideoQwen3VLTextConfig"
"LingBotWorld2UMT5ArchConfig", "LingBotWorld2UMT5Config", "LingBotVideoQwen3VLConfig",
"LingBotVideoQwen3VLTextConfig"
]
+3 -4
View File
@@ -36,10 +36,9 @@ class TextEncoderArchConfig(EncoderArchConfig):
default_factory=list) # mapping from huggingface weight names to custom names
tokenizer_kwargs: dict[str, Any] = field(default_factory=dict)
_fsdp_shard_conditions: list = field(default_factory=lambda: [])
# When True, the tokenizer loader prefers AutoProcessor over AutoTokenizer
# for encoders whose tokenizer dir ships a processor_config.json (e.g. Flux2
# full's Mistral3 multimodal processor). Default False keeps every existing
# encoder on the historical AutoTokenizer path.
# When True, the tokenizer loader uses AutoProcessor for encoders that need
# multimodal preprocessing. Default False keeps text-only encoders on the
# historical AutoTokenizer path.
require_processor: bool = False
def __post_init__(self) -> None:
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
"""Qwen3-VL text-only encoder configuration used by LingBot-Video."""
"""Qwen3-VL encoder configurations used by LingBot-Video."""
from dataclasses import dataclass, field
@@ -41,6 +41,36 @@ class LingBotVideoQwen3VLTextArchConfig(Qwen3TextArchConfig):
}
@dataclass
class LingBotVideoQwen3VLVisionArchConfig:
"""Vision-tower architecture released with LingBot-Video's Qwen3-VL."""
depth: int = 24
hidden_act: str = "gelu_pytorch_tanh"
hidden_size: int = 1024
in_channels: int = 3
intermediate_size: int = 4096
num_heads: int = 16
num_position_embeddings: int = 2304
out_hidden_size: int = 2560
patch_size: int = 16
spatial_merge_size: int = 2
temporal_patch_size: int = 2
deepstack_visual_indexes: tuple[int, ...] = (5, 11, 17)
@dataclass
class LingBotVideoQwen3VLArchConfig(LingBotVideoQwen3VLTextArchConfig):
"""Compound language-and-vision architecture needed by TI2V conditioning."""
architectures: list[str] = field(default_factory=lambda: ["LingBotVideoQwen3VLModel"])
vision_config: dict = field(default_factory=lambda: LingBotVideoQwen3VLVisionArchConfig().__dict__.copy())
image_token_id: int = 151655
video_token_id: int = 151656
vision_start_token_id: int = 151652
vision_end_token_id: int = 151653
@dataclass
class LingBotVideoQwen3VLTextConfig(Qwen3TextConfig):
"""FastVideo loader config for the LingBot-Video text-only Qwen3-VL path."""
@@ -48,3 +78,10 @@ class LingBotVideoQwen3VLTextConfig(Qwen3TextConfig):
arch_config: TextEncoderArchConfig = field(default_factory=LingBotVideoQwen3VLTextArchConfig)
prefix: str = "language_model"
is_chat_model: bool = False
@dataclass
class LingBotVideoQwen3VLConfig(LingBotVideoQwen3VLTextConfig):
"""FastVideo loader config for LingBot-Video's compound Qwen3-VL model."""
arch_config: TextEncoderArchConfig = field(default_factory=LingBotVideoQwen3VLArchConfig)
+2 -2
View File
@@ -31,7 +31,7 @@ class PipelineConfig:
pipeline_config_path: str | None = None
# Video generation parameters
embedded_cfg_scale: float = 6.0
embedded_cfg_scale: float | None = 6.0
flow_shift: float | None = None
flow_shift_sr: float | None = None
disable_autocast: bool = False
@@ -233,7 +233,7 @@ class PipelineConfig:
raise ValueError("model_path is required in kwargs")
# 1. Get the pipeline config class from the registry
pipeline_config_cls = get_pipeline_config_cls_from_name(model_path)
pipeline_config_cls = get_pipeline_config_cls_from_name(model_path, kwargs.get("workload_type"))
# 2. Instantiate PipelineConfig
if pipeline_config_cls is None:
+13 -3
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
"""Dense LingBot-Video T2V pipeline configuration."""
"""LingBot-Video T2V and TI2V pipeline configurations."""
from collections.abc import Callable
from dataclasses import dataclass, field
@@ -9,7 +9,7 @@ import torch
from fastvideo.configs.models import DiTConfig, EncoderConfig, VAEConfig
from fastvideo.configs.models.dits.lingbot_video import LingBotVideoConfig
from fastvideo.configs.models.encoders import BaseEncoderOutput
from fastvideo.configs.models.encoders.lingbot_video import LingBotVideoQwen3VLTextConfig
from fastvideo.configs.models.encoders.lingbot_video import LingBotVideoQwen3VLConfig
from fastvideo.configs.models.vaes import WanVAEConfig
from fastvideo.configs.pipelines.base import PipelineConfig
@@ -54,7 +54,7 @@ class LingBotVideoT2VConfig(PipelineConfig):
dit_config: DiTConfig = field(default_factory=LingBotVideoConfig)
vae_config: VAEConfig = field(default_factory=WanVAEConfig)
text_encoder_configs: tuple[EncoderConfig, ...] = field(default_factory=lambda: (LingBotVideoQwen3VLTextConfig(), ))
text_encoder_configs: tuple[EncoderConfig, ...] = field(default_factory=lambda: (LingBotVideoQwen3VLConfig(), ))
preprocess_text_funcs: tuple[Callable, ...] = field(default_factory=lambda: (preprocess_lingbot_video_prompt, ))
postprocess_text_funcs: tuple[Callable, ...] = field(default_factory=lambda: (postprocess_lingbot_video_text, ))
text_encoder_precisions: tuple[str, ...] = field(default_factory=lambda: ("bf16", ))
@@ -71,3 +71,13 @@ class LingBotVideoT2VConfig(PipelineConfig):
"""Load only the VAE decoder for the T2V workload."""
self.vae_config.load_encoder = False
self.vae_config.load_decoder = True
@dataclass
class LingBotVideoTI2VConfig(LingBotVideoT2VConfig):
"""TI2V wiring with the compound Qwen3-VL model and VAE encoder."""
def __post_init__(self) -> None:
"""Load both VAE directions while custom stages preserve LingBot geometry."""
self.vae_config.load_encoder = True
self.vae_config.load_decoder = True
+17 -4
View File
@@ -130,13 +130,17 @@ def _resolve_output_size(
return fallback
def _validate_request_stage_overrides(model_path: str, request: GenerationRequest) -> None:
def _validate_request_stage_overrides(
model_path: str,
request: GenerationRequest,
workload_type: Any | None = None,
) -> None:
"""Validate typed stage overrides against the model's registered preset."""
if not request.stage_overrides:
return
from fastvideo.api.presets import validate_preset_selection
from fastvideo.registry import get_preset_selection
preset_name, model_family = get_preset_selection(model_path)
preset_name, model_family = get_preset_selection(model_path, workload_type)
if preset_name is None or model_family is None:
raise ValueError(f"Model {model_path!r} has no preset for stage override validation")
validate_preset_selection(
@@ -462,6 +466,7 @@ class VideoGenerator:
resolved_sampling_param = request_to_sampling_param(
request,
model_path=self.fastvideo_args.model_path,
workload_type=self.fastvideo_args.workload_type,
)
return self._generate_video_impl(
prompt=request.prompt,
@@ -477,7 +482,11 @@ class VideoGenerator:
self,
request: GenerationRequest,
) -> GenerationResult | list[GenerationResult]:
_validate_request_stage_overrides(self.fastvideo_args.model_path, request)
_validate_request_stage_overrides(
self.fastvideo_args.model_path,
request,
self.fastvideo_args.workload_type,
)
if isinstance(request.prompt, list):
if request.inputs.prompt_path is not None:
raise ValueError("request.prompt list cannot be combined with request.inputs.prompt_path")
@@ -512,6 +521,7 @@ class VideoGenerator:
sampling_param = request_to_sampling_param(
request,
model_path=self.fastvideo_args.model_path,
workload_type=self.fastvideo_args.workload_type,
)
result = self._generate_video_impl(
prompt=request.prompt,
@@ -537,7 +547,10 @@ class VideoGenerator:
# Handle batch processing from text file
if sampling_param is None:
sampling_param = SamplingParam.from_pretrained(fastvideo_args.model_path)
sampling_param = SamplingParam.from_pretrained(
fastvideo_args.model_path,
fastvideo_args.workload_type,
)
# Add action control inputs to kwargs if provided
if mouse_cond is not None:
+459 -8
View File
@@ -1,11 +1,14 @@
# SPDX-License-Identifier: Apache-2.0
"""Native LingBot-Video Qwen3-VL language model for text-only conditioning."""
"""Native LingBot-Video Qwen3-VL language and vision conditioning models."""
from collections.abc import Iterable
import itertools
from types import SimpleNamespace
from typing import Any
import torch
from torch import nn
import torch.nn.functional as F
from fastvideo.configs.models.encoders import BaseEncoderOutput
from fastvideo.layers.layernorm import RMSNorm
@@ -17,11 +20,275 @@ from fastvideo.models.encoders.qwen3 import (
Qwen3ForCausalLM,
Qwen3MLP,
)
from fastvideo.models.loader.weight_utils import default_weight_loader
def _vision_position_ids(grid_thw: torch.Tensor, merge_size: int) -> torch.Tensor:
"""Lay out vision rotary positions in Qwen3-VL's spatial merge order."""
position_ids = []
for temporal, height, width in grid_thw.tolist():
height_ids, width_ids = torch.meshgrid(
torch.arange(height, device=grid_thw.device),
torch.arange(width, device=grid_thw.device),
indexing="ij",
)
block_shape = (height // merge_size, merge_size, width // merge_size, merge_size)
height_ids = height_ids.reshape(block_shape).transpose(1, 2).flatten()
width_ids = width_ids.reshape(block_shape).transpose(1, 2).flatten()
position_ids.append(torch.stack((height_ids, width_ids), dim=-1).repeat(temporal, 1))
return torch.cat(position_ids, dim=0)
def _vision_bilinear_position_data(
grid_thw: torch.Tensor,
side: int,
merge_size: int,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Compute Qwen3-VL's interpolated absolute-position lookup data."""
index_parts: list[list[torch.Tensor]] = [[] for _ in range(4)]
weight_parts: list[list[torch.Tensor]] = [[] for _ in range(4)]
for temporal, height, width in grid_thw.tolist():
height_grid = torch.linspace(0, side - 1, height, device=grid_thw.device)
width_grid = torch.linspace(0, side - 1, width, device=grid_thw.device)
height_floor, width_floor = height_grid.int(), width_grid.int()
height_ceil = (height_floor + 1).clamp(max=side - 1)
width_ceil = (width_floor + 1).clamp(max=side - 1)
height_frac, width_frac = height_grid - height_floor, width_grid - width_floor
floor_offset, ceil_offset = height_floor * side, height_ceil * side
corner_indices = (
(floor_offset[:, None] + width_floor[None, :]).flatten(),
(floor_offset[:, None] + width_ceil[None, :]).flatten(),
(ceil_offset[:, None] + width_floor[None, :]).flatten(),
(ceil_offset[:, None] + width_ceil[None, :]).flatten(),
)
corner_weights = (
((1 - height_frac)[:, None] * (1 - width_frac)[None, :]).flatten(),
((1 - height_frac)[:, None] * width_frac[None, :]).flatten(),
(height_frac[:, None] * (1 - width_frac)[None, :]).flatten(),
(height_frac[:, None] * width_frac[None, :]).flatten(),
)
height_order = torch.arange(height, device=grid_thw.device).view(height // merge_size, merge_size)
width_order = torch.arange(width, device=grid_thw.device).view(width // merge_size, merge_size)
reorder = (
(height_order[:, :, None, None] * width + width_order[None, None, :, :])
.transpose(1, 2)
.flatten()
.repeat(temporal)
)
for index in range(4):
index_parts[index].append(corner_indices[index][reorder])
weight_parts[index].append(corner_weights[index][reorder])
indices = torch.stack([torch.cat(part) for part in index_parts])
weights = torch.stack([torch.cat(part) for part in weight_parts])
return indices, weights
class LingBotVideoQwen3VLVisionPatchEmbed(nn.Module):
"""Convert processor patch rows into Qwen3-VL vision tokens."""
def __init__(self, config: SimpleNamespace) -> None:
super().__init__()
self.in_channels = config.in_channels
self.temporal_patch_size = config.temporal_patch_size
self.patch_size = config.patch_size
self.hidden_size = config.hidden_size
kernel = (self.temporal_patch_size, self.patch_size, self.patch_size)
self.proj = nn.Conv3d(self.in_channels, self.hidden_size, kernel_size=kernel, stride=kernel, bias=True)
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
"""Apply the released Conv3d patch projection in its parameter dtype."""
hidden_states = hidden_states.view(
-1,
self.in_channels,
self.temporal_patch_size,
self.patch_size,
self.patch_size,
)
return self.proj(hidden_states.to(self.proj.weight.dtype)).view(-1, self.hidden_size)
class LingBotVideoQwen3VLVisionMLP(nn.Module):
"""Qwen3-VL vision feed-forward block with tanh-approximate GELU."""
def __init__(self, config: SimpleNamespace) -> None:
super().__init__()
self.linear_fc1 = nn.Linear(config.hidden_size, config.intermediate_size, bias=True)
self.linear_fc2 = nn.Linear(config.intermediate_size, config.hidden_size, bias=True)
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
"""Apply the two released vision MLP projections."""
return self.linear_fc2(F.gelu(self.linear_fc1(hidden_states), approximate="tanh"))
class LingBotVideoQwen3VLVisionAttention(nn.Module):
"""Packed non-causal Qwen3-VL vision attention using native SDPA."""
def __init__(self, config: SimpleNamespace) -> None:
super().__init__()
self.hidden_size = config.hidden_size
self.num_heads = config.num_heads
self.head_dim = self.hidden_size // self.num_heads
self.scaling = self.head_dim**-0.5
self.qkv = nn.Linear(self.hidden_size, self.hidden_size * 3, bias=True)
self.proj = nn.Linear(self.hidden_size, self.hidden_size, bias=True)
def forward(
self,
hidden_states: torch.Tensor,
cu_seqlens: torch.Tensor,
cos: torch.Tensor,
sin: torch.Tensor,
) -> torch.Tensor:
"""Apply rotary embeddings and attend independently within each packed image."""
sequence_length = hidden_states.shape[0]
query, key, value = (
self.qkv(hidden_states)
.reshape(sequence_length, 3, self.num_heads, self.head_dim)
.permute(1, 0, 2, 3)
.unbind(0)
)
query_dtype, key_dtype = query.dtype, key.dtype
cos, sin = cos.unsqueeze(-2).float(), sin.unsqueeze(-2).float()
query_float, key_float = query.float(), key.float()
query = (query_float * cos + self._rotate_half(query_float) * sin).to(query_dtype)
key = (key_float * cos + self._rotate_half(key_float) * sin).to(key_dtype)
query, key, value = [tensor.transpose(0, 1).unsqueeze(0) for tensor in (query, key, value)]
lengths = (cu_seqlens[1:] - cu_seqlens[:-1]).tolist()
query_chunks, key_chunks, value_chunks = [torch.split(tensor, lengths, dim=2) for tensor in (query, key, value)]
outputs = [
F.scaled_dot_product_attention(
q,
k,
v,
dropout_p=0.0,
is_causal=False,
scale=self.scaling,
).transpose(1, 2)
for q, k, v in zip(query_chunks, key_chunks, value_chunks, strict=True)
]
return self.proj(torch.cat(outputs, dim=1).reshape(sequence_length, -1).contiguous())
@staticmethod
def _rotate_half(hidden_states: torch.Tensor) -> torch.Tensor:
"""Rotate the two halves used by Qwen3-VL's NeoX rotary layout."""
first, second = hidden_states.chunk(2, dim=-1)
return torch.cat((-second, first), dim=-1)
class LingBotVideoQwen3VLVisionBlock(nn.Module):
"""One released Qwen3-VL vision transformer block."""
def __init__(self, config: SimpleNamespace) -> None:
super().__init__()
self.norm1 = nn.LayerNorm(config.hidden_size, eps=1e-6)
self.norm2 = nn.LayerNorm(config.hidden_size, eps=1e-6)
self.attn = LingBotVideoQwen3VLVisionAttention(config)
self.mlp = LingBotVideoQwen3VLVisionMLP(config)
def forward(
self,
hidden_states: torch.Tensor,
cu_seqlens: torch.Tensor,
cos: torch.Tensor,
sin: torch.Tensor,
) -> torch.Tensor:
"""Apply attention and MLP residuals in the official order."""
hidden_states = hidden_states + self.attn(self.norm1(hidden_states), cu_seqlens, cos, sin)
return hidden_states + self.mlp(self.norm2(hidden_states))
class LingBotVideoQwen3VLVisionPatchMerger(nn.Module):
"""Merge each 2x2 vision-token block into language hidden width."""
def __init__(self, config: SimpleNamespace, *, postshuffle_norm: bool) -> None:
super().__init__()
self.hidden_size = config.hidden_size * config.spatial_merge_size**2
self.postshuffle_norm = postshuffle_norm
norm_size = self.hidden_size if postshuffle_norm else config.hidden_size
self.norm = nn.LayerNorm(norm_size, eps=1e-6)
self.linear_fc1 = nn.Linear(self.hidden_size, self.hidden_size)
self.linear_fc2 = nn.Linear(self.hidden_size, config.out_hidden_size)
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
"""Normalize at the released boundary, shuffle, project, and merge."""
if self.postshuffle_norm:
hidden_states = self.norm(hidden_states.view(-1, self.hidden_size))
else:
hidden_states = self.norm(hidden_states).view(-1, self.hidden_size)
hidden_states = self.linear_fc1(hidden_states)
return self.linear_fc2(F.gelu(hidden_states))
class LingBotVideoQwen3VLVisionModel(nn.Module):
"""Native Qwen3-VL vision tower, including the three DeepStack outputs."""
def __init__(self, config: SimpleNamespace) -> None:
"""Build the released patch, position, block, and merge structure."""
super().__init__()
self.config = config
self.spatial_merge_size = config.spatial_merge_size
self.patch_embed = LingBotVideoQwen3VLVisionPatchEmbed(config)
self.pos_embed = nn.Embedding(config.num_position_embeddings, config.hidden_size)
self.num_grid_per_side = int(config.num_position_embeddings**0.5)
self.blocks = nn.ModuleList(LingBotVideoQwen3VLVisionBlock(config) for _ in range(config.depth))
self.merger = LingBotVideoQwen3VLVisionPatchMerger(config, postshuffle_norm=False)
self.deepstack_visual_indexes = tuple(config.deepstack_visual_indexes)
self.deepstack_merger_list = nn.ModuleList(
LingBotVideoQwen3VLVisionPatchMerger(config, postshuffle_norm=True)
for _ in self.deepstack_visual_indexes
)
head_dim = config.hidden_size // config.num_heads
inv_freq = 1.0 / (10000.0 ** (torch.arange(0, head_dim // 2, 2).float() / (head_dim // 2)))
self.register_buffer("rotary_inv_freq", inv_freq, persistent=False)
def forward(
self,
pixel_values: torch.Tensor,
grid_thw: torch.Tensor,
) -> tuple[torch.Tensor, list[torch.Tensor]]:
"""Encode processor patches into merged and DeepStack visual features."""
indices, weights = _vision_bilinear_position_data(
grid_thw,
self.num_grid_per_side,
self.spatial_merge_size,
)
position_ids = _vision_position_ids(grid_thw, self.spatial_merge_size)
cu_seqlens = torch.repeat_interleave(grid_thw[:, 1] * grid_thw[:, 2], grid_thw[:, 0]).cumsum(
dim=0,
dtype=torch.int32,
)
cu_seqlens = F.pad(cu_seqlens, (1, 0), value=0)
hidden_states = self.patch_embed(pixel_values)
position_embeds = (self.pos_embed(indices) * weights[:, :, None]).sum(0)
hidden_states = hidden_states + position_embeds.to(hidden_states.dtype)
rotary = (position_ids.unsqueeze(-1) * self.rotary_inv_freq).flatten(1)
rotary = torch.cat((rotary, rotary), dim=-1)
cos, sin = rotary.cos(), rotary.sin()
deepstack_features = []
for layer_index, block in enumerate(self.blocks):
hidden_states = block(hidden_states, cu_seqlens, cos, sin)
if layer_index in self.deepstack_visual_indexes:
merger_index = self.deepstack_visual_indexes.index(layer_index)
deepstack_features.append(self.deepstack_merger_list[merger_index](hidden_states))
return self.merger(hidden_states), deepstack_features
class LingBotVideoQwen3VLAttention(Qwen3Attention):
"""Qwen3-VL attention with the official masked repeat-K/V SDPA path."""
def __init__(self, config: Any, **kwargs: Any) -> None:
"""Keep the interleaved multimodal RoPE section sizes with attention."""
super().__init__(config=config, **kwargs)
self.mrope_section = config.mrope_section
inverse_frequency = 1.0 / (
self.rope_theta
** (
torch.arange(0, self.head_dim, 2, dtype=torch.int64, device="cpu").float()
/ self.head_dim
)
)
self.register_buffer("mrope_inv_freq", inverse_frequency, persistent=False)
def _apply_qwen3_vl_rope(
self,
positions: torch.Tensor,
@@ -29,6 +296,25 @@ class LingBotVideoQwen3VLAttention(Qwen3Attention):
key: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Apply NeoX RoPE with Qwen3-VL's input-dtype multiply-add ordering."""
if positions.ndim == 3:
inverse_frequency = self.mrope_inv_freq.to(query.device)
expanded_frequency = inverse_frequency[None, None, :, None].expand(
3,
positions.shape[1],
-1,
1,
)
expanded_positions = positions[:, :, None, :].float()
frequencies = (expanded_frequency.float() @ expanded_positions.float()).transpose(2, 3)
frequencies_t = frequencies[0]
for dimension, offset in enumerate((1, 2), start=1):
length = self.mrope_section[dimension] * 3
index = slice(offset, length, 3)
frequencies_t[..., index] = frequencies[dimension, ..., index]
embeddings = torch.cat((frequencies_t, frequencies_t), dim=-1)
cos = embeddings.cos().to(query.dtype).unsqueeze(2)
sin = embeddings.sin().to(query.dtype).unsqueeze(2)
return self._rotate(query, cos, sin), self._rotate(key, cos, sin)
flat_positions = positions.flatten()
cos_sin = self.rotary_emb.cos_sin_cache.index_select(0, flat_positions)
cos_half, sin_half = cos_sin.chunk(2, dim=-1)
@@ -41,12 +327,14 @@ class LingBotVideoQwen3VLAttention(Qwen3Attention):
cos = cos.view(*query.shape[:2], 1, self.head_dim)
sin = sin.view(*query.shape[:2], 1, self.head_dim)
def rotate(tensor: torch.Tensor) -> torch.Tensor:
first, second = tensor.chunk(2, dim=-1)
rotated = torch.cat((-second, first), dim=-1)
return tensor * cos + rotated * sin
return self._rotate(query, cos, sin), self._rotate(key, cos, sin)
return rotate(query), rotate(key)
@staticmethod
def _rotate(tensor: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
"""Apply Qwen3-VL's half-rotation multiply-add ordering."""
first, second = tensor.chunk(2, dim=-1)
rotated = torch.cat((-second, first), dim=-1)
return tensor * cos + rotated * sin
def forward(
self,
@@ -180,6 +468,8 @@ class LingBotVideoQwen3VLTextModel(Qwen3ForCausalLM):
attention_mask: torch.Tensor | None = None,
inputs_embeds: torch.Tensor | None = None,
output_hidden_states: bool | None = None,
visual_pos_masks: torch.Tensor | None = None,
deepstack_visual_embeds: list[torch.Tensor] | None = None,
**kwargs: Any,
) -> BaseEncoderOutput:
"""Run explicit Qwen3-VL layers and return the requested hidden-state tuple."""
@@ -197,10 +487,16 @@ class LingBotVideoQwen3VLTextModel(Qwen3ForCausalLM):
if attention_mask is not None and bool(attention_mask.to(torch.bool).all()):
attention_mask = None
all_hidden_states: tuple[torch.Tensor, ...] | None = () if output_hidden_states else None
for layer in self.layers:
for layer_index, layer in enumerate(self.layers):
if all_hidden_states is not None:
all_hidden_states += (hidden_states, )
hidden_states = layer(position_ids, hidden_states, attention_mask)
if deepstack_visual_embeds is not None and layer_index < len(deepstack_visual_embeds):
if visual_pos_masks is None:
raise ValueError("visual_pos_masks is required with DeepStack features")
hidden_states = hidden_states.clone()
visual_states = deepstack_visual_embeds[layer_index].to(hidden_states.device, hidden_states.dtype)
hidden_states[visual_pos_masks] = hidden_states[visual_pos_masks] + visual_states
hidden_states = self.norm(hidden_states)
if all_hidden_states is not None:
all_hidden_states += (hidden_states, )
@@ -218,4 +514,159 @@ class LingBotVideoQwen3VLTextModel(Qwen3ForCausalLM):
return super().load_weights(language_weights)
EntryClass = LingBotVideoQwen3VLTextModel
class LingBotVideoQwen3VLModel(LingBotVideoQwen3VLTextModel):
"""Compound native Qwen3-VL encoder for LingBot-Video TI2V prompts."""
def __init__(self, config: Any) -> None:
"""Add the released visual tower without changing native language keys."""
super().__init__(config)
vision_config = config.vision_config
if isinstance(vision_config, dict):
vision_config = SimpleNamespace(**vision_config)
self.visual = LingBotVideoQwen3VLVisionModel(vision_config)
self.image_token_id = config.image_token_id
@staticmethod
def _multimodal_vision_positions(
start_position: int,
grid_thw: torch.Tensor,
merge_size: int,
device: torch.device,
) -> torch.Tensor:
"""Create temporal, height, and width positions for one image grid."""
temporal = grid_thw[0].item()
height = grid_thw[1].item() // merge_size
width = grid_thw[2].item() // merge_size
temporal_grid, height_grid, width_grid = torch.meshgrid(
torch.arange(temporal, device=device),
torch.arange(height, device=device) + start_position,
torch.arange(width, device=device) + start_position,
indexing="ij",
)
positions = torch.stack((temporal_grid, height_grid, width_grid), dim=0).reshape(3, -1)
positions[0] += start_position
return positions
def _get_rope_index(
self,
input_ids: torch.Tensor,
mm_token_type_ids: torch.Tensor,
image_grid_thw: torch.Tensor,
attention_mask: torch.Tensor | None,
) -> torch.Tensor:
"""Build Qwen3-VL interleaved text/image MRoPE positions for each prompt."""
merge_size = self.visual.spatial_merge_size
position_ids = torch.zeros(
3,
input_ids.shape[0],
input_ids.shape[1],
dtype=input_ids.dtype,
device=input_ids.device,
)
grid_iterator = iter(image_grid_thw)
for batch_index, token_types in enumerate(mm_token_type_ids):
active = attention_mask[batch_index].bool() if attention_mask is not None else None
current_types = token_types[active] if active is not None else token_types
groups = []
for modality, group in itertools.groupby(enumerate(current_types.tolist()), lambda pair: pair[1]):
entries = list(group)
groups.append((modality, entries[0][0], entries[-1][0] + 1))
current_position = 0
position_parts = []
for modality, start, end in groups:
if modality == 0:
length = end - start
text_positions = torch.arange(length, device=input_ids.device).view(1, -1).expand(3, -1)
position_parts.append(text_positions + current_position)
current_position += length
elif modality == 1:
grid = next(grid_iterator)
position_parts.append(
self._multimodal_vision_positions(
current_position,
grid,
merge_size,
input_ids.device,
)
)
current_position += max(grid[1], grid[2]).item() // merge_size
else:
raise ValueError("LingBot-Video TI2V accepts image tokens but not video tokens")
positions = torch.cat(position_parts, dim=1)
if active is None:
position_ids[:, batch_index] = positions
else:
position_ids[:, batch_index, active] = positions
return position_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,
pixel_values: torch.Tensor | None = None,
image_grid_thw: torch.Tensor | None = None,
mm_token_type_ids: torch.Tensor | None = None,
**kwargs: Any,
) -> BaseEncoderOutput:
"""Replace image placeholders, add DeepStack features, and encode the prompt."""
if pixel_values is None:
return super().forward(
input_ids=input_ids,
position_ids=position_ids,
attention_mask=attention_mask,
inputs_embeds=inputs_embeds,
output_hidden_states=output_hidden_states,
**kwargs,
)
if input_ids is None or inputs_embeds is not None:
raise ValueError("multimodal conditioning requires input_ids and no inputs_embeds")
if image_grid_thw is None or mm_token_type_ids is None:
raise ValueError("image_grid_thw and mm_token_type_ids are required with pixel_values")
inputs_embeds = self.get_input_embeddings(input_ids)
visual_features, deepstack_features = self.visual(
pixel_values.to(self.visual.patch_embed.proj.weight.dtype),
image_grid_thw,
)
visual_features = visual_features.to(inputs_embeds.device, inputs_embeds.dtype)
image_mask = input_ids == self.image_token_id
expected = image_mask.sum().item()
if expected != visual_features.shape[0]:
raise ValueError(f"image tokens ({expected}) do not match visual features ({visual_features.shape[0]})")
inputs_embeds = inputs_embeds.masked_scatter(image_mask.unsqueeze(-1), visual_features)
if position_ids is None:
position_ids = self._get_rope_index(input_ids, mm_token_type_ids, image_grid_thw, attention_mask)
return super().forward(
input_ids=None,
position_ids=position_ids,
attention_mask=attention_mask,
inputs_embeds=inputs_embeds,
output_hidden_states=output_hidden_states,
visual_pos_masks=image_mask,
deepstack_visual_embeds=deepstack_features,
**kwargs,
)
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
"""Load visual tensors directly and delegate fused language tensors."""
visual_parameters = dict(self.visual.named_parameters())
language_weights = []
loaded = set()
for name, tensor in weights:
visual_name = None
if name.startswith("model.visual."):
visual_name = name[len("model.visual.") :]
elif name.startswith("visual."):
visual_name = name[len("visual.") :]
if visual_name is None:
language_weights.append((name, tensor))
elif "rotary_pos_emb.inv_freq" not in visual_name:
default_weight_loader(visual_parameters[visual_name], tensor)
loaded.add(f"visual.{visual_name}")
loaded.update(super().load_weights(language_weights))
return loaded
EntryClass = [LingBotVideoQwen3VLTextModel, LingBotVideoQwen3VLModel]
+4 -6
View File
@@ -627,11 +627,9 @@ class TokenizerLoader(ComponentLoader):
# If parsing fails, fall through to AutoTokenizer below.
pass
# Only Flux2 full's Mistral3 (require_processor=True) must load via
# AutoProcessor. Gate the processor_config.json shortcut on that flag so
# existing encoders (e.g. HunyuanVideo 1.5 / Qwen2.5-VL) stay on the
# historical AutoTokenizer path below even if their tokenizer dir happens
# to ship a processor_config.json.
# Multimodal encoders with require_processor=True must load the complete
# processor (text + other modal). LingBot Qwen3-VL declares it in preprocessor_config.json,
# while Mistral3 ship it via processor_config.json.
require_processor = False
if hasattr(fastvideo_args.pipeline_config, "text_encoder_configs"):
try:
@@ -641,7 +639,7 @@ class TokenizerLoader(ComponentLoader):
except Exception:
require_processor = False
if require_processor and os.path.exists(os.path.join(resolved_model_path, "processor_config.json")):
if require_processor:
processor = AutoProcessor.from_pretrained(
resolved_model_path,
local_files_only=os.path.isdir(resolved_model_path),
+10 -2
View File
@@ -232,8 +232,16 @@ class WanRMS_norm(nn.Module):
self.bias = nn.Parameter(torch.zeros(shape)) if bias else 0.0
def forward(self, x):
return F.normalize(x, dim=(1 if self.channel_first else
-1)) * self.scale * self.gamma + self.bias
# Match Diffusers v0.39.0 WanRMS_norm.forward for exact numerical alignment:
# https://github.com/huggingface/diffusers/blob/v0.39.0/src/diffusers/models/autoencoders/autoencoder_kl_wan.py#L199-L205
needs_fp32_normalize = x.dtype in (torch.float16, torch.bfloat16) or any(
value in str(x.dtype) for value in ("float4_", "float8_")
)
normalized = F.normalize(
x.float() if needs_fp32_normalize else x,
dim=(1 if self.channel_first else -1),
).to(x.dtype)
return normalized * self.scale * self.gamma + self.bias
class WanUpsample(nn.Upsample):
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
"""Stage-composed LingBot-Video Dense and MoE/refiner T2V pipeline."""
"""Stage-composed LingBot-Video T2V and TI2V pipelines."""
from typing import Any
@@ -7,9 +7,13 @@ from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.pipelines import ComposedPipelineBase, LoRAPipeline
from fastvideo.pipelines.basic.lingbot_video.stages import (
LingBotVideoDenoisingStage,
LingBotVideoImageInputValidationStage,
LingBotVideoImageLatentPreparationStage,
LingBotVideoImagePromptEncodingStage,
LingBotVideoInputValidationStage,
LingBotVideoLatentPreparationStage,
LingBotVideoRefinerPreparationStage,
LingBotVideoRefinerTextEncodingStage,
)
from fastvideo.pipelines.stages import (
ConditioningStage,
@@ -103,4 +107,74 @@ class LingBotVideoPipeline(LoRAPipeline, ComposedPipelineBase):
)
EntryClass = LingBotVideoPipeline
class LingBotVideoImageToVideoPipeline(LingBotVideoPipeline):
"""TI2V pipeline with fixed clean frame-zero conditioning in both stages."""
def create_pipeline_stages(self, fastvideo_args: FastVideoArgs) -> None:
"""Create image-conditioned base stages and optional text-only refinement."""
refiner = self.get_module("transformer_2")
self.add_stage(
"input_validation_stage",
LingBotVideoImageInputValidationStage(refiner_enabled=refiner is not None),
)
self.add_stage(
"prompt_encoding_stage",
LingBotVideoImagePromptEncodingStage(
text_encoder=self.get_module("text_encoder"),
processor=self.get_module("tokenizer"),
),
)
self.add_stage("conditioning_stage", ConditioningStage())
self.add_stage(
"image_latent_preparation_stage",
LingBotVideoImageLatentPreparationStage(vae=self.get_module("vae")),
)
self.add_stage(
"timestep_preparation_stage",
TimestepPreparationStage(scheduler=self.get_module("scheduler")),
)
self.add_stage(
"latent_preparation_stage",
LingBotVideoLatentPreparationStage(transformer=self.get_module("transformer")),
)
self.add_stage(
"denoising_stage",
LingBotVideoDenoisingStage(
transformer=self.get_module("transformer"),
scheduler=self.get_module("scheduler"),
),
)
self.add_stage(
"decoding_stage",
DecodingStage(vae=self.get_module("vae"), pipeline=self),
)
if refiner is not None:
self.add_stage(
"refiner_preparation_stage",
LingBotVideoRefinerPreparationStage(
vae=self.get_module("vae"),
scheduler=self.get_module("scheduler"),
),
)
self.add_stage(
"refiner_prompt_encoding_stage",
LingBotVideoRefinerTextEncodingStage(
text_encoder=self.get_module("text_encoder"),
processor=self.get_module("tokenizer"),
),
)
self.add_stage(
"refiner_denoising_stage",
LingBotVideoDenoisingStage(
transformer=refiner,
scheduler=self.get_module("scheduler"),
refiner=True,
),
)
self.add_stage(
"refiner_decoding_stage",
DecodingStage(vae=self.get_module("vae"), pipeline=self),
)
EntryClass = [LingBotVideoPipeline, LingBotVideoImageToVideoPipeline]
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
"""Official LingBot-Video T2V inference presets."""
"""Official LingBot-Video T2V and TI2V inference presets."""
from fastvideo.api.presets import InferencePreset, PresetStageSpec
@@ -89,4 +89,63 @@ LINGBOT_VIDEO_MOE_REFINER_T2V = InferencePreset(
},
)
ALL_PRESETS = (LINGBOT_VIDEO_DENSE_T2V, LINGBOT_VIDEO_MOE_REFINER_T2V)
LINGBOT_VIDEO_DENSE_TI2V = InferencePreset(
name="lingbot_video_dense_ti2v",
version=1,
model_family="lingbot_video",
description="LingBot-Video Dense 1.3B text-and-image-to-video at 480p",
workload_type="i2v",
stage_schemas=(_DENOISE_STAGE, ),
defaults={
"height": 480,
"width": 832,
"num_frames": 121,
"fps": 24,
"num_inference_steps": 40,
"guidance_scale": 3.0,
"batch_cfg": True,
"seed": 42,
"negative_prompt": DEFAULT_NEGATIVE_PROMPT,
},
)
LINGBOT_VIDEO_MOE_REFINER_TI2V = InferencePreset(
name="lingbot_video_moe_refiner_ti2v",
version=1,
model_family="lingbot_video",
description="LingBot-Video MoE 30B-A3B TI2V with clean-frame 1080p refinement",
workload_type="i2v",
stage_schemas=(_DENOISE_STAGE, _REFINE_STAGE),
defaults={
"height": 480,
"width": 832,
"height_sr": 1088,
"width_sr": 1920,
"num_frames": 121,
"fps": 24,
"num_inference_steps": 40,
"num_inference_steps_sr": 8,
"guidance_scale": 3.0,
"guidance_scale_2": 3.0,
"batch_cfg": True,
"t_thresh": 0.85,
"seed": 42,
"negative_prompt": DEFAULT_NEGATIVE_PROMPT,
},
stage_defaults={
"refine": {
"height_sr": 1088,
"width_sr": 1920,
"num_inference_steps_sr": 8,
"guidance_scale_2": 3.0,
"t_thresh": 0.85,
},
},
)
ALL_PRESETS = (
LINGBOT_VIDEO_DENSE_T2V,
LINGBOT_VIDEO_MOE_REFINER_T2V,
LINGBOT_VIDEO_DENSE_TI2V,
LINGBOT_VIDEO_MOE_REFINER_TI2V,
)
@@ -3,17 +3,68 @@
from __future__ import annotations
import math
import numpy as np
import torch
import torch.nn.functional as F
from PIL import Image
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.input_validation import InputValidationStage
from fastvideo.pipelines.stages.text_encoding import TextEncodingStage
LINGBOT_VIDEO_REFINER_TAIL_STEPS = 2
LINGBOT_VIDEO_IMAGE_TEMPLATE = "<|vision_start|><|image_pad|><|vision_end|>"
LINGBOT_VIDEO_IMAGE_MIN_TOKENS = 4
LINGBOT_VIDEO_IMAGE_MAX_TOKENS = 16384
LINGBOT_VIDEO_IMAGE_MAX_RATIO = 200
def _smart_resize_image(height: int, width: int, factor: int) -> tuple[int, int]:
"""Match Qwen3-VL's bounded patch-grid resize used by LingBot-Video."""
if max(height, width) / min(height, width) > LINGBOT_VIDEO_IMAGE_MAX_RATIO:
raise ValueError(f"absolute image aspect ratio must be smaller than {LINGBOT_VIDEO_IMAGE_MAX_RATIO}")
min_pixels = LINGBOT_VIDEO_IMAGE_MIN_TOKENS * factor**2
max_pixels = LINGBOT_VIDEO_IMAGE_MAX_TOKENS * factor**2
resized_height = max(factor, round(height / factor) * factor)
resized_width = max(factor, round(width / factor) * factor)
if resized_height * resized_width > max_pixels:
beta = math.sqrt((height * width) / max_pixels)
resized_height = math.floor(height / beta / factor) * factor
resized_width = math.floor(width / beta / factor) * factor
elif resized_height * resized_width < min_pixels:
beta = math.sqrt(min_pixels / (height * width))
resized_height = math.ceil(height * beta / factor) * factor
resized_width = math.ceil(width * beta / factor) * factor
return resized_height, resized_width
def _preprocess_condition_image(image: Image.Image, height: int, width: int) -> torch.Tensor:
"""Resize and center-crop the clean condition with released uint8 arithmetic."""
raw = torch.from_numpy(np.array(image.convert("RGB"))).permute(2, 0, 1).unsqueeze(0).contiguous()
source_height, source_width = raw.shape[-2:]
scale = max(height / source_height, width / source_width)
resized_height = max(math.ceil(source_height * scale), height)
resized_width = max(math.ceil(source_width * scale), width)
resized = F.interpolate(raw, size=(resized_height, resized_width), mode="bilinear", align_corners=False)
top = int(round((resized_height - height) / 2.0))
left = int(round((resized_width - width) / 2.0))
return resized[:, :, top:top + height, left:left + width].float().div(255.0).unsqueeze(2)
def _condition_tensor_to_vlm_image(pixel: torch.Tensor, patch_size: int) -> Image.Image:
"""Convert the base condition tensor to the processor's exact RGB image."""
frame = pixel[0, :, 0].detach().cpu().clamp(0, 1)
array = frame.permute(1, 2, 0).mul(255).byte().numpy()
image = Image.fromarray(array, mode="RGB")
target_height, target_width = _smart_resize_image(image.height, image.width, patch_size * 2)
return image.resize((target_width, target_height))
def _compute_refiner_sigmas(
@@ -78,6 +129,151 @@ class LingBotVideoInputValidationStage(InputValidationStage):
return batch
class LingBotVideoImageInputValidationStage(LingBotVideoInputValidationStage):
"""Validate TI2V input and prepare its shared base-resolution condition."""
def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch:
"""Load one RGB image and preserve both raw and preprocessed representations."""
batch = super().forward(batch, fastvideo_args)
if not isinstance(batch.pil_image, Image.Image):
raise ValueError("LingBot-Video TI2V requires image_path or one PIL image")
if not isinstance(batch.height, int) or not isinstance(batch.width, int):
raise TypeError("LingBot-Video TI2V requires integer height and width")
batch.preprocessed_image = _preprocess_condition_image(batch.pil_image, batch.height, batch.width)
return batch
class LingBotVideoImagePromptEncodingStage(PipelineStage):
"""Encode positive and negative TI2V prompts with the same Qwen3-VL image."""
performance_component_metric = "text_encoder_time_s"
def __init__(self, text_encoder, processor) -> None:
self.text_encoder = text_encoder
self.processor = processor
def _encode(
self,
text: str,
image: Image.Image,
fastvideo_args: FastVideoArgs,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Run the released processor call and native compound encoder once."""
encoder_config = fastvideo_args.pipeline_config.text_encoder_configs[0]
processed_text = fastvideo_args.pipeline_config.preprocess_text_funcs[0](LINGBOT_VIDEO_IMAGE_TEMPLATE + text)
target_device = get_local_torch_device()
first_parameter = next(self.text_encoder.parameters(), None)
encoder_device = first_parameter.device if first_parameter is not None else target_device
moved_for_forward = False
if (first_parameter is not None and not isinstance(first_parameter, DTensor)
and encoder_device.type != target_device.type):
self.text_encoder.to(target_device)
encoder_device = target_device
moved_for_forward = True
inputs = self.processor(
text=[processed_text],
images=[image],
videos=None,
video_metadata=None,
do_resize=False,
truncation=True,
max_length=encoder_config.text_len,
padding="longest",
return_tensors="pt",
).to(encoder_device)
with set_forward_context(current_timestep=0, attn_metadata=None):
outputs = self.text_encoder(**inputs, output_hidden_states=True)
prompt_embeds, prompt_mask = fastvideo_args.pipeline_config.postprocess_text_funcs[0](
outputs,
inputs["attention_mask"],
)
if moved_for_forward and fastvideo_args.text_encoder_cpu_offload:
self.text_encoder.to("cpu")
return prompt_embeds.to(target_device), prompt_mask.to(target_device)
@torch.no_grad()
def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch:
"""Populate both CFG branches with image-conditioned prompt embeddings."""
if not isinstance(batch.prompt, str) or not isinstance(batch.negative_prompt, str):
raise TypeError("LingBot-Video TI2V requires string positive and negative prompts")
if batch.preprocessed_image is None:
raise ValueError("LingBot-Video TI2V prompt encoding requires the preprocessed image")
vision_config = fastvideo_args.pipeline_config.text_encoder_configs[0].vision_config
patch_size = int(vision_config["patch_size"])
vlm_image = _condition_tensor_to_vlm_image(batch.preprocessed_image, patch_size)
prompt_embeds, prompt_mask = self._encode(batch.prompt, vlm_image, fastvideo_args)
batch.prompt_embeds.append(prompt_embeds)
if batch.prompt_attention_mask is not None:
batch.prompt_attention_mask.append(prompt_mask)
if batch.do_classifier_free_guidance:
negative_embeds, negative_mask = self._encode(batch.negative_prompt, vlm_image, fastvideo_args)
if batch.negative_prompt_embeds is not None:
batch.negative_prompt_embeds.append(negative_embeds)
if batch.negative_attention_mask is not None:
batch.negative_attention_mask.append(negative_mask)
return batch
class LingBotVideoImageLatentPreparationStage(PipelineStage):
"""Encode the clean first frame before base diffusion noise is generated."""
performance_component_metric = "vae_encode_time_s"
def __init__(self, vae) -> None:
self.vae = vae
@torch.no_grad()
def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch:
"""Sample and normalize the released VAE posterior with the shared RNG."""
if batch.preprocessed_image is None or not isinstance(batch.generator, torch.Generator):
raise ValueError("LingBot-Video TI2V requires a condition image and device generator")
device = get_local_torch_device()
if isinstance(self.vae, torch.nn.Module):
self.vae.to(device)
pixels = batch.preprocessed_image.to(device=device, dtype=torch.float32)
normalized = (pixels - 0.5) / 0.5
with torch.autocast(device_type=device.type, dtype=torch.bfloat16, enabled=device.type == "cuda"):
encoded = self.vae.encode(normalized)
distribution = encoded.latent_dist if hasattr(encoded, "latent_dist") else encoded
if not hasattr(distribution, "sample") or not callable(distribution.sample):
raise TypeError("LingBot-Video TI2V requires a VAE posterior distribution")
latents = distribution.sample(batch.generator)
mean = torch.tensor(self.vae.config.latents_mean, device=device, dtype=torch.float32).view(1, -1, 1, 1, 1)
std_inverse = 1.0 / torch.tensor(
self.vae.config.latents_std,
device=device,
dtype=torch.float32,
).view(1, -1, 1, 1, 1)
batch.image_latent = (latents.float() - mean) * std_inverse
if getattr(fastvideo_args, "vae_cpu_offload", False):
self.vae.to("cpu")
return batch
class LingBotVideoRefinerTextEncodingStage(PipelineStage):
"""Replace image-conditioned embeddings with original text-only refiner input."""
performance_component_metric = "text_encoder_time_s"
def __init__(self, text_encoder, processor) -> None:
self.text_stage = TextEncodingStage([text_encoder], [processor])
@torch.no_grad()
def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch:
"""Re-encode only the positive prompt; refiner CFG clones it as zeros."""
if not isinstance(batch.prompt, str):
raise TypeError("LingBot-Video refiner requires one string prompt")
embeds, masks = self.text_stage.encode_text(
batch.prompt,
fastvideo_args,
encoder_index=0,
return_attention_mask=True,
)
batch.prompt_embeds = embeds
batch.prompt_attention_mask = masks
return batch
class LingBotVideoLatentPreparationStage(PipelineStage):
"""Prepare fp32 latents in the released 4x temporal and 8x spatial geometry."""
@@ -108,6 +304,9 @@ class LingBotVideoLatentPreparationStage(PipelineStage):
if tuple(batch.latents.shape) != shape:
raise ValueError(f"supplied latent shape {tuple(batch.latents.shape)} does not match {shape}")
batch.latents = batch.latents.to(device=device, dtype=torch.float32)
if batch.image_latent is not None:
condition_frames = batch.image_latent.shape[2]
batch.latents[:, :, :condition_frames] = batch.image_latent.float()
batch.raw_latent_shape = shape
return batch
@@ -129,6 +328,32 @@ class LingBotVideoRefinerPreparationStage(PipelineStage):
resized = F.interpolate(flat, size=(height, width), mode="bicubic", align_corners=False).clamp(0.0, 1.0)
return resized.reshape(batch, frames, channels, height, width).permute(0, 2, 1, 3, 4).contiguous()
@staticmethod
def _resize_clean_condition(
image: Image.Image,
target_height: int,
target_width: int,
geometry_height: int,
geometry_width: int,
) -> torch.Tensor:
"""Center-crop to base aspect before the released bicubic refiner resize."""
image = image.convert("RGB")
image_width, image_height = image.size
geometry_aspect = float(geometry_width) / float(geometry_height)
image_aspect = float(image_width) / float(image_height)
if image_aspect > geometry_aspect:
crop_height = image_height
crop_width = max(1, int(round(crop_height * geometry_aspect)))
left, top = int(round((image_width - crop_width) / 2.0)), 0
else:
crop_width = image_width
crop_height = max(1, int(round(crop_width / geometry_aspect)))
left, top = 0, int(round((image_height - crop_height) / 2.0))
crop = image.crop((left, top, left + crop_width, top + crop_height))
crop = crop.resize((target_width, target_height), resample=Image.BICUBIC)
frame = torch.from_numpy(np.asarray(crop, dtype=np.float32) / 255.0).permute(2, 0, 1).unsqueeze(0)
return frame.contiguous().permute(1, 0, 2, 3).unsqueeze(0)
def _encode_video(
self,
video: torch.Tensor,
@@ -148,8 +373,12 @@ class LingBotVideoRefinerPreparationStage(PipelineStage):
else:
latents = encoded
mean = torch.tensor(self.vae.config.latents_mean, device=device, dtype=torch.float32).view(1, -1, 1, 1, 1)
std = torch.tensor(self.vae.config.latents_std, device=device, dtype=torch.float32).view(1, -1, 1, 1, 1)
return ((latents.float() - mean) / std).to(latents)
std_inverse = 1.0 / torch.tensor(
self.vae.config.latents_std,
device=device,
dtype=torch.float32,
).view(1, -1, 1, 1, 1)
return ((latents.float() - mean) * std_inverse).to(latents)
def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch:
"""Prepare high-resolution refiner latents and its exact truncated sigma schedule."""
@@ -167,6 +396,21 @@ class LingBotVideoRefinerPreparationStage(PipelineStage):
generator = torch.Generator(device=device).manual_seed(batch.seed)
resized = self._resize_video(batch.output, batch.height_sr, batch.width_sr)
encoded = self._encode_video(resized, generator, device)
if batch.preprocessed_image is not None:
if not isinstance(batch.pil_image, Image.Image):
raise TypeError("LingBot-Video TI2V refiner requires the original PIL condition")
clean_pixels = self._resize_clean_condition(
batch.pil_image,
batch.height_sr,
batch.width_sr,
batch.height,
batch.width,
)
clean_latent = self._encode_video(clean_pixels, generator, device)[:, :, :1].contiguous()
encoded[:, :, :1] = clean_latent.to(encoded.dtype)
batch.image_latent = clean_latent.float()
else:
batch.image_latent = None
noise = torch.randn(encoded.shape, generator=generator, device=device, dtype=encoded.dtype)
batch.latents = ((1.0 - batch.t_thresh) * encoded + batch.t_thresh * noise).float()
batch.generator = generator
@@ -285,6 +529,9 @@ class LingBotVideoDenoisingStage(PipelineStage):
transformer_dtype = next(self.transformer.parameters()).dtype
condition, condition_mask = self._prepare_conditions(batch, transformer_dtype, device)
latents = batch.latents.to(device=device, dtype=torch.float32)
if batch.image_latent is not None:
condition_frames = batch.image_latent.shape[2]
latents[:, :, :condition_frames] = batch.image_latent.float()
do_cfg = self._uses_cfg(batch)
negative = negative_mask = None
if do_cfg and not batch.batch_cfg:
@@ -335,6 +582,9 @@ class LingBotVideoDenoisingStage(PipelineStage):
return_dict=False,
generator=batch.generator,
)[0].float()
if batch.image_latent is not None:
condition_frames = batch.image_latent.shape[2]
latents[:, :, :condition_frames] = batch.image_latent.float()
if batch.return_trajectory_latents:
trajectory.append(latents.detach().cpu())
trajectory_timesteps.append(timestep.detach().cpu())
+54 -14
View File
@@ -29,7 +29,7 @@ from fastvideo.configs.pipelines.hunyuan15 import (Hunyuan15T2V480PConfig, Hunyu
Hunyuan15SR1080PConfig)
from fastvideo.configs.pipelines.hyworld import HYWorldConfig
from fastvideo.configs.pipelines.kandinsky5 import Kandinsky5I2VConfig, Kandinsky5T2VConfig
from fastvideo.configs.pipelines.lingbot_video import LingBotVideoT2VConfig
from fastvideo.configs.pipelines.lingbot_video import LingBotVideoT2VConfig, LingBotVideoTI2VConfig
from fastvideo.configs.pipelines.lingbotworld import LingBotWorldI2V480PConfig
from fastvideo.configs.pipelines.lingbotworld2 import LingBotWorld2CausalFastI2V480PConfig
from fastvideo.configs.pipelines.longcat import LongCatT2V480PConfig
@@ -129,6 +129,9 @@ class ConfigInfo:
# Lets a model family map to a specific pipeline class by path/detector
# (e.g. a T2V and I2V checkpoint that share a `_class_name`).
pipeline_cls_name: str | None = None
pipeline_config_cls_by_workload: dict[WorkloadType, type[PipelineConfig]] = dataclasses.field(default_factory=dict)
default_preset_by_workload: dict[WorkloadType, str] = dataclasses.field(default_factory=dict)
pipeline_cls_name_by_workload: dict[WorkloadType, str] = dataclasses.field(default_factory=dict)
# The central registry mapping a model name to its configuration information
@@ -150,6 +153,9 @@ def register_configs(
model_family: str | None = None,
default_preset: str | None = None,
pipeline_cls_name: str | None = None,
pipeline_config_cls_by_workload: dict[WorkloadType, type[PipelineConfig]] | None = None,
default_preset_by_workload: dict[WorkloadType, str] | None = None,
pipeline_cls_name_by_workload: dict[WorkloadType, str] | None = None,
) -> None:
"""Register config classes for a model family.
@@ -165,6 +171,9 @@ def register_configs(
model_family=model_family,
default_preset=default_preset,
pipeline_cls_name=pipeline_cls_name,
pipeline_config_cls_by_workload=pipeline_config_cls_by_workload or {},
default_preset_by_workload=default_preset_by_workload or {},
pipeline_cls_name_by_workload=pipeline_cls_name_by_workload or {},
)
if hf_model_paths:
@@ -522,27 +531,33 @@ def _register_configs() -> None:
model_family="lingbotworld",
default_preset="lingbotworld_i2v",
)
# LingBot-Video MoE T2V with the released second-stage refiner.
# LingBot-Video MoE T2V/TI2V with the released second-stage refiner.
register_configs(
sampling_param_cls=None,
pipeline_config_cls=LingBotVideoT2VConfig,
workload_types=(WorkloadType.T2V, ),
workload_types=(WorkloadType.T2V, WorkloadType.I2V),
hf_model_paths=["FastVideo/LingBot-Video-MoE-30B-A3B-Diffusers"],
model_detectors=[lambda path: "lingbotvideomoepipeline" in path.lower()],
model_family="lingbot_video",
default_preset="lingbot_video_moe_refiner_t2v",
pipeline_cls_name="LingBotVideoPipeline",
pipeline_config_cls_by_workload={WorkloadType.I2V: LingBotVideoTI2VConfig},
default_preset_by_workload={WorkloadType.I2V: "lingbot_video_moe_refiner_ti2v"},
pipeline_cls_name_by_workload={WorkloadType.I2V: "LingBotVideoImageToVideoPipeline"},
)
# LingBot-Video Dense T2V
# LingBot-Video Dense T2V/TI2V
register_configs(
sampling_param_cls=None,
pipeline_config_cls=LingBotVideoT2VConfig,
workload_types=(WorkloadType.T2V, ),
workload_types=(WorkloadType.T2V, WorkloadType.I2V),
hf_model_paths=["FastVideo/LingBot-Video-Dense-1.3B-Diffusers"],
model_detectors=[lambda path: "lingbotvideodensepipeline" in path.lower()],
model_family="lingbot_video",
default_preset="lingbot_video_dense_t2v",
pipeline_cls_name="LingBotVideoPipeline",
pipeline_config_cls_by_workload={WorkloadType.I2V: LingBotVideoTI2VConfig},
default_preset_by_workload={WorkloadType.I2V: "lingbot_video_dense_ti2v"},
pipeline_cls_name_by_workload={WorkloadType.I2V: "LingBotVideoImageToVideoPipeline"},
)
def _kandinsky5_detector(require: tuple[str, ...] = (), exclude: tuple[str, ...] = ()) -> Callable[[str], bool]:
@@ -1205,12 +1220,14 @@ def get_model_info(
config = maybe_download_model_index(model_path)
pipeline_name = config.get("_class_name")
if config_info.pipeline_cls_name is not None:
workload_pipeline_name = config_info.pipeline_cls_name_by_workload.get(workload_type)
pinned_pipeline_name = workload_pipeline_name or config_info.pipeline_cls_name
if pinned_pipeline_name is not None:
# The resolved (path/detector-based) config pins the pipeline class,
# e.g. an I2V checkpoint whose `_class_name` would otherwise resolve
# to the T2V pipeline.
logger.info("Pinning pipeline class name from %s to %s", pipeline_name, config_info.pipeline_cls_name)
pipeline_name = config_info.pipeline_cls_name
logger.info("Pinning pipeline class name from %s to %s", pipeline_name, pinned_pipeline_name)
pipeline_name = pinned_pipeline_name
if pipeline_name is None:
raise ValueError("Model config does not contain a _class_name attribute. "
@@ -1224,16 +1241,27 @@ def get_model_info(
return ModelInfo(
pipeline_cls=pipeline_cls,
sampling_param_cls=sampling_param_cls,
pipeline_config_cls=config_info.pipeline_config_cls,
pipeline_config_cls=config_info.pipeline_config_cls_by_workload.get(
workload_type,
config_info.pipeline_config_cls,
),
)
def get_pipeline_config_cls_from_name(pipeline_name_or_path: str) -> type[PipelineConfig]:
def get_pipeline_config_cls_from_name(
pipeline_name_or_path: str,
workload_type: WorkloadType | str | None = None,
) -> type[PipelineConfig]:
"""Resolve a model's pipeline config, including workload-specific variants."""
config_info = _get_config_info(pipeline_name_or_path, raise_on_missing=False)
if config_info is None:
raise ValueError(
f"No match found for pipeline {pipeline_name_or_path}, please check the pipeline name or path.")
return config_info.pipeline_config_cls
if isinstance(workload_type, str):
workload_type = WorkloadType.from_string(workload_type)
if workload_type is None:
return config_info.pipeline_config_cls
return config_info.pipeline_config_cls_by_workload.get(workload_type, config_info.pipeline_config_cls)
def get_sampling_param_cls_for_name(pipeline_name_or_path: str) -> Any | None:
@@ -1331,15 +1359,22 @@ def get_model_family(model_path: str) -> str | None:
return config_info.model_family
def get_default_preset(model_path: str) -> str | None:
def get_default_preset(model_path: str, workload_type: WorkloadType | str | None = None) -> str | None:
"""Return the ``default_preset`` name for a model path."""
config_info = _get_config_info(model_path, raise_on_missing=False)
if config_info is None:
return None
if isinstance(workload_type, str):
workload_type = WorkloadType.from_string(workload_type)
if workload_type is not None:
return config_info.default_preset_by_workload.get(workload_type, config_info.default_preset)
return config_info.default_preset
def get_preset_selection(model_path: str) -> tuple[str | None, str | None]:
def get_preset_selection(
model_path: str,
workload_type: WorkloadType | str | None = None,
) -> tuple[str | None, str | None]:
"""Return ``(default_preset, model_family)`` for a model path.
Single-lookup variant of :func:`get_default_preset` +
@@ -1349,7 +1384,12 @@ def get_preset_selection(model_path: str) -> tuple[str | None, str | None]:
config_info = _get_config_info(model_path, raise_on_missing=False)
if config_info is None:
return None, None
return config_info.default_preset, config_info.model_family
if isinstance(workload_type, str):
workload_type = WorkloadType.from_string(workload_type)
preset = config_info.default_preset
if workload_type is not None:
preset = config_info.default_preset_by_workload.get(workload_type, preset)
return preset, config_info.model_family
def get_registered_model_paths() -> list[str]:
+23 -6
View File
@@ -557,16 +557,18 @@ class TestLingBotVideoPresets:
assert "Multiple models matched" not in caplog.text
def test_lingbot_video_presets_registered(self) -> None:
"""Register both released LingBot-Video inference layouts."""
"""Register T2V and TI2V presets for both released model layouts."""
import fastvideo.registry # noqa: F401
from fastvideo.api.sampling_param import SamplingParam
from fastvideo.registry import get_preset_selection
presets = get_presets_for_family("lingbot_video")
assert {preset.name
for preset in presets} == {
"lingbot_video_dense_t2v",
"lingbot_video_moe_refiner_t2v",
}
assert {preset.name for preset in presets} == {
"lingbot_video_dense_t2v",
"lingbot_video_dense_ti2v",
"lingbot_video_moe_refiner_t2v",
"lingbot_video_moe_refiner_ti2v",
}
assert get_preset_selection("FastVideo/LingBot-Video-Dense-1.3B-Diffusers") == (
"lingbot_video_dense_t2v",
"lingbot_video",
@@ -575,6 +577,21 @@ class TestLingBotVideoPresets:
"lingbot_video_moe_refiner_t2v",
"lingbot_video",
)
assert get_preset_selection(
"FastVideo/LingBot-Video-Dense-1.3B-Diffusers",
"i2v",
) == ("lingbot_video_dense_ti2v", "lingbot_video")
assert get_preset_selection(
"FastVideo/LingBot-Video-MoE-30B-A3B-Diffusers",
"i2v",
) == ("lingbot_video_moe_refiner_ti2v", "lingbot_video")
ti2v_sampling = SamplingParam.from_pretrained(
"FastVideo/LingBot-Video-Dense-1.3B-Diffusers",
"i2v",
)
assert ti2v_sampling.height == 480
assert ti2v_sampling.width == 832
assert ti2v_sampling.batch_cfg is True
def test_moe_refiner_defaults(self) -> None:
"""Expose the official base and 1080p refiner sampling defaults."""
@@ -15,9 +15,10 @@ import torch
from safetensors.torch import load_file, save_file
from fastvideo.configs.models.dits.lingbot_video import LingBotVideoConfig
from fastvideo.configs.models.encoders.lingbot_video import LingBotVideoQwen3VLTextConfig
from fastvideo.configs.models.encoders.lingbot_video import LingBotVideoQwen3VLConfig
LANGUAGE_PREFIX = "model.language_model."
VISION_PREFIX = "model.visual."
PASSTHROUGH_COMPONENTS = ("transformer", "vae", "scheduler")
DENSE_PIPELINE_CLASS = "LingBotVideoDensePipeline"
MOE_PIPELINE_CLASS = "LingBotVideoMoePipeline"
@@ -38,11 +39,14 @@ def _copy_tree_with_weight_links(source: Path, destination: Path) -> None:
def _fuse_language_shard(state: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
"""Filter Qwen3-VL language tensors and fuse native QKV and gate/up weights."""
"""Keep vision tensors and fuse native QKV and gate/up language weights."""
converted = {
name[len(LANGUAGE_PREFIX):]: tensor
for name, tensor in state.items() if name.startswith(LANGUAGE_PREFIX)
}
converted.update(
{f"visual.{name[len(VISION_PREFIX) :]}": tensor for name, tensor in state.items() if name.startswith(VISION_PREFIX)}
)
layer_ids = {int(name.split(".")[1]) for name in converted if name.startswith("layers.")}
for layer_id in sorted(layer_ids):
attention = f"layers.{layer_id}.self_attn"
@@ -87,23 +91,30 @@ def _convert_text_encoder(source: Path, destination: Path) -> dict[str, str]:
def _write_text_encoder_config(source: Path, destination: Path) -> dict:
"""Flatten the compound Qwen3-VL config into FastVideo's text-model bucket."""
"""Keep the compound Qwen3-VL architecture in FastVideo's encoder bucket."""
official = json.loads((source / "config.json").read_text())
text = dict(official["text_config"])
valid = {item.name for item in fields(LingBotVideoQwen3VLTextConfig().arch_config)}
valid = {item.name for item in fields(LingBotVideoQwen3VLConfig().arch_config)}
config = {key: value for key, value in text.items() if key in valid}
config.update({
"_class_name": "LingBotVideoQwen3VLTextModel",
"architectures": ["LingBotVideoQwen3VLTextModel"],
"pad_token_id": 151643,
"text_len": 37698,
"output_hidden_states": True,
"require_processor": True,
"rope_scaling": None,
"mrope_interleaved": True,
"mrope_section": [24, 20, 20],
})
LingBotVideoQwen3VLTextConfig().update_model_arch(config)
config.update(
{
"_class_name": "LingBotVideoQwen3VLModel",
"architectures": ["LingBotVideoQwen3VLModel"],
"vision_config": official["vision_config"],
"image_token_id": official["image_token_id"],
"video_token_id": official["video_token_id"],
"vision_start_token_id": official["vision_start_token_id"],
"vision_end_token_id": official["vision_end_token_id"],
"pad_token_id": 151643,
"text_len": 37698,
"output_hidden_states": True,
"require_processor": True,
"rope_scaling": None,
"mrope_interleaved": True,
"mrope_section": [24, 20, 20],
}
)
LingBotVideoQwen3VLConfig().update_model_arch(config)
(destination / "config.json").write_text(json.dumps(config, indent=2, sort_keys=True) + "\n")
for filename in ("generation_config.json", ):
candidate = source / filename
@@ -121,14 +132,14 @@ def _validate_transformer_config(path: Path) -> None:
def _write_model_index(destination: Path, *, has_refiner: bool) -> None:
"""Declare the native T2V components, including an optional refiner DiT."""
"""Declare native T2V/TI2V components, including an optional refiner DiT."""
model_index = {
"_class_name": MOE_PIPELINE_CLASS if has_refiner else DENSE_PIPELINE_CLASS,
"_diffusers_version": "0.39.0",
"workload_type": "video-generation",
"transformer": ["diffusers", "LingBotVideoTransformer3DModel"],
"vae": ["diffusers", "AutoencoderKLWan"],
"text_encoder": ["transformers", "LingBotVideoQwen3VLTextModel"],
"text_encoder": ["transformers", "LingBotVideoQwen3VLModel"],
"tokenizer": ["transformers", "Qwen3VLProcessor"],
"scheduler": ["diffusers", "FlowUniPCMultistepScheduler"],
}
@@ -138,7 +149,7 @@ def _write_model_index(destination: Path, *, has_refiner: bool) -> None:
def convert(source: Path, destination: Path) -> None:
"""Create a Dense or MoE/refiner T2V layout without altering source assets."""
"""Create a Dense or MoE/refiner T2V/TI2V layout without altering source assets."""
required = (*PASSTHROUGH_COMPONENTS, "text_encoder", "processor")
missing = [name for name in required if not (source / name).is_dir()]
if missing:
@@ -0,0 +1,157 @@
# SPDX-License-Identifier: Apache-2.0
"""Exact Qwen3-VL image-conditioning parity for LingBot-Video TI2V."""
from __future__ import annotations
import os
from pathlib import Path
from types import SimpleNamespace
import numpy as np
import pytest
import torch
from PIL import Image
from transformers import AutoProcessor, Qwen3VLForConditionalGeneration
from fastvideo.configs.models.encoders.lingbot_video import LingBotVideoQwen3VLConfig
from fastvideo.configs.pipelines.lingbot_video import preprocess_lingbot_video_prompt
from fastvideo.distributed.parallel_state import init_distributed_environment, initialize_model_parallel
from fastvideo.models.encoders.lingbot_video import LingBotVideoQwen3VLModel
from fastvideo.models.loader.component_loader import TextEncoderLoader
from fastvideo.pipelines.basic.lingbot_video.stages import (
LINGBOT_VIDEO_IMAGE_TEMPLATE,
_condition_tensor_to_vlm_image,
_preprocess_condition_image,
)
from tests.local_tests.lingbot_video.hf_assets import (
FASTVIDEO_DENSE,
OFFICIAL_DENSE,
download_components,
)
def _load_native(device: torch.device, checkpoint: Path) -> LingBotVideoQwen3VLModel:
"""Load the converted compound encoder through FastVideo's production loader."""
args = SimpleNamespace(
text_encoder_cpu_offload=False,
override_text_encoder_quant=None,
override_text_encoder_safetensors=None,
pin_cpu_memory=False,
)
model = TextEncoderLoader().load_model(
str(checkpoint / "text_encoder"),
LingBotVideoQwen3VLConfig(),
device,
args,
dtype="bf16",
use_text_encoder_override=True,
)
return model.eval()
def _assert_exact(name: str, expected: torch.Tensor, actual: torch.Tensor) -> None:
"""Print exact drift metrics and require zero differing hidden-state values."""
difference = (actual.float() - expected.float()).abs()
print(
f"{name}: equal={torch.equal(actual, expected)} "
f"differing={torch.count_nonzero(actual != expected).item()} "
f"max_abs={difference.max().item():.8f} mean_abs={difference.mean().item():.8f}"
)
assert torch.equal(actual, expected)
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Qwen3-VL parity requires CUDA")
def test_lingbot_video_ti2v_text_encoder_exact_parity() -> None:
"""Require exact text-only, image-conditioned, and long-prompt hidden states."""
if os.environ.get("LINGBOT_VIDEO_RUN_GPU_TESTS") != "1":
pytest.skip("set LINGBOT_VIDEO_RUN_GPU_TESTS=1 on a scheduled GPU")
device = torch.device("cuda")
init_distributed_environment(world_size=1, rank=0, local_rank=0)
initialize_model_parallel(tensor_model_parallel_size=1, sequence_model_parallel_size=1)
local_official = os.environ.get("LINGBOT_VIDEO_OFFICIAL_CHECKPOINT")
official_checkpoint = (
Path(local_official)
if local_official
else download_components(OFFICIAL_DENSE, "text_encoder", "processor")
)
local_converted = os.environ.get("LINGBOT_VIDEO_TI2V_CHECKPOINT")
converted_checkpoint = Path(local_converted) if local_converted else download_components(FASTVIDEO_DENSE, "text_encoder")
processor = AutoProcessor.from_pretrained(official_checkpoint / "processor", local_files_only=True)
y, x = np.mgrid[0:941, 0:1672]
image = Image.fromarray(
np.stack((x % 256, y % 256, (x + y) % 256), axis=-1).astype(np.uint8),
mode="RGB",
)
condition = _preprocess_condition_image(image, 480, 832)
vlm_image = _condition_tensor_to_vlm_image(condition, patch_size=16)
prompt = preprocess_lingbot_video_prompt(
LINGBOT_VIDEO_IMAGE_TEMPLATE + "A runner and a white robot move toward the camera."
)
inputs = processor(
text=[prompt],
images=[vlm_image],
videos=None,
video_metadata=None,
do_resize=False,
truncation=True,
max_length=37698,
padding="longest",
return_tensors="pt",
).to(device)
official = Qwen3VLForConditionalGeneration.from_pretrained(
official_checkpoint / "text_encoder",
dtype=torch.bfloat16,
attn_implementation="sdpa",
local_files_only=True,
).to(device).eval()
native = _load_native(device, converted_checkpoint)
with torch.no_grad():
expected = official(**inputs, output_hidden_states=True, use_cache=False).hidden_states[-1]
actual = native(**inputs, output_hidden_states=True).hidden_states[-1]
_assert_exact("image_conditioned", expected, actual)
text_inputs = processor(
text=[preprocess_lingbot_video_prompt("A runner and a white robot move toward the camera.")],
images=None,
videos=None,
do_resize=False,
truncation=True,
max_length=37698,
padding="longest",
return_tensors="pt",
).to(device)
with torch.no_grad():
expected_text = official(
**text_inputs,
output_hidden_states=True,
use_cache=False,
).hidden_states[-1]
actual_text = native(
**text_inputs,
output_hidden_states=True,
).hidden_states[-1]
_assert_exact("text_only", expected_text, actual_text)
long_prompt = " ".join(
["A runner and a white robot move toward the camera under cherry blossoms."] * 160
)
long_inputs = processor(
text=[preprocess_lingbot_video_prompt(LINGBOT_VIDEO_IMAGE_TEMPLATE + long_prompt)],
images=[vlm_image],
videos=None,
video_metadata=None,
do_resize=False,
truncation=True,
max_length=37698,
padding="longest",
return_tensors="pt",
).to(device)
with torch.no_grad():
expected_long = official(
**long_inputs,
output_hidden_states=True,
use_cache=False,
).hidden_states[-1]
actual_long = native(
**long_inputs,
output_hidden_states=True,
).hidden_states[-1]
_assert_exact("long_image_conditioned", expected_long, actual_long)
+24 -8
View File
@@ -3,7 +3,7 @@
## Summary
- model_family: `lingbot_video`
- workload_types: T2V first, then T2I and TI2V (`WorkloadType.I2V`)
- workload_types: T2V and TI2V (`WorkloadType.I2V`); T2I remains deferred
- official_ref: `https://github.com/robbyant/lingbot-video` at `a638721cf2271804d02738b69f2ad788c4a559fc`
- official_weights: `robbyant/lingbot-video-dense-1.3b`; `robbyant/lingbot-video-moe-30b-a3b`
- fastvideo_weights: `FastVideo/LingBot-Video-Dense-1.3B-Diffusers`; `FastVideo/LingBot-Video-MoE-30B-A3B-Diffusers`
@@ -13,7 +13,7 @@
## Current Phase
- phase: `t2v_complete`
- phase: `ti2v_complete`
- status: `complete`
- owner: `pipeline`
- last_updated: `2026-07-13`
@@ -26,11 +26,14 @@
| MoE DiT | DiT | port | `fastvideo/models/dits/lingbot_video.py` | pass | pass | full 48-block exact pass | none |
| MoE DiT refiner | DiT | port | `fastvideo/models/dits/lingbot_video.py` | pass | pass | full 48-block exact pass | none |
| Qwen3-VL text | encoder | reuse/port | `fastvideo/models/encoders/lingbot_video.py` | pass | pass | production exact pass | none |
| Qwen3-VL vision | encoder | deferred | `fastvideo/models/encoders/lingbot_video.py` | deferred | not_started | outside current T2V scope | TI2V phase |
| Qwen3-VL vision | encoder | reuse/port | `fastvideo/models/encoders/lingbot_video.py` | pass | pass | TI2V exact | none |
| Wan VAE | VAE | reuse | `fastvideo/models/vaes/wanvae.py` | pass | passthrough | non-skip pass (`2/2`) | none |
| Flow-UniPC | scheduler | reuse | `fastvideo/models/schedulers/` | pass | not_needed | exact schedule/update pass | none |
| Base pipeline | pipeline | port | `fastvideo/pipelines/basic/lingbot_video/` | pass | pass | Dense exact; MoE smoke pass | none |
| Refiner pipeline | pipeline | port | `fastvideo/pipelines/basic/lingbot_video/` | pass | pass | FSDP plus SP=8 smoke pass | none |
| TI2V VAE encode | VAE | reuse | `fastvideo/models/vaes/wanvae.py` | pass | passthrough | exact condition-latent pass | none |
| TI2V base | pipeline | port | `fastvideo/pipelines/basic/lingbot_video/` | pass | pass | Dense and MoE exact pass | none |
| TI2V refiner | pipeline | port | `fastvideo/pipelines/basic/lingbot_video/` | pass | pass | FSDP plus SP=8 smoke pass | none |
## Checkpoint Conversion
@@ -51,10 +54,10 @@ What changes:
- Text encoder conversion: `scripts/checkpoint_conversion/lingbot_video_to_diffusers.py`
converts the official Qwen3-VL checkpoint into the format loaded by FastVideo's
`LingBotVideoQwen3VLTextModel`. The official checkpoint is multimodal: it
`LingBotVideoQwen3VLModel`. The official checkpoint is multimodal: it
contains both a vision encoder and a language model. The current port supports
text-to-video generation, so the converter keeps the language-model weights and
omits the unused vision-encoder weights. Within each language-model layer,
T2V and TI2V, so the converter preserves the vision encoder and converts the
language-model weights. Within each language-model layer,
"projection weights" are the learned matrices used by the attention and MLP
computations. Qwen3-VL stores the attention matrices separately as `q_proj`,
`k_proj`, and `v_proj`; FastVideo reads the same values stacked into one
@@ -112,6 +115,19 @@ LingBot-Video implementation.
| Complete MoE base transformer | Yes | Yes | `scripts/slurm/lingbot_video_moe_dit_parity.sbatch`, Slurm `1859114` |
| Complete MoE refiner | Yes | Yes | `scripts/slurm/lingbot_video_moe_refiner_dit_parity.sbatch`, Slurm `1859145` |
#### Exact TI2V base-pipeline parity
Dense and MoE both match the official implementation exactly for processor
inputs, Qwen3-VL embeddings, conditional and unconditional DiT outputs, the
one-step result, all 40 denoising states, and decoded floating-point pixels.
The test uses official TI2V example 4, sequential CFG, no refiner, and SDPA for
Qwen3-VL on both sides. MP4 encoding is not part of this comparison.
- Baseline: `tests/local_tests/lingbot_video/generate_ti2v_reference_baseline.py`
- Test: `tests/local_tests/pipelines/test_lingbot_video_ti2v_pipeline_parity.py`
- Dense result: `outputs/lingbot-video/validation/ti2v_exact_parity/fastvideo/dense/result.json`
- MoE result: `outputs/lingbot-video/validation/ti2v_exact_parity/fastvideo/moe/result.json`
### Functional tests without an original-output comparison
These tests checked that FastVideo could load and run the port correctly. They
@@ -249,7 +265,7 @@ NOTE: Component-level numerical parity was achieved. Full MoE/refiner end-to-end
- Canonical Slurm `1859167` completed a 1920x1088, 121-frame, 24-FPS generation in 341.71 FastVideo seconds with a 63,450.65 MB rank-zero worker lifetime peak. Its bytes match superseded Slurm `1859144`.
- The final targeted CPU/API regression passed 127 tests with 3 expected GPU-gated skips; targeted Ruff and tracked/untracked whitespace validation pass.
## Remaining T2V Gates
## Remaining Gates
- Attach reviewer-visible official and FastVideo comparison videos.
- T2I/TI2V and Qwen3-VL vision conditioning remain outside the completed T2V scope.
- T2I remains outside the completed scope.
+161 -58
View File
@@ -1,62 +1,84 @@
# LingBot-Video Local Tests
This directory is the developer runbook for validating the LingBot-Video port in
FastVideo. The current port covers text-to-video (T2V) inference for both released
checkpoints:
FastVideo. The current port covers text-to-video (T2V) and text-and-image-to-video
(TI2V) inference for both released checkpoints:
- Dense: a single Dense transformer generates the video.
- MoE/refiner: an MoE transformer generates a base video, then a second MoE
transformer refines it at a larger resolution.
- Dense: one Dense transformer generates the T2V or TI2V base video.
- MoE/refiner: one MoE transformer generates the base video, then a second MoE
transformer optionally refines it at a larger resolution.
These are local tests, which means they are run manually in this worktree and are
not part of FastVideo's default CI suite. Prompt rewriting, automatic negative-prompt
generation, T2I, TI2V are outside the current port.
not part of FastVideo's default CI suite. For TI2V, the input image is used by
Qwen3-VL for prompt conditioning and by the VAE as the clean first-frame latent.
Prompt rewriting, automatic negative-prompt generation, and T2I are outside the
current port.
## Current Testing Status
Comparing official Lingbot-Video impl vs. FastVideo impl.
The results below compare the pinned official implementation with FastVideo.
They use three distinct meanings of "pass":
| Area | Validation performed | Current result |
| ---------------------------- | ---------------------------------------------------------- | ------------------------------------------- |
| Qwen3-VL text-only encoder | Official versus FastVideo component comparison | Exact parity |
| Flow-UniPC scheduler | Timesteps, sigmas, and deterministic update comparison | Exact parity |
| Dense transformer | Full official versus FastVideo transformer comparison | Exact parity |
| Wan VAE | Official versus FastVideo encode and decode comparison | Passes with `atol=0.05`, `rtol=0.05` |
| Dense controlled pipeline | One- and two-step latent comparison | Exact parity |
| Dense sequence parallelism | Two-GPU pipeline latent comparison with math SDPA | Exact parity |
| MoE transformer block | Official versus FastVideo real-checkpoint block comparison | Exact parity |
| MoE base transformer | Full 48-block sequential comparison | Exact parity |
| MoE refiner transformer | Full 48-block sequential comparison | Exact parity |
| MoE production pipeline | Full-size, 40-step batched-CFG frame comparison | Mean SSIM exceeds 0.78 |
| MoE base pipeline parity | Full-size, 40-step sequential-CFG decoded-pixel comparison | Exact parity |
| MoE base pipeline | Batched CFG over five frames and two denoising steps | Pass |
| MoE plus refiner pipeline | Five-frame, two-step base-to-refiner handoff | Pass |
| Final generated MP4 | Production generation and decode | Generation pass; no exact end-to-end parity |
| T2I, TI2V, and vision branch | Not implemented by this T2V port | Outside current scope |
- **Exact parity:** `torch.equal` is true and zero tensor values differ.
- **Tolerance parity:** values are close within the test's stated threshold.
- **Smoke pass:** the production path completes without error; this does not
compare its tensors with the official implementation.
The production batched classifier-free guidance (CFG) comparison uses the
original repository's packed FlashAttention-3 path and FastVideo's padded Torch
SDPA path. Because those kernels have different bfloat16 rounding, this test
compares decoded frames with a mean SSIM threshold of 0.78 instead of requiring
identical pixels. Like-for-like sequential component and pipeline parity remains
A pytest summary such as `2 passed` means that two test functions satisfied
their own assertions. Use the table's **Test type** column to determine whether
those assertions checked exact parity, tolerance parity, or only execution.
| Scope | Test type | Current result |
| ------------------------------------------- | ------------------- | --------------------------------------------------------------------- |
| CPU/API contracts | Unit and API | 87 passed; 6 GPU-only cases skipped in the CPU run |
| T2V encoder, scheduler, and transformers | Exact parity | Exact at the tested component and controlled-pipeline boundaries |
| T2V Wan VAE | Tolerance parity | Passes with `atol=0.05` and `rtol=0.05` |
| T2V MoE production batched CFG | Tolerance parity | Full-size, 40-step decoded frames have mean SSIM above 0.78 |
| T2V MoE base sequential CFG | Exact parity | Full-size, 40-step decoded pixels match exactly |
| T2V production base and refiner workflows | Smoke | Pass |
| TI2V Qwen3-VL text and vision branches | Exact parity | Zero differing hidden-state values |
| TI2V clean-frame VAE encoding | Exact parity | Zero differing posterior, sample, and condition-latent values |
| TI2V Dense base generation | Exact parity | Exact conditioning, 40-step trajectory, final latent, and pixels |
| TI2V MoE base generation | Exact parity | Exact conditioning, 40-step trajectory, final latent, and pixels |
| TI2V refiner preparation from one MP4 | Exact parity | Exact resized pixels, VAE latents, seeded noise, and initial latent |
| TI2V MoE plus in-memory refiner production | Smoke | Pass with batched CFG, FSDP, and sequence parallelism |
| Complete cross-repo refiner workflow | Not exact by design | Not claimed; the production handoff inputs intentionally differ |
The T2V production batched-CFG comparison uses the original repository's
packed FlashAttention-3 path and FastVideo's padded Torch SDPA path. Because
those kernels have different bfloat16 rounding, the test compares decoded
frames with a mean SSIM threshold of 0.78 instead of requiring identical
pixels. Like-for-like sequential component and pipeline parity remains
bit-exact.
### One NOTE:
Parity is established by pairing the official and FastVideo implementations of
the text encoder, scheduler, Dense or MoE transformer, and VAE. A controlled
Dense pipeline test also compares the latent tensor after denoising. There is no
exact final-MP4 comparison for the complete MoE/refiner workflow because the two
pipelines hand the base video to the refiner differently:
### How Parity Is Interpreted
The exact TI2V base test uses official example 4 at 480x832, 121 frames, 40
denoising steps, guidance 3, and seed 42. Both implementations run the positive
and negative classifier-free-guidance branches as separate passes so their
operation order is identical. The test compares conditioning, the clean-frame
condition latent, initial noise, every denoising step, the final latent, decoded
float pixels. Dense and MoE both match exactly. MP4 encoding is outside this
strict comparison.
FastVideo's released presets use batched CFG for speed. That production setting
is covered by a smoke test, not by the exact base comparison.
The full refiner workflows cannot receive identical inputs because they cross
the base-to-refiner boundary differently:
```text
Official repo: base decode -> save MP4 -> load MP4 -> resize and VAE-encode -> refine
FastVideo: base decode -> keep tensor in memory -> resize and VAE-encode -> refine
```
MP4 encoding is lossy, so the official workflow changes the pixels before the
refiner receives them. FastVideo intentionally avoids that loss. The component
parity tests therefore provide the meaningful numerical comparison, while the
complete MoE/refiner test is a production smoke test.
MP4 encoding is lossy, so the official production workflow changes the pixels
before refinement while FastVideo intentionally preserves the decoded tensor.
The exact refiner test removes that input difference by loading one shared
official base MP4 in both implementations. From that shared input, resizing,
clean-frame preparation, VAE encoding, seeded noise, and the initial refiner
latent match exactly. The separate production smoke test verifies FastVideo's
intended in-memory base-to-refiner workflow end to end.
## Setup: Required Workspace And Environment
@@ -117,9 +139,9 @@ token and no manually converted checkpoint directory are required.
| Role | Repository | Pinned revision |
| --------------------- | ------------------------------------------------------------ | ------------------------------------------ |
| Official Dense | `robbyant/lingbot-video-dense-1.3b` | `f9789a7d9b4772a47aba62d4eb5282ddefd1da21` |
| FastVideo Dense | `FastVideo/LingBot-Video-Dense-1.3B-Diffusers` | `743ed04b96d77150d952eb08a59a56ee61b9bc95` |
| FastVideo Dense | `FastVideo/LingBot-Video-Dense-1.3B-Diffusers` | `efca07f906aa17a9b03380e2fc58ef17089b4e91` |
| Official MoE/refiner | `robbyant/lingbot-video-moe-30b-a3b` | `f2e538f64afe00cc4ae674db2aeb52e2945edfd5` |
| FastVideo MoE/refiner | `FastVideo/LingBot-Video-MoE-30B-A3B-Diffusers` | `401dce84db5897cb950969e766410c8eadd4fbdf` |
| FastVideo MoE/refiner | `FastVideo/LingBot-Video-MoE-30B-A3B-Diffusers` | `575e01b56b39f8fc31a8029ab1789339de078663` |
Keep the cache inside the shared `fv-hub` workspace by setting `HF_HOME` before
running pytest:
@@ -139,10 +161,17 @@ official and FastVideo weights in the shared cache.
Conversion is already reflected in the two public `FastVideo/*-Diffusers`
repositories and is not a test setup step. Maintainers reproducing those
packages can use
`scripts/checkpoint_conversion/lingbot_video_to_diffusers.py`. The script keeps
the released transformer, VAE, and scheduler tensors; converts the text-only
Qwen3-VL weights to FastVideo's fused layout; and maps the official MoE
`refiner/` component to FastVideo's `transformer_2/` component.
`scripts/checkpoint_conversion/lingbot_video_to_diffusers.py`. The script:
- Keeps the released transformer, VAE, and scheduler tensors unchanged.
- Fuses the Qwen3-VL language projections into FastVideo's native layout.
- Preserves the Qwen3-VL vision tower in the same text-encoder component.
- Copies the official multimodal processor into `tokenizer/`.
- Maps the official MoE `refiner/` component to FastVideo's `transformer_2/`.
T2V and TI2V therefore use the same Dense or MoE Hugging Face repository. T2V
uses only the language branch; TI2V also uses the vision branch. There are no
separate TI2V checkpoint repositories.
### Setup Result
@@ -162,14 +191,15 @@ Run GPU tests only inside an allocated GPU job. The activation flags documented
below are safety gates: without the required flag, pytest skips the expensive
test instead of consuming a GPU accidentally.
| Test group | Required compute |
| ---------------------------------- | ------------------------------------------------------------------- |
| Routing, layout, and refiner logic | CPU |
| Scheduler parity | CPU; its small pinned scheduler component downloads automatically |
| Dense component and pipeline tests | 1 allocated CUDA GPU; prior acceptance runs used an H200 |
| Dense sequence-parallel test | 2 allocated CUDA GPUs |
| Full MoE DiT and base pipeline | 1 H200; official and FastVideo models are loaded sequentially |
| Complete MoE/refiner pipeline | 8 H200 GPUs by default; FSDP and sequence parallelism are exercised |
| Test group | Required compute |
| ------------------------------------- | ------------------------------------------------------------------- |
| Routing, layout, and refiner logic | CPU |
| Scheduler parity | CPU; its small pinned scheduler component downloads automatically |
| Dense component and base-pipeline | 1 allocated CUDA GPU; acceptance runs used an H200 |
| Qwen3-VL and clean-frame VAE parity | 1 allocated CUDA GPU; acceptance runs used an H200 |
| Dense sequence-parallel test | 2 allocated CUDA GPUs |
| Full MoE DiT and base pipeline | 1 H200; official and FastVideo models are loaded sequentially |
| Production MoE TI2V plus refiner test | 8 H200 GPUs by default; FSDP and sequence parallelism are exercised |
Minimum GPU memory has not been established for the Dense tests. The H200 counts
above describe the configuration used for the retained acceptance results.
@@ -189,6 +219,7 @@ are outside this `lingbot_video/` directory.
| `tests/local_tests/transformers/test_lingbot_video_moe.py` | MoE routing, expert weighting, dtype policy, and checkpoint surface |
| `tests/local_tests/schedulers/test_lingbot_video_scheduler_parity.py` | Official and FastVideo scheduler timesteps, sigmas, and updates |
| `tests/local_tests/pipelines/test_lingbot_video_refiner_stages.py` | Refiner resize, VAE encode/noise preparation, schedule, and loading |
| `tests/local_tests/pipelines/test_lingbot_video_pipeline_smoke.py` | T2V/TI2V registry, processor, validation, and latent contracts |
| `tests/local_tests/models/test_fsdp_load_mixed_dtype.py` | Checkpoint dtype restoration; its nested-FSDP case is GPU-gated |
Run them from the FastVideo worktree:
@@ -202,6 +233,7 @@ $PY -m pytest -q \
tests/local_tests/transformers/test_lingbot_video_moe.py \
tests/local_tests/schedulers/test_lingbot_video_scheduler_parity.py \
tests/local_tests/pipelines/test_lingbot_video_refiner_stages.py \
tests/local_tests/pipelines/test_lingbot_video_pipeline_smoke.py \
tests/local_tests/models/test_fsdp_load_mixed_dtype.py
```
@@ -301,6 +333,75 @@ LINGBOT_VIDEO_PARITY_DETERMINISTIC=1 \
tests/local_tests/pipelines/test_lingbot_video_pipeline_parity.py
```
### TI2V GPU Tests
| Test path | What it checks |
| ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| `tests/local_tests/encoders/test_lingbot_video_ti2v_text_encoder_parity.py` | Exact text-only and image-conditioned Qwen3-VL hidden states |
| `tests/local_tests/vaes/test_lingbot_video_ti2v_vae_encoder_parity.py` | Exact clean-frame VAE posterior parameters and seeded sample |
| `tests/local_tests/pipelines/test_lingbot_video_ti2v_pipeline_parity.py` | Dense/MoE exact one-step, 40-step, and decoded-pixel parity |
| `tests/local_tests/vaes/test_lingbot_video_ti2v_refiner_vae_parity.py` | Exact refiner VAE, clean-frame, noise, and initial latent from one MP4 |
| `tests/local_tests/pipelines/test_lingbot_video_ti2v_refiner_pipeline_smoke.py` | Production MoE TI2V, batched CFG, in-memory refinement, and decode |
| `tests/local_tests/pipelines/test_lingbot_video_pipeline_smoke.py` | TI2V routing, processor loading, image-latent contract, and T2V smoke |
Run the single-GPU component tests separately:
```bash
cd "$FV_HUB/fastvideo-port-lingbot-video"
PY="$FV_HUB/.venv/bin/python"
LINGBOT_VIDEO_RUN_GPU_TESTS=1 $PY -m pytest -v -s \
tests/local_tests/encoders/test_lingbot_video_ti2v_text_encoder_parity.py
LINGBOT_VIDEO_RUN_GPU_TESTS=1 $PY -m pytest -v -s \
tests/local_tests/vaes/test_lingbot_video_ti2v_vae_encoder_parity.py
```
The exact base-pipeline test first records the official output, then compares
FastVideo with it. Run one variant at a time and set `VARIANT` to `dense` or
`moe`:
```bash
export CASE_DIR="$FV_HUB/lingbot-video-reference/assets/cases/ti2v/example_4"
export PARITY_ROOT="$FV_HUB/fastvideo-port-lingbot-video/outputs/lingbot-video/validation/ti2v_exact_parity"
export LINGBOT_QWEN_ATTN_IMPLEMENTATION=sdpa
export FASTVIDEO_ATTENTION_BACKEND=TORCH_SDPA
VARIANT=dense
$PY tests/local_tests/lingbot_video/generate_ti2v_reference_baseline.py \
"$VARIANT" "$CASE_DIR" "$PARITY_ROOT/reference/$VARIANT"
LINGBOT_VIDEO_RUN_GPU_TESTS=1 \
LINGBOT_VIDEO_TI2V_VARIANT="$VARIANT" \
LINGBOT_VIDEO_TI2V_BASELINE_DIR="$PARITY_ROOT/reference/$VARIANT" \
LINGBOT_VIDEO_TI2V_CASE_DIR="$CASE_DIR" \
LINGBOT_VIDEO_TI2V_RESULT_DIR="$PARITY_ROOT/fastvideo/$VARIANT" \
$PY -m pytest -v -s \
tests/local_tests/pipelines/test_lingbot_video_ti2v_pipeline_parity.py
```
The SDPA settings keep the Qwen3-VL attention backend the same in both
implementations. This removes kernel-rounding differences from the exact test.
The refiner VAE parity test requires one shared 121-frame base MP4. Both VAE
implementations reload that same file, so MP4 compression is held constant:
```bash
LINGBOT_VIDEO_RUN_GPU_TESTS=1 \
LINGBOT_VIDEO_TI2V_SHARED_BASE_MP4=/path/to/original_base.mp4 \
LINGBOT_VIDEO_TI2V_CONDITION_IMAGE="$FV_HUB/lingbot-video-reference/assets/cases/ti2v/example_4/first_frame.png" \
$PY -m pytest -v -s \
tests/local_tests/vaes/test_lingbot_video_ti2v_refiner_vae_parity.py
```
Run the production base-plus-refiner smoke inside one eight-H200 allocation:
```bash
LINGBOT_VIDEO_RUN_REFINER_PIPELINE_TESTS=1 \
LINGBOT_VIDEO_REFINER_NUM_GPUS=8 \
$PY -m pytest -v -s \
tests/local_tests/pipelines/test_lingbot_video_ti2v_refiner_pipeline_smoke.py
```
### MoE And Refiner GPU Tests
| Test path | What it checks |
@@ -358,12 +459,14 @@ cd "$FV_HUB/fastvideo-port-lingbot-video"
- An exact-parity failure means the paired official and FastVideo tensors differ;
inspect the first reported mismatch rather than treating successful generation
as a substitute.
- A VAE failure means the error exceeded the explicit `0.05` absolute or relative
tolerance used by the VAE tests.
- A T2V VAE tolerance failure means the error exceeded `atol=0.05` or
`rtol=0.05`. The TI2V VAE encoder tests instead require exact equality.
- A smoke-test failure means FastVideo could not complete that production path; it
does not by itself identify which component lost numerical parity.
- Missing files under the required workspace paths are setup failures, not model
correctness failures.
This README intentionally uses direct pytest commands. There are no retained
LingBot-Video `launch_env.sh` or Slurm wrapper scripts in this worktree.
This README intentionally uses direct pytest commands. The acceptance Slurm
scripts, exact verdicts, and paired TI2V videos from the current validation are
retained under `outputs/lingbot-video/quality_comparison_ti2v/`; they are
evidence, not prerequisites for running the local tests.
@@ -0,0 +1,238 @@
# SPDX-License-Identifier: Apache-2.0
"""Generate fixed official LingBot-Video TI2V tensors for exact parity tests."""
from __future__ import annotations
import argparse
import gc
import hashlib
import json
import os
import tempfile
from pathlib import Path
from typing import Any
import torch
from PIL import Image
from tests.local_tests.lingbot_video.hf_assets import (
OFFICIAL_DENSE,
OFFICIAL_MOE,
download_components,
materialize_component_view,
)
os.environ.setdefault("DIFFUSERS_ATTN_BACKEND", "native")
os.environ.setdefault("LINGBOT_QWEN_ATTN_IMPLEMENTATION", "sdpa")
HEIGHT = 480
WIDTH = 832
NUM_FRAMES = 121
GUIDANCE_SCALE = 3.0
FLOW_SHIFT = 3.0
SEED = 42
def _prompt(case_dir: Path) -> str:
"""Serialize example 4 exactly as the official runner does."""
sample = json.loads((case_dir / "prompt.json").read_text(encoding="utf-8"))
return json.dumps(sample["caption"], ensure_ascii=False, separators=(",", ":"))
def _as_channel_first(value: Any) -> torch.Tensor:
"""Normalize official latent or pixel output to a CPU float tensor."""
tensor = value if torch.is_tensor(value) else torch.as_tensor(value)
if tensor.ndim == 5 and tensor.shape[-1] == 3:
tensor = tensor.permute(0, 4, 1, 2, 3)
return tensor.detach().float().cpu()
def _configure_backends() -> None:
"""Use one deterministic CUDA policy for baseline generation and comparison."""
torch.use_deterministic_algorithms(True)
torch.backends.cudnn.enabled = False
torch.backends.cuda.matmul.allow_tf32 = False
torch.backends.cudnn.allow_tf32 = False
torch.backends.cuda.enable_cudnn_sdp(False)
def _model_root(variant: str, scratch: Path) -> Path:
"""Materialize the official base-generation component layout for one variant."""
components = ("scheduler", "text_encoder", "processor", "transformer", "vae")
if variant == "dense":
return download_components(OFFICIAL_DENSE, *components)
return materialize_component_view(OFFICIAL_MOE, scratch / "official_moe_base", *components)
def _initial_latents() -> torch.Tensor:
"""Create the fixed CPU noise tensor supplied to both implementations."""
return torch.randn(
(1, 16, (NUM_FRAMES - 1) // 4 + 1, HEIGHT // 8, WIDTH // 8),
generator=torch.Generator(device="cpu").manual_seed(SEED),
dtype=torch.float32,
)
def _run_reference(pipe: Any, case_dir: Path) -> dict[str, torch.Tensor]:
"""Capture real one-step DiT calls, the 40-step trajectory, and decoded pixels."""
from lingbot_video.pipeline_lingbot_video import DEFAULT_NEGATIVE_PROMPT
prompt = _prompt(case_dir)
initial_latents = _initial_latents()
with Image.open(case_dir / "first_frame.png") as image_file:
image = image_file.convert("RGB")
dit_calls: list[dict[str, torch.Tensor]] = []
text_encoder_calls: list[dict[str, torch.Tensor]] = []
def capture_text_encoder_call(
_module: Any,
_args: tuple[Any, ...],
kwargs: dict[str, Any],
_output: Any,
) -> None:
"""Retain the exact processor tensors supplied to both CFG text-encoder calls."""
if len(text_encoder_calls) < 2:
text_encoder_calls.append({
name: value.detach().cpu()
for name, value in kwargs.items()
if torch.is_tensor(value)
})
def capture_dit_call(_module: Any, args: tuple[Any, ...], kwargs: dict[str, Any], output: Any) -> None:
"""Retain the two sequential-CFG transformer calls from the one-step run."""
output_tensor = output[0] if isinstance(output, tuple) else output.sample
dit_calls.append({
"hidden_states": args[0].detach().cpu(),
"timestep": args[1].detach().cpu(),
"encoder_hidden_states": args[2].detach().cpu(),
"encoder_attention_mask": kwargs["encoder_attention_mask"].detach().cpu(),
"output": output_tensor.detach().float().cpu(),
})
text_encoder_hook = pipe.text_encoder.register_forward_hook(capture_text_encoder_call, with_kwargs=True)
hook = pipe.transformer.register_forward_hook(capture_dit_call, with_kwargs=True)
with torch.inference_mode():
one_step = pipe(
prompt=prompt,
image=image,
negative_prompt=DEFAULT_NEGATIVE_PROMPT,
height=HEIGHT,
width=WIDTH,
num_frames=NUM_FRAMES,
num_inference_steps=1,
guidance_scale=GUIDANCE_SCALE,
shift=FLOW_SHIFT,
generator=torch.Generator(device="cuda").manual_seed(SEED),
latents=initial_latents.clone(),
output_type="latent",
batch_cfg=False,
return_dict=True,
)
hook.remove()
text_encoder_hook.remove()
if len(dit_calls) != 2:
raise AssertionError(f"expected two sequential-CFG DiT calls, got {len(dit_calls)}")
if len(text_encoder_calls) != 2:
raise AssertionError(f"expected two sequential-CFG text-encoder calls, got {len(text_encoder_calls)}")
trajectory: list[torch.Tensor] = []
scheduler_step = pipe.scheduler.step
def capture_scheduler_step(*args: Any, **kwargs: Any) -> Any:
"""Retain every official scheduler output before clean-frame reinsertion."""
result = scheduler_step(*args, **kwargs)
trajectory.append(result[0].detach().float().cpu())
return result
pipe.scheduler.step = capture_scheduler_step
try:
with torch.inference_mode():
full = pipe(
prompt=prompt,
image=image,
negative_prompt=DEFAULT_NEGATIVE_PROMPT,
height=HEIGHT,
width=WIDTH,
num_frames=NUM_FRAMES,
num_inference_steps=40,
guidance_scale=GUIDANCE_SCALE,
shift=FLOW_SHIFT,
generator=torch.Generator(device="cuda").manual_seed(SEED),
latents=initial_latents.clone(),
output_type="np",
batch_cfg=False,
return_dict=True,
)
finally:
pipe.scheduler.step = scheduler_step
if len(trajectory) != 40:
raise AssertionError(f"expected 40 scheduler outputs, got {len(trajectory)}")
conditional, unconditional = dit_calls
guided = unconditional["output"] + GUIDANCE_SCALE * (conditional["output"] - unconditional["output"])
return {
"initial_latents": initial_latents,
"condition_latent": conditional["hidden_states"][:, :, :1],
"dit_hidden_states": conditional["hidden_states"],
"dit_timestep": conditional["timestep"],
"dit_prompt_embeds": conditional["encoder_hidden_states"],
"dit_prompt_mask": conditional["encoder_attention_mask"],
"dit_negative_embeds": unconditional["encoder_hidden_states"],
"dit_negative_mask": unconditional["encoder_attention_mask"],
"dit_conditional": conditional["output"],
"dit_unconditional": unconditional["output"],
"dit_guided": guided,
"text_encoder_calls": text_encoder_calls,
"one_step_latent": _as_channel_first(one_step.frames),
"trajectory_before_condition": torch.stack(trajectory, dim=1),
"pixels": _as_channel_first(full.frames),
}
def main() -> None:
"""Load one pinned official variant and write its immutable parity baseline."""
parser = argparse.ArgumentParser()
parser.add_argument("variant", choices=("dense", "moe"))
parser.add_argument("case_dir", type=Path)
parser.add_argument("output_dir", type=Path)
args = parser.parse_args()
args.output_dir.mkdir(parents=True, exist_ok=True)
_configure_backends()
from lingbot_video.runner import _load_diffusers_pipe
dtype_map = {
"default": torch.bfloat16,
"transformer": torch.bfloat16,
"text_encoder": torch.bfloat16,
"vae": torch.float32,
}
with tempfile.TemporaryDirectory(dir=args.output_dir) as temporary:
model_root = _model_root(args.variant, Path(temporary))
pipe = _load_diffusers_pipe(model_root, dtype_map, mode="ti2v", transformer_subfolder="transformer")
try:
tensors = _run_reference(pipe, args.case_dir)
finally:
del pipe
gc.collect()
torch.cuda.empty_cache()
torch.save(tensors, args.output_dir / "baseline.pt")
image_sha256 = hashlib.sha256((args.case_dir / "first_frame.png").read_bytes()).hexdigest()
manifest = {
"variant": args.variant,
"height": HEIGHT,
"width": WIDTH,
"num_frames": NUM_FRAMES,
"steps": [1, 40],
"guidance_scale": GUIDANCE_SCALE,
"flow_shift": FLOW_SHIFT,
"seed": SEED,
"qwen_attention": os.environ["LINGBOT_QWEN_ATTN_IMPLEMENTATION"],
"prompt": _prompt(args.case_dir),
"image_sha256": image_sha256,
"baseline": "baseline.pt",
}
(args.output_dir / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
if __name__ == "__main__":
main()
+2 -2
View File
@@ -28,11 +28,11 @@ OFFICIAL_MOE = HubModel(
)
FASTVIDEO_DENSE = HubModel(
"FastVideo/LingBot-Video-Dense-1.3B-Diffusers",
"743ed04b96d77150d952eb08a59a56ee61b9bc95",
"efca07f906aa17a9b03380e2fc58ef17089b4e91",
)
FASTVIDEO_MOE = HubModel(
"FastVideo/LingBot-Video-MoE-30B-A3B-Diffusers",
"401dce84db5897cb950969e766410c8eadd4fbdf",
"575e01b56b39f8fc31a8029ab1789339de078663",
)
@@ -6,10 +6,44 @@ import os
from pathlib import Path
import pytest
import torch
from scripts.checkpoint_conversion import lingbot_video_to_diffusers as converter
def test_converter_keeps_visual_tensors_beside_fused_language_tensors() -> None:
"""Preserve the compound Qwen3-VL tower while fusing native language projections."""
state = {
"model.visual.patch_embed.proj.weight": torch.ones(2, 1),
"model.language_model.layers.0.self_attn.q_proj.weight": torch.full((2, 2), 1.0),
"model.language_model.layers.0.self_attn.k_proj.weight": torch.full((1, 2), 2.0),
"model.language_model.layers.0.self_attn.v_proj.weight": torch.full((1, 2), 3.0),
"model.language_model.layers.0.mlp.gate_proj.weight": torch.full((2, 2), 4.0),
"model.language_model.layers.0.mlp.up_proj.weight": torch.full((2, 2), 5.0),
}
converted = converter._fuse_language_shard(state)
assert torch.equal(converted["visual.patch_embed.proj.weight"], state["model.visual.patch_embed.proj.weight"])
assert torch.equal(
converted["layers.0.self_attn.qkv_proj.weight"],
torch.cat(
[
state["model.language_model.layers.0.self_attn.q_proj.weight"],
state["model.language_model.layers.0.self_attn.k_proj.weight"],
state["model.language_model.layers.0.self_attn.v_proj.weight"],
]
),
)
assert torch.equal(
converted["layers.0.mlp.gate_up_proj.weight"],
torch.cat(
[
state["model.language_model.layers.0.mlp.gate_proj.weight"],
state["model.language_model.layers.0.mlp.up_proj.weight"],
]
),
)
def _make_source(tmp_path: Path, has_refiner: bool) -> Path:
"""Create the minimal official component tree exercised by ``convert``."""
source = tmp_path / "source"
@@ -38,7 +72,7 @@ def _fake_convert_text_encoder(_source: Path, destination: Path) -> dict[str, st
def _fake_write_text_encoder_config(_source: Path, _destination: Path) -> dict:
"""Return the metadata consumed by the converter's completion message."""
return {"architectures": ["LingBotVideoQwen3VLTextModel"]}
return {"architectures": ["LingBotVideoQwen3VLModel"]}
@pytest.mark.parametrize("has_refiner", [False, True])
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
"""Import, registry, stage-contract, and optional real Dense T2V smoke tests."""
"""Import, registry, stage-contract, and optional real LingBot-Video smoke tests."""
from __future__ import annotations
@@ -18,10 +18,12 @@ from tests.local_tests.lingbot_video.hf_assets import FASTVIDEO_DENSE, download_
def test_lingbot_video_pipeline_registry_and_preset(tmp_path: Path) -> None:
"""Verify exact class resolution, required modules, config, and official defaults."""
from fastvideo.api.presets import get_preset
from fastvideo.configs.pipelines.lingbot_video import LingBotVideoT2VConfig
from fastvideo.configs.models.encoders.lingbot_video import LingBotVideoQwen3VLConfig
from fastvideo.configs.pipelines.lingbot_video import LingBotVideoT2VConfig, LingBotVideoTI2VConfig
from fastvideo.fastvideo_args import WorkloadType
from fastvideo.pipelines.basic.lingbot_video.lingbot_video_pipeline import (
EntryClass,
LingBotVideoImageToVideoPipeline,
LingBotVideoPipeline,
)
from fastvideo.registry import get_model_info, get_preset_selection
@@ -31,7 +33,7 @@ def test_lingbot_video_pipeline_registry_and_preset(tmp_path: Path) -> None:
"_class_name": "LingBotVideoDensePipeline",
"_diffusers_version": "0.39.0",
"scheduler": ["diffusers", "FlowUniPCMultistepScheduler"],
"text_encoder": ["transformers", "LingBotVideoQwen3VLTextModel"],
"text_encoder": ["transformers", "LingBotVideoQwen3VLModel"],
"tokenizer": ["transformers", "Qwen3VLProcessor"],
"transformer": ["diffusers", "LingBotVideoTransformer3DModel"],
"vae": ["diffusers", "AutoencoderKLWan"],
@@ -41,7 +43,7 @@ def test_lingbot_video_pipeline_registry_and_preset(tmp_path: Path) -> None:
(model_dir / component).mkdir()
(model_dir / "model_index.json").write_text(json.dumps(model_index), encoding="utf-8")
assert model_index["_class_name"] == "LingBotVideoDensePipeline"
assert EntryClass is LingBotVideoPipeline
assert EntryClass == [LingBotVideoPipeline, LingBotVideoImageToVideoPipeline]
assert set(model_index) >= set(LingBotVideoPipeline._required_config_modules)
preset_name, family = get_preset_selection(str(model_dir))
assert (preset_name, family) == ("lingbot_video_dense_t2v", "lingbot_video")
@@ -60,6 +62,10 @@ def test_lingbot_video_pipeline_registry_and_preset(tmp_path: Path) -> None:
info = get_model_info(str(model_dir), workload_type=WorkloadType.T2V)
assert info.pipeline_cls is LingBotVideoPipeline
assert info.pipeline_config_cls is LingBotVideoT2VConfig
assert isinstance(LingBotVideoT2VConfig().text_encoder_configs[0], LingBotVideoQwen3VLConfig)
i2v_info = get_model_info(str(model_dir), workload_type=WorkloadType.I2V)
assert i2v_info.pipeline_cls is LingBotVideoImageToVideoPipeline
assert i2v_info.pipeline_config_cls is LingBotVideoTI2VConfig
def test_lingbot_video_prompt_crop_contract() -> None:
@@ -81,6 +87,27 @@ def test_lingbot_video_prompt_crop_contract() -> None:
assert torch.equal(embeds, hidden[:, 140:144])
def test_lingbot_video_tokenizer_loader_uses_multimodal_processor(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Load the required Qwen3-VL processor even without processor_config.json."""
from fastvideo.configs.pipelines.lingbot_video import LingBotVideoTI2VConfig
from fastvideo.models.loader import component_loader
expected = object()
monkeypatch.setattr(
component_loader.AutoProcessor,
"from_pretrained",
lambda *args, **kwargs: expected,
)
args = SimpleNamespace(
pipeline_config=LingBotVideoTI2VConfig(),
trust_remote_code=False,
)
assert component_loader.TokenizerLoader().load(str(tmp_path), args) is expected
def test_lingbot_video_latent_geometry_and_validation(monkeypatch: pytest.MonkeyPatch) -> None:
"""Validate 4n+1 frames, multiples of 16, and fp32 latent geometry."""
from fastvideo.pipelines.basic.lingbot_video import stages
@@ -90,6 +117,7 @@ def test_lingbot_video_latent_geometry_and_validation(monkeypatch: pytest.Monkey
transformer = SimpleNamespace(num_channels_latents=16)
stage = stages.LingBotVideoLatentPreparationStage(transformer)
supplied = torch.zeros(1, 16, 3, 8, 10, dtype=torch.bfloat16)
condition = torch.full((1, 16, 1, 8, 10), 0.25, dtype=torch.float32)
batch = ForwardBatch(
data_type="video",
prompt="fox",
@@ -97,10 +125,48 @@ def test_lingbot_video_latent_geometry_and_validation(monkeypatch: pytest.Monkey
height=64,
width=80,
latents=supplied,
image_latent=condition,
)
result = stage.forward(batch, cast(Any, SimpleNamespace()))
assert result.raw_latent_shape == (1, 16, 3, 8, 10)
assert result.latents is not None and result.latents.dtype == torch.float32
assert torch.equal(result.latents[:, :, :1], condition)
def test_lingbot_video_image_latent_stage_accepts_native_vae_distribution(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Consume FastVideo's direct posterior return instead of requiring a wrapper."""
from fastvideo.pipelines.basic.lingbot_video import stages
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
class DirectDistribution:
"""Expose the native Wan VAE sample contract used by the stage."""
def sample(self, generator: torch.Generator) -> torch.Tensor:
del generator
return torch.full((1, 16, 1, 2, 2), 0.5)
class NativeVAE:
"""Return the posterior directly, as FastVideo's Wan VAE does."""
config = SimpleNamespace(latents_mean=[0.0] * 16, latents_std=[1.0] * 16)
def encode(self, _pixels: torch.Tensor) -> DirectDistribution:
return DirectDistribution()
monkeypatch.setattr(stages, "get_local_torch_device", lambda: torch.device("cpu"))
batch = ForwardBatch(
data_type="video",
preprocessed_image=torch.zeros(1, 3, 1, 16, 16),
generator=torch.Generator().manual_seed(42),
)
result = stages.LingBotVideoImageLatentPreparationStage(NativeVAE()).forward(
batch,
cast(Any, SimpleNamespace(vae_cpu_offload=False)),
)
assert result.image_latent is not None
assert torch.equal(result.image_latent, torch.full((1, 16, 1, 2, 2), 0.5))
def test_lingbot_video_uses_released_vae_denormalization_arithmetic() -> None:
@@ -0,0 +1,334 @@
# SPDX-License-Identifier: Apache-2.0
"""Exact one-step, 40-step, and decoded-pixel LingBot-Video TI2V parity."""
from __future__ import annotations
import gc
import hashlib
import json
import os
import tempfile
from pathlib import Path
from typing import Any, cast
import pytest
import torch
os.environ.setdefault("FASTVIDEO_ATTENTION_BACKEND", "TORCH_SDPA")
os.environ.setdefault("LINGBOT_MOE_EXPERT_BACKEND", "grouped_mm")
os.environ.setdefault("LINGBOT_MOE_PAD_BACKEND", "loop")
os.environ.setdefault("LINGBOT_MOE_REORDER_BACKEND", "sort")
os.environ.setdefault("LINGBOT_MOE_RESTORE_BACKEND", "scatter")
from fastvideo.configs.models.dits.lingbot_video import LingBotVideoConfig
from fastvideo.models.dits.lingbot_video import LingBotVideoTransformer3DModel
from fastvideo.models.loader.fsdp_load import maybe_load_fsdp_model
from tests.local_tests.lingbot_video.hf_assets import (
FASTVIDEO_DENSE,
FASTVIDEO_MOE,
download_components,
materialize_component_view,
)
BASELINE_DIR = os.environ.get("LINGBOT_VIDEO_TI2V_BASELINE_DIR")
CASE_DIR = os.environ.get("LINGBOT_VIDEO_TI2V_CASE_DIR")
RESULT_DIR = os.environ.get("LINGBOT_VIDEO_TI2V_RESULT_DIR")
VARIANT = os.environ.get("LINGBOT_VIDEO_TI2V_VARIANT", "dense")
def _configure_backends(_worker: Any | None = None) -> dict[str, bool]:
"""Apply the baseline's deterministic CUDA policy in parent and worker processes."""
torch.use_deterministic_algorithms(True)
torch.backends.cudnn.enabled = False
torch.backends.cuda.matmul.allow_tf32 = False
torch.backends.cudnn.allow_tf32 = False
torch.backends.cuda.enable_cudnn_sdp(False)
return {
"deterministic": torch.are_deterministic_algorithms_enabled(),
"cudnn": torch.backends.cudnn.enabled,
"matmul_tf32": torch.backends.cuda.matmul.allow_tf32,
"cudnn_tf32": torch.backends.cudnn.allow_tf32,
"cudnn_sdp": torch.backends.cuda.cudnn_sdp_enabled(),
}
def _install_worker_dit_capture(worker: Any, capture_path: str) -> str:
"""Capture the first production text-encoder and DiT calls."""
pipeline = worker.pipeline
pipeline.post_init()
text_encoder = pipeline.get_module("text_encoder")
transformer = pipeline.get_module("transformer")
text_encoder_calls: list[dict[str, torch.Tensor]] = []
dit_calls: list[dict[str, torch.Tensor]] = []
def capture_text_encoder_call(
_module: Any,
_args: tuple[Any, ...],
kwargs: dict[str, Any],
_output: Any,
) -> None:
"""Retain the exact processor tensors supplied to both CFG encoder calls."""
if len(text_encoder_calls) < 2:
text_encoder_calls.append({
name: value.detach().cpu()
for name, value in kwargs.items()
if torch.is_tensor(value)
})
def capture_call(_module: Any, args: tuple[Any, ...], kwargs: dict[str, Any], output: Any) -> None:
"""Write the two one-step sequential-CFG calls after both have completed."""
if len(dit_calls) >= 2:
return
output_tensor = output[0] if isinstance(output, tuple) else output.sample
dit_calls.append({
"hidden_states": args[0].detach().float().cpu(),
"timestep": args[1].detach().float().cpu(),
"encoder_hidden_states": args[2].detach().cpu(),
"encoder_attention_mask": kwargs["encoder_attention_mask"].detach().cpu(),
"output": output_tensor.detach().float().cpu(),
})
if len(dit_calls) == 2:
torch.save({"text_encoder": text_encoder_calls, "dit": dit_calls}, capture_path)
worker._lingbot_ti2v_text_encoder_capture_handle = text_encoder.register_forward_hook(
capture_text_encoder_call,
with_kwargs=True,
)
worker._lingbot_ti2v_capture_handle = transformer.register_forward_hook(capture_call, with_kwargs=True)
return capture_path
def _require_inputs() -> tuple[Path, Path, Path]:
"""Require explicit baseline, example-input, and result directories."""
if os.environ.get("LINGBOT_VIDEO_RUN_GPU_TESTS") != "1":
pytest.skip("set LINGBOT_VIDEO_RUN_GPU_TESTS=1 on an allocated H200")
if not torch.cuda.is_available():
pytest.skip("LingBot-Video TI2V parity requires CUDA")
if VARIANT not in {"dense", "moe"}:
raise ValueError(f"unsupported TI2V parity variant: {VARIANT}")
if not BASELINE_DIR or not CASE_DIR or not RESULT_DIR:
raise ValueError(
"set LINGBOT_VIDEO_TI2V_BASELINE_DIR, LINGBOT_VIDEO_TI2V_CASE_DIR, "
"and LINGBOT_VIDEO_TI2V_RESULT_DIR"
)
return Path(BASELINE_DIR), Path(CASE_DIR), Path(RESULT_DIR)
def _model_root(scratch: Path) -> Path:
"""Materialize the converted base-generation components for the selected variant."""
components = ("scheduler", "text_encoder", "tokenizer", "transformer", "vae")
if VARIANT == "dense":
return download_components(FASTVIDEO_DENSE, *components)
return materialize_component_view(FASTVIDEO_MOE, scratch / "fastvideo_moe_base", *components)
def _load_native_transformer(transformer_dir: Path, device: torch.device) -> torch.nn.Module:
"""Strict-load one converted Dense or MoE DiT through FastVideo's production loader."""
hf_config = json.loads((transformer_dir / "config.json").read_text(encoding="utf-8"))
weight_files = sorted(str(path) for path in transformer_dir.glob("*.safetensors"))
return maybe_load_fsdp_model(
model_cls=LingBotVideoTransformer3DModel,
init_params={"config": LingBotVideoConfig(), "hf_config": hf_config},
weight_dir_list=weight_files,
device=device,
hsdp_replicate_dim=1,
hsdp_shard_dim=1,
default_dtype=torch.bfloat16,
param_dtype=torch.bfloat16,
reduce_dtype=torch.float32,
strict=True,
cpu_offload=False,
fsdp_inference=False,
training_mode=False,
pin_cpu_memory=False,
).eval()
def _run_native_dit(model_root: Path, baseline: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
"""Run the native DiT on the exact conditional and unconditional reference inputs."""
device = torch.device("cuda:0")
model = _load_native_transformer(model_root / "transformer", device)
hidden_states = baseline["dit_hidden_states"].to(device)
timestep = baseline["dit_timestep"].to(device)
results: dict[str, torch.Tensor] = {}
try:
with torch.inference_mode(), torch.autocast("cuda", dtype=torch.bfloat16):
for branch in ("conditional", "unconditional"):
embeds = baseline[f"dit_{'prompt' if branch == 'conditional' else 'negative'}_embeds"].to(device)
mask = baseline[f"dit_{'prompt' if branch == 'conditional' else 'negative'}_mask"].to(device)
output = model(
hidden_states,
timestep,
embeds,
encoder_attention_mask=mask,
return_dict=False,
)[0]
results[branch] = output.detach().float().cpu()
results["guided"] = results["unconditional"] + 3.0 * (
results["conditional"] - results["unconditional"]
)
return results
finally:
del model
gc.collect()
torch.cuda.empty_cache()
def _as_channel_first(value: Any) -> torch.Tensor:
"""Normalize FastVideo latent or pixel output to a CPU float tensor."""
tensor = value if torch.is_tensor(value) else torch.as_tensor(value)
if tensor.ndim == 5 and tensor.shape[-1] == 3:
tensor = tensor.permute(0, 4, 1, 2, 3)
return tensor.detach().float().cpu()
def _run_fastvideo(
model_root: Path,
case_dir: Path,
manifest: dict[str, Any],
baseline: dict[str, torch.Tensor],
initial_latents: torch.Tensor,
result_dir: Path,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""Run one-step and 40-step native TI2V calls without reloading the model."""
from fastvideo import VideoGenerator
from lingbot_video.pipeline_lingbot_video import DEFAULT_NEGATIVE_PROMPT
generator = VideoGenerator.from_pretrained(
str(model_root),
workload_type="i2v",
num_gpus=1,
sp_size=1,
use_fsdp_inference=False,
dit_cpu_offload=False,
dit_layerwise_offload=False,
vae_cpu_offload=True,
text_encoder_cpu_offload=True,
pin_cpu_memory=False,
output_type="np",
refine_enabled=False,
)
common = {
"prompt": manifest["prompt"],
"negative_prompt": DEFAULT_NEGATIVE_PROMPT,
"image_path": str(case_dir / "first_frame.png"),
"output_path": str(result_dir),
"save_video": False,
"return_frames": True,
"return_trajectory_latents": True,
"height": manifest["height"],
"width": manifest["width"],
"num_frames": manifest["num_frames"],
"guidance_scale": manifest["guidance_scale"],
"batch_cfg": False,
"seed": manifest["seed"],
}
try:
states = generator.executor.collective_rpc(_configure_backends)
assert all(state == _configure_backends() for state in states)
capture_path = result_dir / "pipeline_dit_calls.pt"
generator.executor.collective_rpc(
_install_worker_dit_capture,
kwargs={"capture_path": str(capture_path)},
)
one_step = cast(
dict[str, Any],
generator.generate_video(num_inference_steps=1, latents=initial_latents.clone(), **common),
)
calls = torch.load(capture_path, map_location="cpu", weights_only=True)
for index, branch in enumerate(("conditional", "unconditional")):
expected_inputs = baseline["text_encoder_calls"][index]
actual_inputs = calls["text_encoder"][index]
assert actual_inputs.keys() == expected_inputs.keys()
for name in actual_inputs:
metrics = _metrics(actual_inputs[name], expected_inputs[name])
print(f"pipeline_{branch}_text_encoder_{name}={json.dumps(metrics, sort_keys=True)}")
assert metrics["equal"]
for index, branch in enumerate(("conditional", "unconditional")):
expected_prefix = "prompt" if branch == "conditional" else "negative"
comparisons = {
f"pipeline_{branch}_hidden_states": (calls["dit"][index]["hidden_states"], baseline["dit_hidden_states"]),
f"pipeline_{branch}_timestep": (calls["dit"][index]["timestep"], baseline["dit_timestep"]),
f"pipeline_{branch}_embeds": (
calls["dit"][index]["encoder_hidden_states"],
baseline[f"dit_{expected_prefix}_embeds"],
),
f"pipeline_{branch}_mask": (
calls["dit"][index]["encoder_attention_mask"],
baseline[f"dit_{expected_prefix}_mask"],
),
f"pipeline_{branch}_output": (calls["dit"][index]["output"], baseline[f"dit_{branch}"]),
}
for name, (actual, expected) in comparisons.items():
metrics = _metrics(actual, expected)
print(f"{name}={json.dumps(metrics, sort_keys=True)}")
assert metrics["equal"]
full = cast(
dict[str, Any],
generator.generate_video(num_inference_steps=40, latents=initial_latents.clone(), **common),
)
one_trajectory = cast(torch.Tensor, one_step["trajectory"])
full_trajectory = cast(torch.Tensor, full["trajectory"])
return (
one_trajectory[:, 0].detach().float().cpu(),
full_trajectory.detach().float().cpu(),
_as_channel_first(full["samples"]),
)
finally:
generator.shutdown()
def _metrics(actual: torch.Tensor, expected: torch.Tensor) -> dict[str, int | float | bool]:
"""Return exact-equality and absolute-drift metrics for one tensor boundary."""
difference = (actual - expected).abs()
return {
"equal": torch.equal(actual, expected),
"differing": int(torch.count_nonzero(actual != expected).item()),
"max_abs": float(difference.max().item()),
"mean_abs": float(difference.float().mean().item()),
}
def test_lingbot_video_ti2v_exact_parity() -> None:
"""Require exact actual-input DiT, one-step, 40-step, and pixel parity."""
baseline_dir, case_dir, result_dir = _require_inputs()
result_dir.mkdir(parents=True, exist_ok=True)
manifest = json.loads((baseline_dir / "manifest.json").read_text(encoding="utf-8"))
if manifest["variant"] != VARIANT:
raise ValueError(f"baseline variant {manifest['variant']} does not match requested {VARIANT}")
image_sha256 = hashlib.sha256((case_dir / "first_frame.png").read_bytes()).hexdigest()
if image_sha256 != manifest["image_sha256"]:
raise ValueError("condition image does not match the reference baseline")
baseline = torch.load(baseline_dir / manifest["baseline"], map_location="cpu", weights_only=True)
_configure_backends()
with tempfile.TemporaryDirectory(dir=result_dir) as temporary:
model_root = _model_root(Path(temporary))
dit = _run_native_dit(model_root, baseline)
results = {
"dit_conditional": _metrics(dit["conditional"], baseline["dit_conditional"]),
"dit_unconditional": _metrics(dit["unconditional"], baseline["dit_unconditional"]),
"dit_guided": _metrics(dit["guided"], baseline["dit_guided"]),
}
for name, metrics in results.items():
print(f"{name}={json.dumps(metrics, sort_keys=True)}")
assert metrics["equal"]
one_step, trajectory, pixels = _run_fastvideo(
model_root,
case_dir,
manifest,
baseline,
baseline["initial_latents"],
result_dir,
)
conditioned_reference = baseline["trajectory_before_condition"].clone()
condition_frames = baseline["condition_latent"].shape[2]
conditioned_reference[:, :, :, :condition_frames] = baseline["condition_latent"][:, None]
results.update({
"one_step_latent": _metrics(one_step, baseline["one_step_latent"]),
"forty_step_trajectory": _metrics(trajectory, conditioned_reference),
"decoded_pixels": _metrics(pixels, baseline["pixels"]),
})
(result_dir / "result.json").write_text(json.dumps(results, indent=2) + "\n", encoding="utf-8")
for name in ("one_step_latent", "forty_step_trajectory", "decoded_pixels"):
print(f"{name}={json.dumps(results[name], sort_keys=True)}")
assert results[name]["equal"]
@@ -0,0 +1,77 @@
# SPDX-License-Identifier: Apache-2.0
"""Multi-GPU production smoke for LingBot-Video TI2V plus refinement."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, cast
import numpy as np
import pytest
import torch
from PIL import Image
from tests.local_tests.lingbot_video.hf_assets import FASTVIDEO_MOE, download_components
def test_lingbot_video_moe_ti2v_refiner_pipeline_smoke(tmp_path: Path) -> None:
"""Run one conditioned frame through image prompting, both VAEs, and both MoE DiTs."""
if os.environ.get("LINGBOT_VIDEO_RUN_REFINER_PIPELINE_TESTS") != "1":
pytest.skip("Set LINGBOT_VIDEO_RUN_REFINER_PIPELINE_TESTS=1 on a scheduled H200 node.")
required_gpus = int(os.environ.get("LINGBOT_VIDEO_REFINER_NUM_GPUS", "8"))
if not torch.cuda.is_available() or torch.cuda.device_count() < required_gpus:
pytest.skip(f"LingBot-Video TI2V refiner smoke requires {required_gpus} CUDA devices.")
local_checkpoint = os.environ.get("LINGBOT_VIDEO_TI2V_CHECKPOINT")
model_dir = Path(local_checkpoint) if local_checkpoint else download_components(
FASTVIDEO_MOE,
"scheduler",
"text_encoder",
"tokenizer",
"transformer",
"transformer_2",
"vae",
)
image_path = tmp_path / "condition.png"
Image.fromarray(np.full((64, 96, 3), 127, dtype=np.uint8), mode="RGB").save(image_path)
from fastvideo import VideoGenerator
generator = VideoGenerator.from_pretrained(
str(model_dir),
workload_type="i2v",
num_gpus=required_gpus,
sp_size=required_gpus,
use_fsdp_inference=True,
refine_enabled=True,
dit_cpu_offload=False,
dit_layerwise_offload=False,
vae_cpu_offload=False,
text_encoder_cpu_offload=True,
pin_cpu_memory=False,
)
try:
result = generator.generate_video(
prompt="A red fox runs through fresh snow at sunrise.",
image_path=str(image_path),
output_path=str(tmp_path),
save_video=False,
return_frames=True,
height=32,
width=32,
height_sr=64,
width_sr=64,
num_frames=1,
num_inference_steps=1,
num_inference_steps_sr=1,
guidance_scale=3.0,
guidance_scale_2=3.0,
t_thresh=0.85,
batch_cfg=True,
seed=42,
)
finally:
generator.shutdown()
samples = cast(dict[str, Any], result)["samples"]
assert torch.is_tensor(samples)
assert tuple(samples.shape) == (1, 3, 1, 64, 64)
assert torch.isfinite(samples).all()
@@ -0,0 +1,137 @@
# SPDX-License-Identifier: Apache-2.0
"""Exact LingBot-Video TI2V refiner-state parity from one shared MP4."""
from __future__ import annotations
import gc
import json
import os
from pathlib import Path
import pytest
import torch
from diffusers import AutoencoderKLWan as OfficialAutoencoderKLWan
from PIL import Image
from safetensors.torch import load_file
from torch.nn.attention import SDPBackend, sdpa_kernel
from fastvideo.configs.models.vaes.wanvae import WanVAEConfig
from fastvideo.models.vaes.wanvae import AutoencoderKLWan as FastVideoAutoencoderKLWan
from fastvideo.pipelines.basic.lingbot_video.stages import LingBotVideoRefinerPreparationStage
from tests.local_tests.lingbot_video.hf_assets import FASTVIDEO_MOE, OFFICIAL_MOE, download_components
def _load_native(checkpoint: Path, device: torch.device) -> FastVideoAutoencoderKLWan:
"""Strict-load the released full VAE into FastVideo's native implementation."""
vae_dir = checkpoint / "vae"
raw_config = json.loads((vae_dir / "config.json").read_text())
config = WanVAEConfig()
config.update_model_arch({key: value for key, value in raw_config.items() if not key.startswith("_")})
config.load_encoder = True
config.load_decoder = True
model = FastVideoAutoencoderKLWan(config)
model.load_state_dict(load_file(vae_dir / "diffusion_pytorch_model.safetensors"), strict=True)
return model.to(device=device, dtype=torch.float32).eval()
def _encode(vae: torch.nn.Module, video: torch.Tensor, generator: torch.Generator) -> torch.Tensor:
"""Run the released refiner VAE encode and DiT-space normalization boundary."""
device = next(vae.parameters()).device
normalized = video.to(device=device, dtype=torch.float32).mul(2.0).sub(1.0)
with torch.inference_mode(), torch.autocast("cuda", dtype=torch.bfloat16), sdpa_kernel(SDPBackend.MATH):
encoded = vae.encode(normalized)
distribution = encoded.latent_dist if hasattr(encoded, "latent_dist") else encoded
latents = distribution.sample(generator)
mean = torch.tensor(vae.config.latents_mean, device=device, dtype=torch.float32).view(1, -1, 1, 1, 1)
std_inverse = 1.0 / torch.tensor(
vae.config.latents_std,
device=device,
dtype=torch.float32,
).view(1, -1, 1, 1, 1)
return ((latents.float() - mean) * std_inverse).to(latents)
def _prepare_state(
vae: torch.nn.Module,
video: torch.Tensor,
clean_frame: torch.Tensor,
device: torch.device,
) -> dict[str, torch.Tensor]:
"""Encode video then clean frame and create the seeded TI2V refiner state."""
generator = torch.Generator(device=device).manual_seed(42)
encoded = _encode(vae, video, generator)
clean_latent = _encode(vae, clean_frame, generator)[:, :, :1].contiguous()
encoded[:, :, :1] = clean_latent.to(encoded.dtype)
noise = torch.randn(encoded.shape, generator=generator, device=device, dtype=encoded.dtype)
initial = (1.0 - 0.85) * encoded + 0.85 * noise
return {
"encoded": encoded.detach().cpu(),
"clean_latent": clean_latent.detach().cpu(),
"noise": noise.detach().cpu(),
"initial_latent": initial.detach().cpu(),
}
def _report_exact(name: str, expected: torch.Tensor, actual: torch.Tensor) -> None:
"""Print exact drift metrics and require zero differing values."""
difference = (actual.float() - expected.float()).abs()
print(
f"{name}: equal={torch.equal(actual, expected)} "
f"differing={torch.count_nonzero(actual != expected).item()} "
f"max_abs={difference.max().item():.8f} mean_abs={difference.mean().item():.8f}"
)
assert torch.equal(actual, expected)
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Wan VAE refiner parity requires CUDA")
def test_lingbot_video_ti2v_refiner_state_exact_parity() -> None:
"""Require exact official/native refiner initialization from one reloaded MP4."""
if os.environ.get("LINGBOT_VIDEO_RUN_GPU_TESTS") != "1":
pytest.skip("set LINGBOT_VIDEO_RUN_GPU_TESTS=1 on a scheduled GPU")
shared_mp4 = os.environ.get("LINGBOT_VIDEO_TI2V_SHARED_BASE_MP4")
condition_image = os.environ.get("LINGBOT_VIDEO_TI2V_CONDITION_IMAGE")
if not shared_mp4 or not condition_image:
pytest.skip("set the shared base MP4 and clean condition image paths")
from decord import VideoReader, cpu
from lingbot_video.utils import (
compute_training_aligned_indices,
load_first_frame_condition_tensor,
load_refiner_video_tensor,
)
expected_video, metadata = load_refiner_video_tensor(shared_mp4, 1088, 1920, sample_fps=24, vae_tc=4)
assert metadata["sample_frame"] == 121
reader = VideoReader(shared_mp4, ctx=cpu(0))
indices = compute_training_aligned_indices(len(reader), metadata["sample_frame"])
raw_frames = torch.from_numpy(reader.get_batch(indices).asnumpy()).permute(0, 3, 1, 2).float().div(255.0)
raw_video = raw_frames.permute(1, 0, 2, 3).unsqueeze(0).contiguous()
actual_video = LingBotVideoRefinerPreparationStage._resize_video(raw_video, 1088, 1920)
_report_exact("resized_video_pixels", expected_video, actual_video)
expected_clean = load_first_frame_condition_tensor(condition_image, 1088, 1920, 480, 832)
with Image.open(condition_image) as image:
actual_clean = LingBotVideoRefinerPreparationStage._resize_clean_condition(
image,
1088,
1920,
480,
832,
)
_report_exact("clean_condition_pixels", expected_clean, actual_clean)
device = torch.device("cuda")
official_path = os.environ.get("LINGBOT_VIDEO_OFFICIAL_CHECKPOINT")
official_checkpoint = Path(official_path) if official_path else download_components(OFFICIAL_MOE, "vae")
converted_path = os.environ.get("LINGBOT_VIDEO_TI2V_CHECKPOINT")
converted_checkpoint = Path(converted_path) if converted_path else download_components(FASTVIDEO_MOE, "vae")
official = OfficialAutoencoderKLWan.from_pretrained(
official_checkpoint / "vae",
torch_dtype=torch.float32,
local_files_only=True,
).to(device).eval()
expected = _prepare_state(official, expected_video, expected_clean, device)
del official
gc.collect()
torch.cuda.empty_cache()
native = _load_native(converted_checkpoint, device)
actual = _prepare_state(native, actual_video, actual_clean, device)
for name in ("encoded", "clean_latent", "noise", "initial_latent"):
_report_exact(name, expected[name], actual[name])
@@ -0,0 +1,77 @@
# SPDX-License-Identifier: Apache-2.0
"""Exact clean-frame VAE encoder parity for LingBot-Video TI2V."""
from __future__ import annotations
import json
import os
from pathlib import Path
import numpy as np
import pytest
import torch
from diffusers import AutoencoderKLWan as OfficialAutoencoderKLWan
from PIL import Image
from safetensors.torch import load_file
from fastvideo.configs.models.vaes.wanvae import WanVAEConfig
from fastvideo.models.vaes.wanvae import AutoencoderKLWan as FastVideoAutoencoderKLWan
from fastvideo.pipelines.basic.lingbot_video.stages import _preprocess_condition_image
from tests.local_tests.lingbot_video.hf_assets import FASTVIDEO_DENSE, OFFICIAL_DENSE, download_components
def _load_native(checkpoint: Path, device: torch.device) -> FastVideoAutoencoderKLWan:
"""Strict-load the released full VAE into FastVideo's native implementation."""
vae_dir = checkpoint / "vae"
raw_config = json.loads((vae_dir / "config.json").read_text())
config = WanVAEConfig()
config.update_model_arch({key: value for key, value in raw_config.items() if not key.startswith("_")})
config.load_encoder = True
config.load_decoder = True
model = FastVideoAutoencoderKLWan(config)
model.load_state_dict(load_file(vae_dir / "diffusion_pytorch_model.safetensors"), strict=True)
return model.to(device=device, dtype=torch.float32).eval()
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Wan VAE parity requires CUDA")
def test_lingbot_video_ti2v_vae_encoder_exact_parity() -> None:
"""Require equal posterior parameters and samples for one clean frame."""
if os.environ.get("LINGBOT_VIDEO_RUN_GPU_TESTS") != "1":
pytest.skip("set LINGBOT_VIDEO_RUN_GPU_TESTS=1 on a scheduled GPU")
device = torch.device("cuda")
local_official = os.environ.get("LINGBOT_VIDEO_OFFICIAL_CHECKPOINT")
official_checkpoint = Path(local_official) if local_official else download_components(OFFICIAL_DENSE, "vae")
local_converted = os.environ.get("LINGBOT_VIDEO_TI2V_CHECKPOINT")
converted_checkpoint = Path(local_converted) if local_converted else download_components(FASTVIDEO_DENSE, "vae")
y, x = np.mgrid[0:941, 0:1672]
image = Image.fromarray(
np.stack((x % 256, y % 256, (x + y) % 256), axis=-1).astype(np.uint8),
mode="RGB",
)
pixels = _preprocess_condition_image(image, 480, 832).to(device)
normalized = (pixels - 0.5) / 0.5
official = OfficialAutoencoderKLWan.from_pretrained(
official_checkpoint / "vae",
torch_dtype=torch.float32,
local_files_only=True,
).to(device).eval()
native = _load_native(converted_checkpoint, device)
with torch.no_grad(), torch.autocast("cuda", dtype=torch.bfloat16):
expected = official.encode(normalized).latent_dist
actual = native.encode(normalized)
expected_generator = torch.Generator(device=device).manual_seed(42)
actual_generator = torch.Generator(device=device).manual_seed(42)
expected_sample = expected.sample(expected_generator)
actual_sample = actual.sample(actual_generator)
for name, expected_tensor, actual_tensor in (
("mean", expected.mean, actual.mean),
("logvar", expected.logvar, actual.logvar),
("sample", expected_sample, actual_sample),
):
difference = (actual_tensor.float() - expected_tensor.float()).abs()
print(
f"{name}: equal={torch.equal(actual_tensor, expected_tensor)} "
f"differing={torch.count_nonzero(actual_tensor != expected_tensor).item()} "
f"max_abs={difference.max().item():.8f} mean_abs={difference.mean().item():.8f}"
)
assert torch.equal(actual_tensor, expected_tensor)