[feat]: add native Z-Image transformer prototype

This commit is contained in:
SolitaryThinker
2026-07-13 17:46:16 -07:00
parent 5421a77e71
commit fac19aa6fa
7 changed files with 1018 additions and 23 deletions
+2 -1
View File
@@ -12,6 +12,7 @@ from fastvideo.configs.models.dits.ltx2 import LTX2VideoConfig
from fastvideo.configs.models.dits.magi_human import MagiHumanVideoConfig
from fastvideo.configs.models.dits.stable_audio import StableAudioConfig
from fastvideo.configs.models.dits.wanvideo import WanVideoConfig
from fastvideo.configs.models.dits.zimage import ZImageDiTConfig
from fastvideo.configs.models.dits.hyworld import HYWorldConfig
from fastvideo.configs.models.dits.kandinsky5 import Kandinsky5VideoConfig
@@ -19,5 +20,5 @@ __all__ = [
"HunyuanVideoConfig", "HunyuanVideo15Config", "HunyuanGameCraftConfig", "WanVideoConfig", "DreamXWorldConfig",
"DreamXWorldARConfig", "CosmosVideoConfig", "Cosmos25VideoConfig", "FluxDiTConfig", "Flux2Config",
"LongCatVideoConfig", "LTX2VideoConfig", "HYWorldConfig", "Kandinsky5VideoConfig", "MagiHumanVideoConfig",
"StableAudioConfig", "GlmImageDiTConfig"
"StableAudioConfig", "GlmImageDiTConfig", "ZImageDiTConfig"
]
+60
View File
@@ -0,0 +1,60 @@
# SPDX-License-Identifier: Apache-2.0
from dataclasses import dataclass, field
from fastvideo.configs.models.dits.base import DiTArchConfig, DiTConfig
from fastvideo.platforms import AttentionBackendEnum
def is_zimage_block(name: str, module) -> bool:
parts = name.split(".")
return len(parts) >= 2 and parts[-2] in {"noise_refiner", "context_refiner", "layers"} and parts[-1].isdigit()
@dataclass
class ZImageDiTArchConfig(DiTArchConfig):
_fsdp_shard_conditions: list = field(default_factory=lambda: [is_zimage_block])
_supported_attention_backends: tuple[AttentionBackendEnum, ...] = (AttentionBackendEnum.TORCH_SDPA, )
all_patch_size: tuple[int, ...] = (2, )
all_f_patch_size: tuple[int, ...] = (1, )
in_channels: int = 16
dim: int = 3840
n_layers: int = 30
n_refiner_layers: int = 2
n_heads: int = 30
n_kv_heads: int = 30
norm_eps: float = 1e-5
qk_norm: bool = True
cap_feat_dim: int = 2560
rope_theta: float = 256.0
t_scale: float = 1000.0
axes_dims: tuple[int, ...] = (32, 48, 48)
axes_lens: tuple[int, ...] = (1536, 512, 512)
adaln_embed_dim: int = 256
frequency_embedding_size: int = 256
timestep_mid_size: int = 1024
max_period: int = 10000
seq_multi_of: int = 32
def __post_init__(self) -> None:
super().__post_init__()
if len(self.all_patch_size) != len(self.all_f_patch_size):
raise ValueError("all_patch_size and all_f_patch_size must have equal length")
if self.dim % self.n_heads:
raise ValueError("dim must be divisible by n_heads")
if self.dim // self.n_heads != sum(self.axes_dims):
raise ValueError("attention head dimension must equal sum(axes_dims)")
if len(self.axes_dims) != len(self.axes_lens) or any(dim % 2 for dim in self.axes_dims):
raise ValueError("RoPE axes require matching lengths and even dimensions")
self.hidden_size = self.dim
self.num_attention_heads = self.n_heads
self.num_channels_latents = self.in_channels
self.out_channels = self.in_channels
@dataclass
class ZImageDiTConfig(DiTConfig):
arch_config: DiTArchConfig = field(default_factory=ZImageDiTArchConfig)
prefix: str = "ZImage"
+570
View File
@@ -0,0 +1,570 @@
# SPDX-License-Identifier: Apache-2.0
"""FastVideo-native Z-Image transformer.
Z-Image attends over padded variable-length image/text streams and requires a
key-padding mask. FastVideo's distributed attention wrappers do not expose that
mask contract yet, so this implementation uses torch SDPA and is SP=1 only.
"""
from __future__ import annotations
import math
from typing import Any
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.nn.utils.rnn import pad_sequence
from fastvideo.configs.models.dits.zimage import ZImageDiTConfig
from fastvideo.distributed.parallel_state import get_sp_world_size, model_parallel_is_initialized
from fastvideo.layers.linear import ReplicatedLinear
from fastvideo.models.dits.base import BaseDiT
from fastvideo.platforms import AttentionBackendEnum
def _linear(layer: ReplicatedLinear, x: torch.Tensor) -> torch.Tensor:
return layer(x)[0]
def _prepare_attention_mask(attention_mask: torch.Tensor | None, dtype: torch.dtype) -> torch.Tensor | None:
if attention_mask is None:
return None
if attention_mask.ndim == 2:
attention_mask = attention_mask[:, None, None, :]
if attention_mask.dtype == torch.bool:
additive_mask = torch.zeros_like(attention_mask, dtype=dtype)
additive_mask.masked_fill_(~attention_mask, float("-inf"))
return additive_mask
return attention_mask
class TimestepEmbedder(nn.Module):
def __init__(
self,
out_size: int,
mid_size: int,
frequency_embedding_size: int,
max_period: int,
) -> None:
super().__init__()
self.mlp = nn.ModuleList([
ReplicatedLinear(frequency_embedding_size, mid_size, bias=True),
nn.SiLU(),
ReplicatedLinear(mid_size, out_size, bias=True),
])
self.frequency_embedding_size = frequency_embedding_size
self.max_period = max_period
@staticmethod
def timestep_embedding(t: torch.Tensor, dim: int, max_period: int) -> torch.Tensor:
with torch.amp.autocast("cuda", enabled=False):
half = dim // 2
freqs = torch.exp(
-math.log(max_period) * torch.arange(half, dtype=torch.float32, device=t.device) / half)
args = t[:, None].float() * freqs[None]
embedding = torch.cat([torch.cos(args), torch.sin(args)], dim=-1)
if dim % 2:
embedding = torch.cat([embedding, torch.zeros_like(embedding[:, :1])], dim=-1)
return embedding
def forward(self, t: torch.Tensor) -> torch.Tensor:
t_freq = self.timestep_embedding(t, self.frequency_embedding_size, self.max_period)
weight_dtype = self.mlp[0].weight.dtype
if weight_dtype.is_floating_point:
t_freq = t_freq.to(weight_dtype)
return _linear(self.mlp[2], self.mlp[1](_linear(self.mlp[0], t_freq)))
class RMSNorm(nn.Module):
def __init__(self, dim: int, eps: float = 1e-5) -> None:
super().__init__()
self.eps = eps
self.weight = nn.Parameter(torch.ones(dim))
def forward(self, x: torch.Tensor) -> torch.Tensor:
output = x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)
return output * self.weight
class FeedForward(nn.Module):
def __init__(self, dim: int, hidden_dim: int) -> None:
super().__init__()
self.w1 = ReplicatedLinear(dim, hidden_dim, bias=False)
self.w2 = ReplicatedLinear(hidden_dim, dim, bias=False)
self.w3 = ReplicatedLinear(dim, hidden_dim, bias=False)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return _linear(self.w2, F.silu(_linear(self.w1, x)) * _linear(self.w3, x))
def apply_rotary_emb(x_in: torch.Tensor, freqs_cis: torch.Tensor) -> torch.Tensor:
with torch.amp.autocast("cuda", enabled=False):
x = torch.view_as_complex(x_in.float().reshape(*x_in.shape[:-1], -1, 2))
x_out = torch.view_as_real(x * freqs_cis.unsqueeze(2)).flatten(3)
return x_out.type_as(x_in)
class ZImageAttention(nn.Module):
def __init__(self, dim: int, n_heads: int, n_kv_heads: int, qk_norm: bool = True, eps: float = 1e-5) -> None:
super().__init__()
self.n_heads = n_heads
self.n_kv_heads = n_kv_heads
self.head_dim = dim // n_heads
self.to_q = ReplicatedLinear(dim, n_heads * self.head_dim, bias=False)
self.to_k = ReplicatedLinear(dim, n_kv_heads * self.head_dim, bias=False)
self.to_v = ReplicatedLinear(dim, n_kv_heads * self.head_dim, bias=False)
self.to_out = nn.ModuleList([ReplicatedLinear(n_heads * self.head_dim, dim, bias=False)])
self.norm_q = RMSNorm(self.head_dim, eps=eps) if qk_norm else None
self.norm_k = RMSNorm(self.head_dim, eps=eps) if qk_norm else None
def forward(
self,
hidden_states: torch.Tensor,
attention_mask: torch.Tensor | None = None,
freqs_cis: torch.Tensor | None = None,
) -> torch.Tensor:
query = _linear(self.to_q, hidden_states).unflatten(-1, (self.n_heads, -1))
key = _linear(self.to_k, hidden_states).unflatten(-1, (self.n_kv_heads, -1))
value = _linear(self.to_v, hidden_states).unflatten(-1, (self.n_kv_heads, -1))
if self.norm_q is not None:
query = self.norm_q(query)
if self.norm_k is not None:
key = self.norm_k(key)
if freqs_cis is not None:
query = apply_rotary_emb(query, freqs_cis)
key = apply_rotary_emb(key, freqs_cis)
mask = _prepare_attention_mask(attention_mask, query.dtype)
hidden_states = F.scaled_dot_product_attention(
query.transpose(1, 2),
key.transpose(1, 2),
value.transpose(1, 2),
attn_mask=mask,
dropout_p=0.0,
is_causal=False,
).transpose(1, 2).contiguous()
return _linear(self.to_out[0], hidden_states.flatten(2, 3).to(query.dtype))
class ZImageTransformerBlock(nn.Module):
def __init__(
self,
layer_id: int,
dim: int,
n_heads: int,
n_kv_heads: int,
norm_eps: float,
qk_norm: bool,
adaln_embed_dim: int,
modulation: bool = True,
) -> None:
super().__init__()
self.dim = dim
self.head_dim = dim // n_heads
self.layer_id = layer_id
self.modulation = modulation
self.attention = ZImageAttention(dim, n_heads, n_kv_heads, qk_norm, norm_eps)
self.feed_forward = FeedForward(dim=dim, hidden_dim=int(dim / 3 * 8))
self.attention_norm1 = RMSNorm(dim, eps=norm_eps)
self.ffn_norm1 = RMSNorm(dim, eps=norm_eps)
self.attention_norm2 = RMSNorm(dim, eps=norm_eps)
self.ffn_norm2 = RMSNorm(dim, eps=norm_eps)
if modulation:
self.adaLN_modulation = nn.ModuleList(
[ReplicatedLinear(min(dim, adaln_embed_dim), 4 * dim, bias=True)])
def forward(
self,
x: torch.Tensor,
attn_mask: torch.Tensor,
freqs_cis: torch.Tensor,
adaln_input: torch.Tensor | None = None,
) -> torch.Tensor:
if self.modulation:
assert adaln_input is not None
scale_msa, gate_msa, scale_mlp, gate_mlp = _linear(
self.adaLN_modulation[0], adaln_input).unsqueeze(1).chunk(4, dim=2)
gate_msa, gate_mlp = gate_msa.tanh(), gate_mlp.tanh()
scale_msa, scale_mlp = 1.0 + scale_msa, 1.0 + scale_mlp
attn_out = self.attention(
self.attention_norm1(x) * scale_msa,
attention_mask=attn_mask,
freqs_cis=freqs_cis,
)
x = x + gate_msa * self.attention_norm2(attn_out)
x = x + gate_mlp * self.ffn_norm2(self.feed_forward(self.ffn_norm1(x) * scale_mlp))
else:
attn_out = self.attention(
self.attention_norm1(x),
attention_mask=attn_mask,
freqs_cis=freqs_cis,
)
x = x + self.attention_norm2(attn_out)
x = x + self.ffn_norm2(self.feed_forward(self.ffn_norm1(x)))
return x
class FinalLayer(nn.Module):
def __init__(self, hidden_size: int, out_channels: int, adaln_embed_dim: int) -> None:
super().__init__()
self.norm_final = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
self.linear = ReplicatedLinear(hidden_size, out_channels, bias=True)
self.adaLN_modulation = nn.ModuleList([
nn.SiLU(),
ReplicatedLinear(min(hidden_size, adaln_embed_dim), hidden_size, bias=True),
])
def forward(self, x: torch.Tensor, c: torch.Tensor) -> torch.Tensor:
scale = 1.0 + _linear(self.adaLN_modulation[1], self.adaLN_modulation[0](c))
return _linear(self.linear, self.norm_final(x) * scale.unsqueeze(1))
class RopeEmbedder:
def __init__(self, theta: float, axes_dims: tuple[int, ...], axes_lens: tuple[int, ...]) -> None:
if len(axes_dims) != len(axes_lens):
raise ValueError("RoPE axes require matching dimensions and lengths")
self.theta = theta
self.axes_dims = axes_dims
self.axes_lens = axes_lens
self.freqs_cis: list[torch.Tensor] | None = None
@staticmethod
def precompute_freqs_cis(dim: tuple[int, ...], end: tuple[int, ...], theta: float) -> list[torch.Tensor]:
with torch.device("cpu"):
freqs_cis = []
for axis_dim, axis_end in zip(dim, end):
freqs = 1.0 / (theta**(torch.arange(0, axis_dim, 2, dtype=torch.float64) / axis_dim))
timestep = torch.arange(axis_end, dtype=torch.float64)
angles = torch.outer(timestep, freqs).float()
freqs_cis.append(torch.polar(torch.ones_like(angles), angles).to(torch.complex64))
return freqs_cis
def __call__(self, ids: torch.Tensor) -> torch.Tensor:
if ids.ndim != 2 or ids.shape[-1] != len(self.axes_dims):
raise ValueError("RoPE ids must have shape [sequence, number_of_axes]")
if self.freqs_cis is None:
self.freqs_cis = [
freqs.to(ids.device)
for freqs in self.precompute_freqs_cis(self.axes_dims, self.axes_lens, self.theta)
]
elif self.freqs_cis[0].device != ids.device:
self.freqs_cis = [freqs.to(ids.device) for freqs in self.freqs_cis]
return torch.cat([self.freqs_cis[i][ids[:, i]] for i in range(len(self.axes_dims))], dim=-1)
class ZImageTransformer2DModel(BaseDiT):
_default_config = ZImageDiTConfig()
_fsdp_shard_conditions = _default_config.arch_config._fsdp_shard_conditions
_compile_conditions = _default_config.arch_config._compile_conditions
_supported_attention_backends = (AttentionBackendEnum.TORCH_SDPA, )
param_names_mapping = _default_config.arch_config.param_names_mapping
reverse_param_names_mapping = _default_config.arch_config.reverse_param_names_mapping
def __init__(self, config: ZImageDiTConfig, hf_config: dict[str, Any]) -> None:
super().__init__(config=config, hf_config=hf_config)
arch = config.arch_config
self.in_channels = arch.in_channels
self.out_channels = arch.in_channels
self.all_patch_size = tuple(arch.all_patch_size)
self.all_f_patch_size = tuple(arch.all_f_patch_size)
self.dim = arch.dim
self.n_heads = arch.n_heads
self.rope_theta = arch.rope_theta
self.t_scale = arch.t_scale
self.seq_multi_of = arch.seq_multi_of
self.hidden_size = arch.dim
self.num_attention_heads = arch.n_heads
self.num_channels_latents = arch.in_channels
self.all_x_embedder = nn.ModuleDict({
f"{patch_size}-{f_patch_size}": ReplicatedLinear(
f_patch_size * patch_size * patch_size * arch.in_channels, arch.dim, bias=True)
for patch_size, f_patch_size in zip(self.all_patch_size, self.all_f_patch_size)
})
self.all_final_layer = nn.ModuleDict({
f"{patch_size}-{f_patch_size}": FinalLayer(
arch.dim,
patch_size * patch_size * f_patch_size * self.out_channels,
arch.adaln_embed_dim,
)
for patch_size, f_patch_size in zip(self.all_patch_size, self.all_f_patch_size)
})
block_kwargs = {
"dim": arch.dim,
"n_heads": arch.n_heads,
"n_kv_heads": arch.n_kv_heads,
"norm_eps": arch.norm_eps,
"qk_norm": arch.qk_norm,
"adaln_embed_dim": arch.adaln_embed_dim,
}
self.noise_refiner = nn.ModuleList([
ZImageTransformerBlock(1000 + layer_id, modulation=True, **block_kwargs)
for layer_id in range(arch.n_refiner_layers)
])
self.context_refiner = nn.ModuleList([
ZImageTransformerBlock(layer_id, modulation=False, **block_kwargs)
for layer_id in range(arch.n_refiner_layers)
])
self.t_embedder = TimestepEmbedder(
min(arch.dim, arch.adaln_embed_dim),
mid_size=arch.timestep_mid_size,
frequency_embedding_size=arch.frequency_embedding_size,
max_period=arch.max_period,
)
self.cap_embedder = nn.ModuleList([
RMSNorm(arch.cap_feat_dim, eps=arch.norm_eps),
ReplicatedLinear(arch.cap_feat_dim, arch.dim, bias=True),
])
self.x_pad_token = nn.Parameter(torch.empty((1, arch.dim)))
self.cap_pad_token = nn.Parameter(torch.empty((1, arch.dim)))
self.layers = nn.ModuleList([
ZImageTransformerBlock(layer_id, modulation=True, **block_kwargs) for layer_id in range(arch.n_layers)
])
self.axes_dims = tuple(arch.axes_dims)
self.axes_lens = tuple(arch.axes_lens)
self.rope_embedder = RopeEmbedder(arch.rope_theta, self.axes_dims, self.axes_lens)
self.__post_init__()
def unpatchify(
self,
x: list[torch.Tensor],
size: list[tuple[int, int, int]],
patch_size: int,
f_patch_size: int,
) -> list[torch.Tensor]:
patch_height = patch_width = patch_size
patch_frames = f_patch_size
if len(x) != len(size):
raise ValueError("output batch and original sizes must have equal length")
for i, (frames, height, width) in enumerate(size):
original_length = (frames // patch_frames) * (height // patch_height) * (width // patch_width)
x[i] = (x[i][:original_length].view(
frames // patch_frames,
height // patch_height,
width // patch_width,
patch_frames,
patch_height,
patch_width,
self.out_channels,
).permute(6, 0, 3, 1, 4, 2, 5).reshape(self.out_channels, frames, height, width))
return x
@staticmethod
def create_coordinate_grid(
size: tuple[int, int, int],
start: tuple[int, int, int] | None = None,
device: torch.device | None = None,
) -> torch.Tensor:
start = start or (0, ) * len(size)
axes = [
torch.arange(axis_start, axis_start + span, dtype=torch.int32, device=device)
for axis_start, span in zip(start, size)
]
return torch.stack(torch.meshgrid(axes, indexing="ij"), dim=-1)
def patchify_and_embed(
self,
all_image: list[torch.Tensor],
all_cap_feats: list[torch.Tensor],
patch_size: int,
f_patch_size: int,
) -> tuple[
list[torch.Tensor],
list[torch.Tensor],
list[tuple[int, int, int]],
list[torch.Tensor],
list[torch.Tensor],
list[torch.Tensor],
list[torch.Tensor],
]:
patch_height = patch_width = patch_size
patch_frames = f_patch_size
device = all_image[0].device
image_out = []
image_sizes = []
image_pos_ids = []
image_pad_masks = []
cap_pos_ids = []
cap_pad_masks = []
cap_feats_out = []
for image, cap_feat in zip(all_image, all_cap_feats):
cap_length = len(cap_feat)
cap_padding = (-cap_length) % self.seq_multi_of
cap_pos_ids.append(
self.create_coordinate_grid(
(cap_length + cap_padding, 1, 1),
start=(1, 0, 0),
device=device,
).flatten(0, 2))
cap_pad_masks.append(
torch.cat([
torch.zeros(cap_length, dtype=torch.bool, device=device),
torch.ones(cap_padding, dtype=torch.bool, device=device),
]) if cap_padding else torch.zeros(cap_length, dtype=torch.bool, device=device))
cap_feats_out.append(
torch.cat([cap_feat, cap_feat[-1:].repeat(cap_padding, 1)]) if cap_padding else cap_feat)
channels, frames, height, width = image.size()
image_sizes.append((frames, height, width))
frame_tokens, height_tokens, width_tokens = (
frames // patch_frames,
height // patch_height,
width // patch_width,
)
image = image.view(
channels,
frame_tokens,
patch_frames,
height_tokens,
patch_height,
width_tokens,
patch_width,
)
image = image.permute(1, 3, 5, 2, 4, 6, 0).reshape(
frame_tokens * height_tokens * width_tokens,
patch_frames * patch_height * patch_width * channels,
)
image_length = len(image)
image_padding = (-image_length) % self.seq_multi_of
original_pos_ids = self.create_coordinate_grid(
(frame_tokens, height_tokens, width_tokens),
start=(cap_length + cap_padding + 1, 0, 0),
device=device,
).flatten(0, 2)
if image_padding:
padding_pos_ids = self.create_coordinate_grid((1, 1, 1), device=device).flatten(0, 2).repeat(
image_padding, 1)
image_pos_ids.append(torch.cat([original_pos_ids, padding_pos_ids]))
else:
image_pos_ids.append(original_pos_ids)
image_pad_masks.append(
torch.cat([
torch.zeros(image_length, dtype=torch.bool, device=device),
torch.ones(image_padding, dtype=torch.bool, device=device),
]) if image_padding else torch.zeros(image_length, dtype=torch.bool, device=device))
image_out.append(
torch.cat([image, image[-1:].repeat(image_padding, 1)]) if image_padding else image)
return (
image_out,
cap_feats_out,
image_sizes,
image_pos_ids,
cap_pos_ids,
image_pad_masks,
cap_pad_masks,
)
@staticmethod
def _attention_mask(lengths: list[int], device: torch.device) -> torch.Tensor:
mask = torch.zeros((len(lengths), max(lengths)), dtype=torch.bool, device=device)
for i, length in enumerate(lengths):
mask[i, :length] = True
return mask
def forward(
self,
hidden_states: torch.Tensor | list[torch.Tensor],
encoder_hidden_states: torch.Tensor | list[torch.Tensor],
timestep: torch.LongTensor,
encoder_hidden_states_image: torch.Tensor | list[torch.Tensor] | None = None,
guidance=None,
patch_size: int = 2,
f_patch_size: int = 1,
**kwargs,
) -> tuple[list[torch.Tensor], dict]:
del encoder_hidden_states_image, guidance, kwargs
if model_parallel_is_initialized() and get_sp_world_size() != 1:
raise NotImplementedError(
"Z-Image masked SDPA does not support sequence parallelism; run with sp_size=1")
if patch_size not in self.all_patch_size or f_patch_size not in self.all_f_patch_size:
raise ValueError(f"unsupported patch sizes: spatial={patch_size}, temporal={f_patch_size}")
if isinstance(hidden_states, torch.Tensor):
hidden_states = list(hidden_states.unbind(0))
if isinstance(encoder_hidden_states, torch.Tensor):
encoder_hidden_states = list(encoder_hidden_states.unbind(0))
device = hidden_states[0].device
timestep_embedding = self.t_embedder(timestep * self.t_scale)
(
hidden_states,
encoder_hidden_states,
image_sizes,
image_pos_ids,
cap_pos_ids,
image_inner_pad_masks,
cap_inner_pad_masks,
) = self.patchify_and_embed(hidden_states, encoder_hidden_states, patch_size, f_patch_size)
image_lengths = [len(item) for item in hidden_states]
if not all(length % self.seq_multi_of == 0 for length in image_lengths):
raise ValueError("padded image sequence lengths must be aligned")
hidden_states = torch.cat(hidden_states)
hidden_states = _linear(self.all_x_embedder[f"{patch_size}-{f_patch_size}"], hidden_states)
adaln_input = timestep_embedding.type_as(hidden_states)
hidden_states[torch.cat(image_inner_pad_masks)] = self.x_pad_token
hidden_states = list(hidden_states.split(image_lengths))
image_freqs_cis = list(
self.rope_embedder(torch.cat(image_pos_ids)).split([len(item) for item in image_pos_ids]))
hidden_states = pad_sequence(hidden_states, batch_first=True, padding_value=0.0)
image_freqs_cis = pad_sequence(image_freqs_cis, batch_first=True, padding_value=0.0)
image_freqs_cis = image_freqs_cis[:, :hidden_states.shape[1]]
image_attn_mask = self._attention_mask(image_lengths, device)
for layer in self.noise_refiner:
hidden_states = layer(hidden_states, image_attn_mask, image_freqs_cis, adaln_input)
cap_lengths = [len(item) for item in encoder_hidden_states]
if not all(length % self.seq_multi_of == 0 for length in cap_lengths):
raise ValueError("padded caption sequence lengths must be aligned")
encoder_hidden_states = torch.cat(encoder_hidden_states)
encoder_hidden_states = _linear(self.cap_embedder[1], self.cap_embedder[0](encoder_hidden_states))
encoder_hidden_states[torch.cat(cap_inner_pad_masks)] = self.cap_pad_token
encoder_hidden_states = list(encoder_hidden_states.split(cap_lengths))
cap_freqs_cis = list(self.rope_embedder(torch.cat(cap_pos_ids)).split([len(item) for item in cap_pos_ids]))
encoder_hidden_states = pad_sequence(encoder_hidden_states, batch_first=True, padding_value=0.0)
cap_freqs_cis = pad_sequence(cap_freqs_cis, batch_first=True, padding_value=0.0)
cap_freqs_cis = cap_freqs_cis[:, :encoder_hidden_states.shape[1]]
cap_attn_mask = self._attention_mask(cap_lengths, device)
for layer in self.context_refiner:
encoder_hidden_states = layer(encoder_hidden_states, cap_attn_mask, cap_freqs_cis)
unified = []
unified_freqs_cis = []
for i, (image_length, cap_length) in enumerate(zip(image_lengths, cap_lengths)):
unified.append(
torch.cat([hidden_states[i][:image_length], encoder_hidden_states[i][:cap_length]]))
unified_freqs_cis.append(
torch.cat([image_freqs_cis[i][:image_length], cap_freqs_cis[i][:cap_length]]))
unified_lengths = [image_length + cap_length for image_length, cap_length in zip(image_lengths, cap_lengths)]
unified = pad_sequence(unified, batch_first=True, padding_value=0.0)
unified_freqs_cis = pad_sequence(unified_freqs_cis, batch_first=True, padding_value=0.0)
unified_attn_mask = self._attention_mask(unified_lengths, device)
for layer in self.layers:
unified = layer(unified, unified_attn_mask, unified_freqs_cis, adaln_input)
unified = self.all_final_layer[f"{patch_size}-{f_patch_size}"](unified, adaln_input)
outputs = self.unpatchify(list(unified.unbind(0)), image_sizes, patch_size, f_patch_size)
return outputs, {}
EntryClass = ZImageTransformer2DModel
+1
View File
@@ -64,6 +64,7 @@ _IMAGE_TO_VIDEO_DIT_MODELS = {
# Text-to-image DiT models (2D image generation)
_TEXT_TO_IMAGE_DIT_MODELS = {
"GlmImageTransformer2DModel": ("dits", "glm_image", "GlmImageTransformer2DModel"),
"ZImageTransformer2DModel": ("dits", "zimage", "ZImageTransformer2DModel"),
}
_TEXT_ENCODER_MODELS = {
+17 -12
View File
@@ -5,7 +5,7 @@
- model_family: `zimage`
- variant: `Z-Image-Turbo`
- workload_types: `T2I`
- PR scope: component-only (`text_encoder`, `tokenizer`, `vae`, `scheduler`)
- PR scope: components plus native transformer prototype/parity
- official_ref: `https://github.com/Tongyi-MAI/Z-Image`
- official_ref_dir: `<repo_root>/Z-Image/src`
- official_ref_commit: `26f23eda626ffadda020b04ff79488e1d72004cd`
@@ -20,10 +20,10 @@ intermediate size 9728, 32 attention heads, 8 KV heads, and head dimension
## Current Phase
- phase: Phase 6 (component parity revalidation)
- phase: Phase 6 (transformer parity activation and component revalidation)
- status: in_progress
- owner: parity
- last_updated: 2026-07-04
- owner: dit/parity
- last_updated: 2026-07-13
## Component Matrix
@@ -33,7 +33,7 @@ intermediate size 9728, 32 attention heads, 8 KV heads, and head dimension
| Tokenizer | tokenizer | reused | `AutoTokenizer` from `<weights>/tokenizer/`; official thinking template and length 512 | `TokenizerLoader` | DONE | not_needed | REVALIDATION REQUIRED (historical unpinned PASS) | I003, I010 |
| VAE | vae | reused with accepted wrapper exception | Pinned `zimage.AutoencoderKL`; official decode uses `(latents / scaling_factor) + shift_factor` | Existing Diffusers-backed `fastvideo.models.vaes.autoencoder_kl.AutoencoderKL` through production `VAELoader`, plus direct implementation coverage | DONE | not_needed | REVALIDATION REQUIRED (historical unpinned direct-decode PASS only) | I008 |
| Scheduler | scheduler | reused with Z-Image extension | Pinned `zimage.FlowMatchEulerDiscreteScheduler`; official pipeline sets `sigma_min=0.0` | `FlowMatchEulerDiscreteScheduler(use_reference_discrete_timesteps=True, sigma_min=0.0)` | DONE | not_needed | REVALIDATION REQUIRED; asset-free regressions remain separately runnable | I002, I009 |
| Transformer (`ZImageTransformer2DModel`) | dit | planned native port | `<weights>/transformer/` and pinned official source | future PR | NOT_STARTED | unknown_until_prototype | NOT_STARTED | I004 |
| Transformer (`ZImageTransformer2DModel`) | dit | native port | Pinned `src/zimage/transformer.py`; published Diffusers transformer subfolder | `fastvideo.models.dits.zimage.ZImageTransformer2DModel` through `TransformerLoader` | DONE (production meta + tiny CPU forward) | not_needed; exact 521-key surface | TINY NON-SKIP PASS; production real-weight forward PENDING | I011 |
| Pipeline | pipeline | planned | Pinned `ZImagePipeline` | future PR | NOT_STARTED | depends_on_transformer | NOT_STARTED | I005 |
## Conversion State
@@ -41,8 +41,9 @@ intermediate size 9728, 32 attention heads, 8 KV heads, and head dimension
- conversion_script: none for the reused components in this component-only PR
- converted_weights_dir: none
- source_layout: diffusers
- future_transformer_conversion: unknown until native key/shape prototype exists
- strict_load_status: revalidation_required
- transformer_conversion: not_needed; official, HF index, and native names align exactly
- transformer_key_surface: 521 tensors; sorted-key SHA256 `3a9216f208c1873b2cf06394411a53e1e95e10fae3b01dca0f7223556e47c354`
- strict_load_status: transformer real-weight strict load pending; other components revalidation_required
- production_passthrough: body-only Qwen3 `AutoModel`, tokenizer assets
- direct_load_components: native Qwen3 parity target, VAE, scheduler
- native_qwen_exclusion: `lm_head.weight` only; the native encoder owns the body, not an LM head
@@ -60,6 +61,7 @@ intermediate size 9728, 32 attention heads, 8 KV heads, and head dimension
| Text encoder fp32 | `pytest tests/local_tests/zimage/test_zimage_encoder_parity.py::test_zimage_qwen3_encoder_parity_forward[fp32] -v -s` | RERUN REQUIRED; historical unpinned native PASS on L40S, 2026-06-21 | New run must cover independent reference, body-only production path, and native implementation. |
| Text encoder bf16 | `pytest tests/local_tests/zimage/test_zimage_encoder_parity.py::test_zimage_qwen3_encoder_parity_forward[bf16] -v -s` | RERUN REQUIRED; historical unpinned native PASS on L40S, 2026-06-21 | Historical thresholds retained pending pinned-snapshot validation. |
| Per-layer bf16 diagnostic | `pytest tests/local_tests/zimage/test_zimage_encoder_parity.py::test_zimage_qwen3_encoder_per_layer_bf16_diagnostic -v -s` | RERUN REQUIRED; historical unpinned informational run on L40S, 2026-06-21 | Pinned architecture has 36 blocks and 37 hidden-state entries under the tested contract. |
| Transformer meta/tiny parity | `ZIMAGE_OFFICIAL_REF_DIR=<pinned-clone> pytest tests/local_tests/zimage/test_zimage_transformer_parity.py -v -s` | 4 PASSED, 1 SKIPPED on CPU, 2026-07-13 | Production 521-key/shape surface and pinned HF key digest match; additive-mask attention and tiny variable-length full-forward parity are exact; SP>1 guard passes. Real-weight test skipped because the 24.6 GB transformer subfolder/CUDA were absent. |
## Open Questions
@@ -68,7 +70,7 @@ intermediate size 9728, 32 attention heads, 8 KV heads, and head dimension
| Q001 | Which exact Z-Image source revision is the parity oracle? | prep | Phase 1 | resolved | Pinned `Tongyi-MAI/Z-Image@26f23eda626ffadda020b04ff79488e1d72004cd`; scheduler/VAE tests fail on a wrong HEAD or import origin. |
| Q002 | Which immutable HF snapshot supplies component weights? | prep | Phase 1 | resolved | `Tongyi-MAI/Z-Image-Turbo@f332072aa78be7aecdf3ee76d5c247082da564a6`. |
| Q003 | What prompt/tokenization/hidden-state contract does the official pipeline use? | pipeline | Phase 6 | resolved | `apply_chat_template(tokenize=False, add_generation_prompt=True, enable_thinking=True)`, tokenization length 512, and valid-token `hidden_states[-2]`. |
| Q004 | Does the future native transformer require conversion? | conversion | Phase 5 of future transformer PR | open | Decide from official/native key-shape dumps; Diffusers layout alone is not proof. |
| Q004 | Does the native transformer require conversion? | conversion | Phase 5 | resolved | No. Pinned HF index, official production meta model, and FastVideo production meta model expose the same 521 keys; official/native key shapes match exactly. |
## Issues And Blockers
@@ -77,13 +79,14 @@ intermediate size 9728, 32 attention heads, 8 KV heads, and head dimension
| I001 | Phase 6 | text_encoder | medium | The full checkpoint includes `lm_head.weight`, while the native FastVideo encoder owns only the body. | Native parity allowlists exactly `lm_head.weight`; production uses body-only Transformers `AutoModel`. | parity | in_progress | Implementation is complete; resolve only after the pinned fp32/bf16 rerun. |
| I002 | Phase 7 | scheduler/pipeline | high | Published `scheduler_config.json` omits the reference-schedule flag and official zero endpoint. | Pinned pipeline mutates `sigma_min=0.0`; parity requires both scheduler values. | pipeline | open | Future pipeline config must serialize `use_reference_discrete_timesteps=True` and `sigma_min=0.0`. |
| I003 | Phase 6 | tokenizer/text_encoder | medium | Prompt formatting and target length previously differed from the official call. | Pinned source uses thinking chat template, 512 tokens, and `hidden_states[-2]`. | parity | in_progress | Test/config behavior corrected; pinned asset rerun required before resolving. |
| I004 | Phase 4 | transformer | high | `ZImageTransformer2DModel` is not ported. | No FastVideo target or parity test exists. | port | open | Separate future PR. |
| I004 | Phase 4 | transformer | high | `ZImageTransformer2DModel` was not ported. | Native config/model/registry and deterministic parity test now exist. | port | resolved | Production meta surface matches all 521 pinned HF keys; tiny full-forward parity passes exactly. |
| I005 | Phase 7 | pipeline | high | No FastVideo pipeline/config/preset/registry/example or quality regression exists. | Component-only PR scope. | pipeline | open | Separate future PR after component gates pass. |
| I006 | Phase 6 | text_encoder | high | Production loading previously used a causal-LM object and could override CPU placement, while parity exercised only direct native construction. | Production path is now body-only `AutoModel`; loader records `_fastvideo_input_device`; stage moves tokens to that device; parity uses a distinct reference instance. | encoder | in_progress | Implementation complete; pinned production-loader rerun required. |
| I007 | Phase 6 | text_encoder | high | Lenient native loading could miss one fused source shard or an auxiliary quantization scale. | Loader completeness now covers every destination, required split shard, exact fused destination, and mapped scale parameter. | encoder | in_progress | Asset-free strictness regressions and pinned checkpoint load must pass before resolving. |
| I008 | Phase 6 | vae | medium | Direct decode alone did not cover production registry/config/device/strict-load behavior; the shared target is a runtime Diffusers wrapper. | VAE parity scope is now `both` and includes `VAELoader` plus official normalization. | parity | in_progress | Existing Diffusers `AutoencoderKL` wrapper exception is accepted only for this component-only PR; pinned rerun required. |
| I009 | Phase 2 | reference assets | high | Adding a nonexistent clone path to `sys.path` could import another installed `zimage` package and yield false parity. | Tests now verify exact clone HEAD and module paths under `Z-Image/src`; `/Z-Image/` is ignored. | prep | resolved | Wrong SHA/path/import is a failure; absent local assets may skip. |
| I010 | Phase 6 | tokenizer | medium | Missing `apply_chat_template` previously skipped and could hide an incompatible tokenizer. | Tokenizer parity now asserts the API and exact official kwargs. | parity | resolved | Missing chat-template behavior fails. Asset-backed numerical rerun remains I003. |
| I011 | Phase 6 | transformer | high | Production real-weight strict load and full-forward parity are not verified. | The pinned transformer is 24.6 GB and is not present locally; the CUDA test scaffold therefore skipped. | parity | open | Run `test_zimage_transformer_production_loader_forward_parity` non-skip with the pinned HF transformer subfolder on CUDA and record drift. |
## Escape Hatches
@@ -94,6 +97,7 @@ intermediate size 9728, 32 attention heads, 8 KV heads, and head dimension
| Date | Decision | Rationale | Impact |
|---|---|---|---|
| 2026-07-13 | Preserve the published transformer key surface and use raw masked SDPA at SP=1. | The checkpoint already matches the official class, while padded variable-length attention needs a key mask unavailable in current distributed wrappers. | No converter is needed; runtime rejects SP>1 until a mask-aware distributed path has parity evidence. |
| 2026-07-04 | Pin both external sources and treat all earlier GPU results as historical until rerun. | Earlier evidence did not record an immutable HF revision, and source imports were not origin-checked. | README has executable clone/download commands; tests enforce the source SHA; all asset-backed parity rows are revalidation-required. |
| 2026-07-04 | Use a body-only Transformers `AutoModel` for production and a separate `AutoModel` object as the parity oracle. | Every FastVideo consumer needs hidden states, not full-vocabulary logits or an LM head; comparing an object with itself is not independent evidence. | Production avoids the LM head while parity covers production loading and native implementation separately. |
| 2026-07-04 | Make fused-source and quantization-scale completeness part of native Qwen strict loading. | A loaded destination name alone did not prove that every required Q/K/V or gate/up shard and auxiliary scale arrived. | Missing split shards or mapped scale parameters fail; an exact fused destination remains valid. |
@@ -115,6 +119,7 @@ intermediate size 9728, 32 attention heads, 8 KV heads, and head dimension
fused/split source completeness, and auxiliary quantization scales.
- VAE revalidation must cover both direct implementation decode and production
`VAELoader` behavior with official scaling/shift normalization.
- Next separate-PR work: transformer prototype/parity, conversion decision,
pipeline/config/preset/registry/example, pipeline parity, and image-quality
regression.
- Transformer production key/shape and tiny output parity pass. The real-weight
production-loader/full-forward CUDA test remains I011 and must pass non-skip.
- Next work after I011: pipeline/config/preset/registry/example, pipeline parity,
and image-quality regression.
+29 -10
View File
@@ -1,10 +1,10 @@
# Z-Image Local Tests
Local-only component coverage for the `zimage` FastVideo port (`T2I`,
Z-Image-Turbo only). This component-only PR covers the Qwen3 text encoder,
tokenizer, AutoencoderKL VAE, and FlowMatchEulerDiscreteScheduler. The native
`ZImageTransformer2DModel`, pipeline, pipeline parity, example, and image-quality
regression remain out of scope.
Z-Image-Turbo only). Coverage includes the Qwen3 text encoder, tokenizer,
AutoencoderKL VAE, FlowMatchEulerDiscreteScheduler, and a native
`ZImageTransformer2DModel`. Pipeline wiring, pipeline parity, examples, and
image-quality regression remain out of scope.
> **Status:** DRAFT port, in progress. Every test that consumes the reference
> clone or HF assets requires a fresh non-skip rerun against the immutable pins
@@ -18,14 +18,14 @@ regression remain out of scope.
|---|---|
| Model family / variant | `zimage` / `Z-Image-Turbo` |
| Workload | `T2I` |
| Component-only PR scope | text encoder, tokenizer, VAE, scheduler |
| Component scope | text encoder, tokenizer, VAE, scheduler, native transformer |
| Official reference | `https://github.com/Tongyi-MAI/Z-Image` |
| Local reference dir | `<repo_root>/Z-Image/src` |
| Official commit | `26f23eda626ffadda020b04ff79488e1d72004cd` |
| HF weights | `Tongyi-MAI/Z-Image-Turbo@f332072aa78be7aecdf3ee76d5c247082da564a6` |
| Local weights dir | `<repo_root>/official_weights/Z-Image/` (`text_encoder/`, `tokenizer/`, `vae/`, `scheduler/`) |
| Local weights dir | `<repo_root>/official_weights/Z-Image/` (component subfolders; transformer is 24.6 GB) |
| Source layout | Diffusers-style per-component subfolders |
| Conversion | not needed for the reused components in this PR; the future native transformer/full-pipeline decision will come from native and official key/shape prototypes |
| Conversion | not needed; the native and official production transformer expose the same 521 checkpoint keys/shapes |
| HF token env | `HF_TOKEN` (the pinned repository is public; never record a token value) |
The pinned text-encoder config declares `Qwen3ForCausalLM` with 36 decoder
@@ -69,14 +69,18 @@ python .agents/skills/add-model-01-prep/scripts/download_hf_weights.py \
--allow-pattern 'scheduler/*'
```
The real-weight transformer test additionally needs `transformer/*` from the
same pinned revision (24.6 GB). Do not download it without approving that cost;
point `ZIMAGE_TRANSFORMER_DIR` at an existing pinned transformer subfolder.
Do not change core dependency versions (`torch`, `diffusers`, `transformers`,
`flash-attn`, `triton`, or CUDA packages) without explicit approval.
```text
dependency_changes: none
official_env_status: not_verified
official_env_status: transformer source import and tiny CPU parity verified; asset-backed components pending
private_dep_stubs: none
blocked_on: pinned-snapshot non-skip component reruns
blocked_on: pinned-snapshot component reruns and transformer real-weight CUDA parity
```
## Run the tests
@@ -91,6 +95,7 @@ pytest tests/local_tests/zimage/ -v -s
| Tokenizer ([`test_zimage_tokenizer_parity.py`](./test_zimage_tokenizer_parity.py)) | `production_loader`; exact `apply_chat_template(tokenize=False, add_generation_prompt=True, enable_thinking=True)` and `max_length=512`; absence of `apply_chat_template` is a failure, not a skip | REVALIDATION REQUIRED (historical unpinned PASS) |
| VAE ([`test_zimage_vae_parity.py`](./test_zimage_vae_parity.py)) | `both`; direct implementation decode plus production `VAELoader`; production check applies `(latents / scaling_factor) + shift_factor` before decode | REVALIDATION REQUIRED (historical unpinned direct-decode PASS only) |
| Text encoder ([`test_zimage_encoder_parity.py`](./test_zimage_encoder_parity.py)) | `both`; independent Transformers `AutoModel` reference, body-only production loader, and FastVideo-native implementation; fp32/bf16 output checks plus fused/split/quant-scale strictness | REVALIDATION REQUIRED (historical unpinned native PASS only) |
| Transformer ([`test_zimage_transformer_parity.py`](./test_zimage_transformer_parity.py)) | `both`; pinned production meta key/shape surface, additive-mask attention parity, deterministic tiny CPU full-forward parity, explicit `sp_size=1` guard, and real-weight production-loader/full-forward scaffold | TINY PARITY PASS (4 passed); REAL-WEIGHT PARITY PENDING (24.6 GB weights absent) |
## Component contracts
@@ -133,10 +138,24 @@ its `num_steps + 1` schedule. A future Z-Image pipeline config must set both
`use_reference_discrete_timesteps=True` and `sigma_min=0.0`; the published
`scheduler_config.json` alone does not encode the complete runtime contract.
### Transformer
- The FastVideo-native transformer preserves all 521 published checkpoint keys.
The sorted key digest from the pinned HF safetensors index is
`3a9216f208c1873b2cf06394411a53e1e95e10fae3b01dca0f7223556e47c354`.
- Tiny CPU parity uses the pinned official class, identical deterministic state,
variable-length image/text batches, and concrete full-forward output checks.
- Attention uses raw torch SDPA because the padded variable-length stream needs
a key mask not exposed by FastVideo's distributed wrappers. Runtime rejects
initialized sequence-parallel world sizes above one.
- The production-loader/full-forward test runs when the pinned transformer
subfolder and CUDA are available; its non-skip result is still required.
## Remaining work
- Run every asset-backed component test non-skip against the pinned clone and HF
snapshot before claiming component parity.
- Port and validate `ZImageTransformer2DModel` in a separate PR.
- Run the transformer production-loader/full-forward parity test non-skip with
the pinned 24.6 GB transformer weights on CUDA.
- Add the Z-Image pipeline/config/preset/registry/example, pipeline parity, and
image-quality regression after all component gates pass.
@@ -0,0 +1,339 @@
# SPDX-License-Identifier: Apache-2.0
"""Z-Image transformer parity.
Coverage scope: both. The tiny CPU tests compare the pinned implementation and
FastVideo model directly. The production-loader test activates when the real
transformer subfolder is available.
"""
from __future__ import annotations
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import sys
from types import ModuleType
import pytest
import torch
from torch.testing import assert_close
from fastvideo.configs.models.dits import ZImageDiTConfig
from fastvideo.models.dits.zimage import ZImageAttention, ZImageTransformer2DModel, _prepare_attention_mask
REPO_ROOT = Path(__file__).resolve().parents[3]
OFFICIAL_REF_DIR = Path(os.getenv("ZIMAGE_OFFICIAL_REF_DIR", REPO_ROOT / "Z-Image"))
OFFICIAL_SRC = OFFICIAL_REF_DIR / "src"
TRANSFORMER_DIR = Path(
os.getenv("ZIMAGE_TRANSFORMER_DIR", REPO_ROOT / "official_weights" / "Z-Image" / "transformer"))
REFERENCE_REVISION = "26f23eda626ffadda020b04ff79488e1d72004cd"
HF_REVISION = "f332072aa78be7aecdf3ee76d5c247082da564a6"
HF_KEY_COUNT = 521
HF_KEY_SHA256 = "3a9216f208c1873b2cf06394411a53e1e95e10fae3b01dca0f7223556e47c354"
PARITY_SCOPE = "both"
PRODUCTION_CONFIG = {
"all_patch_size": (2, ),
"all_f_patch_size": (1, ),
"in_channels": 16,
"dim": 3840,
"n_layers": 30,
"n_refiner_layers": 2,
"n_heads": 30,
"n_kv_heads": 30,
"norm_eps": 1e-5,
"qk_norm": True,
"cap_feat_dim": 2560,
"rope_theta": 256.0,
"t_scale": 1000.0,
"axes_dims": (32, 48, 48),
"axes_lens": (1536, 512, 512),
}
TINY_CONFIG = {
"all_patch_size": (2, ),
"all_f_patch_size": (1, ),
"in_channels": 2,
"dim": 12,
"n_layers": 1,
"n_refiner_layers": 1,
"n_heads": 2,
"n_kv_heads": 2,
"norm_eps": 1e-5,
"qk_norm": True,
"cap_feat_dim": 8,
"rope_theta": 256.0,
"t_scale": 1000.0,
"axes_dims": (2, 2, 2),
"axes_lens": (192, 16, 16),
}
def _load_source_module(name: str, path: Path):
spec = importlib.util.spec_from_file_location(name, path)
if spec is None or spec.loader is None:
pytest.fail(f"Cannot build import spec for {path}")
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
return module
@pytest.fixture(scope="module")
def reference_transformer_cls():
source_file = OFFICIAL_SRC / "zimage" / "transformer.py"
if not OFFICIAL_REF_DIR.exists():
pytest.skip(f"Pinned Z-Image reference clone not found: {OFFICIAL_REF_DIR}")
if not source_file.is_file():
pytest.fail(f"Z-Image reference clone is incomplete; missing {source_file}")
try:
revision = subprocess.run(
["git", "-C", str(OFFICIAL_REF_DIR), "rev-parse", "HEAD"],
check=True,
capture_output=True,
text=True,
).stdout.strip()
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
pytest.fail(f"Cannot verify Z-Image reference revision: {exc}")
assert revision == REFERENCE_REVISION, (
f"expected Z-Image reference {REFERENCE_REVISION}, got {revision}")
module_names = (
"config",
"config.inference",
"config.model",
"utils",
"utils.import_utils",
"utils.attention",
"_zimage_reference_transformer",
)
saved_modules = {name: sys.modules.get(name) for name in module_names}
added_path = str(OFFICIAL_SRC) not in sys.path
if added_path:
sys.path.insert(0, str(OFFICIAL_SRC))
try:
for name in module_names:
sys.modules.pop(name, None)
utils_package = ModuleType("utils")
utils_package.__path__ = [str(OFFICIAL_SRC / "utils")]
sys.modules["utils"] = utils_package
_load_source_module("utils.import_utils", OFFICIAL_SRC / "utils" / "import_utils.py")
_load_source_module("utils.attention", OFFICIAL_SRC / "utils" / "attention.py")
module = _load_source_module("_zimage_reference_transformer", source_file)
assert Path(module.__file__).resolve() == source_file.resolve()
yield module.ZImageTransformer2DModel
finally:
for name in module_names:
sys.modules.pop(name, None)
if saved_modules[name] is not None:
sys.modules[name] = saved_modules[name]
if added_path:
sys.path.remove(str(OFFICIAL_SRC))
def _build_fastvideo(config_values: dict) -> ZImageTransformer2DModel:
config = ZImageDiTConfig()
config.update_model_arch(config_values)
return ZImageTransformer2DModel(
config=config,
hf_config={"_class_name": "ZImageTransformer2DModel", **config_values},
)
def _state_shapes(model: torch.nn.Module) -> dict[str, tuple[int, ...]]:
return {name: tuple(tensor.shape) for name, tensor in model.state_dict().items()}
def test_zimage_transformer_production_meta_key_surface(reference_transformer_cls):
with torch.device("meta"):
reference = reference_transformer_cls(**PRODUCTION_CONFIG)
fastvideo = _build_fastvideo(PRODUCTION_CONFIG)
reference_shapes = _state_shapes(reference)
fastvideo_shapes = _state_shapes(fastvideo)
assert fastvideo_shapes == reference_shapes
keys = sorted(reference_shapes)
key_digest = hashlib.sha256(("\n".join(keys) + "\n").encode()).hexdigest()
assert len(keys) == HF_KEY_COUNT
assert key_digest == HF_KEY_SHA256
print(f"Z-Image production transformer key/shape surface: {len(reference_shapes)} tensors")
def test_zimage_transformer_tiny_cpu_parity(reference_transformer_cls):
reference = reference_transformer_cls(**TINY_CONFIG).eval()
generator = torch.Generator(device="cpu").manual_seed(123)
with torch.no_grad():
for parameter in reference.parameters():
parameter.copy_(torch.randn(parameter.shape, generator=generator, dtype=parameter.dtype) * 0.02)
fastvideo = _build_fastvideo(TINY_CONFIG).eval()
assert _state_shapes(fastvideo) == _state_shapes(reference)
fastvideo.load_state_dict(reference.state_dict(), strict=True)
generator.manual_seed(456)
images = [
torch.randn((2, 1, 4, 4), generator=generator),
torch.randn((2, 1, 8, 10), generator=generator),
]
caption_features = [
torch.randn((3, 8), generator=generator),
torch.randn((35, 8), generator=generator),
]
timestep = torch.tensor([0.25, 0.75], dtype=torch.float32)
with torch.inference_mode(), torch.nn.attention.sdpa_kernel(torch.nn.attention.SDPBackend.MATH):
reference_outputs = reference(images, timestep, caption_features)[0]
positional_outputs = fastvideo(images, caption_features, timestep)[0]
keyword_outputs = fastvideo(
hidden_states=images,
encoder_hidden_states=caption_features,
timestep=timestep,
)[0]
assert len(positional_outputs) == len(keyword_outputs) == len(reference_outputs) == 2
for positional, actual, expected in zip(positional_outputs, keyword_outputs, reference_outputs):
assert actual.shape == expected.shape
assert expected.abs().max() > 0
assert_close(positional, expected, atol=1e-6, rtol=1e-6)
assert_close(actual, expected, atol=1e-6, rtol=1e-6)
def test_zimage_attention_additive_mask_parity(reference_transformer_cls):
reference_module = sys.modules[reference_transformer_cls.__module__]
reference = reference_module.ZImageAttention(12, 2, 2).eval()
generator = torch.Generator(device="cpu").manual_seed(321)
with torch.no_grad():
for parameter in reference.parameters():
parameter.copy_(torch.randn(parameter.shape, generator=generator, dtype=parameter.dtype) * 0.02)
fastvideo = ZImageAttention(12, 2, 2).eval()
fastvideo.load_state_dict(reference.state_dict(), strict=True)
hidden_states = torch.randn((2, 5, 12), generator=generator)
attention_mask = torch.tensor([[True, True, True, False, False], [True, True, True, True, True]])
additive_mask = _prepare_attention_mask(attention_mask, hidden_states.dtype)
assert additive_mask is not None
assert additive_mask.dtype == hidden_states.dtype
assert additive_mask.shape == (2, 1, 1, 5)
assert additive_mask[0, 0, 0, 0] == 0
assert torch.isneginf(additive_mask[0, 0, 0, -1])
with torch.inference_mode(), torch.nn.attention.sdpa_kernel(torch.nn.attention.SDPBackend.MATH):
expected = reference(hidden_states, attention_mask=attention_mask)
actual = fastvideo(hidden_states, attention_mask=attention_mask)
assert_close(actual, expected, atol=1e-6, rtol=1e-6)
def test_zimage_transformer_rejects_sequence_parallelism(monkeypatch: pytest.MonkeyPatch):
model = _build_fastvideo(TINY_CONFIG)
monkeypatch.setattr("fastvideo.models.dits.zimage.model_parallel_is_initialized", lambda: True)
monkeypatch.setattr("fastvideo.models.dits.zimage.get_sp_world_size", lambda: 2)
with pytest.raises(NotImplementedError, match="sp_size=1"):
model(
hidden_states=[torch.zeros(2, 1, 4, 4)],
encoder_hidden_states=[torch.zeros(3, 8)],
timestep=torch.tensor([0.5]),
)
def _has_real_weights() -> bool:
return any(TRANSFORMER_DIR.glob("*.safetensors"))
def _load_real_config() -> dict:
with (TRANSFORMER_DIR / "config.json").open(encoding="utf-8") as file:
config = json.load(file)
assert config.pop("_class_name") == "ZImageTransformer2DModel"
config.pop("_diffusers_version", None)
config.pop("_name_or_path", None)
for key, expected in PRODUCTION_CONFIG.items():
actual = config[key]
if isinstance(expected, tuple):
actual = tuple(actual)
assert actual == expected, f"unexpected production config {key}={actual!r}"
assert set(config) == set(PRODUCTION_CONFIG)
return config
def _run_fastvideo_production(inputs: tuple, dtype: torch.dtype) -> list[torch.Tensor]:
from fastvideo.configs.pipelines.base import PipelineConfig
from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.models.loader.component_loader import TransformerLoader
precision = "bf16" if dtype == torch.bfloat16 else "fp16"
args = FastVideoArgs(
model_path=str(TRANSFORMER_DIR),
dit_cpu_offload=False,
dit_layerwise_offload=False,
use_fsdp_inference=False,
pipeline_config=PipelineConfig(dit_config=ZImageDiTConfig(), dit_precision=precision),
)
model = TransformerLoader().load(str(TRANSFORMER_DIR), args).eval()
assert isinstance(model, ZImageTransformer2DModel)
assert args.model_paths["transformer"] == str(TRANSFORMER_DIR)
assert next(model.parameters()).device.type == "cuda"
with torch.inference_mode(), torch.nn.attention.sdpa_kernel(torch.nn.attention.SDPBackend.MATH):
outputs = model(
hidden_states=inputs[0],
encoder_hidden_states=inputs[1],
timestep=inputs[2],
)[0]
result = [output.detach().float().cpu() for output in outputs]
del model
torch.cuda.empty_cache()
return result
def _run_reference_production(reference_transformer_cls, config: dict, inputs: tuple,
dtype: torch.dtype) -> list[torch.Tensor]:
from safetensors.torch import load_file
state_dict = {}
for shard in sorted(TRANSFORMER_DIR.glob("*.safetensors")):
state_dict.update(load_file(str(shard), device="cpu"))
with torch.device("meta"):
model = reference_transformer_cls(**config)
model.load_state_dict(state_dict, strict=True, assign=True)
del state_dict
model = model.to(device="cuda", dtype=dtype).eval()
with torch.inference_mode(), torch.nn.attention.sdpa_kernel(torch.nn.attention.SDPBackend.MATH):
outputs = model(inputs[0], inputs[2], inputs[1])[0]
result = [output.detach().float().cpu() for output in outputs]
del model
torch.cuda.empty_cache()
return result
@pytest.mark.skipif(
not _has_real_weights(),
reason=(
"Pinned Z-Image transformer weights are absent; set ZIMAGE_TRANSFORMER_DIR "
f"to Tongyi-MAI/Z-Image-Turbo@{HF_REVISION}/transformer"),
)
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Real-weight Z-Image transformer parity requires CUDA")
def test_zimage_transformer_production_loader_forward_parity(reference_transformer_cls):
config = _load_real_config()
dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
generator = torch.Generator(device="cuda").manual_seed(789)
inputs = (
[torch.randn((16, 1, 4, 4), device="cuda", dtype=dtype, generator=generator)],
[torch.randn((3, 2560), device="cuda", dtype=dtype, generator=generator)],
torch.tensor([0.25], device="cuda", dtype=dtype),
)
fastvideo_outputs = _run_fastvideo_production(inputs, dtype)
reference_outputs = _run_reference_production(reference_transformer_cls, config, inputs, dtype)
assert len(fastvideo_outputs) == len(reference_outputs) == 1
actual, expected = fastvideo_outputs[0], reference_outputs[0]
diff = (actual - expected).abs()
mean_scale = expected.abs().mean().clamp_min(1e-6)
print(f"Z-Image real-weight parity: max={diff.max():.6f}, mean={diff.mean():.6f}")
assert diff.mean() / mean_scale < 0.01
assert_close(actual, expected, atol=1e-2, rtol=1e-2)