1
0
mirror of https://github.com/huggingface/diffusers.git synced 2026-10-24 20:59:27 +03:00
Files
diffusers/tests/models/autoencoders/test_models_ltx2_diffusion_decoder.py
Sayak Paul 356c2fc7e5 [tests] skip parallelism tests for some models. (#14505)
* skip parallelism tests for some models.

* clarify comment

* skip others too.
2026-08-18 10:06:23 +02:00

243 lines
11 KiB
Python

# coding=utf-8
# Copyright 2026 HuggingFace Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import pytest
import torch
from diffusers import LTX2VideoDiffusionDecoderModel
from diffusers.models.autoencoders import ltx2_diffusion_decoder
from diffusers.models.autoencoders.ltx2_diffusion_decoder import LTX2VideoVaeNeighborhoodNattenProcessor
from diffusers.utils import is_kernels_available
from diffusers.utils.torch_utils import randn_tensor
from ...testing_utils import enable_full_determinism, require_accelerator, require_torch_gpu, torch_device
from ..testing_utils import (
AttentionTesterMixin,
BaseModelTesterConfig,
MemoryTesterMixin,
ModelTesterMixin,
)
enable_full_determinism()
class LTX2VideoDiffusionDecoderModelTesterConfig(BaseModelTesterConfig):
"""Tiny config for the LTX-2.5 diffusion decoder.
The decoder's neighborhood attention needs every stage to be at least its kernel size in T/H/W, which
sets the floor on the dummy input: with a kernel of 3, a compression of 16x spatial / 8x temporal and
the production stride pattern, the smallest usable latent is 2x3x3, i.e. a 9x48x48 video.
"""
@property
def main_input_name(self):
return "z"
@property
def model_class(self):
return LTX2VideoDiffusionDecoderModel
@property
def output_shape(self):
return (3, 9, 48, 48)
@property
def generator(self):
return torch.Generator("cpu").manual_seed(0)
def get_init_dict(self):
return {
"out_channels": 3,
"latent_channels": 8,
"patch_size": 2,
"decoder_head_dim": 16,
"decoder_stage_channels": (64, 32, 16, 16, 16),
"decoder_stage_depths": (1, 1, 1, 1, 2),
"decoder_stage_kernels": ((3, 3, 3), (3, 3, 3), (3, 3, 3), (3, 3, 3)),
"decoder_upsample_strides": ((1, 2, 2), (2, 1, 1), (2, 2, 2), (2, 2, 2)),
"decoder_upsample_channel_reductions": (2, 2, 1, 1),
"decoder_stage5_kernel": (3, 3, 3),
"decoder_t_emb_dim": 32,
"spatial_compression_ratio": 16,
"temporal_compression_ratio": 8,
}
def get_dummy_inputs(self):
# The decoder takes latents directly now: 2 latent frames decode to 9 pixel frames.
latents = randn_tensor((2, 8, 2, 3, 3), generator=self.generator, device=torch_device)
# The decoder denoises, so it draws noise on every call: without a seeded generator no two forward
# passes agree and every output comparison below would be meaningless.
return {"z": latents, "generator": self.generator}
class TestLTX2VideoDiffusionDecoderModel(LTX2VideoDiffusionDecoderModelTesterConfig, ModelTesterMixin):
base_precision = 1e-2
@pytest.mark.skip(
"`forward` runs through the `apply_forward_hook`-decorated `decode`, and that decorator's "
"`pre_forward` call clears the input device accelerate's `AlignDevicesHook` recorded for the caller, so the "
"output comes back on the last device of the split rather than the input device and the comparison raises. "
"`test_cpu_offload` covers split placement instead — there every submodule executes on the same device."
)
def test_model_parallelism(self, base_model_output, tmp_path, atol=1e-5, rtol=0):
pass
class TestLTX2VideoDiffusionDecoderModelSwiGLUTiling(LTX2VideoDiffusionDecoderModelTesterConfig):
"""The SwiGLU evaluates in token tiles to bound decode memory; that must not change the result."""
def test_token_tiled_swiglu_matches_untiled(self):
"""Force the tiled path at test scale and require near-identical output.
The dummy video is 9x48x48, so its stage-5 grid is 5184 tokens -- an order of magnitude under the
16384-token tile size, which means every other test in this file exercises only the untiled
branch. Shrinking the tile size is what actually covers the loop.
The comparison is a tight `allclose`, not `torch.equal`: the MLP is pointwise across tokens, so
tiling cannot change what is computed, but a matmul over a 128-token slice may reduce in a
different order than the same rows inside the full-tensor call.
"""
model = self.model_class(**self.get_init_dict()).to(torch_device).eval()
inputs = self.get_dummy_inputs()
latent = inputs["z"]
def decode():
# Re-seed per call: the decoder samples the noise it denoises, so a shared generator would
# hand the second call different noise and the comparison would be vacuous.
generator = torch.Generator(device=torch_device).manual_seed(0)
with torch.no_grad():
return model.decode(latent, generator=generator, return_dict=False)[0]
original = ltx2_diffusion_decoder._SWIGLU_TILE_SIZE
try:
ltx2_diffusion_decoder._SWIGLU_TILE_SIZE = 10**9 # larger than the volume
untiled = decode()
ltx2_diffusion_decoder._SWIGLU_TILE_SIZE = 128 # ~41 tiles at this size
tiled = decode()
finally:
ltx2_diffusion_decoder._SWIGLU_TILE_SIZE = original
assert tiled.shape == untiled.shape
assert torch.allclose(tiled, untiled, rtol=1e-5, atol=1e-5), (
f"tiled SwiGLU diverged from untiled by {(tiled - untiled).abs().max().item():.3e}"
)
class TestLTX2VideoDiffusionDecoderModelTiling(LTX2VideoDiffusionDecoderModelTesterConfig):
"""Tiled decoding: the early stages run on the full latent, stages 4-5 run per tile with blending.
The latent is 3x4x5 (17x64x80 pixels) so every axis is large enough to split: the tiling grid — the
stage-4 input grid — is 9x16x20, and the tile sizes below cut it into three temporal and two/three
spatial tiles.
"""
def get_latent(self):
return randn_tensor((1, 8, 3, 4, 5), generator=self.generator, device=torch_device)
def decode(self, model, latent, num_inference_steps=None):
# Re-seed per call: the decoder samples the noise it denoises, so outputs are only comparable
# across calls that drew from the same generator state.
generator = torch.Generator("cpu").manual_seed(0)
with torch.no_grad():
return model.decode(latent, generator=generator, num_inference_steps=num_inference_steps)[0]
@require_accelerator
def test_tiles_covering_the_video_match_untiled_exactly(self):
"""A tile schedule with a single covering tile must reproduce the untiled decode bit for bit.
This pins the per-tile plumbing — the ghost-frame carry/crop, the leading-frame drop, and the
stitching — because any offset in them shifts the single tile's output relative to the untiled path.
The default tile sizes are larger than the test video, so `tiled_decode` builds exactly one tile.
"""
model = self.model_class(**self.get_init_dict()).to(torch_device).eval()
latent = self.get_latent()
for num_inference_steps in (None, 3): # None: the single-step x0 shortcut; 3: the Euler loop
untiled = self.decode(model, latent, num_inference_steps)
generator = torch.Generator("cpu").manual_seed(0)
with torch.no_grad():
tiled = model.tiled_decode(latent, generator=generator, num_inference_steps=num_inference_steps)
assert torch.equal(tiled, untiled), (
f"single-tile tiled decode diverged from untiled by {(tiled - untiled).abs().max().item():.3e} "
f"with num_inference_steps={num_inference_steps}"
)
def test_tiled_decode_with_splits(self):
"""Actually-split tiles must reassemble to the untiled output shape, on both noise paths.
Values legitimately differ from the untiled decode (each tile sees a truncated attention context at
its borders), so this asserts geometry, not closeness. The multi-step run additionally covers the
shared noise canvas that overlapping tiles slice from.
"""
model = self.model_class(**self.get_init_dict()).to(torch_device).eval()
latent = self.get_latent()
untiled = self.decode(model, latent)
model.enable_tiling(
# Tiling-grid cells are 2 frames x 4 px x 4 px here (last upsample stride (2, 2, 2), patch 2), so
# this is a 4-cell tile with a 3-cell stride temporally and 8x8-cell tiles with 6-cell strides
# spatially: tiles (0, 4), (3, 7), (6, 9) over T and (0, 8), (6, 16|20) over H/W.
tile_sample_min_num_frames=8,
tile_sample_stride_num_frames=6,
tile_sample_min_height=32,
tile_sample_stride_height=24,
tile_sample_min_width=32,
tile_sample_stride_width=24,
)
for num_inference_steps in (None, 3):
tiled = self.decode(model, latent, num_inference_steps)
assert tiled.shape == untiled.shape
assert torch.isfinite(tiled).all()
model.disable_tiling()
assert torch.equal(self.decode(model, latent), untiled)
class TestLTX2VideoDiffusionDecoderModelMemory(LTX2VideoDiffusionDecoderModelTesterConfig, MemoryTesterMixin):
"""Memory optimization tests for LTX2VideoDiffusionDecoderModel."""
class TestLTX2VideoDiffusionDecoderModelAttention(LTX2VideoDiffusionDecoderModelTesterConfig, AttentionTesterMixin):
"""Attention processor tests for LTX2VideoDiffusionDecoderModel."""
@require_torch_gpu
@pytest.mark.skipif(not is_kernels_available(), reason="Fetching NATTEN from the Hub requires the `kernels` package.")
class TestLTX2VideoDiffusionDecoderModelNattenProcessor(LTX2VideoDiffusionDecoderModelTesterConfig):
"""The NATTEN processor is the reference decoder's attention path; it must agree with the default flex path.
CUDA-only twice over: NATTEN has no CPU kernels, and the processor fetches its build from the Hub
(`shi-labs/natten`) through `kernels`, which resolves a variant for the running torch/CUDA.
"""
def test_natten_processor_decodes(self):
model = self.model_class(**self.get_init_dict()).to(torch_device).eval()
inputs = self.get_dummy_inputs()
# One shared instance swaps every attention module: the decoder's attention is homogeneous, with
# per-stage differences (the kernel size) living on the module rather than the processor.
model.set_attn_processor(LTX2VideoVaeNeighborhoodNattenProcessor())
processors = model.attn_processors
assert processors and all(
isinstance(processor, LTX2VideoVaeNeighborhoodNattenProcessor) for processor in processors.values()
)
with torch.no_grad():
output = model.decode(inputs["z"], generator=inputs["generator"], return_dict=False)[0]
assert output.shape == (inputs["z"].shape[0], *self.output_shape)
assert torch.isfinite(output).all(), "NATTEN decode produced NaN/inf values"