Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f27573c59e | ||
|
|
fac19aa6fa | ||
|
|
5421a77e71 | ||
|
|
91389697bf | ||
|
|
70bd4bb756 | ||
|
|
35e55aa7f6 | ||
|
|
67c023fca9 | ||
|
|
bf839dde18 | ||
|
|
b1d954c894 | ||
|
|
e4eef6ed20 | ||
|
|
7390f528c6 | ||
|
|
92168e5020 | ||
|
|
d4b8f08674 | ||
|
|
13daf39e91 | ||
|
|
2d0e552a7e | ||
|
|
46d1f91c7e | ||
|
|
9307aeb590 | ||
|
|
926f0c9ab4 | ||
|
|
b2ba79a4cd |
@@ -1,20 +1,24 @@
|
||||
---
|
||||
name: seed-ssim-references
|
||||
description: Seed HF reference artefacts for a single newly-added SSIM test (pixel `.mp4` for `run_text_to_video_similarity_test`-style tests, or latent `.pt` for `run_text_to_latent_similarity_test`-style tests). Runs the test on Modal L40S, downloads the generated artefacts via `modal volume get`, pauses for the user to verify (visual eyeball for mp4, numerics dump for pt), then uploads only that test's files to `FastVideo/ssim-reference-videos`. Use when a new `fastvideo/tests/ssim/test_*_similarity.py` has just been added and has no references on HF yet.
|
||||
description: Seed HF reference artefacts for a single newly-added SSIM test (pixel video/image or latent `.pt`). Runs the test on Modal L40S, downloads the generated artefacts via `modal volume get`, pauses for the user to verify (visual review for videos/images, numerics dump for pt), then uploads only that test's files to `FastVideo/ssim-reference-videos`. Use when a new `fastvideo/tests/ssim/test_*_similarity.py` has just been added and has no references on HF yet.
|
||||
---
|
||||
|
||||
# Seed SSIM Reference Artefacts (mp4 or pt)
|
||||
# Seed SSIM Reference Artefacts (video, image, or pt)
|
||||
|
||||
## Purpose
|
||||
|
||||
A brand-new SSIM test in `fastvideo/tests/ssim/` fails forever until its
|
||||
reference artefacts exist on the HF dataset
|
||||
(`FastVideo/ssim-reference-videos`). The dataset hosts two kinds of artefacts
|
||||
(`FastVideo/ssim-reference-videos`). The dataset hosts three kinds of artefacts
|
||||
side-by-side per `(model_id, backend, prompt)`:
|
||||
|
||||
- **`.mp4`** — pixel ground-truth for tests that call
|
||||
`run_text_to_video_similarity_test` / `run_image_to_video_similarity_test`
|
||||
in `inference_similarity_utils.py`. Compared via SSIM.
|
||||
in `inference_similarity_utils.py` with the default media extension.
|
||||
Compared via SSIM.
|
||||
- **`.png` / `.jpg` / `.jpeg`** — pixel ground-truth for image-generation
|
||||
tests that call `run_text_to_video_similarity_test` with the matching
|
||||
`media_extension`. Compared via SSIM.
|
||||
- **`.pt`** — pre-VAE latent bundle (fp16 full latent + fp32 slice +
|
||||
metadata + `slice_spec` + `format_version`) for tests that call
|
||||
`run_text_to_latent_similarity_test` in `latent_similarity_utils.py`.
|
||||
@@ -22,11 +26,12 @@ side-by-side per `(model_id, backend, prompt)`:
|
||||
|
||||
This skill:
|
||||
|
||||
1. Detects which artefact type the test produces (pixel vs latent).
|
||||
1. Detects which artefact type the test produces (video, image, or latent).
|
||||
2. Runs the test on Modal's L40S pool to generate the artefacts.
|
||||
3. Downloads them to the local repo via `modal volume get`.
|
||||
4. Pauses so the user can verify quality:
|
||||
- **mp4**: visual eyeball in a video player.
|
||||
- **video**: visual eyeball in a video player.
|
||||
- **image**: visual eyeball in an image viewer.
|
||||
- **pt**: numerics dump (shape, slice stats, NaN/Inf check, metadata).
|
||||
5. Uploads only the new test's files to HF, with a guard that refuses to
|
||||
overwrite anything already present.
|
||||
@@ -67,8 +72,8 @@ Everything else is fixed:
|
||||
seeded by this skill.
|
||||
- HF repo: `FastVideo/ssim-reference-videos` (dataset).
|
||||
- Multi-model test files: all model ids in `*_MODEL_TO_PARAMS` are seeded
|
||||
together; the Modal run produces one mp4 per (model, prompt, backend) and
|
||||
the upload scopes by `--model-id`, looping if there is more than one.
|
||||
together; the Modal run produces one artefact per (model, prompt, backend)
|
||||
and the upload scopes by `--model-id`, looping if there is more than one.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -77,7 +82,7 @@ The user has confirmed:
|
||||
- `modal` CLI authenticated.
|
||||
- `HF_API_KEY` (or `HUGGINGFACE_HUB_TOKEN` / `HF_TOKEN`) exported with write
|
||||
access to `FastVideo/ssim-reference-videos`.
|
||||
- The test file runs locally end-to-end (generates an mp4; SSIM assertion
|
||||
- The test file runs locally end-to-end (generates its declared artefact; SSIM assertion
|
||||
failure due to missing reference is expected and fine).
|
||||
|
||||
Fail fast if the token env var is missing.
|
||||
@@ -100,16 +105,22 @@ Detect artefact type by inspecting the file's imports / helper call:
|
||||
- **latent** (`.pt`) — file imports `run_text_to_latent_similarity_test`
|
||||
from `fastvideo.tests.ssim.latent_similarity_utils` (or any other helper
|
||||
that ends with `_latent_similarity_test`).
|
||||
- **pixel** (`.mp4`) — file imports
|
||||
- **image** (`.png`, `.jpg`, or `.jpeg`) — a pixel helper call resolves
|
||||
`media_extension` to one of those image suffixes. For example, Z-Image calls
|
||||
`run_text_to_video_similarity_test(..., media_extension=".png")`.
|
||||
- **video** (`.mp4` by default) — file imports
|
||||
`run_text_to_video_similarity_test` / `run_image_to_video_similarity_test`
|
||||
from `fastvideo.tests.ssim.inference_similarity_utils`, OR uses the
|
||||
legacy custom-inline helper pattern (see `test_gamecraft`,
|
||||
`test_longcat`, etc.). Default to pixel when both heuristics fail.
|
||||
from `fastvideo.tests.ssim.inference_similarity_utils` without an image
|
||||
`media_extension`, OR uses the legacy custom-inline helper pattern (see
|
||||
`test_gamecraft`, `test_longcat`, etc.). Default to video when all
|
||||
heuristics fail.
|
||||
|
||||
Record `ARTEFACT_TYPE ∈ {pixel, latent}` for use in step 4. Steps 2, 3, 5,
|
||||
Record `ARTEFACT_TYPE ∈ {video, image, latent}` for use in step 4. Steps 2, 3, 5,
|
||||
and 6 are artefact-type-agnostic — `_iter_reference_files`,
|
||||
`copy_generated_to_reference`, and `upload_reference_videos` already walk
|
||||
both `.mp4` and `.pt` (see `reference_videos_cli.py`).
|
||||
videos, images, and `.pt` bundles (see `REFERENCE_EXTENSIONS` in
|
||||
`reference_videos_cli.py`). The Modal exporter likewise counts all three via
|
||||
`GENERATED_ARTIFACT_EXTENSIONS` before copying the whole generated tree.
|
||||
|
||||
If either check fails, stop and tell the user what's wrong.
|
||||
|
||||
@@ -166,10 +177,10 @@ Flag rationale:
|
||||
- `--skip-reference-download`: no refs exist yet, so conftest must not try to
|
||||
pull them.
|
||||
- `--no-fail-fast`: lets the test finish generation before `_assert_similarity`
|
||||
raises `FileNotFoundError: Reference video folder does not exist`. The
|
||||
expected failure is what we want — the mp4 has already been written.
|
||||
raises for the missing reference. The expected failure is what we want —
|
||||
the generated artefact has already been written.
|
||||
- `--sync-generated-to-volume` + `--generated-volume-subdir`: copies the
|
||||
generated mp4s to the `hf-model-weights` Modal volume under
|
||||
generated artefacts to the `hf-model-weights` Modal volume under
|
||||
`ssim_generated_videos/default/<SUBDIR>/generated_videos/` so we can pull
|
||||
them locally.
|
||||
|
||||
@@ -177,7 +188,7 @@ The Modal run will end with a nonzero exit (expected) and print a
|
||||
`modal volume get hf-model-weights ssim_generated_videos/default/<SUBDIR>/generated_videos ./generated_videos_modal/default`
|
||||
command. Capture that `<SUBDIR>` — you need it for step 3.
|
||||
|
||||
### 3. Download generated videos locally
|
||||
### 3. Download generated artefacts locally
|
||||
|
||||
```bash
|
||||
modal volume get --force hf-model-weights \
|
||||
@@ -189,8 +200,8 @@ modal volume get --force hf-model-weights \
|
||||
already exists; without it, `modal volume get` errors with `[Errno 21] Is a
|
||||
directory`. Safe to pass on the first run too.
|
||||
|
||||
After this, the mp4s live at
|
||||
`./generated_videos_modal/default/generated_videos/L40S_reference_videos/<model_id>/<backend>/<prompt>.mp4`.
|
||||
After this, the artefacts live at
|
||||
`./generated_videos_modal/default/generated_videos/L40S_reference_videos/<model_id>/<backend>/<prompt>.<ext>`.
|
||||
The extra `generated_videos/` level comes from the volume layout in
|
||||
`_sync_generated_videos_to_volume` (`ssim_test.py`) — the command copies
|
||||
`<repo>/fastvideo/tests/ssim/generated_videos/<tier>` to
|
||||
@@ -201,11 +212,16 @@ get` preserves that trailing `generated_videos/` segment.
|
||||
|
||||
Type-aware verification.
|
||||
|
||||
**For `ARTEFACT_TYPE = pixel`** — list the downloaded mp4s and ask the user to
|
||||
**For `ARTEFACT_TYPE = video`** — list the downloaded videos and ask the user to
|
||||
open them in a video player:
|
||||
|
||||
> "Generated videos downloaded to `./generated_videos_modal/default/generated_videos/L40S_reference_videos/`. Please open them and confirm the quality looks correct. Reply **`upload`** to continue, or anything else to abort."
|
||||
|
||||
**For `ARTEFACT_TYPE = image`** — list the downloaded `.png` / `.jpg` /
|
||||
`.jpeg` files and ask the user to open them in an image viewer:
|
||||
|
||||
> "Generated images downloaded to `./generated_videos_modal/default/generated_videos/L40S_reference_videos/`. Please open them and confirm the quality looks correct. Reply **`upload`** to continue, or anything else to abort."
|
||||
|
||||
**For `ARTEFACT_TYPE = latent`** — `.pt` files are not human-watchable. Print
|
||||
a numerics dump for each `.pt` so the user can sanity-check shape, distribution,
|
||||
and metadata:
|
||||
@@ -250,8 +266,9 @@ everything on disk so they can inspect further — no cleanup.
|
||||
|
||||
### 5. Copy into the local reference layout
|
||||
|
||||
Scoped copy — only the new test's artefacts. Single command works for both
|
||||
artefact types because `_iter_reference_files` walks `.mp4` and `.pt`:
|
||||
Scoped copy — only the new test's artefacts. One command works for all three
|
||||
artefact types because `_iter_reference_files` walks video, image, and latent
|
||||
extensions:
|
||||
|
||||
```bash
|
||||
python fastvideo/tests/ssim/reference_videos_cli.py copy-local \
|
||||
@@ -261,12 +278,13 @@ python fastvideo/tests/ssim/reference_videos_cli.py copy-local \
|
||||
```
|
||||
|
||||
(The `--generated-dir` points at the device-folder root inside the
|
||||
downloaded tree; `copy-local` walks all `<model>/<backend>/*.{mp4,pt}`
|
||||
downloaded tree; `copy-local` walks all supported reference extensions
|
||||
underneath it. Since the Modal run was scoped to a single test file via
|
||||
`--test-files`, only that test's model(s) are present — so the copy is
|
||||
implicitly per-test.)
|
||||
|
||||
Result for pixel: `fastvideo/tests/ssim/reference_videos/default/L40S_reference_videos/<model_id>/<backend>/<prompt>.mp4`.
|
||||
Result for video: `fastvideo/tests/ssim/reference_videos/default/L40S_reference_videos/<model_id>/<backend>/<prompt>.mp4`.
|
||||
Result for image: same path with `.png`, `.jpg`, or `.jpeg` extension.
|
||||
Result for latent: same path with `.pt` extension.
|
||||
|
||||
### 6. Upload to HF — scoped per model_id, with overwrite guard
|
||||
@@ -307,7 +325,7 @@ it will auto-download the refs they just uploaded.
|
||||
- **Modal run fails before generation.** No artefacts on the volume — nothing
|
||||
to download. Fix the test locally (`pytest fastvideo/tests/ssim/<test_file>`)
|
||||
and retry from step 2.
|
||||
- **`./generated_videos_modal/default/L40S_reference_videos/` missing after
|
||||
- **`./generated_videos_modal/default/generated_videos/L40S_reference_videos/` missing after
|
||||
`modal volume get`.** The run didn't produce artefacts (most likely the
|
||||
test crashed before writing, or `REQUIRED_GPUS` exceeded the partition
|
||||
capacity — see Modal logs).
|
||||
@@ -340,14 +358,14 @@ it will auto-download the refs they just uploaded.
|
||||
- The overwrite guard in `reference_videos_cli.py upload` is default-on
|
||||
specifically because this skill exists. Re-seeding is a distinct operation
|
||||
that requires explicit `--force`.
|
||||
- Both artefact types share the same Modal flow: the orchestrator sets
|
||||
- All three artefact types share the same Modal flow: the orchestrator sets
|
||||
`--skip-reference-download` + `--no-fail-fast`, runs pytest, the test's
|
||||
helper writes the artefact (`.mp4` via `imageio` for pixel,
|
||||
helper writes the artefact (`.mp4` / `.png` / `.jpg` / `.jpeg` for pixel,
|
||||
`save_latent_reference` → `torch.save` for latent) BEFORE the
|
||||
missing-reference assertion raises. `_sync_generated_videos_to_volume` in
|
||||
`ssim_test.py` does a `shutil.copytree` of the whole `generated_videos/`
|
||||
tree, picking up `.mp4`, `.pt`, and the `*_ssim.json` / `*_latent.json`
|
||||
metric files alongside.
|
||||
tree. Its artifact count recognizes videos, images, and `.pt` bundles; JSON
|
||||
metric files are copied alongside but are not prepared as references.
|
||||
|
||||
## References
|
||||
|
||||
@@ -357,7 +375,7 @@ it will auto-download the refs they just uploaded.
|
||||
- `fastvideo/tests/ssim/reference_videos_cli.py` — `copy-local`, `upload`
|
||||
(with `--model-id`, `--force`), `download`, `ensure` subcommands.
|
||||
Extension allowlist is `REFERENCE_EXTENSIONS = VIDEO_EXTENSIONS +
|
||||
LATENT_EXTENSIONS` (`.pt`).
|
||||
IMAGE_EXTENSIONS + LATENT_EXTENSIONS`.
|
||||
- `fastvideo/tests/ssim/README.md` — reference layout, HF repo conventions.
|
||||
- `fastvideo/tests/ssim/inference_similarity_utils.py` — pixel helpers
|
||||
(`run_text_to_video_similarity_test`,
|
||||
@@ -376,3 +394,4 @@ it will auto-download the refs they just uploaded.
|
||||
| 2026-04-21 | Rewrite: single-test scope, explicit user-review pause, per-`model_id` upload, HF overwrite guard. Dropped `scripts/seed_ssim.sh`. |
|
||||
| 2026-04-21 | Post-first-run fixes: `modal volume get` needs `--force` when parent exists; download tree has an extra `generated_videos/` level so `--generated-dir` must reflect it. |
|
||||
| 2026-05-01 | Latent (`*.pt`) artefact support: artefact-type detection in step 1, type-aware verification (visual eyeball for mp4, numerics dump for pt) in step 4, FSDP+inference_mode failure-mode added, design notes for the unified Modal flow. Triggered by PR #1253 (LTX-2 latent migration + Stable Audio latent test). |
|
||||
| 2026-07-13 | Image (`.png`, `.jpg`, `.jpeg`) artefact support: image-aware detection and preview guidance, plus current Modal export and reference CLI extension coverage. |
|
||||
|
||||
@@ -34,6 +34,7 @@ env
|
||||
*.log
|
||||
weights/
|
||||
logs/
|
||||
/Z-Image/
|
||||
official_weights/
|
||||
converted_weights/
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
@@ -83,6 +83,7 @@ class TextEncoderConfig(EncoderConfig):
|
||||
arch_config: ArchConfig = field(default_factory=TextEncoderArchConfig)
|
||||
is_chat_model: bool = False
|
||||
treat_empty_as_dot: bool = False
|
||||
chat_template_enable_thinking: bool = field(default=False, kw_only=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -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
|
||||
@@ -327,14 +327,12 @@ class Qwen3ForCausalLM(TextEncoder):
|
||||
dtype: torch.dtype,
|
||||
device: torch.device,
|
||||
) -> nn.Module:
|
||||
from transformers import AutoModelForCausalLM
|
||||
from transformers import AutoModel
|
||||
|
||||
if device.type == "cpu" and torch.cuda.is_available():
|
||||
from fastvideo.distributed import get_local_torch_device
|
||||
|
||||
device = get_local_torch_device()
|
||||
|
||||
return AutoModelForCausalLM.from_pretrained(
|
||||
# FastVideo uses Qwen3 only as a text encoder. Loading the body avoids
|
||||
# materializing an unused LM head and full-vocabulary logits, including
|
||||
# for checkpoints whose metadata names Qwen3ForCausalLM.
|
||||
return AutoModel.from_pretrained(
|
||||
model_path,
|
||||
local_files_only=True,
|
||||
torch_dtype=dtype,
|
||||
@@ -368,9 +366,18 @@ class Qwen3ForCausalLM(TextEncoder):
|
||||
residual = None
|
||||
|
||||
if position_ids is None:
|
||||
# Expand to [batch_size, seq_len]: the rotary layer flattens
|
||||
# positions to ``num_tokens`` and reshapes q/k to
|
||||
# ``(num_tokens, -1, head_dim)``. A bare [1, seq_len] only matches
|
||||
# ``num_tokens`` when batch_size == 1; for batched inputs it folds
|
||||
# the batch dim into the head dim and misaligns RoPE. Expanding to
|
||||
# ``batch_size * seq_len`` tokens keeps the layout correct.
|
||||
position_ids = torch.arange(
|
||||
0, hidden_states.shape[1], device=hidden_states.device
|
||||
).unsqueeze(0)
|
||||
0,
|
||||
hidden_states.shape[1],
|
||||
device=hidden_states.device,
|
||||
dtype=torch.long,
|
||||
).unsqueeze(0).expand(hidden_states.shape[0], -1)
|
||||
|
||||
all_hidden_states: tuple[Any, ...] | None = (
|
||||
() if output_hidden_states else None
|
||||
@@ -405,6 +412,20 @@ class Qwen3ForCausalLM(TextEncoder):
|
||||
) -> set[str]:
|
||||
params_dict = dict(self.named_parameters())
|
||||
loaded_params: set[str] = set()
|
||||
stacked_params_mapping = self.config.arch_config.stacked_params_mapping
|
||||
# A fused destination is initialized either by one already-fused tensor
|
||||
# or after every split source projection has been loaded. Include
|
||||
# auxiliary quantization parameters (for example scale_weight) rather
|
||||
# than limiting completeness checks to weight/bias tensors.
|
||||
expected_stacked_shards = {
|
||||
(name, shard_id)
|
||||
for name in params_dict
|
||||
for param_name, _, shard_id in stacked_params_mapping
|
||||
if param_name in name
|
||||
}
|
||||
fused_param_names = {name for name, _ in expected_stacked_shards}
|
||||
loaded_stacked_shards: set[tuple[str, str | int]] = set()
|
||||
loaded_fused_params: set[str] = set()
|
||||
|
||||
for name, loaded_weight in weights:
|
||||
if name.startswith("model."):
|
||||
@@ -423,37 +444,66 @@ class Qwen3ForCausalLM(TextEncoder):
|
||||
continue
|
||||
name = kv_scale_name
|
||||
|
||||
for (
|
||||
param_name,
|
||||
weight_name,
|
||||
shard_id,
|
||||
) in self.config.arch_config.stacked_params_mapping:
|
||||
matched_stacked_param = False
|
||||
for param_name, weight_name, shard_id in stacked_params_mapping:
|
||||
if weight_name not in name:
|
||||
continue
|
||||
name = name.replace(weight_name, param_name)
|
||||
matched_stacked_param = True
|
||||
target_name = name.replace(weight_name, param_name)
|
||||
|
||||
if name.endswith(".bias") and name not in params_dict:
|
||||
continue
|
||||
if target_name.endswith(".bias") and target_name not in params_dict:
|
||||
break
|
||||
|
||||
if name not in params_dict:
|
||||
continue
|
||||
if target_name not in params_dict:
|
||||
break
|
||||
|
||||
param = params_dict[name]
|
||||
param = params_dict[target_name]
|
||||
weight_loader = param.weight_loader
|
||||
weight_loader(param, loaded_weight, shard_id)
|
||||
loaded_params.add(target_name)
|
||||
loaded_stacked_shards.add((target_name, shard_id))
|
||||
break
|
||||
|
||||
if matched_stacked_param:
|
||||
continue
|
||||
|
||||
if name.endswith(".bias") and name not in params_dict:
|
||||
continue
|
||||
|
||||
if name not in params_dict:
|
||||
continue
|
||||
|
||||
param = params_dict[name]
|
||||
if name in fused_param_names and name.endswith(".scale_weight"):
|
||||
# Merged scale loaders interpret a missing shard id as shard 0.
|
||||
# An exact fused key is already a complete vector, so copy it
|
||||
# atomically and retain the default loader's shape validation.
|
||||
default_weight_loader(param, loaded_weight)
|
||||
else:
|
||||
if name.endswith(".bias") and name not in params_dict:
|
||||
continue
|
||||
|
||||
if name not in params_dict:
|
||||
continue
|
||||
|
||||
param = params_dict[name]
|
||||
weight_loader = getattr(param, "weight_loader", default_weight_loader)
|
||||
weight_loader(param, loaded_weight)
|
||||
|
||||
loaded_params.add(name)
|
||||
if name in fused_param_names:
|
||||
loaded_fused_params.add(name)
|
||||
|
||||
required_split_shards = {
|
||||
(name, shard_id)
|
||||
for name, shard_id in expected_stacked_shards
|
||||
if name not in loaded_fused_params
|
||||
}
|
||||
missing_stacked_shards = required_split_shards - loaded_stacked_shards
|
||||
if missing_stacked_shards:
|
||||
formatted_missing = ", ".join(
|
||||
f"{name}[{shard_id}]"
|
||||
for name, shard_id in sorted(
|
||||
missing_stacked_shards,
|
||||
key=lambda item: (item[0], str(item[1])),
|
||||
)
|
||||
)
|
||||
raise ValueError(
|
||||
"Missing required stacked checkpoint shards: "
|
||||
f"{formatted_missing}"
|
||||
)
|
||||
|
||||
return loaded_params
|
||||
|
||||
|
||||
@@ -401,6 +401,10 @@ class TextEncoderLoader(ComponentLoader):
|
||||
dtype=PRECISION_TO_TYPE[dtype],
|
||||
device=target_device,
|
||||
)
|
||||
# HF passthrough encoders return before FastVideo's FSDP
|
||||
# wrapping path, so the text stage needs their placement to
|
||||
# put token tensors on the same device.
|
||||
model._fastvideo_input_device = target_device
|
||||
return model.eval()
|
||||
|
||||
model = model_cls(model_config) # type: ignore
|
||||
|
||||
@@ -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 = {
|
||||
@@ -78,6 +79,9 @@ _TEXT_ENCODER_MODELS = {
|
||||
"Reason1TextEncoder": ("encoders", "reason1", "Reason1TextEncoder"),
|
||||
"Qwen2_5_VLForConditionalGeneration":
|
||||
("encoders", "reason1", "Reason1TextEncoder"),
|
||||
# Z-Image-Turbo's text_encoder/config.json declares architecture
|
||||
# "Qwen3Model"; route it to the shared Qwen3 encoder (added for Flux2 Klein).
|
||||
"Qwen3Model": ("encoders", "qwen3", "Qwen3ForCausalLM"),
|
||||
"LTX2GemmaTextEncoderModel": ("encoders", "gemma", "LTX2GemmaTextEncoderModel"),
|
||||
"Qwen3ForCausalLM": ("encoders", "qwen3", "Qwen3ForCausalLM"),
|
||||
"Mistral3ForConditionalGeneration":
|
||||
|
||||
@@ -96,6 +96,14 @@ class FlowMatchEulerDiscreteScheduler(SchedulerMixin, ConfigMixin,
|
||||
The minimum sigma value for the noise schedule.
|
||||
sigma_data (`float`, *optional*):
|
||||
The sigma data value for scaling.
|
||||
use_reference_discrete_timesteps (`bool`, defaults to False):
|
||||
Some reference schedulers (e.g. Z-Image) construct the timestep
|
||||
schedule by linspacing `num_inference_steps + 1` points from
|
||||
`t_max` to `t_min` and dropping the terminal point. Default
|
||||
(`False`) preserves the original `np.linspace(t_max, t_min,
|
||||
num_inference_steps)` (float64) behaviour used by every existing
|
||||
model. Enable this flag only when matching a reference scheduler
|
||||
that expects the +1 + drop-terminal construction.
|
||||
"""
|
||||
|
||||
_compatibles: list[Any] = []
|
||||
@@ -122,6 +130,7 @@ class FlowMatchEulerDiscreteScheduler(SchedulerMixin, ConfigMixin,
|
||||
sigma_max: float | None = None,
|
||||
sigma_min: float | None = None,
|
||||
sigma_data: float | None = None,
|
||||
use_reference_discrete_timesteps: bool = False,
|
||||
):
|
||||
if sum([
|
||||
self.config.use_beta_sigmas, self.config.use_exponential_sigmas,
|
||||
@@ -155,7 +164,7 @@ class FlowMatchEulerDiscreteScheduler(SchedulerMixin, ConfigMixin,
|
||||
|
||||
self.sigmas = sigmas.to(
|
||||
"cpu") # to avoid too much CPU/GPU communication
|
||||
self.sigma_min = self.sigmas[-1].item()
|
||||
self.sigma_min = sigma_min if sigma_min is not None else self.sigmas[-1].item()
|
||||
self.sigma_max = self.sigmas[0].item()
|
||||
|
||||
BaseScheduler.__init__(self)
|
||||
@@ -350,7 +359,19 @@ class FlowMatchEulerDiscreteScheduler(SchedulerMixin, ConfigMixin,
|
||||
if timesteps_array is None:
|
||||
t_max = self._sigma_to_t(self.sigma_max)
|
||||
t_min = self._sigma_to_t(self.sigma_min)
|
||||
timesteps_array = np.linspace(t_max, t_min, num_inference_steps)
|
||||
if self.config.use_reference_discrete_timesteps:
|
||||
# Some reference schedulers (for example Z-Image) build a
|
||||
# float64 num_steps+1 linspace and drop the terminal point.
|
||||
timesteps_array = np.linspace(
|
||||
t_max,
|
||||
t_min,
|
||||
num_inference_steps + 1,
|
||||
)[:-1]
|
||||
else:
|
||||
# Preserve the original numpy default (float64) here —
|
||||
# casting to float32 silently shifts rounded timestep
|
||||
# values for every existing model that uses this branch.
|
||||
timesteps_array = np.linspace(t_max, t_min, num_inference_steps)
|
||||
sigmas_array = timesteps_array / self.config.num_train_timesteps
|
||||
else:
|
||||
sigmas_array = np.array(sigmas).astype(np.float32)
|
||||
|
||||
@@ -73,25 +73,36 @@ class DecodingStage(PipelineStage):
|
||||
|
||||
cfg = getattr(self.vae, "config", None)
|
||||
|
||||
# Matrix-Game 2.0-style: z = z * std + mean
|
||||
if (cfg is not None and hasattr(cfg, "latents_mean") and hasattr(cfg, "latents_std")):
|
||||
latents_mean = torch.tensor(cfg.latents_mean, device=latents.device,
|
||||
# Matrix-Game 2.0-style: z = z * std + mean. Some configs declare these
|
||||
# fields as None, so test the values rather than mere attribute presence
|
||||
# -- otherwise torch.tensor(None) raises instead of falling through.
|
||||
latents_mean_value = getattr(cfg, "latents_mean", None) if cfg is not None else None
|
||||
latents_std_value = getattr(cfg, "latents_std", None) if cfg is not None else None
|
||||
if latents_mean_value is not None and latents_std_value is not None:
|
||||
latents_mean = torch.tensor(latents_mean_value, device=latents.device,
|
||||
dtype=latents.dtype).view(1, -1, 1, 1, 1)
|
||||
latents_std = torch.tensor(cfg.latents_std, device=latents.device, dtype=latents.dtype).view(1, -1, 1, 1, 1)
|
||||
latents_std = torch.tensor(latents_std_value, device=latents.device,
|
||||
dtype=latents.dtype).view(1, -1, 1, 1, 1)
|
||||
return latents * latents_std + latents_mean
|
||||
|
||||
# Diffusers-style: scaling_factor (+ optional shift_factor)
|
||||
if hasattr(self.vae, "scaling_factor"):
|
||||
if isinstance(self.vae.scaling_factor, torch.Tensor):
|
||||
latents = latents / self.vae.scaling_factor.to(latents.device, latents.dtype)
|
||||
# Diffusers-style: scaling_factor (+ optional shift_factor), read from
|
||||
# the module only. Nearly every VAE *config* also carries a
|
||||
# scaling_factor whose decode() already accounts for it, so sourcing it
|
||||
# from cfg here would newly divide latents for VAEs that have always
|
||||
# passed through untouched (flux2, hunyuanvideo, hunyuanvideo15, ltx2).
|
||||
scaling_factor = getattr(self.vae, "scaling_factor", None)
|
||||
if scaling_factor is not None:
|
||||
if isinstance(scaling_factor, torch.Tensor):
|
||||
latents = latents / scaling_factor.to(latents.device, latents.dtype)
|
||||
else:
|
||||
latents = latents / self.vae.scaling_factor
|
||||
latents = latents / scaling_factor
|
||||
|
||||
if hasattr(self.vae, "shift_factor") and self.vae.shift_factor is not None:
|
||||
if isinstance(self.vae.shift_factor, torch.Tensor):
|
||||
latents = latents + self.vae.shift_factor.to(latents.device, latents.dtype)
|
||||
shift_factor = getattr(self.vae, "shift_factor", None)
|
||||
if shift_factor is not None:
|
||||
if isinstance(shift_factor, torch.Tensor):
|
||||
latents = latents + shift_factor.to(latents.device, latents.dtype)
|
||||
else:
|
||||
latents = latents + self.vae.shift_factor
|
||||
latents = latents + shift_factor
|
||||
|
||||
return latents
|
||||
|
||||
|
||||
@@ -221,6 +221,20 @@ class TextEncodingStage(PipelineStage):
|
||||
encoder_device = torch.device(target_device)
|
||||
moved_for_forward = True
|
||||
|
||||
# An explicit `device=` wins. Otherwise follow the encoder's real
|
||||
# param device. Once it has been moved for the forward that is the
|
||||
# target device, and an HF-passthrough encoder's
|
||||
# _fastvideo_input_device (stamped at load, e.g. "cpu" under
|
||||
# text_encoder_cpu_offload) is stale -- honouring it would feed cpu
|
||||
# token ids to cuda weights. The marker only speaks when nothing
|
||||
# moved and the module has no parameters to speak for it.
|
||||
if device is not None:
|
||||
input_device = torch.device(target_device)
|
||||
elif moved_for_forward:
|
||||
input_device = encoder_device
|
||||
else:
|
||||
input_device = getattr(text_encoder, "_fastvideo_input_device", encoder_device)
|
||||
|
||||
tok_kwargs = dict(encoder_config.tokenizer_kwargs)
|
||||
if max_length is not None:
|
||||
tok_kwargs["max_length"] = max_length
|
||||
@@ -264,7 +278,7 @@ class TextEncodingStage(PipelineStage):
|
||||
# pre-format prompts into message lists upstream and rely on
|
||||
# the inner tokenizer + full tokenizer_kwargs (which include
|
||||
# add_generation_prompt). Preserve that original path exactly.
|
||||
text_inputs = tok.apply_chat_template(processed_texts, **tok_kwargs).to(encoder_device)
|
||||
text_inputs = tok.apply_chat_template(processed_texts, **tok_kwargs).to(input_device)
|
||||
else:
|
||||
# Two-step approach matching Diffusers: format with chat
|
||||
# template first, then tokenize the resulting strings.
|
||||
@@ -275,12 +289,12 @@ class TextEncodingStage(PipelineStage):
|
||||
messages,
|
||||
tokenize=False,
|
||||
add_generation_prompt=True,
|
||||
enable_thinking=False,
|
||||
enable_thinking=encoder_config.chat_template_enable_thinking,
|
||||
)
|
||||
formatted_texts.append(formatted)
|
||||
text_inputs = tokenizer(formatted_texts, **tok_kwargs).to(encoder_device)
|
||||
text_inputs = tokenizer(formatted_texts, **tok_kwargs).to(input_device)
|
||||
else:
|
||||
text_inputs = tok(processed_texts, **tok_kwargs).to(encoder_device)
|
||||
text_inputs = tok(processed_texts, **tok_kwargs).to(input_device)
|
||||
|
||||
input_ids = text_inputs["input_ids"]
|
||||
attention_mask = text_inputs["attention_mask"]
|
||||
|
||||
@@ -79,6 +79,18 @@ RAW_GENERATED_VOLUME_ROOT = "ssim_generated_videos"
|
||||
DEFAULT_OUTPUT_QUALITY_TIER = "default"
|
||||
FULL_OUTPUT_QUALITY_TIER = "full_quality"
|
||||
MODAL_DEVICE_REFERENCE_FOLDER = "L40S_reference_videos"
|
||||
GENERATED_ARTIFACT_EXTENSIONS = (
|
||||
".mp4",
|
||||
".avi",
|
||||
".mov",
|
||||
".mkv",
|
||||
".webm",
|
||||
".flv",
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".pt",
|
||||
)
|
||||
SSIM_COMMON_KWARGS = dict(
|
||||
image=image,
|
||||
timeout=5400,
|
||||
@@ -277,11 +289,11 @@ def _print_local_reference_copy_command(quality_tier: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _count_video_files(root: str) -> int:
|
||||
def _count_generated_artifacts(root: str) -> int:
|
||||
count = 0
|
||||
for current_root, _, filenames in os.walk(root):
|
||||
for filename in filenames:
|
||||
if filename.lower().endswith((".mp4", ".avi", ".mov", ".mkv", ".webm", ".flv")):
|
||||
if filename.lower().endswith(GENERATED_ARTIFACT_EXTENSIONS):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
@@ -316,8 +328,11 @@ def _sync_generated_videos_to_volume(
|
||||
shutil.copytree(generated_root, absolute_dst)
|
||||
model_vol.commit()
|
||||
|
||||
num_videos = _count_video_files(absolute_dst)
|
||||
print(f"Raw generated videos exported to Modal volume path: {relative_dst} ({num_videos} video files).")
|
||||
num_artifacts = _count_generated_artifacts(absolute_dst)
|
||||
print(
|
||||
f"Raw generated artifacts exported to Modal volume path: "
|
||||
f"{relative_dst} ({num_artifacts} artifact files)."
|
||||
)
|
||||
print(
|
||||
"Download command:\n"
|
||||
f" modal volume get hf-model-weights {relative_dst} "
|
||||
@@ -1002,7 +1017,7 @@ def run_ssim_tests(
|
||||
quality_tier=quality_tier,
|
||||
)
|
||||
print(
|
||||
"Raw generated videos will be saved to Modal volume path: "
|
||||
"Raw generated artifacts will be saved to Modal volume path: "
|
||||
f"{generated_volume_path}"
|
||||
)
|
||||
else:
|
||||
@@ -1042,7 +1057,7 @@ def run_ssim_tests(
|
||||
)
|
||||
local_download_dir = _build_local_generated_download_dir(quality_tier)
|
||||
print(
|
||||
"To download raw generated videos locally, run:\n"
|
||||
"To download raw generated artifacts locally, run:\n"
|
||||
f" modal volume get hf-model-weights {download_src} "
|
||||
f"{local_download_dir}"
|
||||
)
|
||||
|
||||
@@ -99,3 +99,11 @@ def test_spawn_ssim_task_passes_hf_api_key_to_pytest_env(monkeypatch, tmp_path):
|
||||
assert env["FASTVIDEO_SSIM_MODEL_ID"] == "model-a"
|
||||
assert running_task.process.__class__ is FakeProcess
|
||||
running_task.log_handle.close()
|
||||
|
||||
|
||||
def test_count_generated_artifacts_includes_images(monkeypatch, tmp_path):
|
||||
module = _load_ssim_test_module(monkeypatch)
|
||||
for filename in ("sample.mp4", "sample.png", "sample.jpg", "sample.jpeg", "sample.pt", "metrics.json"):
|
||||
(tmp_path / filename).write_text(filename, encoding="utf-8")
|
||||
|
||||
assert module._count_generated_artifacts(str(tmp_path)) == 5
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
The reference videos are used as part of e2e SSIM regression tests.
|
||||
Reference media are used as part of e2e SSIM regression tests.
|
||||
Wan inference coverage now lives in `test_wan_t2v_similarity.py` and
|
||||
`test_wan_i2v_similarity.py` alongside the other model-specific SSIM files.
|
||||
These tests compare newly generated videos against references to detect quality
|
||||
These tests compare newly generated videos or images against references to detect quality
|
||||
regressions.
|
||||
|
||||
reference layout:
|
||||
- `reference_videos/default/<GPU>_reference_videos/<model>/<backend>/...`
|
||||
- `reference_videos/full_quality/<GPU>_reference_videos/<model>/<backend>/...`
|
||||
|
||||
Reference artifacts may be videos, images (`.png`, `.jpg`, `.jpeg`), or latent
|
||||
bundles (`.pt`).
|
||||
|
||||
`A40_reference_videos` are generated on A40s and so on. (legacy default layout
|
||||
`<ssim_dir>/<GPU>_reference_videos/...` is still read as fallback.)
|
||||
|
||||
Before SSIM tests run, missing reference videos are auto-downloaded from a
|
||||
Before SSIM tests run, missing reference artifacts are auto-downloaded from a
|
||||
public HF repo (configured by `FASTVIDEO_SSIM_REFERENCE_HF_REPO`, default:
|
||||
`FastVideo/ssim-reference-videos`).
|
||||
|
||||
@@ -43,7 +46,7 @@ to skip auto-download of missing refs:
|
||||
to bootstrap draft refs for a new model:
|
||||
`pytest fastvideo/tests/ssim/ -vs --ssim-bootstrap-mode`
|
||||
|
||||
generated videos are written under:
|
||||
generated artifacts are written under:
|
||||
- default params: `generated_videos/default/<GPU>_reference_videos/<model_id>/<ATTENTION_BACKEND>/`
|
||||
- full-quality params: `generated_videos/full_quality/<GPU>_reference_videos/<model_id>/<ATTENTION_BACKEND>/`
|
||||
|
||||
@@ -86,7 +89,7 @@ Normal SSIM runs are strict: missing references fail the test. For a new model
|
||||
PR, add `[new-model]` to the PR title or set `FASTVIDEO_SSIM_BOOTSTRAP_MODE=1`
|
||||
for the Buildkite job. CI then passes `--ssim-bootstrap-mode` to pytest.
|
||||
|
||||
In bootstrap mode, a missing pixel `.mp4` or latent `.pt` reference is treated
|
||||
In bootstrap mode, a missing pixel `.mp4`/`.png`/`.jpg`/`.jpeg` or latent `.pt` reference is treated
|
||||
as an expected draft-reference case after the generated artifact has been
|
||||
written. The test uploads that generated artifact to the HF reference repo under
|
||||
`drafts/...` and marks the case `xfail`. Bootstrap mode does not weaken normal
|
||||
|
||||
@@ -13,12 +13,13 @@ from pathlib import Path
|
||||
|
||||
|
||||
VIDEO_EXTENSIONS = (".mp4", ".avi", ".mov", ".mkv", ".webm", ".flv")
|
||||
IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg")
|
||||
# Additional artefact types stored under the same reference folders. Latent
|
||||
# tensors (`.pt`) back the latent-slice regression tests used by flaky
|
||||
# pixel-space models (e.g. LTX-2 distilled). Keeping them in the same upload
|
||||
# flow means seeding a new test only requires one HF round-trip.
|
||||
LATENT_EXTENSIONS = (".pt",)
|
||||
REFERENCE_EXTENSIONS = VIDEO_EXTENSIONS + LATENT_EXTENSIONS
|
||||
REFERENCE_EXTENSIONS = VIDEO_EXTENSIONS + IMAGE_EXTENSIONS + LATENT_EXTENSIONS
|
||||
HF_TOKEN_ENV_KEYS = ("HF_API_KEY", "HUGGINGFACE_HUB_TOKEN", "HF_TOKEN")
|
||||
HF_REPO_ENV_KEY = "FASTVIDEO_SSIM_REFERENCE_HF_REPO"
|
||||
HF_REPO_TYPE_ENV_KEY = "FASTVIDEO_SSIM_REFERENCE_HF_REPO_TYPE"
|
||||
@@ -51,10 +52,10 @@ def _default_repo_type() -> str:
|
||||
|
||||
|
||||
def _iter_reference_files(root: Path) -> Iterable[Path]:
|
||||
"""Yield video and latent (.pt) references under `root`.
|
||||
"""Yield video, image, and latent references under `root`.
|
||||
|
||||
Used by copy-local and the "has local references" marker probe so that
|
||||
latent-only tests (no mp4) still satisfy readiness checks.
|
||||
image- and latent-only tests still satisfy readiness checks.
|
||||
"""
|
||||
for path in root.rglob("*"):
|
||||
if path.is_file() and path.suffix.lower() in REFERENCE_EXTENSIONS:
|
||||
@@ -824,7 +825,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
reference_dir=reference_dir,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
print(f"Done. {'Would copy' if args.dry_run else 'Copied'} {copied} video files.")
|
||||
print(f"Done. {'Would copy' if args.dry_run else 'Copied'} {copied} reference files.")
|
||||
if not args.dry_run and copied > 0:
|
||||
marker = (
|
||||
_ssim_dir() / REFERENCE_VIDEOS_DIRNAME / args.quality_tier / f".download_complete_{args.quality_tier}"
|
||||
|
||||
@@ -55,6 +55,46 @@ def _fake_hf(monkeypatch):
|
||||
)
|
||||
|
||||
|
||||
def test_image_references_are_copied_ready_and_uploaded(tmp_path):
|
||||
generated_dir = tmp_path / "generated"
|
||||
generated_backend_dir = generated_dir / "model-a" / "flash"
|
||||
generated_backend_dir.mkdir(parents=True)
|
||||
for filename in ("sample.png", "sample.jpg", "sample.jpeg", "ignored.json"):
|
||||
(generated_backend_dir / filename).write_text(filename, encoding="utf-8")
|
||||
|
||||
ssim_dir = tmp_path / "ssim"
|
||||
reference_dir = (
|
||||
ssim_dir
|
||||
/ "reference_videos"
|
||||
/ "default"
|
||||
/ "L40S_reference_videos"
|
||||
)
|
||||
|
||||
copied = reference_videos_cli.copy_generated_to_reference(
|
||||
generated_dir=generated_dir,
|
||||
reference_dir=reference_dir,
|
||||
)
|
||||
|
||||
assert copied == 3
|
||||
assert not (reference_dir / "model-a" / "flash" / "ignored.json").exists()
|
||||
(reference_dir.parent / ".download_complete_default").touch()
|
||||
assert reference_videos_cli._has_local_reference_videos(ssim_dir, "default")
|
||||
|
||||
reference_videos_cli.upload_reference_videos(
|
||||
repo_id="FastVideo/ssim-reference-videos",
|
||||
repo_type="dataset",
|
||||
reference_dirs_by_tier=[("default", reference_dir)],
|
||||
token="hf_token",
|
||||
private=False,
|
||||
model_id="model-a",
|
||||
)
|
||||
uploaded_folder = Path(_FakeHfApi.instances[-1].uploaded_folders[0]["folder_path"])
|
||||
assert all(
|
||||
(uploaded_folder / "flash" / filename).exists()
|
||||
for filename in ("sample.png", "sample.jpg", "sample.jpeg")
|
||||
)
|
||||
|
||||
|
||||
def test_upload_draft_reference_artifact_uses_drafts_prefix(tmp_path):
|
||||
ssim_dir = tmp_path / "ssim"
|
||||
generated = (
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from fastvideo.logger import init_logger
|
||||
from fastvideo.tests.ssim.inference_similarity_utils import (
|
||||
run_text_to_video_similarity_test,
|
||||
)
|
||||
from fastvideo.tests.ssim.reference_utils import (
|
||||
get_cuda_device_name,
|
||||
resolve_device_reference_folder,
|
||||
)
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
REQUIRED_GPUS = 1
|
||||
# Calibrate after the first repeated L40S reference runs.
|
||||
ZIMAGE_MIN_SSIM = 0.98
|
||||
|
||||
ZIMAGE_MODEL_PATH = os.getenv(
|
||||
"ZIMAGE_MODEL_DIR",
|
||||
"Tongyi-MAI/Z-Image-Turbo",
|
||||
)
|
||||
|
||||
TEST_PROMPTS = [
|
||||
"Astronaut in a jungle, cold color palette, muted colors, detailed, 8k",
|
||||
]
|
||||
|
||||
device_reference_folder = resolve_device_reference_folder(
|
||||
(
|
||||
("A40", "A40"),
|
||||
("L40S", "L40S"),
|
||||
("H100", "H100"),
|
||||
("H200", "H200"),
|
||||
("B200", "B200"),
|
||||
),
|
||||
device_name=get_cuda_device_name(),
|
||||
fallback_device_prefix="L40S",
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
ZIMAGE_MODEL_TO_PARAMS: dict[str, dict[str, object]] = {
|
||||
"Tongyi-MAI__Z-Image-Turbo": {
|
||||
"num_gpus": 1,
|
||||
"model_path": ZIMAGE_MODEL_PATH,
|
||||
"sp_size": 1,
|
||||
"tp_size": 1,
|
||||
"height": 1024,
|
||||
"width": 1024,
|
||||
"num_frames": 1,
|
||||
"fps": 1,
|
||||
"num_inference_steps": 9,
|
||||
"guidance_scale": 0.0,
|
||||
"seed": 42,
|
||||
},
|
||||
}
|
||||
|
||||
ZIMAGE_FULL_QUALITY_MODEL_TO_PARAMS: dict[str, dict[str, object]] = {
|
||||
"Tongyi-MAI__Z-Image-Turbo": dict(
|
||||
ZIMAGE_MODEL_TO_PARAMS["Tongyi-MAI__Z-Image-Turbo"]
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not torch.cuda.is_available(),
|
||||
reason="Z-Image SSIM test requires CUDA",
|
||||
)
|
||||
@pytest.mark.parametrize("prompt", TEST_PROMPTS)
|
||||
@pytest.mark.parametrize("attention_backend_name", ["TORCH_SDPA"])
|
||||
@pytest.mark.parametrize("model_id", list(ZIMAGE_MODEL_TO_PARAMS))
|
||||
def test_zimage_similarity(
|
||||
prompt: str,
|
||||
attention_backend_name: str,
|
||||
model_id: str,
|
||||
) -> None:
|
||||
is_hf_repo = "/" in ZIMAGE_MODEL_PATH and not ZIMAGE_MODEL_PATH.startswith("/")
|
||||
if not is_hf_repo and not os.path.isdir(ZIMAGE_MODEL_PATH):
|
||||
pytest.skip(
|
||||
f"Z-Image weights not found at {ZIMAGE_MODEL_PATH} "
|
||||
"(set ZIMAGE_MODEL_DIR to override)"
|
||||
)
|
||||
|
||||
run_text_to_video_similarity_test(
|
||||
logger=logger,
|
||||
script_dir=os.path.dirname(os.path.abspath(__file__)),
|
||||
device_reference_folder=device_reference_folder,
|
||||
prompt=prompt,
|
||||
attention_backend_name=attention_backend_name,
|
||||
model_id=model_id,
|
||||
default_params_map=ZIMAGE_MODEL_TO_PARAMS,
|
||||
full_quality_params_map=ZIMAGE_FULL_QUALITY_MODEL_TO_PARAMS,
|
||||
min_acceptable_ssim=ZIMAGE_MIN_SSIM,
|
||||
media_extension=".png",
|
||||
init_kwargs_override={
|
||||
"workload_type": "t2i",
|
||||
"use_fsdp_inference": False,
|
||||
},
|
||||
generation_kwargs_override={
|
||||
"save_video": True,
|
||||
},
|
||||
)
|
||||
@@ -2,6 +2,7 @@ import torch
|
||||
import types
|
||||
import pytest
|
||||
|
||||
from fastvideo.configs.models.encoders.clip import CLIPTextArchConfig, CLIPTextConfig
|
||||
from fastvideo.fastvideo_args import FastVideoArgs
|
||||
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
|
||||
from fastvideo.configs.pipelines.base import PipelineConfig
|
||||
@@ -13,6 +14,9 @@ class TensorDict(dict):
|
||||
return TensorDict({k: v.to(device) for k, v in self.items()})
|
||||
|
||||
class FakeTokenizer:
|
||||
def __init__(self):
|
||||
self.last_chat_template_kwargs = None
|
||||
|
||||
def __call__(self, texts, **kwargs):
|
||||
B = len(texts)
|
||||
seq_len = int(kwargs.get("max_length", 4))
|
||||
@@ -21,6 +25,10 @@ class FakeTokenizer:
|
||||
"attention_mask": torch.ones(B, seq_len, dtype=torch.long),
|
||||
})
|
||||
|
||||
def apply_chat_template(self, messages, **kwargs):
|
||||
self.last_chat_template_kwargs = kwargs
|
||||
return "formatted prompt"
|
||||
|
||||
|
||||
class FakeChatTokenizer:
|
||||
def __init__(self):
|
||||
@@ -40,17 +48,22 @@ class FakeChatTokenizer:
|
||||
"attention_mask": torch.ones(B, seq_len, dtype=torch.long),
|
||||
})
|
||||
|
||||
|
||||
class FakeTextEncoder(torch.nn.Module):
|
||||
def __init__(self, hidden_size=8):
|
||||
super().__init__()
|
||||
self.hidden_size = hidden_size
|
||||
self.last_input_device = None
|
||||
self.last_output_hidden_states = None
|
||||
|
||||
def forward(self, input_ids, attention_mask, output_hidden_states=False):
|
||||
self.last_input_device = input_ids.device
|
||||
self.last_output_hidden_states = bool(output_hidden_states)
|
||||
B, T = input_ids.shape
|
||||
last_hidden_state = torch.arange(B * T * self.hidden_size, dtype=torch.float32).view(B, T, self.hidden_size)
|
||||
last_hidden_state = torch.arange(
|
||||
B * T * self.hidden_size,
|
||||
dtype=torch.float32,
|
||||
device=input_ids.device,
|
||||
).view(B, T, self.hidden_size)
|
||||
hidden_states = (last_hidden_state, ) if output_hidden_states else None
|
||||
return types.SimpleNamespace(last_hidden_state=last_hidden_state,
|
||||
hidden_states=hidden_states)
|
||||
@@ -213,3 +226,104 @@ def test_chat_list_preprocess_output_is_not_stripped():
|
||||
{"role": "user", "content": "a robotic arm welding a metal structure"},
|
||||
]]
|
||||
assert tokenizer.last_kwargs["return_tensors"] == "pt"
|
||||
|
||||
|
||||
def test_chat_template_thinking_is_keyword_only_and_preserves_subclass_positions():
|
||||
arch = CLIPTextArchConfig()
|
||||
config = CLIPTextConfig(
|
||||
arch,
|
||||
"legacy-prefix",
|
||||
None,
|
||||
None,
|
||||
False,
|
||||
False,
|
||||
7,
|
||||
False,
|
||||
True,
|
||||
False,
|
||||
)
|
||||
|
||||
assert config.num_hidden_layers_override == 7
|
||||
assert config.require_post_norm is False
|
||||
assert config.enable_scale is True
|
||||
assert config.is_causal is False
|
||||
assert config.chat_template_enable_thinking is False
|
||||
|
||||
configured = CLIPTextConfig(chat_template_enable_thinking=True)
|
||||
assert configured.chat_template_enable_thinking is True
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
CLIPTextConfig(
|
||||
arch,
|
||||
"legacy-prefix",
|
||||
None,
|
||||
None,
|
||||
False,
|
||||
False,
|
||||
7,
|
||||
False,
|
||||
True,
|
||||
False,
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_thinking", [False, True])
|
||||
def test_encode_text_forwards_chat_template_thinking_config(enable_thinking):
|
||||
fastvideo_args, hidden = make_args(num_encoders=1, text_len=4, hidden_size=8)
|
||||
encoder_config = fastvideo_args.pipeline_config.text_encoder_configs[0]
|
||||
encoder_config.is_chat_model = True
|
||||
encoder_config.chat_template_enable_thinking = enable_thinking
|
||||
|
||||
stage = make_stage(num_encoders=1, hidden_size=hidden)
|
||||
stage.encode_text("a", fastvideo_args, encoder_index=[0])
|
||||
|
||||
assert stage.tokenizers[0].last_chat_template_kwargs == {
|
||||
"tokenize": False,
|
||||
"add_generation_prompt": True,
|
||||
"enable_thinking": enable_thinking,
|
||||
}
|
||||
|
||||
|
||||
def test_encode_text_uses_hf_passthrough_input_device(monkeypatch):
|
||||
fastvideo_args, hidden = make_args(num_encoders=1, text_len=4, hidden_size=8)
|
||||
stage = make_stage(num_encoders=1, hidden_size=hidden)
|
||||
stage.text_encoders[0]._fastvideo_input_device = torch.device("cpu")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"fastvideo.pipelines.stages.text_encoding.get_local_torch_device",
|
||||
lambda: torch.device("meta"),
|
||||
)
|
||||
|
||||
batch = ForwardBatch(
|
||||
data_type="video",
|
||||
prompt="a",
|
||||
do_classifier_free_guidance=False,
|
||||
prompt_embeds=[],
|
||||
negative_prompt_embeds=None,
|
||||
prompt_attention_mask=[],
|
||||
negative_attention_mask=None,
|
||||
)
|
||||
output = stage.forward(batch, fastvideo_args)
|
||||
|
||||
# The marker governs where the encoder *receives* its tokens...
|
||||
assert stage.text_encoders[0].last_input_device == torch.device("cpu")
|
||||
# ...while the stage still normalizes the embeds it returns onto the
|
||||
# caller's target device.
|
||||
assert output.prompt_embeds[0].device.type == "meta"
|
||||
|
||||
|
||||
def test_encode_text_explicit_device_overrides_hf_passthrough_marker():
|
||||
fastvideo_args, hidden = make_args(num_encoders=1, text_len=4, hidden_size=8)
|
||||
stage = make_stage(num_encoders=1, hidden_size=hidden)
|
||||
stage.text_encoders[0]._fastvideo_input_device = torch.device("cpu")
|
||||
|
||||
output = stage.encode_text(
|
||||
"a",
|
||||
fastvideo_args,
|
||||
encoder_index=[0],
|
||||
device=torch.device("meta"),
|
||||
)
|
||||
|
||||
assert stage.text_encoders[0].last_input_device == torch.device("meta")
|
||||
assert output[0].device.type == "meta"
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# Z-Image Port Status
|
||||
|
||||
## Summary
|
||||
|
||||
- model_family: `zimage`
|
||||
- variant: `Z-Image-Turbo`
|
||||
- workload_types: `T2I`
|
||||
- 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`
|
||||
- hf_weights_path: `Tongyi-MAI/Z-Image-Turbo@f332072aa78be7aecdf3ee76d5c247082da564a6`
|
||||
- local_weights_dir: `<repo_root>/official_weights/Z-Image/`
|
||||
- source_layout: `diffusers`
|
||||
- local_tests_readme: `tests/local_tests/zimage/README.md`
|
||||
|
||||
The pinned text encoder has 36 decoder blocks, hidden size 2560,
|
||||
intermediate size 9728, 32 attention heads, 8 KV heads, and head dimension
|
||||
128. FastVideo obtains those values from the pinned `config.json`.
|
||||
|
||||
## Current Phase
|
||||
|
||||
- phase: Phase 6 (transformer parity activation and component revalidation)
|
||||
- status: in_progress
|
||||
- owner: dit/parity
|
||||
- last_updated: 2026-07-13
|
||||
|
||||
## Component Matrix
|
||||
|
||||
| Component | Type | Reuse/Port | Official Definition / Instantiation | FastVideo Target | Prototype | Conversion | Parity | Open Issues |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| Text encoder (Qwen3 body) | text_encoder | reused production body + native implementation | Pinned `Qwen3ForCausalLM` checkpoint; Transformers `AutoModel` body; official pipeline consumes `hidden_states[-2]` | `TextEncoderLoader` -> `Qwen3ForCausalLM.from_pretrained_local` -> independent body-only `AutoModel`, plus native `fastvideo.models.encoders.qwen3.Qwen3ForCausalLM` | DONE | not_needed for current HF subfolder | REVALIDATION REQUIRED (historical unpinned native fp32/bf16 PASS only) | I001, I006, I007 |
|
||||
| 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 | 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
|
||||
|
||||
- conversion_script: none for the reused components in this component-only PR
|
||||
- converted_weights_dir: none
|
||||
- source_layout: diffusers
|
||||
- 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
|
||||
- native_qwen_fused_contract: every Q/K/V and gate/up shard is required unless the exact fused destination is present
|
||||
- native_qwen_auxiliary_contract: mapped quantization auxiliaries, including scale parameters, must load and may not be silently discarded
|
||||
- retry_history: none
|
||||
|
||||
## Parity Commands
|
||||
|
||||
| Scope | Command | Last Result | Notes |
|
||||
|---|---|---|---|
|
||||
| Scheduler | `pytest tests/local_tests/zimage/test_zimage_scheduler_parity.py -v -s` | RERUN REQUIRED; historical unpinned 2/2 PASS on A40, 2026-05-12 | Now enforces reference SHA/import origin and exact zero endpoint; includes asset-free positional/default regressions. |
|
||||
| Tokenizer | `pytest tests/local_tests/zimage/test_zimage_tokenizer_parity.py -v -s` | RERUN REQUIRED; historical unpinned 2/2 PASS on A40, 2026-05-12 | Now uses `enable_thinking=True`, `max_length=512`, and fails if chat templating is unavailable. |
|
||||
| VAE | `pytest tests/local_tests/zimage/test_zimage_vae_parity.py -v -s` | RERUN REQUIRED; historical unpinned 1/1 direct-decode PASS on A40, 2026-05-12 | Scope is now `both`: direct decode plus `VAELoader` and official latent normalization. |
|
||||
| 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
|
||||
|
||||
| ID | Question | Owner | Needed By Phase | Status | Resolution |
|
||||
|---|---|---|---|---|---|
|
||||
| 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 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
|
||||
|
||||
| ID | Phase | Component | Severity | Issue | Evidence | Owner | Status | Resolution |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| 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` 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
|
||||
|
||||
| ID | Phase | Decision Type | Question | Recommended Option | Status | Resolution |
|
||||
|---|---|---|---|---|---|---|
|
||||
|
||||
## Decisions
|
||||
|
||||
| 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. |
|
||||
| 2026-07-04 | Accept the existing Diffusers-backed AutoencoderKL wrapper only for this component-only PR. | The target is established shared code and this PR adds production-loader evidence rather than a new VAE implementation. | Exception is limited to the VAE here and is not precedent for the transformer or future full pipeline. |
|
||||
| 2026-07-04 | Treat absent chat-template behavior as incompatibility, not an optional test environment. | Thinking-format prompt construction is part of the official numerical contract. | Tokenizer parity fails when `apply_chat_template` is unavailable. |
|
||||
| 2026-07-04 | Record the pinned Qwen architecture as 36 blocks, hidden size 2560, intermediate size 9728, 32 attention heads, and 8 KV heads. | Earlier notes described a different architecture and could mis-size the native target. | Config/review evidence now matches `f332072aa78be7aecdf3ee76d5c247082da564a6`; expected hidden-state length is 37. |
|
||||
| 2026-06-21 | Expand generated Qwen `position_ids` to `[batch_size, seq_len]`. | The rotary layer flattens positions; `[1, seq_len]` misaligned RoPE for batch sizes greater than one. | Batch-one behavior is unchanged; pinned batch-two parity still requires rerun. |
|
||||
| 2026-06-21 | Reuse the shared native `Qwen3ForCausalLM` implementation rather than maintain a second Z-Image encoder. | The shared implementation supports the same config-driven GQA, fused QKV/gate-up, and hidden-state output contract. | Registry/native parity uses the shared class; architecture values come from the pinned config. |
|
||||
| 2026-05-12 | Use mean and median drift checks for deep bf16 encoder parity. | Cross-kernel fused/unfused bf16 arithmetic produces a long max-error tail; fp32 and per-layer diagnostics distinguish this from structural divergence. | Existing thresholds remain provisional until the pinned 36-block snapshot is rerun. |
|
||||
| 2026-05-12 | Forward the complete scheduler config except loader metadata. | Hand-picking three fields could silently omit future scheduler behavior. | Scheduler parity consumes all on-disk constructor fields and explicitly supplies Z-Image runtime overrides. |
|
||||
|
||||
## Handoff Notes
|
||||
|
||||
- No asset-backed component currently has pinned-snapshot `non_skip_pass`
|
||||
evidence. Do not promote a historical row to PASS without recording the exact
|
||||
command and non-skip result against both immutable pins.
|
||||
- Text-encoder revalidation must cover the independent `AutoModel` oracle,
|
||||
body-only production loader, native implementation, requested input device,
|
||||
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.
|
||||
- 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.
|
||||
@@ -0,0 +1,161 @@
|
||||
# Z-Image Local Tests
|
||||
|
||||
Local-only component coverage for the `zimage` FastVideo port (`T2I`,
|
||||
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
|
||||
> below. Earlier A40/L40S results used an unrecorded HF snapshot and are retained
|
||||
> only as historical diagnostics, not current verification evidence. See
|
||||
> [`PORT_STATUS.md`](./PORT_STATUS.md) for the live state.
|
||||
|
||||
## Reference assets and scope
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Model family / variant | `zimage` / `Z-Image-Turbo` |
|
||||
| Workload | `T2I` |
|
||||
| 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/` (component subfolders; transformer is 24.6 GB) |
|
||||
| Source layout | Diffusers-style per-component subfolders |
|
||||
| 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
|
||||
blocks, hidden size 2560, intermediate size 9728, 32 attention heads, 8 KV
|
||||
heads, and head dimension 128. FastVideo loads those values from
|
||||
`text_encoder/config.json`; they are not hard-coded in the Z-Image integration.
|
||||
|
||||
## Shared environment setup
|
||||
|
||||
Run from the FastVideo repository root in the same environment used for
|
||||
FastVideo. Keep the reference clone and weights inside the ignored paths shown
|
||||
below.
|
||||
|
||||
Clone and pin the official reference:
|
||||
|
||||
```bash
|
||||
python .agents/skills/add-model-01-prep/scripts/clone_reference_repo.py \
|
||||
https://github.com/Tongyi-MAI/Z-Image.git \
|
||||
Z-Image \
|
||||
--commit 26f23eda626ffadda020b04ff79488e1d72004cd \
|
||||
--update-gitignore
|
||||
git -C Z-Image rev-parse HEAD
|
||||
```
|
||||
|
||||
The final command must print
|
||||
`26f23eda626ffadda020b04ff79488e1d72004cd`. The scheduler and VAE tests also
|
||||
enforce that exact HEAD and verify that imported `zimage` modules resolve under
|
||||
`<repo_root>/Z-Image/src`. A missing clone may skip local parity; a wrong SHA or
|
||||
wrong import origin fails rather than silently testing another installation.
|
||||
|
||||
Download the immutable HF snapshot:
|
||||
|
||||
```bash
|
||||
python .agents/skills/add-model-01-prep/scripts/download_hf_weights.py \
|
||||
Tongyi-MAI/Z-Image-Turbo \
|
||||
official_weights/Z-Image \
|
||||
--revision f332072aa78be7aecdf3ee76d5c247082da564a6 \
|
||||
--allow-pattern 'text_encoder/*' \
|
||||
--allow-pattern 'tokenizer/*' \
|
||||
--allow-pattern 'vae/*' \
|
||||
--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: transformer source import and tiny CPU parity verified; asset-backed components pending
|
||||
private_dep_stubs: none
|
||||
blocked_on: pinned-snapshot component reruns and transformer real-weight CUDA parity
|
||||
```
|
||||
|
||||
## Run the tests
|
||||
|
||||
```bash
|
||||
pytest tests/local_tests/zimage/ -v -s
|
||||
```
|
||||
|
||||
| Component | Coverage scope and contract | Status |
|
||||
|---|---|---|
|
||||
| Scheduler ([`test_zimage_scheduler_parity.py`](./test_zimage_scheduler_parity.py)) | `implementation_subcomponent`; exact official `sigma_min=0.0` plus `use_reference_discrete_timesteps=True`; positional/default-path regressions remain asset-free | REVALIDATION REQUIRED |
|
||||
| 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
|
||||
|
||||
### Text encoder and tokenizer
|
||||
|
||||
- The production `TextEncoderLoader` path deliberately loads Transformers
|
||||
`AutoModel`, even when the checkpoint advertises `Qwen3ForCausalLM`. FastVideo
|
||||
consumes hidden states only, so the production path is body-only and does not
|
||||
materialize or execute an LM head.
|
||||
- The parity oracle is a separate `AutoModel` instance; it is not the object
|
||||
returned by the production loader. The native `Qwen3ForCausalLM` implementation
|
||||
is compared separately.
|
||||
- CPU/MPS text-encoder offload remains on the requested device. The loader records
|
||||
`_fastvideo_input_device`, and `TextEncodingStage` sends token tensors there.
|
||||
- Native loading must account for every destination parameter and every required
|
||||
fused source shard. Q/K/V and gate/up shards are all required unless the exact
|
||||
destination is supplied already fused; quantized auxiliary parameters such as
|
||||
`scale_weight` are not silently ignored.
|
||||
- The pinned official prompt path enables thinking, tokenizes to 512, and consumes
|
||||
`hidden_states[-2]` at valid-token positions. A 36-block Qwen model exposes 37
|
||||
hidden-state entries in this contract (embedding/intermediate entries plus the
|
||||
final normalized state).
|
||||
- `TextEncoderConfig.chat_template_enable_thinking` is keyword-only and defaults
|
||||
to `False` for existing model families. A future Z-Image pipeline config must
|
||||
opt in with `True`.
|
||||
|
||||
### VAE
|
||||
|
||||
The test covers both direct implementation parity and production `VAELoader`
|
||||
resolution/strict loading. This component-only PR explicitly accepts the
|
||||
existing shared `fastvideo.models.vaes.autoencoder_kl.AutoencoderKL` wrapper,
|
||||
which subclasses Diffusers `AutoencoderKL`, as a narrowly scoped exception to
|
||||
the native-component boundary. This is not precedent for the future transformer
|
||||
or full Z-Image pipeline, and the exception must be revisited if that scope grows.
|
||||
|
||||
### Scheduler
|
||||
|
||||
The pinned official pipeline mutates `scheduler.sigma_min = 0.0` before building
|
||||
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.
|
||||
- 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 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
@@ -0,0 +1,743 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""
|
||||
Parity test for Z-Image Qwen3 text encoder support in FastVideo.
|
||||
|
||||
This compares:
|
||||
1) direct transformers AutoModel output, and
|
||||
2) FastVideo's production ``TextEncoderLoader`` passthrough, and
|
||||
3) FastVideo's shared native Qwen3 encoder (``Qwen3ForCausalLM``, added for Flux2
|
||||
Klein and reused here — Z-Image-Turbo's ``Qwen3Model`` checkpoint routes
|
||||
to it via the model registry),
|
||||
|
||||
using identical local checkpoint, tokenization, and inputs.
|
||||
|
||||
Usage:
|
||||
pytest tests/local_tests/zimage/test_zimage_encoder_parity.py -v -s
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import gc
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
|
||||
def _reclaim_vram() -> None:
|
||||
"""Actually reclaim VRAM after the caller has ``del``'d its model refs.
|
||||
|
||||
HF transformer models hold reference cycles, so ``del`` alone does not free
|
||||
them — a ``gc.collect()`` is required before the CUDA caching allocator will
|
||||
release the blocks. Without this the parity test keeps two full encoder
|
||||
copies resident and OOMs on a 44 GB GPU.
|
||||
"""
|
||||
gc.collect()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
|
||||
def _gpu_mem_mb() -> float:
|
||||
if torch.cuda.is_available():
|
||||
return torch.cuda.memory_allocated() / (1024 ** 2)
|
||||
return 0.0
|
||||
from torch.testing import assert_close
|
||||
from transformers import AutoModel, AutoModelForCausalLM, AutoTokenizer
|
||||
from safetensors.torch import safe_open
|
||||
|
||||
from fastvideo.configs.models.encoders.qwen3 import Qwen3TextConfig
|
||||
from fastvideo.distributed.parallel_state import cleanup_dist_env_and_memory, maybe_init_distributed_environment_and_model_parallel
|
||||
from fastvideo.layers.quantization.absmax_fp8 import AbsMaxFP8MergedParameter
|
||||
from fastvideo.models.encoders.qwen3 import Qwen3ForCausalLM
|
||||
from fastvideo.models.loader.component_loader import TextEncoderLoader
|
||||
from fastvideo.models.registry import ModelRegistry
|
||||
|
||||
PARITY_SCOPE = "both"
|
||||
|
||||
# Strict-load contract: the shared Qwen3 encoder is body-only (embed_tokens +
|
||||
# layers + norm, no lm_head). Z-Image-Turbo ships a full Qwen3 checkpoint, so
|
||||
# `lm_head.weight` is the only key that may go unmatched; anything else means a
|
||||
# real silent drop. Enforced here in the test since the shared encoder's loader
|
||||
# is intentionally lenient (it's used by multiple models).
|
||||
_ALLOWED_UNEXPECTED_KEYS = {"lm_head.weight"}
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
ZIMAGE_TEXT_ENCODER_DIR = REPO_ROOT / "official_weights" / "Z-Image" / "text_encoder"
|
||||
ZIMAGE_TOKENIZER_DIR = REPO_ROOT / "official_weights" / "Z-Image" / "tokenizer"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def _init_dist_and_tp_groups():
|
||||
os.environ.setdefault("MASTER_ADDR", "localhost")
|
||||
os.environ.setdefault("MASTER_PORT", "29531")
|
||||
os.environ.setdefault("RANK", "0")
|
||||
os.environ.setdefault("WORLD_SIZE", "1")
|
||||
os.environ.setdefault("LOCAL_RANK", "0")
|
||||
|
||||
maybe_init_distributed_environment_and_model_parallel(1, 1)
|
||||
yield
|
||||
cleanup_dist_env_and_memory()
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict:
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _iter_safetensors(path: Path):
|
||||
with safe_open(str(path), framework="pt", device="cpu") as f:
|
||||
for k in f.keys():
|
||||
yield k, f.get_tensor(k)
|
||||
|
||||
|
||||
def _iter_pretrained_safetensors(model_dir: Path):
|
||||
single = model_dir / "model.safetensors"
|
||||
if single.exists():
|
||||
yield from _iter_safetensors(single)
|
||||
return
|
||||
|
||||
index = model_dir / "model.safetensors.index.json"
|
||||
if index.exists():
|
||||
idx = _load_json(index)
|
||||
shard_names = sorted(set(idx["weight_map"].values()))
|
||||
for shard in shard_names:
|
||||
yield from _iter_safetensors(model_dir / shard)
|
||||
return
|
||||
|
||||
raise FileNotFoundError(
|
||||
f"Missing safetensors checkpoint in {model_dir} (expected model.safetensors or model.safetensors.index.json)"
|
||||
)
|
||||
|
||||
|
||||
def _pretrained_safetensor_keys(model_dir: Path) -> set[str]:
|
||||
return {name for name, _ in _iter_pretrained_safetensors(model_dir)}
|
||||
|
||||
|
||||
def _load_qwen3_config() -> Qwen3TextConfig:
|
||||
cfg_raw = _load_json(ZIMAGE_TEXT_ENCODER_DIR / "config.json")
|
||||
for key in ("_name_or_path", "transformers_version", "model_type", "torch_dtype"):
|
||||
cfg_raw.pop(key, None)
|
||||
config = Qwen3TextConfig()
|
||||
config.update_model_arch(cfg_raw)
|
||||
assert config.num_hidden_layers == 36
|
||||
assert config.hidden_size == 2560
|
||||
assert config.intermediate_size == 9728
|
||||
assert config.num_attention_heads == 32
|
||||
assert config.num_key_value_heads == 8
|
||||
return config
|
||||
|
||||
|
||||
def _loader_args(cpu_offload: bool) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
text_encoder_cpu_offload=cpu_offload,
|
||||
override_text_encoder_quant=None,
|
||||
override_text_encoder_safetensors=None,
|
||||
pin_cpu_memory=False,
|
||||
)
|
||||
|
||||
|
||||
def _precision_name(dtype: torch.dtype) -> str:
|
||||
if dtype == torch.float32:
|
||||
return "fp32"
|
||||
if dtype == torch.bfloat16:
|
||||
return "bf16"
|
||||
raise ValueError(f"Unsupported parity dtype: {dtype}")
|
||||
|
||||
|
||||
def _tokenize_official_prompts(tokenizer, prompts: list[str]):
|
||||
formatted = [
|
||||
tokenizer.apply_chat_template(
|
||||
[{"role": "user", "content": prompt}],
|
||||
tokenize=False,
|
||||
add_generation_prompt=True,
|
||||
enable_thinking=True,
|
||||
)
|
||||
for prompt in prompts
|
||||
]
|
||||
return tokenizer(
|
||||
formatted,
|
||||
padding="max_length",
|
||||
truncation=True,
|
||||
max_length=512,
|
||||
return_tensors="pt",
|
||||
)
|
||||
|
||||
|
||||
def _assert_native_load_surface(
|
||||
model,
|
||||
checkpoint_keys: set[str],
|
||||
loaded_params: set[str],
|
||||
) -> None:
|
||||
param_names = {name for name, _ in model.named_parameters()}
|
||||
missing = param_names - loaded_params
|
||||
assert not missing, f"FastVideo parameters not loaded: {sorted(missing)}"
|
||||
|
||||
normalized_keys = {
|
||||
name[len("model."):] if name.startswith("model.") else name
|
||||
for name in checkpoint_keys
|
||||
}
|
||||
mapped_keys: set[str] = set()
|
||||
for checkpoint_name in normalized_keys:
|
||||
for param_name, weight_name, _ in model.config.arch_config.stacked_params_mapping:
|
||||
if weight_name in checkpoint_name:
|
||||
mapped_keys.add(checkpoint_name.replace(weight_name, param_name))
|
||||
break
|
||||
else:
|
||||
mapped_keys.add(checkpoint_name)
|
||||
|
||||
unexpected = mapped_keys - param_names - {
|
||||
"rotary_emb.inv_freq",
|
||||
"rotary_emb.cos_cached",
|
||||
"rotary_emb.sin_cached",
|
||||
}
|
||||
assert unexpected <= _ALLOWED_UNEXPECTED_KEYS, (
|
||||
"Unexpected checkpoint keys not in allowlist: "
|
||||
f"{sorted(unexpected - _ALLOWED_UNEXPECTED_KEYS)}"
|
||||
)
|
||||
|
||||
|
||||
def _fake_qwen3_weight_target(include_quant_scales: bool = False):
|
||||
stacked = Qwen3TextConfig().arch_config.stacked_params_mapping
|
||||
params = {
|
||||
"layers.0.self_attn.qkv_proj.weight": torch.nn.Parameter(torch.empty(1)),
|
||||
"layers.0.mlp.gate_up_proj.weight": torch.nn.Parameter(torch.empty(1)),
|
||||
}
|
||||
if include_quant_scales:
|
||||
qkv_scale = AbsMaxFP8MergedParameter(torch.zeros(3), requires_grad=False)
|
||||
qkv_scale.output_partition_sizes = [1, 1, 1]
|
||||
gate_up_scale = AbsMaxFP8MergedParameter(torch.zeros(2), requires_grad=False)
|
||||
gate_up_scale.output_partition_sizes = [1, 1]
|
||||
params.update({
|
||||
"layers.0.self_attn.qkv_proj.scale_weight": qkv_scale,
|
||||
"layers.0.mlp.gate_up_proj.scale_weight": gate_up_scale,
|
||||
})
|
||||
for param in params.values():
|
||||
if not hasattr(param, "weight_loader"):
|
||||
param.weight_loader = (
|
||||
lambda target, loaded, *args: target.data.copy_(loaded)
|
||||
)
|
||||
|
||||
model = SimpleNamespace(
|
||||
config=SimpleNamespace(
|
||||
arch_config=SimpleNamespace(stacked_params_mapping=stacked)
|
||||
),
|
||||
named_parameters=lambda: params.items(),
|
||||
)
|
||||
return model, params, stacked
|
||||
|
||||
|
||||
# The provisional bf16 thresholds below come from the historical unpinned
|
||||
# 35-layer run. Per add-model-02-parity's calibration block, bf16 max-based
|
||||
# asserts are meaningless for deep encoders — fused-vs-unfused linear order
|
||||
# alone produces a long tail, so the test uses mean + median instead. The
|
||||
# pinned snapshot has 36 blocks and must be rerun before these thresholds are
|
||||
# treated as current evidence.
|
||||
#
|
||||
# Historical empirical numbers (unrecorded snapshot, 35 layers, CUDA):
|
||||
# last_hidden_state (post-norm): mean=0.0152, median=0.0117
|
||||
# hidden_states[-2] (pre-norm): mean=0.0754, median=0.0625
|
||||
# Thresholds below add ~1.6x headroom over observed.
|
||||
_BF16_MEAN_DRIFT_POST_NORM = 0.025
|
||||
_BF16_MEAN_DRIFT_PRE_NORM = 0.120
|
||||
_BF16_MEDIAN_DRIFT_POST_NORM = 0.020
|
||||
_BF16_MEDIAN_DRIFT_PRE_NORM = 0.100
|
||||
_FP32_ATOL = 1e-4
|
||||
_FP32_RTOL = 1e-4
|
||||
|
||||
|
||||
def _print_diag(label: str, ref: torch.Tensor, fv: torch.Tensor) -> torch.Tensor:
|
||||
diff = (ref.float() - fv.float()).abs()
|
||||
flat = diff.flatten()
|
||||
p99 = flat.kthvalue(max(1, int(0.99 * flat.numel()))).values
|
||||
print(
|
||||
f"[{label}] max_diff={diff.max():.4f} mean_diff={diff.mean():.4f} "
|
||||
f"median_diff={diff.median():.4f} p99_diff={p99:.4f}",
|
||||
flush=True,
|
||||
)
|
||||
return diff
|
||||
|
||||
|
||||
def test_qwen3_production_loader_uses_body_only_model_and_honors_cpu_offload(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""The production passthrough must avoid the LM head and preserve CPU placement."""
|
||||
import fastvideo.platforms as platforms
|
||||
|
||||
placements: list[torch.device] = []
|
||||
auto_model_calls: list[dict] = []
|
||||
|
||||
class FakeHFModel:
|
||||
def eval(self):
|
||||
return self
|
||||
|
||||
def to(self, device: torch.device):
|
||||
placements.append(torch.device(device))
|
||||
return self
|
||||
|
||||
fake_model = FakeHFModel()
|
||||
|
||||
def fake_auto_model_from_pretrained(*args, **kwargs):
|
||||
auto_model_calls.append(kwargs)
|
||||
return fake_model
|
||||
|
||||
def fail_causal_lm_from_pretrained(*args, **kwargs):
|
||||
raise AssertionError("Qwen text encoding must not load AutoModelForCausalLM")
|
||||
|
||||
monkeypatch.setattr(
|
||||
AutoModel,
|
||||
"from_pretrained",
|
||||
staticmethod(fake_auto_model_from_pretrained),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
AutoModelForCausalLM,
|
||||
"from_pretrained",
|
||||
staticmethod(fail_causal_lm_from_pretrained),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
platforms,
|
||||
"_current_platform",
|
||||
SimpleNamespace(
|
||||
is_mps=lambda: False,
|
||||
verify_model_arch=lambda _arch: None,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(torch.cuda, "is_available", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
"fastvideo.distributed.get_local_torch_device",
|
||||
lambda: torch.device("cuda"),
|
||||
)
|
||||
|
||||
config = Qwen3TextConfig()
|
||||
# The pinned checkpoint names the causal wrapper even though the official
|
||||
# Z-Image loader intentionally asks Transformers for the body-only model.
|
||||
config.arch_config.architectures = ["Qwen3ForCausalLM"]
|
||||
loaded = TextEncoderLoader().load_model(
|
||||
"unused-by-mock",
|
||||
config,
|
||||
torch.device("cuda"),
|
||||
_loader_args(cpu_offload=True),
|
||||
dtype="fp32",
|
||||
use_text_encoder_override=True,
|
||||
)
|
||||
|
||||
assert loaded is fake_model
|
||||
assert auto_model_calls == [{
|
||||
"local_files_only": True,
|
||||
"torch_dtype": torch.float32,
|
||||
"low_cpu_mem_usage": True,
|
||||
}]
|
||||
assert placements == [torch.device("cpu")]
|
||||
assert loaded._fastvideo_input_device == torch.device("cpu")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("checkpoint_prefix", ["", "model."])
|
||||
def test_qwen3_native_load_accepts_already_fused_weights_and_scales(
|
||||
checkpoint_prefix: str,
|
||||
):
|
||||
"""Direct FastVideo/state-dict keys satisfy each fused target atomically."""
|
||||
fake_model, params, _ = _fake_qwen3_weight_target(include_quant_scales=True)
|
||||
expected = {
|
||||
name: torch.arange(1, param.numel() + 1, dtype=param.dtype)
|
||||
for name, param in params.items()
|
||||
}
|
||||
weights = [
|
||||
(f"{checkpoint_prefix}{name}", expected[name])
|
||||
for name in params
|
||||
]
|
||||
|
||||
loaded = Qwen3ForCausalLM.load_weights(fake_model, weights)
|
||||
|
||||
assert loaded == set(params)
|
||||
for name, param in params.items():
|
||||
assert torch.equal(param.data, expected[name])
|
||||
_assert_native_load_surface(
|
||||
fake_model,
|
||||
{name for name, _ in weights},
|
||||
loaded,
|
||||
)
|
||||
|
||||
|
||||
def test_qwen3_native_load_rejects_incompatible_fused_quant_scale_shape():
|
||||
fake_model, params, _ = _fake_qwen3_weight_target(include_quant_scales=True)
|
||||
weights = [
|
||||
(name, torch.ones_like(param))
|
||||
for name, param in params.items()
|
||||
if name != "layers.0.self_attn.qkv_proj.scale_weight"
|
||||
]
|
||||
weights.append(("layers.0.self_attn.qkv_proj.scale_weight", torch.ones(1)))
|
||||
|
||||
with pytest.raises(AssertionError, match="Attempted to load weight"):
|
||||
Qwen3ForCausalLM.load_weights(fake_model, weights)
|
||||
|
||||
|
||||
def test_qwen3_native_load_uses_real_shard_loader_for_split_quant_scales():
|
||||
fake_model, params, stacked = _fake_qwen3_weight_target(include_quant_scales=True)
|
||||
weights = [
|
||||
(name, torch.ones_like(param))
|
||||
for name, param in params.items()
|
||||
if name.endswith(".weight")
|
||||
]
|
||||
weights.extend(
|
||||
(f"model.layers.0.self_attn{weight_name}.scale_weight", torch.tensor(float(index + 1)))
|
||||
for index, (_, weight_name, _) in enumerate(stacked[:3])
|
||||
)
|
||||
weights.extend(
|
||||
(f"model.layers.0.mlp{weight_name}.scale_weight", torch.tensor(float(index + 4)))
|
||||
for index, (_, weight_name, _) in enumerate(stacked[3:])
|
||||
)
|
||||
|
||||
loaded = Qwen3ForCausalLM.load_weights(fake_model, weights)
|
||||
|
||||
assert loaded == set(params)
|
||||
assert torch.equal(
|
||||
params["layers.0.self_attn.qkv_proj.scale_weight"].data,
|
||||
torch.tensor([1.0, 2.0, 3.0]),
|
||||
)
|
||||
assert torch.equal(
|
||||
params["layers.0.mlp.gate_up_proj.scale_weight"].data,
|
||||
torch.tensor([4.0, 5.0]),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("missing_weight_name", "missing_shard_id"),
|
||||
[
|
||||
(".q_proj", "q"),
|
||||
(".k_proj", "k"),
|
||||
(".v_proj", "v"),
|
||||
(".gate_proj", 0),
|
||||
(".up_proj", 1),
|
||||
],
|
||||
)
|
||||
def test_qwen3_native_load_rejects_missing_fused_weight_shard(
|
||||
missing_weight_name: str,
|
||||
missing_shard_id: str | int,
|
||||
):
|
||||
fake_model, _, stacked = _fake_qwen3_weight_target()
|
||||
weights = [
|
||||
(f"model.layers.0.self_attn{weight_name}.weight", torch.empty(1))
|
||||
for _, weight_name, _ in stacked[:3]
|
||||
if weight_name != missing_weight_name
|
||||
]
|
||||
weights.extend(
|
||||
(f"model.layers.0.mlp{weight_name}.weight", torch.empty(1))
|
||||
for _, weight_name, _ in stacked[3:]
|
||||
if weight_name != missing_weight_name
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Missing required stacked checkpoint shards") as exc_info:
|
||||
Qwen3ForCausalLM.load_weights(fake_model, weights)
|
||||
|
||||
destination = (
|
||||
"layers.0.self_attn.qkv_proj.weight"
|
||||
if isinstance(missing_shard_id, str)
|
||||
else "layers.0.mlp.gate_up_proj.weight"
|
||||
)
|
||||
assert f"{destination}[{missing_shard_id}]" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("missing_weight_name", "missing_shard_id"),
|
||||
[
|
||||
(".q_proj", "q"),
|
||||
(".k_proj", "k"),
|
||||
(".v_proj", "v"),
|
||||
(".gate_proj", 0),
|
||||
(".up_proj", 1),
|
||||
],
|
||||
)
|
||||
def test_qwen3_native_load_rejects_missing_fused_quant_scale_shard(
|
||||
missing_weight_name: str,
|
||||
missing_shard_id: str | int,
|
||||
):
|
||||
fake_model, params, stacked = _fake_qwen3_weight_target(include_quant_scales=True)
|
||||
weights = [
|
||||
(name, torch.empty(1))
|
||||
for name in params
|
||||
if name.endswith(".weight")
|
||||
]
|
||||
weights.extend(
|
||||
(f"model.layers.0.self_attn{weight_name}.scale_weight", torch.empty(1))
|
||||
for _, weight_name, _ in stacked[:3]
|
||||
if weight_name != missing_weight_name
|
||||
)
|
||||
weights.extend(
|
||||
(f"model.layers.0.mlp{weight_name}.scale_weight", torch.empty(1))
|
||||
for _, weight_name, _ in stacked[3:]
|
||||
if weight_name != missing_weight_name
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Missing required stacked checkpoint shards") as exc_info:
|
||||
Qwen3ForCausalLM.load_weights(fake_model, weights)
|
||||
|
||||
destination = (
|
||||
"layers.0.self_attn.qkv_proj.scale_weight"
|
||||
if isinstance(missing_shard_id, str)
|
||||
else "layers.0.mlp.gate_up_proj.scale_weight"
|
||||
)
|
||||
assert f"{destination}[{missing_shard_id}]" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not ZIMAGE_TEXT_ENCODER_DIR.exists(), reason="Z-Image text encoder checkpoint required")
|
||||
@pytest.mark.parametrize(
|
||||
"dtype",
|
||||
[
|
||||
pytest.param(torch.float32, id="fp32"),
|
||||
pytest.param(
|
||||
torch.bfloat16,
|
||||
id="bf16",
|
||||
marks=pytest.mark.skipif(
|
||||
not (torch.cuda.is_available() and torch.cuda.is_bf16_supported()),
|
||||
reason="bf16 parity requires a bf16-capable CUDA device",
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_zimage_qwen3_encoder_parity_forward(dtype: torch.dtype):
|
||||
if not ZIMAGE_TOKENIZER_DIR.exists():
|
||||
pytest.skip(f"Z-Image tokenizer dir not found: {ZIMAGE_TOKENIZER_DIR}")
|
||||
|
||||
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
torch.manual_seed(11)
|
||||
|
||||
tokenizer = AutoTokenizer.from_pretrained(str(ZIMAGE_TOKENIZER_DIR), local_files_only=True)
|
||||
|
||||
prompts = [
|
||||
"A cinematic shot of a rainy neon street at night.",
|
||||
"A watercolor illustration of a fox in autumn leaves.",
|
||||
]
|
||||
toks = _tokenize_official_prompts(tokenizer, prompts)
|
||||
input_ids = toks["input_ids"].to(device)
|
||||
attention_mask = toks["attention_mask"].to(device)
|
||||
config = _load_qwen3_config()
|
||||
|
||||
ref = AutoModel.from_pretrained(
|
||||
str(ZIMAGE_TEXT_ENCODER_DIR),
|
||||
local_files_only=True,
|
||||
trust_remote_code=True,
|
||||
dtype=dtype,
|
||||
low_cpu_mem_usage=True,
|
||||
).eval().to(device=device, dtype=dtype)
|
||||
|
||||
with torch.no_grad():
|
||||
ref_out = ref(
|
||||
input_ids=input_ids,
|
||||
attention_mask=attention_mask,
|
||||
output_hidden_states=True,
|
||||
return_dict=True,
|
||||
)
|
||||
ref_last = ref_out.last_hidden_state.detach().float().cpu()
|
||||
assert ref_out.hidden_states is not None
|
||||
ref_hs_m2 = ref_out.hidden_states[-2].detach().float().cpu()
|
||||
|
||||
print(f"[mem] after HF ref forward: {_gpu_mem_mb():.0f} MiB", flush=True)
|
||||
# Keep only CPU outputs so each full encoder is resident one at a time.
|
||||
del ref, ref_out
|
||||
_reclaim_vram()
|
||||
print(f"[mem] after freeing HF ref: {_gpu_mem_mb():.0f} MiB", flush=True)
|
||||
|
||||
production = TextEncoderLoader().load_model(
|
||||
str(ZIMAGE_TEXT_ENCODER_DIR),
|
||||
config,
|
||||
device,
|
||||
_loader_args(cpu_offload=False),
|
||||
dtype=_precision_name(dtype),
|
||||
use_text_encoder_override=True,
|
||||
)
|
||||
assert production.__class__.__name__ == "Qwen3Model"
|
||||
assert not hasattr(production, "lm_head")
|
||||
assert production._fastvideo_input_device == device
|
||||
|
||||
with torch.no_grad():
|
||||
production_out = production(
|
||||
input_ids=input_ids,
|
||||
attention_mask=attention_mask,
|
||||
output_hidden_states=True,
|
||||
return_dict=True,
|
||||
)
|
||||
production_last = production_out.last_hidden_state.detach().float().cpu()
|
||||
assert production_out.hidden_states is not None
|
||||
production_hs_m2 = production_out.hidden_states[-2].detach().float().cpu()
|
||||
|
||||
# Production loading must preserve the independent Transformers oracle
|
||||
# exactly; native-kernel drift is assessed separately below.
|
||||
assert_close(ref_last, production_last, atol=0.0, rtol=0.0)
|
||||
assert_close(ref_hs_m2, production_hs_m2, atol=0.0, rtol=0.0)
|
||||
del production, production_out, production_last, production_hs_m2
|
||||
_reclaim_vram()
|
||||
|
||||
fv_cls, _ = ModelRegistry.resolve_model_cls("Qwen3Model")
|
||||
fv = fv_cls(config).eval()
|
||||
loaded = fv.load_weights(_iter_pretrained_safetensors(ZIMAGE_TEXT_ENCODER_DIR))
|
||||
assert loaded, "No Qwen3 weights were loaded into FastVideo model"
|
||||
fv = fv.to(device=device, dtype=dtype)
|
||||
print(f"[mem] after FastVideo load: {_gpu_mem_mb():.0f} MiB", flush=True)
|
||||
|
||||
_assert_native_load_surface(
|
||||
fv,
|
||||
_pretrained_safetensor_keys(ZIMAGE_TEXT_ENCODER_DIR),
|
||||
loaded,
|
||||
)
|
||||
|
||||
with torch.no_grad():
|
||||
fv_out = fv(
|
||||
input_ids=input_ids,
|
||||
attention_mask=attention_mask,
|
||||
output_hidden_states=True,
|
||||
)
|
||||
fv_last = fv_out.last_hidden_state.detach().float().cpu()
|
||||
assert fv_out.hidden_states is not None
|
||||
fv_hs_m2 = fv_out.hidden_states[-2].detach().float().cpu()
|
||||
|
||||
# Z-Image uses only valid token embeddings selected by attention mask.
|
||||
# Compare parity on that exact path to avoid padded-token artifacts.
|
||||
mask_cpu = attention_mask.detach().bool().cpu()
|
||||
is_bf16 = dtype == torch.bfloat16
|
||||
|
||||
for i in range(mask_cpu.shape[0]):
|
||||
valid = mask_cpu[i]
|
||||
ref_last_v = ref_last[i][valid]
|
||||
fv_last_v = fv_last[i][valid]
|
||||
ref_hs_m2_v = ref_hs_m2[i][valid]
|
||||
fv_hs_m2_v = fv_hs_m2[i][valid]
|
||||
|
||||
last_diff = _print_diag(f"batch{i} last_hidden_state {dtype}", ref_last_v, fv_last_v)
|
||||
hs_m2_diff = _print_diag(f"batch{i} hidden_states[-2] {dtype}", ref_hs_m2_v, fv_hs_m2_v)
|
||||
|
||||
if is_bf16:
|
||||
# Distribution checks instead of element-wise assert_close: cross-
|
||||
# kernel bf16 produces a long max tail (fused vs unfused linears),
|
||||
# but a real op bug pushes mean *and* median high. Mean catches
|
||||
# systematic drift; median catches "wrong weights / swapped
|
||||
# layers" where most elements diverge. Pre-norm tensors get a
|
||||
# looser bound because the final RMSNorm compresses drift ~5x.
|
||||
assert last_diff.mean().item() < _BF16_MEAN_DRIFT_POST_NORM, (
|
||||
f"batch{i} last_hidden_state bf16 mean drift "
|
||||
f"{last_diff.mean():.4f} >= {_BF16_MEAN_DRIFT_POST_NORM}"
|
||||
)
|
||||
assert last_diff.median().item() < _BF16_MEDIAN_DRIFT_POST_NORM, (
|
||||
f"batch{i} last_hidden_state bf16 median drift "
|
||||
f"{last_diff.median():.4f} >= {_BF16_MEDIAN_DRIFT_POST_NORM}"
|
||||
)
|
||||
assert hs_m2_diff.mean().item() < _BF16_MEAN_DRIFT_PRE_NORM, (
|
||||
f"batch{i} hidden_states[-2] bf16 mean drift "
|
||||
f"{hs_m2_diff.mean():.4f} >= {_BF16_MEAN_DRIFT_PRE_NORM}"
|
||||
)
|
||||
assert hs_m2_diff.median().item() < _BF16_MEDIAN_DRIFT_PRE_NORM, (
|
||||
f"batch{i} hidden_states[-2] bf16 median drift "
|
||||
f"{hs_m2_diff.median():.4f} >= {_BF16_MEDIAN_DRIFT_PRE_NORM}"
|
||||
)
|
||||
else:
|
||||
assert_close(ref_last_v, fv_last_v, atol=_FP32_ATOL, rtol=_FP32_RTOL)
|
||||
assert_close(ref_hs_m2_v, fv_hs_m2_v, atol=_FP32_ATOL, rtol=_FP32_RTOL)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not ZIMAGE_TEXT_ENCODER_DIR.exists(), reason="Z-Image text encoder checkpoint required")
|
||||
@pytest.mark.skipif(
|
||||
not (torch.cuda.is_available() and torch.cuda.is_bf16_supported()),
|
||||
reason="bf16 per-layer diagnostic requires a bf16-capable CUDA device",
|
||||
)
|
||||
def test_zimage_qwen3_encoder_per_layer_bf16_diagnostic():
|
||||
"""Diagnostic-only: prints per-layer FastVideo-vs-HF drift for the bf16
|
||||
forward to distinguish monotonic accumulation (bf16 tail) from a
|
||||
single-layer spike (real op mismatch).
|
||||
|
||||
This test always passes; review the captured stdout to interpret.
|
||||
Expected pattern for healthy bf16 accumulation: max/mean/median grow
|
||||
smoothly with layer depth. Suspect pattern: one layer's diff is
|
||||
>>2x the previous, suggesting a real bug at that block.
|
||||
"""
|
||||
if not ZIMAGE_TOKENIZER_DIR.exists():
|
||||
pytest.skip(f"Z-Image tokenizer dir not found: {ZIMAGE_TOKENIZER_DIR}")
|
||||
|
||||
device = torch.device("cuda")
|
||||
dtype = torch.bfloat16
|
||||
torch.manual_seed(11)
|
||||
|
||||
tokenizer = AutoTokenizer.from_pretrained(str(ZIMAGE_TOKENIZER_DIR), local_files_only=True)
|
||||
toks = _tokenize_official_prompts(
|
||||
tokenizer,
|
||||
["A cinematic shot of a rainy neon street at night."],
|
||||
)
|
||||
input_ids = toks["input_ids"].to(device)
|
||||
attention_mask = toks["attention_mask"].to(device)
|
||||
|
||||
ref = AutoModel.from_pretrained(
|
||||
str(ZIMAGE_TEXT_ENCODER_DIR),
|
||||
local_files_only=True,
|
||||
trust_remote_code=True,
|
||||
dtype=dtype,
|
||||
low_cpu_mem_usage=True,
|
||||
).eval().to(device=device, dtype=dtype)
|
||||
|
||||
with torch.no_grad():
|
||||
ref_out = ref(
|
||||
input_ids=input_ids,
|
||||
attention_mask=attention_mask,
|
||||
output_hidden_states=True,
|
||||
return_dict=True,
|
||||
)
|
||||
# Cache the reference hidden states on CPU, then free the HF model so
|
||||
# only one encoder is resident when FastVideo runs (avoids OOM).
|
||||
ref_hidden_cpu = [h.detach().float().cpu() for h in ref_out.hidden_states]
|
||||
del ref, ref_out
|
||||
_reclaim_vram()
|
||||
|
||||
fv_cls, _ = ModelRegistry.resolve_model_cls("Qwen3Model")
|
||||
cfg_raw = _load_json(ZIMAGE_TEXT_ENCODER_DIR / "config.json")
|
||||
for k in ("_name_or_path", "transformers_version", "model_type", "torch_dtype"):
|
||||
cfg_raw.pop(k, None)
|
||||
cfg = Qwen3TextConfig()
|
||||
cfg.update_model_arch(cfg_raw)
|
||||
fv = fv_cls(cfg).eval()
|
||||
fv.load_weights(_iter_pretrained_safetensors(ZIMAGE_TEXT_ENCODER_DIR))
|
||||
fv = fv.to(device=device, dtype=dtype)
|
||||
|
||||
with torch.no_grad():
|
||||
fv_out = fv(
|
||||
input_ids=input_ids,
|
||||
attention_mask=attention_mask,
|
||||
output_hidden_states=True,
|
||||
)
|
||||
fv_hidden_cpu = [h.detach().float().cpu() for h in fv_out.hidden_states]
|
||||
|
||||
assert fv_out.hidden_states is not None
|
||||
assert len(ref_hidden_cpu) == 37, (
|
||||
f"expected embeddings plus 36 block outputs, got {len(ref_hidden_cpu)} entries"
|
||||
)
|
||||
assert len(ref_hidden_cpu) == len(fv_hidden_cpu), (
|
||||
f"len mismatch: HF={len(ref_hidden_cpu)} FV={len(fv_hidden_cpu)}"
|
||||
)
|
||||
|
||||
mask_cpu = attention_mask.detach().bool().cpu()[0]
|
||||
print(
|
||||
f"\n[per-layer bf16 diagnostic] num_hidden_states={len(ref_hidden_cpu)} "
|
||||
f"valid_tokens={mask_cpu.sum().item()}/{mask_cpu.numel()}",
|
||||
flush=True,
|
||||
)
|
||||
print(
|
||||
f"{'idx':>3} {'kind':<10} {'max':>8} {'mean':>8} {'median':>8} {'p99':>8}",
|
||||
flush=True,
|
||||
)
|
||||
for idx, (ref_h, fv_h) in enumerate(zip(ref_hidden_cpu, fv_hidden_cpu)):
|
||||
ref_v = ref_h[0][mask_cpu]
|
||||
fv_v = fv_h[0][mask_cpu]
|
||||
diff = (ref_v - fv_v).abs()
|
||||
flat = diff.flatten()
|
||||
p99 = torch.quantile(flat, 0.99).item()
|
||||
if idx == 0:
|
||||
kind = "embedding"
|
||||
elif idx == len(ref_hidden_cpu) - 1:
|
||||
kind = "post-norm"
|
||||
else:
|
||||
kind = f"layer{idx - 1}-out"
|
||||
print(
|
||||
f"{idx:>3} {kind:<10} "
|
||||
f"{diff.max().item():>8.4f} {diff.mean().item():>8.4f} "
|
||||
f"{diff.median().item():>8.4f} {p99:>8.4f}",
|
||||
flush=True,
|
||||
)
|
||||
@@ -0,0 +1,227 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""
|
||||
Parity tests for Z-Image FlowMatchEulerDiscreteScheduler.
|
||||
|
||||
These tests compare FastVideo's scheduler implementation against the
|
||||
reference scheduler shipped in the local Z-Image repository.
|
||||
|
||||
Usage:
|
||||
pytest tests/local_tests/zimage/test_zimage_scheduler_parity.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
from torch.testing import assert_close
|
||||
|
||||
from fastvideo.models.schedulers.scheduling_flow_match_euler_discrete import (
|
||||
FlowMatchEulerDiscreteScheduler as FastVideoScheduler,
|
||||
)
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
ZIMAGE_REPO = REPO_ROOT / "Z-Image"
|
||||
ZIMAGE_SRC = REPO_ROOT / "Z-Image" / "src"
|
||||
ZIMAGE_SCHEDULER_CFG = (
|
||||
REPO_ROOT / "official_weights" / "Z-Image" / "scheduler" / "scheduler_config.json"
|
||||
)
|
||||
ZIMAGE_REFERENCE_REVISION = "26f23eda626ffadda020b04ff79488e1d72004cd"
|
||||
PARITY_SCOPE = "implementation_subcomponent"
|
||||
|
||||
|
||||
def _require_pinned_reference_module(module_name: str, source_file: Path):
|
||||
if not ZIMAGE_REPO.exists():
|
||||
pytest.skip(f"Pinned Z-Image reference clone not found: {ZIMAGE_REPO}")
|
||||
if not source_file.is_file():
|
||||
pytest.fail(f"Z-Image reference clone is incomplete; missing {source_file}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(ZIMAGE_REPO), "rev-parse", "HEAD"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
|
||||
pytest.fail(f"Cannot verify Z-Image reference revision: {exc}")
|
||||
|
||||
actual_revision = result.stdout.strip()
|
||||
assert actual_revision == ZIMAGE_REFERENCE_REVISION, (
|
||||
"Z-Image reference clone is not at the pinned revision: "
|
||||
f"expected {ZIMAGE_REFERENCE_REVISION}, got {actual_revision}"
|
||||
)
|
||||
|
||||
if str(ZIMAGE_SRC) not in sys.path:
|
||||
sys.path.insert(0, str(ZIMAGE_SRC))
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
except Exception as exc:
|
||||
pytest.fail(f"Cannot import pinned Z-Image module {module_name}: {exc}")
|
||||
|
||||
module_file = Path(module.__file__ or "").resolve()
|
||||
assert module_file.is_relative_to(ZIMAGE_SRC.resolve()), (
|
||||
f"{module_name} resolved outside the pinned clone: {module_file}"
|
||||
)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def reference_scheduler_cls():
|
||||
module = _require_pinned_reference_module(
|
||||
"zimage.scheduler",
|
||||
ZIMAGE_SRC / "zimage" / "scheduler.py",
|
||||
)
|
||||
return module.FlowMatchEulerDiscreteScheduler
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def scheduler_config() -> dict:
|
||||
if not ZIMAGE_SCHEDULER_CFG.exists():
|
||||
pytest.skip(f"Z-Image scheduler config not found: {ZIMAGE_SCHEDULER_CFG}")
|
||||
with ZIMAGE_SCHEDULER_CFG.open("r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
# Diffusers-style scheduler-config keys we do NOT want to forward — they are
|
||||
# loader/state metadata, not constructor kwargs. Anything else in
|
||||
# scheduler_config.json (including future keys like `time_shift_type`,
|
||||
# `invert_sigmas`, etc.) must be forwarded so parity actually exercises the
|
||||
# config-on-disk and not a hand-curated subset.
|
||||
_SCHEDULER_CONFIG_LOADER_KEYS = frozenset({"_class_name", "_diffusers_version", "_name_or_path"})
|
||||
|
||||
|
||||
def _scheduler_kwargs_from_config(scheduler_config: dict) -> dict:
|
||||
return {k: v for k, v in scheduler_config.items() if k not in _SCHEDULER_CONFIG_LOADER_KEYS}
|
||||
|
||||
|
||||
def _make_scheduler_pair(reference_scheduler_cls, scheduler_config: dict, **overrides):
|
||||
kwargs = _scheduler_kwargs_from_config(scheduler_config)
|
||||
kwargs.update(overrides)
|
||||
|
||||
ref = reference_scheduler_cls(**kwargs)
|
||||
# The pinned Z-Image pipeline mutates this immediately before scheduling.
|
||||
ref.sigma_min = 0.0
|
||||
|
||||
# These values must be serialized by the future production pipeline config.
|
||||
# setdefault also allows this test to consume that config once it lands.
|
||||
fv_kwargs = dict(kwargs)
|
||||
fv_kwargs.setdefault("use_reference_discrete_timesteps", True)
|
||||
fv_kwargs.setdefault("sigma_min", 0.0)
|
||||
fv = FastVideoScheduler(**fv_kwargs)
|
||||
return ref, fv
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def scheduler_pair(reference_scheduler_cls, scheduler_config: dict):
|
||||
return _make_scheduler_pair(reference_scheduler_cls, scheduler_config)
|
||||
|
||||
|
||||
def _run_step_loop(ref_scheduler, fv_scheduler, sample_shape=(2, 4, 32, 32)):
|
||||
torch.manual_seed(123)
|
||||
|
||||
ref_sample = torch.randn(sample_shape, dtype=torch.float32)
|
||||
fv_sample = ref_sample.clone()
|
||||
|
||||
for t in ref_scheduler.timesteps:
|
||||
model_output = torch.randn_like(ref_sample)
|
||||
|
||||
ref_next = ref_scheduler.step(
|
||||
model_output,
|
||||
t,
|
||||
ref_sample,
|
||||
return_dict=False,
|
||||
)[0]
|
||||
fv_next = fv_scheduler.step(
|
||||
model_output,
|
||||
t,
|
||||
fv_sample,
|
||||
return_dict=False,
|
||||
)[0]
|
||||
|
||||
assert_close(ref_next, fv_next, atol=1e-6, rtol=1e-6)
|
||||
|
||||
ref_sample = ref_next
|
||||
fv_sample = fv_next
|
||||
|
||||
|
||||
def test_zimage_scheduler_parity_default_schedule_and_step(scheduler_pair):
|
||||
ref, fv = scheduler_pair
|
||||
|
||||
num_inference_steps = 8
|
||||
ref.set_timesteps(num_inference_steps=num_inference_steps, device="cpu")
|
||||
fv.set_timesteps(num_inference_steps=num_inference_steps, device="cpu")
|
||||
|
||||
assert_close(ref.timesteps, fv.timesteps, atol=1e-4, rtol=1e-6)
|
||||
assert_close(ref.sigmas, fv.sigmas, atol=1e-7, rtol=1e-6)
|
||||
|
||||
_run_step_loop(ref, fv)
|
||||
|
||||
|
||||
def test_scheduler_positional_args_keep_existing_bindings_and_default_schedule():
|
||||
scheduler = FastVideoScheduler(1000, 1.0, False, 0.7)
|
||||
|
||||
assert scheduler.config.base_shift == 0.7
|
||||
assert scheduler.config.use_reference_discrete_timesteps is False
|
||||
|
||||
scheduler.set_timesteps(num_inference_steps=8, device="cpu")
|
||||
assert_close(scheduler.sigmas[-2], torch.tensor(scheduler.sigma_min))
|
||||
|
||||
|
||||
def test_scheduler_honors_explicit_zero_sigma_min():
|
||||
scheduler = FastVideoScheduler(
|
||||
sigma_min=0.0,
|
||||
use_reference_discrete_timesteps=True,
|
||||
)
|
||||
|
||||
assert scheduler.sigma_min == 0.0
|
||||
scheduler.set_timesteps(num_inference_steps=8, device="cpu")
|
||||
assert_close(scheduler.timesteps[-1], torch.tensor(125.0))
|
||||
|
||||
|
||||
def test_reference_schedule_preserves_float64_linspace_rounding():
|
||||
scheduler = FastVideoScheduler(
|
||||
sigma_min=0.0,
|
||||
use_reference_discrete_timesteps=True,
|
||||
)
|
||||
scheduler.set_timesteps(num_inference_steps=9, device="cpu")
|
||||
|
||||
expected = torch.from_numpy(
|
||||
np.linspace(1000.0, 0.0, 10)[:-1] / 1000.0,
|
||||
).to(torch.float32) * 1000.0
|
||||
float32_regression = torch.from_numpy(
|
||||
np.linspace(1000.0, 0.0, 10, dtype=np.float32)[:-1] / np.float32(1000.0),
|
||||
) * 1000.0
|
||||
|
||||
# The fifth timestep differs by one float32 ULP depending on where the
|
||||
# linspace is rounded, so this assertion fails if dtype=np.float32 returns.
|
||||
assert expected[4].item() != float32_regression[4].item()
|
||||
assert scheduler.timesteps[4].item() == expected[4].item()
|
||||
|
||||
|
||||
def test_zimage_scheduler_parity_dynamic_shifting_with_mu(
|
||||
reference_scheduler_cls,
|
||||
scheduler_config: dict,
|
||||
):
|
||||
ref, fv = _make_scheduler_pair(
|
||||
reference_scheduler_cls,
|
||||
scheduler_config,
|
||||
use_dynamic_shifting=True,
|
||||
)
|
||||
|
||||
mu = 0.75
|
||||
num_inference_steps = 8
|
||||
ref.set_timesteps(num_inference_steps=num_inference_steps, device="cpu", mu=mu)
|
||||
fv.set_timesteps(num_inference_steps=num_inference_steps, device="cpu", mu=mu)
|
||||
|
||||
assert_close(ref.timesteps, fv.timesteps, atol=1e-4, rtol=1e-6)
|
||||
assert_close(ref.sigmas, fv.sigmas, atol=1e-7, rtol=1e-6)
|
||||
|
||||
_run_step_loop(ref, fv)
|
||||
@@ -0,0 +1,130 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""
|
||||
Parity test for Z-Image tokenizer loading and tokenization behavior.
|
||||
|
||||
This compares:
|
||||
1) direct transformers AutoTokenizer loading from local tokenizer dir, and
|
||||
2) FastVideo TokenizerLoader loading path,
|
||||
|
||||
using the exact chat-template and tokenization settings from the pinned
|
||||
Z-Image pipeline.
|
||||
|
||||
Usage:
|
||||
pytest tests/local_tests/zimage/test_zimage_tokenizer_parity.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from torch.testing import assert_close
|
||||
from transformers import AutoTokenizer
|
||||
|
||||
from fastvideo.models.loader.component_loader import TokenizerLoader
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
ZIMAGE_TOKENIZER_DIR = REPO_ROOT / "official_weights" / "Z-Image" / "tokenizer"
|
||||
PARITY_SCOPE = "production_loader"
|
||||
OFFICIAL_MAX_SEQUENCE_LENGTH = 512
|
||||
OFFICIAL_CHAT_TEMPLATE_KWARGS = {
|
||||
"tokenize": False,
|
||||
"add_generation_prompt": True,
|
||||
"enable_thinking": True,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class _DummyPipelineConfig:
|
||||
text_encoder_configs: tuple = field(default_factory=tuple)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _DummyFastVideoArgs:
|
||||
pipeline_config: _DummyPipelineConfig = field(default_factory=_DummyPipelineConfig)
|
||||
|
||||
|
||||
def _load_reference_tokenizer():
|
||||
if not ZIMAGE_TOKENIZER_DIR.exists():
|
||||
pytest.skip(f"Z-Image tokenizer dir not found: {ZIMAGE_TOKENIZER_DIR}")
|
||||
return AutoTokenizer.from_pretrained(str(ZIMAGE_TOKENIZER_DIR), local_files_only=True)
|
||||
|
||||
|
||||
def _load_fastvideo_tokenizer():
|
||||
if not ZIMAGE_TOKENIZER_DIR.exists():
|
||||
pytest.skip(f"Z-Image tokenizer dir not found: {ZIMAGE_TOKENIZER_DIR}")
|
||||
loader = TokenizerLoader()
|
||||
return loader.load(str(ZIMAGE_TOKENIZER_DIR), _DummyFastVideoArgs())
|
||||
|
||||
|
||||
def test_zimage_tokenizer_loader_and_tokenization_parity():
|
||||
ref_tok = _load_reference_tokenizer()
|
||||
fv_tok = _load_fastvideo_tokenizer()
|
||||
|
||||
prompts = [
|
||||
"A close-up portrait of a cat in warm studio lighting.",
|
||||
"An oil painting of a lighthouse at sunset.",
|
||||
]
|
||||
|
||||
tok_kwargs = {
|
||||
"padding": "max_length",
|
||||
"max_length": OFFICIAL_MAX_SEQUENCE_LENGTH,
|
||||
"truncation": True,
|
||||
"return_tensors": "pt",
|
||||
}
|
||||
|
||||
ref_out = ref_tok(prompts, **tok_kwargs)
|
||||
fv_out = fv_tok(prompts, **tok_kwargs)
|
||||
|
||||
assert_close(ref_out["input_ids"], fv_out["input_ids"], atol=0, rtol=0)
|
||||
assert_close(ref_out["attention_mask"], fv_out["attention_mask"], atol=0, rtol=0)
|
||||
|
||||
# Compare key tokenizer attributes that affect generation-time behavior.
|
||||
assert ref_tok.padding_side == fv_tok.padding_side
|
||||
assert ref_tok.pad_token_id == fv_tok.pad_token_id
|
||||
assert ref_tok.eos_token_id == fv_tok.eos_token_id
|
||||
|
||||
|
||||
@pytest.mark.skipif(not ZIMAGE_TOKENIZER_DIR.exists(), reason="Z-Image tokenizer assets are required")
|
||||
def test_zimage_tokenizer_chat_template_parity():
|
||||
ref_tok = _load_reference_tokenizer()
|
||||
fv_tok = _load_fastvideo_tokenizer()
|
||||
|
||||
assert callable(getattr(ref_tok, "apply_chat_template", None)), (
|
||||
"Pinned Z-Image tokenizer must expose apply_chat_template"
|
||||
)
|
||||
assert callable(getattr(fv_tok, "apply_chat_template", None)), (
|
||||
"FastVideo TokenizerLoader dropped the required apply_chat_template API"
|
||||
)
|
||||
|
||||
messages = [{"role": "user", "content": "Describe a futuristic city skyline."}]
|
||||
|
||||
ref_text = ref_tok.apply_chat_template(messages, **OFFICIAL_CHAT_TEMPLATE_KWARGS)
|
||||
fv_text = fv_tok.apply_chat_template(messages, **OFFICIAL_CHAT_TEMPLATE_KWARGS)
|
||||
|
||||
assert ref_text == fv_text
|
||||
|
||||
# Z-Image deliberately enables Qwen's thinking template. Guard against a
|
||||
# pipeline silently using the generic non-thinking prompt path.
|
||||
non_thinking_text = fv_tok.apply_chat_template(
|
||||
messages,
|
||||
tokenize=False,
|
||||
add_generation_prompt=True,
|
||||
enable_thinking=False,
|
||||
)
|
||||
assert fv_text != non_thinking_text
|
||||
|
||||
tokenization_kwargs = {
|
||||
"padding": "max_length",
|
||||
"max_length": OFFICIAL_MAX_SEQUENCE_LENGTH,
|
||||
"truncation": True,
|
||||
"return_tensors": "pt",
|
||||
}
|
||||
ref_tokens = ref_tok(ref_text, **tokenization_kwargs)
|
||||
fv_tokens = fv_tok(fv_text, **tokenization_kwargs)
|
||||
|
||||
assert_close(ref_tokens["input_ids"], fv_tokens["input_ids"], atol=0, rtol=0)
|
||||
assert_close(ref_tokens["attention_mask"], fv_tokens["attention_mask"], atol=0, rtol=0)
|
||||
@@ -0,0 +1,344 @@
|
||||
# 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):
|
||||
from fastvideo.models.registry import ModelRegistry
|
||||
|
||||
model_cls, _ = ModelRegistry.resolve_model_cls("ZImageTransformer2DModel")
|
||||
assert model_cls is ZImageTransformer2DModel
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,226 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""
|
||||
Parity test for Z-Image VAE (AutoencoderKL decode path).
|
||||
|
||||
This compares FastVideo's AutoencoderKL wrapper against the Z-Image
|
||||
reference AutoencoderKL implementation using the same local weights.
|
||||
|
||||
Usage:
|
||||
pytest tests/local_tests/zimage/test_zimage_vae_parity.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from safetensors.torch import load_file as safetensors_load_file
|
||||
from torch.testing import assert_close
|
||||
|
||||
from fastvideo.configs.models.vaes.autoencoder_kl import AutoencoderKLVAEConfig
|
||||
from fastvideo.models.loader.component_loader import VAELoader
|
||||
from fastvideo.models.vaes.autoencoder_kl import AutoencoderKL as FastVideoAutoencoderKL
|
||||
from fastvideo.pipelines.stages.decoding import DecodingStage
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
ZIMAGE_REPO = REPO_ROOT / "Z-Image"
|
||||
ZIMAGE_SRC = REPO_ROOT / "Z-Image" / "src"
|
||||
ZIMAGE_VAE_DIR = REPO_ROOT / "official_weights" / "Z-Image" / "vae"
|
||||
ZIMAGE_VAE_CFG = ZIMAGE_VAE_DIR / "config.json"
|
||||
ZIMAGE_VAE_WEIGHTS = ZIMAGE_VAE_DIR / "diffusion_pytorch_model.safetensors"
|
||||
ZIMAGE_REFERENCE_REVISION = "26f23eda626ffadda020b04ff79488e1d72004cd"
|
||||
ZIMAGE_VAE_SCALING_FACTOR = 0.3611
|
||||
ZIMAGE_VAE_SHIFT_FACTOR = 0.1159
|
||||
PARITY_SCOPE = "both"
|
||||
|
||||
|
||||
def _require_pinned_reference_module(module_name: str, source_file: Path):
|
||||
if not ZIMAGE_REPO.exists():
|
||||
pytest.skip(f"Pinned Z-Image reference clone not found: {ZIMAGE_REPO}")
|
||||
if not source_file.is_file():
|
||||
pytest.fail(f"Z-Image reference clone is incomplete; missing {source_file}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(ZIMAGE_REPO), "rev-parse", "HEAD"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
|
||||
pytest.fail(f"Cannot verify Z-Image reference revision: {exc}")
|
||||
|
||||
actual_revision = result.stdout.strip()
|
||||
assert actual_revision == ZIMAGE_REFERENCE_REVISION, (
|
||||
"Z-Image reference clone is not at the pinned revision: "
|
||||
f"expected {ZIMAGE_REFERENCE_REVISION}, got {actual_revision}"
|
||||
)
|
||||
|
||||
if str(ZIMAGE_SRC) not in sys.path:
|
||||
sys.path.insert(0, str(ZIMAGE_SRC))
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
except Exception as exc:
|
||||
pytest.fail(f"Cannot import pinned Z-Image module {module_name}: {exc}")
|
||||
|
||||
module_file = Path(module.__file__ or "").resolve()
|
||||
assert module_file.is_relative_to(ZIMAGE_SRC.resolve()), (
|
||||
f"{module_name} resolved outside the pinned clone: {module_file}"
|
||||
)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def reference_autoencoder_cls():
|
||||
module = _require_pinned_reference_module(
|
||||
"zimage.autoencoder",
|
||||
ZIMAGE_SRC / "zimage" / "autoencoder.py",
|
||||
)
|
||||
return module.AutoencoderKL
|
||||
|
||||
|
||||
def _load_cfg() -> dict:
|
||||
if not ZIMAGE_VAE_CFG.exists():
|
||||
pytest.skip(f"Z-Image VAE config not found: {ZIMAGE_VAE_CFG}")
|
||||
with ZIMAGE_VAE_CFG.open("r", encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
cfg.pop("_class_name", None)
|
||||
cfg.pop("_diffusers_version", None)
|
||||
cfg.pop("_name_or_path", None)
|
||||
return cfg
|
||||
|
||||
|
||||
def _load_weights() -> dict[str, torch.Tensor]:
|
||||
if not ZIMAGE_VAE_WEIGHTS.exists():
|
||||
pytest.skip(f"Z-Image VAE weights not found: {ZIMAGE_VAE_WEIGHTS}")
|
||||
return safetensors_load_file(str(ZIMAGE_VAE_WEIGHTS), device="cpu")
|
||||
|
||||
|
||||
def _build_reference(reference_autoencoder_cls, cfg: dict, weights: dict[str, torch.Tensor]) -> torch.nn.Module:
|
||||
ref = reference_autoencoder_cls(
|
||||
in_channels=cfg["in_channels"],
|
||||
out_channels=cfg["out_channels"],
|
||||
down_block_types=tuple(cfg["down_block_types"]),
|
||||
up_block_types=tuple(cfg["up_block_types"]),
|
||||
block_out_channels=tuple(cfg["block_out_channels"]),
|
||||
layers_per_block=cfg["layers_per_block"],
|
||||
latent_channels=cfg["latent_channels"],
|
||||
norm_num_groups=cfg["norm_num_groups"],
|
||||
scaling_factor=cfg["scaling_factor"],
|
||||
shift_factor=cfg["shift_factor"],
|
||||
use_quant_conv=cfg["use_quant_conv"],
|
||||
use_post_quant_conv=cfg["use_post_quant_conv"],
|
||||
mid_block_add_attention=cfg["mid_block_add_attention"],
|
||||
).eval()
|
||||
ref.load_state_dict(weights, strict=True)
|
||||
return ref
|
||||
|
||||
|
||||
def _build_fastvideo(cfg: dict, weights: dict[str, torch.Tensor]) -> torch.nn.Module:
|
||||
fv_cfg = AutoencoderKLVAEConfig()
|
||||
fv_cfg.update_model_arch(cfg)
|
||||
fv = FastVideoAutoencoderKL(fv_cfg).eval()
|
||||
fv.load_state_dict(weights, strict=True)
|
||||
return fv
|
||||
|
||||
|
||||
def _load_fastvideo_production_vae(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
"fastvideo.models.loader.component_loader.get_local_torch_device",
|
||||
lambda: torch.device("cpu"),
|
||||
)
|
||||
pipeline_config = SimpleNamespace(
|
||||
vae_config=AutoencoderKLVAEConfig(),
|
||||
vae_precision="fp32",
|
||||
)
|
||||
fastvideo_args = SimpleNamespace(
|
||||
model_paths={},
|
||||
pipeline_config=pipeline_config,
|
||||
vae_cpu_offload=False,
|
||||
)
|
||||
vae = VAELoader().load(str(ZIMAGE_VAE_DIR), fastvideo_args)
|
||||
assert isinstance(vae, FastVideoAutoencoderKL)
|
||||
assert fastvideo_args.model_paths["vae"] == str(ZIMAGE_VAE_DIR)
|
||||
return vae
|
||||
|
||||
|
||||
def _official_decode_latents(latents: torch.Tensor, vae: torch.nn.Module) -> torch.Tensor:
|
||||
scaling_factor = vae.config.scaling_factor
|
||||
shift_factor = vae.config.shift_factor or 0.0
|
||||
dtype = next(vae.parameters()).dtype
|
||||
return (latents.to(dtype=dtype) / scaling_factor) + shift_factor
|
||||
|
||||
|
||||
def test_zimage_decode_normalization_uses_autoencoder_config_values():
|
||||
vae = SimpleNamespace(
|
||||
config=SimpleNamespace(
|
||||
latents_mean=None,
|
||||
latents_std=None,
|
||||
scaling_factor=ZIMAGE_VAE_SCALING_FACTOR,
|
||||
shift_factor=ZIMAGE_VAE_SHIFT_FACTOR,
|
||||
)
|
||||
)
|
||||
latents = torch.tensor([[[[0.0, 0.5], [-0.5, 1.0]]]], dtype=torch.float32)
|
||||
|
||||
actual = DecodingStage(vae)._denormalize_latents(latents)
|
||||
expected = latents / ZIMAGE_VAE_SCALING_FACTOR + ZIMAGE_VAE_SHIFT_FACTOR
|
||||
|
||||
assert_close(actual, expected, atol=0, rtol=0)
|
||||
|
||||
|
||||
def test_zimage_vae_decode_parity(reference_autoencoder_cls):
|
||||
torch.manual_seed(7)
|
||||
|
||||
cfg = _load_cfg()
|
||||
weights = _load_weights()
|
||||
|
||||
ref = _build_reference(reference_autoencoder_cls, cfg, weights)
|
||||
fv = _build_fastvideo(cfg, weights)
|
||||
|
||||
# Decode-only parity is the critical path for Z-Image pipeline integration.
|
||||
latents = torch.randn(1, cfg["latent_channels"], 8, 8, dtype=torch.float32)
|
||||
|
||||
with torch.no_grad():
|
||||
ref_out = ref.decode(latents, return_dict=False)[0].detach().float()
|
||||
fv_out = fv.decode(latents, return_dict=False)[0].detach().float()
|
||||
|
||||
assert ref_out.shape == fv_out.shape
|
||||
assert_close(ref_out, fv_out, atol=1e-4, rtol=1e-4)
|
||||
|
||||
|
||||
def test_zimage_vae_production_loader_and_decode_normalization(
|
||||
reference_autoencoder_cls,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
torch.manual_seed(17)
|
||||
|
||||
cfg = _load_cfg()
|
||||
weights = _load_weights()
|
||||
assert cfg["scaling_factor"] == ZIMAGE_VAE_SCALING_FACTOR
|
||||
assert cfg["shift_factor"] == ZIMAGE_VAE_SHIFT_FACTOR
|
||||
ref = _build_reference(reference_autoencoder_cls, cfg, weights)
|
||||
fv = _load_fastvideo_production_vae(monkeypatch)
|
||||
|
||||
assert ref.config.scaling_factor == cfg["scaling_factor"]
|
||||
assert fv.config.scaling_factor == cfg["scaling_factor"]
|
||||
assert ref.config.shift_factor == cfg["shift_factor"]
|
||||
assert fv.config.shift_factor == cfg["shift_factor"]
|
||||
|
||||
pipeline_latents = torch.randn(1, cfg["latent_channels"], 8, 8, dtype=torch.float32)
|
||||
ref_decode_latents = _official_decode_latents(pipeline_latents, ref)
|
||||
fv_decode_latents = DecodingStage(fv)._denormalize_latents(pipeline_latents)
|
||||
assert_close(ref_decode_latents, fv_decode_latents, atol=0, rtol=0)
|
||||
|
||||
with torch.no_grad():
|
||||
ref_out = ref.decode(ref_decode_latents, return_dict=False)[0].detach().float()
|
||||
fv_out = fv.decode(fv_decode_latents, return_dict=False)[0].detach().float()
|
||||
|
||||
assert ref_out.shape == fv_out.shape
|
||||
assert_close(ref_out, fv_out, atol=1e-4, rtol=1e-4)
|
||||
Reference in New Issue
Block a user