Compare commits

..
Author SHA1 Message Date
SolitaryThinker a288460497 [test]: collect the sdpa mask contract test in the CI unit lane
fastvideo/tests/attention/ has no CI lane (dark-directory allowlist), so
wire the new CPU-only contract test file into run_unit_test explicitly.
2026-07-06 17:26:02 -07:00
SolitaryThinker 45fc714f88 [bugfix]: keep sdpa metadata masks 2D; lift to 4D only inside the torch-sdpa impl
SDPAMetadataBuilder.build() lifted 2D [batch, key_len] padding masks to 4D
[batch, 1, 1, key_len], but the metadata is cross-backend: HYWorld and
HunyuanVideo15 build SDPAMetadata while the layer selector picks FLASH_ATTN
when installed. flash_attn.py's equal-length branch pads
attn_mask.shape[1] assuming 2D, so a 4D mask produced a garbage-length pad
and an out-of-bounds gather (CUDA device-side assert, SIGABRT in CI
transformer tests).

Store the mask 2D in the builder (the convention every in-tree
producer/consumer assumes) and do the 2D -> [batch, 1, 1, key_len] lift
inside _normalize_attn_mask_for_sdpa, the torch-sdpa-only consumer, so
torch.sdpa still does not misread 2D as its [query_len, key_len]
broadcast. The int -> bool coercion is unchanged. Adds a CPU-only
regression test for the contract.
2026-07-06 17:15:29 -07:00
Will LinandClaude Fable 5 38d1d7bdc5 [misc]: scope the kandinsky ssim FA4 pin per-test and name the min-length validator
The module-level FASTVIDEO_FA4=0 executed at pytest collection and disabled
FA4 for every SSIM test in the same process, diverging them from their
FA4-seeded references; set and restore it inside the test instead. Replace
the lambda prompt-embeds check with a curried V.min_list_length(2) whose
__name__ shows up in verification failure messages, and drop the no-op
num_frames re-assignment in the full-quality params map.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:53:24 +00:00
Will LinandClaude Fable 5 3a44ae6316 [bugfix]: probe gated-repo access with auth_check and keep transient hub failures loud
Replace the three copy-pasted .gitattributes-download guards with one shared
skip_if_gated_repo_inaccessible helper: local weights bypass the probe (an
offline box with cached weights runs instead of ERRORing at collection via
the uncaught LocalEntryNotFoundError), HfApi.auth_check identifies real
authorization failures, and only those skip — transient hub errors proceed
and let the download serve from cache or fail loudly, instead of
green-skipping with a message that falsely blames gated access. Drop the
except-ValueError wrappers around maybe_download_model for the same reason.
Also skip the HYWorld transformer test before its multi-GB download when an
env-forced backend has no reference latent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:53:24 +00:00
Will LinandClaude Fable 5 6918418525 [bugfix]: coerce integer sdpa masks and keep torch 2D mask semantics
_normalize_attn_mask_for_sdpa passed tokenizer-produced int64 0/1 masks
straight to F.scaled_dot_product_attention, which rejects long dtypes — the
HYWorld pipeline crashed on exactly the SDPA-fallback machines the fastcheck
stabilization targeted; coerce non-bool/non-float masks to bool. Lift the
[batch, key_len] -> [batch, 1, 1, key_len] reinterpretation out of the impl
into SDPAMetadataBuilder.build, where that semantic is known, so 2D masks
reaching the impl keep torch's documented [query_len, key_len] broadcast
meaning. Reuse the identical metadata for HYWorld's prope attention instead
of rebuilding it, and note that the metadata does not choose the executing
kernel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:53:09 +00:00
Aryan Kumar 881400490c [bugfix]: stabilize fastcheck lanes for Kandinsky PR 2026-07-06 11:42:26 -07:00
SolitaryThinker fca6fb3aa1 [test]: add reduced-size Kandinsky-5 T2V SSIM test 2026-07-05 16:31:59 -07:00
SolitaryThinker fa6e50fc7c [misc]: drop the bundled Modal checkout rewrite (separate PR material) 2026-07-05 16:09:06 -07:00
14 changed files with 323 additions and 65 deletions
+44 -1
View File
@@ -2,6 +2,7 @@
import torch
from dataclasses import dataclass
from torch.nn import functional as F
from fastvideo.attention.backends.abstract import ( # FlashAttentionMetadata,
AttentionBackend, AttentionImpl, AttentionMetadata, AttentionMetadataBuilder)
from fastvideo.logger import init_logger
@@ -19,7 +20,7 @@ class SDPABackend(AttentionBackend):
@staticmethod
def get_name() -> str:
return "SDPA"
return "TORCH_SDPA"
@staticmethod
def get_impl_cls() -> type["SDPAImpl"]:
@@ -49,9 +50,50 @@ class SDPAMetadataBuilder(AttentionMetadataBuilder):
current_timestep: int,
attn_mask: torch.Tensor,
) -> SDPAMetadata:
# Store the mask exactly as passed. The metadata is cross-backend:
# call sites (HYWorld, HunyuanVideo15) build SDPAMetadata while the
# layer's selector may pick FLASH_ATTN, whose impl requires the
# tokenizer-style 2D [batch, key_len] padding mask. Any reshaping for
# torch.sdpa happens inside the SDPA impl (_normalize_attn_mask_for_sdpa).
return SDPAMetadata(current_timestep=current_timestep, attn_mask=attn_mask)
def _normalize_attn_mask_for_sdpa(
attn_mask: torch.Tensor | None,
query: torch.Tensor,
key: torch.Tensor,
) -> torch.Tensor | None:
if attn_mask is None:
return None
attn_mask = attn_mask.to(device=query.device)
# F.scaled_dot_product_attention only accepts bool or float masks;
# tokenizers commonly produce int64 0/1 padding masks.
if attn_mask.dtype != torch.bool and not attn_mask.dtype.is_floating_point:
attn_mask = attn_mask != 0
key_len = key.shape[-2]
if attn_mask.shape[-1] > key_len:
raise ValueError("Invalid attention mask length for SDPA: "
f"expected at most {key_len}, got {attn_mask.shape[-1]}")
if attn_mask.shape[-1] < key_len:
# Front-pad as "attend": double-stream layouts (HYWorld) prepend
# non-text tokens the tokenizer mask does not cover.
valid_value = True if attn_mask.dtype == torch.bool else 0.0
attn_mask = F.pad(attn_mask, (key_len - attn_mask.shape[-1], 0), value=valid_value)
if attn_mask.dim() == 2:
# In-tree producers pass 2D [batch, key_len] padding masks; lift to a
# broadcastable [batch, 1, 1, key_len] here so torch.sdpa does not
# reinterpret 2D as its documented [query_len, key_len] broadcast.
return attn_mask[:, None, None, :]
if attn_mask.dim() == 3:
return attn_mask[:, None, :, :]
if attn_mask.dim() == 4:
return attn_mask
raise ValueError(f"Unsupported attention mask shape for SDPA: {attn_mask.shape}")
class SDPAImpl(AttentionImpl):
def __init__(
@@ -82,6 +124,7 @@ class SDPAImpl(AttentionImpl):
attn_mask = attn_metadata.attn_mask if (attn_metadata is not None
and hasattr(attn_metadata, "attn_mask")) else None
attn_mask = _normalize_attn_mask_for_sdpa(attn_mask, query, key)
attn_kwargs = {
"attn_mask": attn_mask,
"dropout_p": self.dropout,
@@ -84,6 +84,12 @@ class Kandinsky5T2VConfig(PipelineConfig):
if len(self.text_encoder_configs) != 2:
raise ValueError(f"Kandinsky5 pipeline requires exactly 2 text encoders (qwen and clip), "
f"but got {len(self.text_encoder_configs)} encoder(s).")
if len(self.text_encoder_precisions) != 2:
raise ValueError("Kandinsky5 pipeline requires exactly 2 text encoder precisions, "
f"but got {len(self.text_encoder_precisions)}.")
if len(self.text_encoder_max_lengths) != 2:
raise ValueError("Kandinsky5 pipeline requires exactly 2 text encoder max lengths, "
f"but got {len(self.text_encoder_max_lengths)}.")
self.vae_config.load_encoder = False
self.vae_config.load_decoder = True
+2 -2
View File
@@ -699,8 +699,8 @@ class IndividualTokenRefinerBlock(nn.Module):
if mask is None:
attn_output = self.attn(q, k, v)
else:
from fastvideo.attention.backends.flash_attn import FlashAttnMetadataBuilder
attn_metadata = FlashAttnMetadataBuilder().build(
from fastvideo.attention.backends.sdpa import SDPAMetadataBuilder
attn_metadata = SDPAMetadataBuilder().build(
current_timestep=0,
attn_mask=mask,
)
+7 -9
View File
@@ -173,8 +173,11 @@ class HYWorldDoubleStreamBlock(MMDoubleStreamBlock):
img_v_prope = img_v_prope.permute(0, 2, 1, 3) # [batch, seqlen, num_heads, head_dim]
# end hyworld
from fastvideo.attention.backends.flash_attn import FlashAttnMetadataBuilder
attn_metadata = FlashAttnMetadataBuilder().build(
# The metadata only carries the text padding mask; the executing
# attention kernel is still whatever the layer's selector picked
# (flash-attn when installed), not necessarily torch SDPA.
from fastvideo.attention.backends.sdpa import SDPAMetadataBuilder
attn_metadata = SDPAMetadataBuilder().build(
current_timestep=0,
attn_mask=encoder_attention_mask,
)
@@ -192,14 +195,9 @@ class HYWorldDoubleStreamBlock(MMDoubleStreamBlock):
)
# begin hyworld
# attention with prope
from fastvideo.attention.backends.flash_attn import FlashAttnMetadataBuilder
attn_metadata_prope = FlashAttnMetadataBuilder().build(
current_timestep=0,
attn_mask=encoder_attention_mask,
)
# attention with prope (same text mask, so reuse the metadata)
# NOTE: Do NOT pass freqs_cis to prope attention - HY-WorldPlay does not apply RoPE to prope
with set_forward_context(current_timestep=0, attn_metadata=attn_metadata_prope):
with set_forward_context(current_timestep=0, attn_metadata=attn_metadata):
img_attn_prope, _ = self.attn(
img_q_prope,
img_k_prope,
+20 -9
View File
@@ -51,9 +51,11 @@ class Kandinsky5LatentPreparationStage(PipelineStage):
num_frames = num_frames // temporal_ratio * temporal_ratio + 1
batch.num_frames = num_frames
required_divisor = spatial_ratio * patch_size[1]
if height % required_divisor != 0 or width % required_divisor != 0:
raise ValueError(f"Kandinsky5 height/width must be divisible by {required_divisor}; "
required_divisor_h = spatial_ratio * patch_size[1]
required_divisor_w = spatial_ratio * patch_size[2]
if height % required_divisor_h != 0 or width % required_divisor_w != 0:
raise ValueError(f"Kandinsky5 height must be divisible by {required_divisor_h} and width by "
f"{required_divisor_w}; "
f"got height={height}, width={width}.")
# NABLA sparse attention (Pro checkpoints) reshapes the post-patch grid
@@ -61,9 +63,11 @@ class Kandinsky5LatentPreparationStage(PipelineStage):
# all the encoding work is done.
arch_cfg = getattr(self.transformer, "config", None) or fastvideo_args.pipeline_config.dit_config.arch_config
if getattr(arch_cfg, "attention_type", "regular") == "nabla":
nabla_divisor = required_divisor * 8
if height % nabla_divisor != 0 or width % nabla_divisor != 0:
raise ValueError(f"Kandinsky5 NABLA checkpoints require height/width divisible by {nabla_divisor}; "
nabla_divisor_h = required_divisor_h * 8
nabla_divisor_w = required_divisor_w * 8
if height % nabla_divisor_h != 0 or width % nabla_divisor_w != 0:
raise ValueError(f"Kandinsky5 NABLA checkpoints require height divisible by {nabla_divisor_h} and "
f"width divisible by {nabla_divisor_w}; "
f"got height={height}, width={width}.")
if isinstance(batch.prompt, list):
@@ -93,14 +97,21 @@ class Kandinsky5LatentPreparationStage(PipelineStage):
if isinstance(batch.generator, list) and len(batch.generator) != batch_size:
raise ValueError(f"generator list length {len(batch.generator)} does not match batch size {batch_size}.")
visual_cond = getattr(self.transformer, "visual_cond", False)
if batch.latents is None:
latents = randn_tensor(shape, generator=batch.generator, device=device, dtype=dtype)
if hasattr(self.scheduler, "init_noise_sigma"):
latents = latents * self.scheduler.init_noise_sigma
else:
valid_shapes = [shape]
if visual_cond:
valid_shapes.append((*shape[:-1], num_channels * 2 + 1))
if tuple(batch.latents.shape) not in valid_shapes:
raise ValueError(f"Provided latents shape {list(batch.latents.shape)} does not match expected "
f"Kandinsky5 latent shape(s): {[list(s) for s in valid_shapes]}.")
latents = batch.latents.to(device=device, dtype=dtype)
visual_cond = getattr(self.transformer, "visual_cond", False)
if visual_cond and latents.shape[-1] == num_channels:
cond = torch.zeros_like(latents)
cond_mask = torch.zeros(
@@ -309,7 +320,7 @@ class Kandinsky5DenoisingStage(PipelineStage):
with tqdm(total=batch.num_inference_steps, desc="Kandinsky5 Denoising") as progress_bar:
for i, timestep in enumerate(batch.timesteps):
if hasattr(self, "interrupt") and self.interrupt:
continue
break
t_expand = timestep.unsqueeze(0).repeat(latents.shape[0]).to(device=device, dtype=target_dtype)
attn_metadata = None
@@ -376,7 +387,7 @@ class Kandinsky5DenoisingStage(PipelineStage):
def verify_input(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> VerificationResult:
result = VerificationResult()
result.add_check("latents", batch.latents, [V.is_tensor, V.with_dims(5)])
result.add_check("prompt_embeds", batch.prompt_embeds, V.list_not_empty)
result.add_check("prompt_embeds", batch.prompt_embeds, V.min_list_length(2))
return result
+10
View File
@@ -219,6 +219,16 @@ class StageValidators:
return validator
@staticmethod
def min_list_length(min_length: int) -> Callable[[Any], bool]:
"""Return a validator that checks if a list has at least min_length items."""
def validator(value: Any) -> bool:
return StageValidators.list_min_length(value, min_length)
validator.__name__ = f"list_min_length_{min_length}"
return validator
@staticmethod
def min_dims(min_dims: int) -> Callable[[Any], bool]:
"""Return a validator that checks if tensor has at least min_dims dimensions and no NaN values."""
@@ -0,0 +1,42 @@
# SPDX-License-Identifier: Apache-2.0
"""CPU-only regression test for the SDPA metadata mask contract.
SDPAMetadata is cross-backend: HYWorld and HunyuanVideo15 build it while the
layer's attention selector may pick FLASH_ATTN, whose impl requires the
tokenizer-style 2D [batch, key_len] padding mask (flash_attn.py pads
``attn_mask.shape[1]`` assuming 2D). The builder must therefore store 2D
masks unchanged; only the torch-sdpa impl lifts them to 4D internally.
Regression guard for the CI break where the builder lifted 2D -> 4D and the
FLASH_ATTN consumer produced a garbage-length pad (CUDA device-side assert).
"""
from __future__ import annotations
import torch
from fastvideo.attention.backends.sdpa import (SDPAMetadataBuilder, _normalize_attn_mask_for_sdpa)
def test_builder_keeps_2d_padding_mask_2d() -> None:
"""A tokenizer-style 2D int mask must be stored as-is (cross-backend contract)."""
mask = torch.tensor([[1, 1, 0], [1, 0, 0]], dtype=torch.int64)
md = SDPAMetadataBuilder().build(current_timestep=0, attn_mask=mask)
assert md.attn_mask is not None
assert md.attn_mask.dim() == 2
assert md.attn_mask.shape == (2, 3)
assert torch.equal(md.attn_mask, mask)
def test_normalize_lifts_2d_int_mask_to_4d_bool_for_torch_sdpa() -> None:
"""The torch-sdpa consumer coerces int -> bool and lifts 2D -> [B,1,1,K]."""
batch, q_len, k_len, heads, head_dim = 2, 4, 3, 1, 8
query = torch.zeros(batch, heads, q_len, head_dim)
key = torch.zeros(batch, heads, k_len, head_dim)
mask = torch.tensor([[1, 1, 0], [1, 0, 0]], dtype=torch.int64)
out = _normalize_attn_mask_for_sdpa(mask, query, key)
assert out is not None
assert out.dtype == torch.bool
assert out.shape == (batch, 1, 1, k_len)
assert torch.equal(out[:, 0, 0, :], mask != 0)
+6 -3
View File
@@ -12,6 +12,7 @@ from fastvideo.configs.pipelines import CosmosConfig, PipelineConfig, WanT2V480P
from fastvideo.forward_context import set_forward_context
from fastvideo.logger import init_logger
from fastvideo.models.loader.component_loader import TextEncoderLoader
from fastvideo.tests.utils import skip_if_gated_repo_inaccessible
from fastvideo.utils import maybe_download_model, PRECISION_TO_TYPE
from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.configs.models.encoders import T5Config, T5LargeConfig
@@ -36,9 +37,11 @@ def t5_model_paths_and_config():
@pytest.fixture
def t5_large_model_paths_and_config():
base_model_path = "nvidia/Cosmos-Predict2-2B-Video2World"
model_path = maybe_download_model(base_model_path,
local_dir=os.path.join(
'data', base_model_path))
local_dir = os.path.join('data', base_model_path)
skip_if_gated_repo_inaccessible(base_model_path,
local_path=local_dir,
test_name="Cosmos T5-large encoder test")
model_path = maybe_download_model(base_model_path, local_dir=local_dir)
text_encoder_path = os.path.join(model_path, "text_encoder")
tokenizer_path = os.path.join(model_path, "tokenizer")
return text_encoder_path, tokenizer_path, CosmosConfig()
+5 -23
View File
@@ -106,30 +106,12 @@ def run_test_command(test_command: str,
if pr_number:
print(f"PR number: {pr_number}")
# The blob-less clone (--filter=blob:none) defers all file-content
# downloads to the checkout, so BOTH paths below perform a large lazy blob
# fetch from GitHub. Retry transient GitHub/HTTP2 disconnects on each;
# otherwise Modal shards can fail before pytest starts.
def with_retries(inner_command: str) -> str:
return f"""
for attempt in 1 2 3; do
{inner_command} &&
break
status=$?
if [ "$attempt" -eq 3 ]; then
exit "$status"
fi
sleep $((attempt * 5))
done"""
# For PRs (including forks), use GitHub's PR refs to get the correct commit.
# For PRs (including forks), use GitHub's PR refs to get the correct commit
if pr_number and pr_number != "false":
checkout_command = with_retries(f"git fetch --prune --no-tags --depth=1 origin refs/pull/{pr_number}/head &&"
"\n git checkout --detach FETCH_HEAD")
checkout_command = f"git fetch --prune origin refs/pull/{pr_number}/head && git checkout FETCH_HEAD"
print(f"Using PR ref for checkout: {checkout_command}")
else:
checkout_command = with_retries(f"git checkout {git_commit}")
checkout_command = f"git checkout {git_commit}"
print(f"Using direct commit checkout: {checkout_command}")
build_kernel_command = """
@@ -143,7 +125,7 @@ def run_test_command(test_command: str,
command = f"""
source $HOME/.local/bin/env &&
source /opt/venv/bin/activate &&
git clone --filter=blob:none --no-checkout {git_repo} /FastVideo &&
git clone {git_repo} /FastVideo &&
cd /FastVideo &&
{checkout_command} &&
git submodule update --init --recursive &&
@@ -294,7 +276,7 @@ def run_self_forcing_tests():
@app.function(gpu="L40S:1", image=image, timeout=900)
def run_unit_test():
run_test(
"pytest ./fastvideo/tests/api/ ./fastvideo/tests/contract/ ./fastvideo/tests/dataset/ ./fastvideo/tests/workflow/ ./fastvideo/tests/entrypoints/ ./fastvideo/tests/train/ ./fastvideo/tests/stages/ --ignore=./fastvideo/tests/entrypoints/test_openai_api_integration.py --ignore=./fastvideo/tests/train/models --ignore=./fastvideo/tests/train/methods -vs"
"pytest ./fastvideo/tests/api/ ./fastvideo/tests/contract/ ./fastvideo/tests/dataset/ ./fastvideo/tests/workflow/ ./fastvideo/tests/entrypoints/ ./fastvideo/tests/train/ ./fastvideo/tests/stages/ ./fastvideo/tests/attention/test_sdpa_metadata_mask_contract.py --ignore=./fastvideo/tests/entrypoints/test_openai_api_integration.py --ignore=./fastvideo/tests/train/models --ignore=./fastvideo/tests/train/methods -vs"
)
@@ -0,0 +1,109 @@
# SPDX-License-Identifier: Apache-2.0
"""SSIM regression test for Kandinsky-5.0 Lite text-to-video.
Runs a reduced-size generation (256x384, 21 frames) with a fixed seed and
compares against a device-specific reference video via MS-SSIM. The preset
default is 512x768 x 121 frames; the reduced size keeps CI cheap while the
patch (1, 2, 2) and VAE temporal-4 / spatial-8 constraints stay satisfied
(dims divisible by 16, num_frames % 4 == 1).
"""
import os
import pytest
from fastvideo.logger import init_logger
from fastvideo.pipelines.basic.kandinsky5.presets import KANDINSKY5_T2V_LITE_5S
from fastvideo.tests.ssim.inference_similarity_utils import (
resolve_inference_device_reference_folder,
run_text_to_video_similarity_test,
)
logger = init_logger(__name__)
REQUIRED_GPUS = 1
device_reference_folder = resolve_inference_device_reference_folder(logger)
_PRESET_DEFAULTS = KANDINSKY5_T2V_LITE_5S.defaults
KANDINSKY5_T2V_PARAMS = {
"num_gpus": 1,
"model_path": "kandinskylab/Kandinsky-5.0-T2V-Lite-sft-5s-Diffusers",
"height": 256,
"width": 384,
"num_frames": 21,
"num_inference_steps": _PRESET_DEFAULTS["num_inference_steps"],
"guidance_scale": _PRESET_DEFAULTS["guidance_scale"],
"seed": 1024,
"sp_size": 1,
"tp_size": 1,
"fps": _PRESET_DEFAULTS["fps"],
"neg_prompt": _PRESET_DEFAULTS["negative_prompt"],
}
KANDINSKY5_T2V_FULL_QUALITY_PARAMS = {
**KANDINSKY5_T2V_PARAMS,
"height": _PRESET_DEFAULTS["height"],
"width": _PRESET_DEFAULTS["width"],
# num_frames stays at the inherited reduced 21 even at full quality
# (preset default: 121).
}
KANDINSKY5_T2V_MODEL_TO_PARAMS = {
"Kandinsky-5.0-T2V-Lite-sft-5s-Diffusers": KANDINSKY5_T2V_PARAMS,
}
FULL_QUALITY_KANDINSKY5_T2V_MODEL_TO_PARAMS = {
"Kandinsky-5.0-T2V-Lite-sft-5s-Diffusers": KANDINSKY5_T2V_FULL_QUALITY_PARAMS,
}
KANDINSKY5_T2V_TEST_PROMPTS = [
"A curious raccoon peers through a vibrant field of yellow sunflowers, its "
"eyes wide with interest. The playful yet serene atmosphere is complemented "
"by soft natural light filtering through the petals. Mid-shot, warm and "
"cheerful tones.",
]
@pytest.mark.parametrize("prompt", KANDINSKY5_T2V_TEST_PROMPTS)
@pytest.mark.parametrize("attention_backend_name", ["FLASH_ATTN"])
@pytest.mark.parametrize("model_id", list(KANDINSKY5_T2V_MODEL_TO_PARAMS.keys()))
def test_kandinsky5_t2v_inference_similarity(
prompt: str,
attention_backend_name: str,
model_id: str,
) -> None:
# The SSIM lane image bakes FASTVIDEO_FA4=1, but on L40S (sm89) the CLIP
# encoder's dense MHA inference call routes into the FA4 CuTeDSL JIT,
# which fails to compile there (nvvm.fmax signature mismatch vs the
# image's nvidia_cutlass_dsl). Pin FA4 off so reference seeding and CI
# runs both use the FA2 path with identical numerics. Scoped to this test
# and restored afterwards: the other SSIM references are seeded with FA4
# on, so a module-level pin would corrupt every test collected in the
# same pytest process.
saved_fa4 = os.environ.get("FASTVIDEO_FA4")
os.environ["FASTVIDEO_FA4"] = "0"
try:
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=KANDINSKY5_T2V_MODEL_TO_PARAMS,
full_quality_params_map=FULL_QUALITY_KANDINSKY5_T2V_MODEL_TO_PARAMS,
min_acceptable_ssim=0.93,
# Match examples/inference/basic/basic_kandinsky5_t2v.py: FSDP
# inference is not used for Kandinsky-5, and the Qwen2.5-VL text
# encoder stays on CPU between encodes.
init_kwargs_override={
"use_fsdp_inference": False,
"text_encoder_cpu_offload": True,
"pin_cpu_memory": True,
},
)
finally:
if saved_fa4 is None:
os.environ.pop("FASTVIDEO_FA4", None)
else:
os.environ["FASTVIDEO_FA4"] = saved_fa4
+7 -12
View File
@@ -11,6 +11,7 @@ from fastvideo.forward_context import set_forward_context
from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.logger import init_logger
from fastvideo.models.loader.component_loader import TransformerLoader
from fastvideo.tests.utils import skip_if_gated_repo_inaccessible
from fastvideo.utils import maybe_download_model
from fastvideo.configs.models.dits import CosmosVideoConfig
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
@@ -25,18 +26,12 @@ BASE_MODEL_PATH = "nvidia/Cosmos-Predict2-2B-Video2World"
def _resolve_model_path() -> str:
try:
return maybe_download_model(
BASE_MODEL_PATH,
local_dir=os.path.join("data", BASE_MODEL_PATH),
)
except ValueError as exc:
pytest.skip(
"Skipping Cosmos transformer test because the configured "
"HuggingFace token cannot access the gated Cosmos weights: "
f"{exc}",
allow_module_level=True,
)
local_dir = os.path.join("data", BASE_MODEL_PATH)
skip_if_gated_repo_inaccessible(BASE_MODEL_PATH,
local_path=local_dir,
test_name="Cosmos transformer test",
allow_module_level=True)
return maybe_download_model(BASE_MODEL_PATH, local_dir=local_dir)
MODEL_PATH = _resolve_model_path()
@@ -19,6 +19,7 @@ if os.path.exists(COSMOS_PREDICT2_5_PATH) and COSMOS_PREDICT2_5_PATH not in sys.
from fastvideo.forward_context import set_forward_context
from fastvideo.logger import init_logger
from fastvideo.tests.utils import skip_if_gated_repo_inaccessible
from fastvideo.utils import maybe_download_model
# Use Cosmos 2.5 specific config
from fastvideo.configs.models.dits.cosmos2_5 import Cosmos25VideoConfig
@@ -43,7 +44,14 @@ CHECKPOINT_SUBDIR = "base/post-trained"
CHECKPOINT_FILENAME = "81edfebe-bd6a-4039-8c1d-737df1a790bf_ema_bf16.pt"
MODEL_PATH = maybe_download_model(BASE_MODEL_PATH, local_dir=None)
def _resolve_model_path() -> str:
skip_if_gated_repo_inaccessible(BASE_MODEL_PATH,
test_name="Cosmos 2.5 transformer test",
allow_module_level=True)
return maybe_download_model(BASE_MODEL_PATH, local_dir=None)
MODEL_PATH = _resolve_model_path()
TRANSFORMER_PATH = os.path.join(MODEL_PATH, CHECKPOINT_SUBDIR, "transformer")
if not os.path.exists(TRANSFORMER_PATH):
# Try without subdirectory
@@ -592,4 +600,3 @@ if __name__ == "__main__":
# Run tests directly
test_cosmos25_transformer()
test_cosmos25_transformer_video()
+21 -4
View File
@@ -15,6 +15,7 @@ from fastvideo.forward_context import set_forward_context
from fastvideo.logger import init_logger
from fastvideo.models.loader.component_loader import TransformerLoader
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
from fastvideo.platforms import AttentionBackendEnum
from fastvideo.utils import maybe_download_model
logger = init_logger(__name__)
@@ -24,13 +25,25 @@ os.environ["MASTER_PORT"] = "29503"
MODEL_PATH = maybe_download_model("FastVideo/HY-WorldPlay-Bidirectional-Diffusers")
TRANSFORMER_PATH = os.path.join(MODEL_PATH, "transformer")
REFERENCE_LATENT = -197132.85557549074 # Pre-computed reference value
REFERENCE_LATENTS = {
AttentionBackendEnum.FLASH_ATTN: -197132.85557549074,
AttentionBackendEnum.TORCH_SDPA: -211007.95158730447,
}
@pytest.mark.usefixtures("distributed_setup")
def test_hyworld_transformer():
transformer_path = TRANSFORMER_PATH
# An env-forced backend is knowable before the multi-GB download and GPU
# load; the authoritative check on the layer's resolved backend still runs
# after construction below.
forced_backend_name = os.environ.get("FASTVIDEO_ATTENTION_BACKEND")
if forced_backend_name:
forced_backend = getattr(AttentionBackendEnum, forced_backend_name, None)
if forced_backend is not None and forced_backend not in REFERENCE_LATENTS:
pytest.skip(f"HYWorld transformer test has no reference latent for {forced_backend_name}.")
sp_rank = get_sp_parallel_rank()
sp_world_size = get_sp_world_size()
@@ -51,6 +64,9 @@ def test_hyworld_transformer():
loader = TransformerLoader()
model = loader.load(transformer_path, args).to(device, dtype=precision)
model.eval()
attention_backend = model.double_blocks[0].attn.backend
if attention_backend not in REFERENCE_LATENTS:
pytest.skip(f"HYWorld transformer test has no reference latent for {attention_backend}.")
total_params = sum(p.numel() for p in model.parameters())
weight_sum = sum(p.to(torch.float64).sum().item() for p in model.parameters())
@@ -147,9 +163,10 @@ def test_hyworld_transformer():
latent = output.double().sum().item()
diff = abs(REFERENCE_LATENT - latent)
relative_diff = diff / abs(REFERENCE_LATENT)
logger.info(f"Reference latent: {REFERENCE_LATENT}, Current latent: {latent}")
reference_latent = REFERENCE_LATENTS[attention_backend]
diff = abs(reference_latent - latent)
relative_diff = diff / abs(reference_latent)
logger.info(f"Reference latent: {reference_latent}, Current latent: {latent}")
logger.info(f"Absolute diff: {diff}, Relative diff: {relative_diff * 100:.4f}%")
# Allow 0.5% relative difference
+35
View File
@@ -11,6 +11,41 @@ from pytorch_msssim import ms_ssim, ssim
logger = init_logger(__name__)
def skip_if_gated_repo_inaccessible(repo_id: str,
*,
local_path: str | None = None,
test_name: str = "test",
allow_module_level: bool = False) -> None:
"""Skip a test when ``repo_id`` is gated/private and the token lacks access.
Local weights win: when ``local_path`` already exists the check is skipped
entirely, so cached machines keep running offline. Transient hub failures
(offline, DNS, 429/5xx) do NOT skip either — the subsequent download will
serve from cache or fail loudly, preserving the CI failure signal. Only a
positively-identified authorization problem produces a skip.
"""
import pytest
if local_path is not None and os.path.exists(local_path):
return
from huggingface_hub import HfApi
from huggingface_hub.errors import GatedRepoError, RepositoryNotFoundError
try:
HfApi().auth_check(repo_id)
except (GatedRepoError, RepositoryNotFoundError) as exc:
pytest.skip(
f"Skipping {test_name}: the configured HuggingFace token cannot "
f"access the gated repo {repo_id}: {exc}",
allow_module_level=allow_module_level,
)
except Exception as exc: # noqa: BLE001 - hub unreachable, proxies, 5xx
logger.warning(
"Gated-repo access probe for %s failed (%s); proceeding — the "
"download will serve from cache or fail loudly.", repo_id, exc)
def _read_video_frames(path: str) -> torch.Tensor:
"""Read video frames as a ``(T, C, H, W)`` uint8 tensor.