3045 lines
128 KiB
Python
3045 lines
128 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""
|
|
LTX-2 transformer implementation
|
|
"""
|
|
|
|
from dataclasses import dataclass, replace
|
|
from enum import Enum
|
|
import functools
|
|
import math
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any, Optional, Tuple, Callable
|
|
|
|
import torch
|
|
import torch.nn as nn
|
|
from einops import rearrange, repeat
|
|
|
|
from fastvideo.attention.backends.sdpa import SDPAMetadata
|
|
from fastvideo.attention.layer import DistributedAttention, LocalAttention
|
|
from fastvideo.configs.models.dits import LTX2VideoConfig
|
|
from fastvideo.distributed.communication_op import (
|
|
sequence_model_parallel_all_gather,
|
|
sequence_model_parallel_all_gather_with_unpad,
|
|
sequence_model_parallel_all_to_all_4D,
|
|
sequence_model_parallel_shard,
|
|
)
|
|
from fastvideo.distributed.parallel_state import get_sp_parallel_rank, get_sp_world_size
|
|
from fastvideo.forward_context import ForwardContext, get_forward_context, set_forward_context
|
|
from fastvideo.layers.linear import ReplicatedLinear
|
|
from fastvideo.layers.quantization.base_config import QuantizationConfig
|
|
from fastvideo.logger import init_logger
|
|
from fastvideo.models.dits.base import BaseDiT
|
|
from fastvideo.platforms import AttentionBackendEnum
|
|
|
|
logger = init_logger(__name__)
|
|
|
|
# QuACK provides a fast NVFP4 RMSNorm used only for the LTX-2.3 refine stage.
|
|
# Import defensively: upstream installs without QuACK must still work, simply
|
|
# falling back to ``torch.nn.functional.rms_norm`` (the non-refine path).
|
|
try:
|
|
from quack import rmsnorm as _quack_rmsnorm
|
|
except Exception: # pragma: no cover - optional dependency
|
|
_quack_rmsnorm = None
|
|
|
|
|
|
def _is_ltx2_refine_stage() -> bool:
|
|
"""Return True when running LTX-2 stage-2 refinement denoising.
|
|
|
|
Only the refine stage uses the QuACK fast path; everything else (and any
|
|
install lacking QuACK) uses the standard Torch RMSNorm, which reproduces
|
|
LTX-2.0 numerics exactly.
|
|
"""
|
|
if _quack_rmsnorm is None:
|
|
return False
|
|
try:
|
|
forward_ctx = get_forward_context()
|
|
except AssertionError:
|
|
return False
|
|
forward_batch = forward_ctx.forward_batch
|
|
if forward_batch is None:
|
|
return False
|
|
return forward_batch.extra.get("ltx2_fp4_stage_profile") == "refine"
|
|
|
|
|
|
def _functional_rms_norm(
|
|
x: torch.Tensor,
|
|
eps: float,
|
|
weight: torch.Tensor | None = None,
|
|
) -> torch.Tensor:
|
|
"""Out-of-place RMSNorm equivalent to ``torch.nn.functional.rms_norm``.
|
|
|
|
``F.rms_norm`` / ``nn.RMSNorm`` decompose under AOTAutograd into an
|
|
in-place ``variance.add_(eps)`` (an ``aten.copy_`` into the computed
|
|
``mean``). AOTAutograd's ``assert_functional_graph`` requires every
|
|
``copy_`` destination to be a graph input, so this trips when the DiT is
|
|
compiled through the ``torch.compile(backend="torch_tensorrt")`` path
|
|
(Dynamo -> AOTAutograd functionalization -> torch-tensorrt), breaking
|
|
TensorRT + sequence-parallel (Ulysses) export. Computing ``variance + eps``
|
|
out-of-place keeps the graph functional. Numerically matches
|
|
``F.rms_norm`` (fp32 accumulation, dtype restored to the input)."""
|
|
input_dtype = x.dtype
|
|
hidden = x.to(torch.float32)
|
|
variance = hidden.pow(2).mean(-1, keepdim=True)
|
|
hidden = hidden * torch.rsqrt(variance + eps)
|
|
out = hidden.to(input_dtype)
|
|
if weight is not None:
|
|
# A weight dtype differing from x (e.g. fp32 params under autocast)
|
|
# would promote the output; keep the input-dtype guarantee.
|
|
out = out * weight
|
|
return out.to(input_dtype)
|
|
|
|
|
|
def _rms_norm_dispatch(
|
|
x: torch.Tensor,
|
|
eps: float,
|
|
weight: torch.Tensor | None = None,
|
|
) -> torch.Tensor:
|
|
"""Use QuACK RMSNorm only for LTX-2 refine stage, else Torch RMSNorm."""
|
|
if _is_ltx2_refine_stage():
|
|
return _quack_rmsnorm(x, weight=weight, eps=eps)
|
|
# Official LTX-2 runs the DiT natively in bf16 with no autocast, so its
|
|
# rms_norm outputs stay bf16 on every torch version. torch 2.12 added
|
|
# rms_norm to autocast's float32 cast policy, which under our
|
|
# autocast(bfloat16) forward returns float32 instead. Restore the input
|
|
# dtype to match the official numerics (and torch <= 2.11 behavior).
|
|
return _functional_rms_norm(x, eps, weight)
|
|
|
|
|
|
class StageAwareRMSNorm(nn.RMSNorm):
|
|
"""Use torch.nn.RMSNorm for base stage and QuACK for refine stage.
|
|
|
|
When QuACK is unavailable or outside the refine stage this behaves
|
|
identically to ``torch.nn.RMSNorm`` (LTX-2.0 q_norm/k_norm behavior).
|
|
"""
|
|
|
|
def __init__(self, hidden_size: int, eps: float = 1e-6) -> None:
|
|
super().__init__(hidden_size, eps=eps)
|
|
|
|
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
if _is_ltx2_refine_stage():
|
|
return _quack_rmsnorm(
|
|
x,
|
|
weight=self.weight,
|
|
eps=self.eps,
|
|
)
|
|
# torch 2.12 added rms_norm to autocast's float32 cast policy
|
|
# (aten/src/ATen/autocast_mode.h), so under autocast(bfloat16) this
|
|
# norm now returns float32 where torch <= 2.11 returned bfloat16.
|
|
# Restore the input dtype so autocast-blind consumers downstream
|
|
# (e.g. the flash-attention custom op) don't receive upcast q/k.
|
|
return _functional_rms_norm(x, self.eps, self.weight)
|
|
|
|
|
|
def _supports_prequantized_input(linear: ReplicatedLinear) -> bool:
|
|
"""Whether ``linear``'s quant method is willing to accept a
|
|
pre-quantized ``(x_fp4, x_scale, x_global_sf)`` input tuple.
|
|
|
|
Used by the LTX-2 attention forward path so that a single input
|
|
tensor can be quantized once and reused across q/k/v projections
|
|
when they share the same source (self-attention).
|
|
"""
|
|
quant_method = getattr(linear, "quant_method", None)
|
|
if not callable(getattr(quant_method, "quantize_input", None)):
|
|
return False
|
|
wants_prequant = getattr(quant_method, "wants_prequantized_input", None)
|
|
if callable(wants_prequant):
|
|
try:
|
|
return bool(wants_prequant())
|
|
except Exception:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _linear_project_with_optional_prequant(
|
|
linear: ReplicatedLinear,
|
|
x: torch.Tensor,
|
|
pre_quantized: tuple[torch.Tensor, torch.Tensor, torch.Tensor] | None,
|
|
) -> torch.Tensor:
|
|
"""Project ``x`` through ``linear``, optionally bypassing the
|
|
in-method quantize step when ``pre_quantized`` is supplied.
|
|
|
|
When the linear's quant method does not support pre-quantized
|
|
inputs (e.g. ``UnquantizedLinearMethod``), falls back to the
|
|
standard ``linear(x)`` call and discards the bias-pass-through
|
|
tuple element.
|
|
"""
|
|
if pre_quantized is None or not _supports_prequantized_input(linear):
|
|
return linear(x)[0]
|
|
bias = linear.bias if not linear.skip_bias_add else None
|
|
return linear.quant_method.apply( # type: ignore[union-attr]
|
|
linear,
|
|
x,
|
|
bias=bias,
|
|
pre_quantized=pre_quantized,
|
|
)
|
|
|
|
|
|
def get_timestep_embedding(
|
|
timesteps: torch.Tensor,
|
|
embedding_dim: int,
|
|
flip_sin_to_cos: bool = False,
|
|
downscale_freq_shift: float = 1,
|
|
scale: float = 1,
|
|
max_period: int = 10000,
|
|
) -> torch.Tensor:
|
|
"""Sinusoidal timestep embedding used by LTX-2 AdaLN."""
|
|
if len(timesteps.shape) != 1:
|
|
raise ValueError("Timesteps should be a 1d-array")
|
|
|
|
half_dim = embedding_dim // 2
|
|
exponent = -math.log(max_period) * torch.arange(start=0, end=half_dim, dtype=torch.float32, device=timesteps.device)
|
|
exponent = exponent / (half_dim - downscale_freq_shift)
|
|
|
|
emb = torch.exp(exponent)
|
|
emb = timesteps[:, None].float() * emb[None, :]
|
|
emb = scale * emb
|
|
emb = torch.cat([torch.sin(emb), torch.cos(emb)], dim=-1)
|
|
|
|
if flip_sin_to_cos:
|
|
emb = torch.cat([emb[:, half_dim:], emb[:, :half_dim]], dim=-1)
|
|
|
|
if embedding_dim % 2 == 1:
|
|
emb = torch.nn.functional.pad(emb, (0, 1, 0, 0))
|
|
return emb
|
|
|
|
|
|
class TimestepEmbedding(torch.nn.Module):
|
|
"""Two-layer MLP to project timestep embeddings."""
|
|
|
|
def __init__(
|
|
self,
|
|
in_channels: int,
|
|
time_embed_dim: int,
|
|
out_dim: int | None = None,
|
|
post_act_fn: str | None = None,
|
|
cond_proj_dim: int | None = None,
|
|
sample_proj_bias: bool = True,
|
|
):
|
|
super().__init__()
|
|
self.linear_1 = torch.nn.Linear(in_channels, time_embed_dim, sample_proj_bias)
|
|
self.cond_proj = torch.nn.Linear(cond_proj_dim, in_channels, bias=False) if cond_proj_dim is not None else None
|
|
self.act = torch.nn.SiLU()
|
|
time_embed_dim_out = out_dim if out_dim is not None else time_embed_dim
|
|
self.linear_2 = torch.nn.Linear(time_embed_dim, time_embed_dim_out, sample_proj_bias)
|
|
self.post_act = None if post_act_fn is None else None
|
|
|
|
def forward(self, sample: torch.Tensor, condition: torch.Tensor | None = None) -> torch.Tensor:
|
|
if condition is not None:
|
|
sample = sample + self.cond_proj(condition)
|
|
sample = self.linear_1(sample)
|
|
if self.act is not None:
|
|
sample = self.act(sample)
|
|
sample = self.linear_2(sample)
|
|
if self.post_act is not None:
|
|
sample = self.post_act(sample)
|
|
return sample
|
|
|
|
|
|
class Timesteps(torch.nn.Module):
|
|
"""Sinusoidal timestep embedding wrapper with scaling knobs."""
|
|
|
|
def __init__(self, num_channels: int, flip_sin_to_cos: bool, downscale_freq_shift: float, scale: int = 1):
|
|
super().__init__()
|
|
self.num_channels = num_channels
|
|
self.flip_sin_to_cos = flip_sin_to_cos
|
|
self.downscale_freq_shift = downscale_freq_shift
|
|
self.scale = scale
|
|
|
|
def forward(self, timesteps: torch.Tensor) -> torch.Tensor:
|
|
return get_timestep_embedding(
|
|
timesteps,
|
|
self.num_channels,
|
|
flip_sin_to_cos=self.flip_sin_to_cos,
|
|
downscale_freq_shift=self.downscale_freq_shift,
|
|
scale=self.scale,
|
|
)
|
|
|
|
|
|
class PixArtAlphaCombinedTimestepSizeEmbeddings(torch.nn.Module):
|
|
"""PixArt-Alpha timestep embedding used by LTX-2 AdaLN."""
|
|
|
|
def __init__(self, embedding_dim: int, size_emb_dim: int):
|
|
super().__init__()
|
|
self.outdim = size_emb_dim
|
|
self.time_proj = Timesteps(num_channels=256, flip_sin_to_cos=True, downscale_freq_shift=0)
|
|
self.timestep_embedder = TimestepEmbedding(in_channels=256, time_embed_dim=embedding_dim)
|
|
|
|
def forward(self, timestep: torch.Tensor, hidden_dtype: torch.dtype) -> torch.Tensor:
|
|
timesteps_proj = self.time_proj(timestep)
|
|
timesteps_emb = self.timestep_embedder(timesteps_proj.to(dtype=hidden_dtype))
|
|
return timesteps_emb
|
|
|
|
|
|
class AdaLayerNormSingle(torch.nn.Module):
|
|
"""AdaLN-single modulation that emits scale/shift/gates for LTX-2."""
|
|
|
|
def __init__(self, embedding_dim: int, embedding_coefficient: int = 6):
|
|
super().__init__()
|
|
self.emb = PixArtAlphaCombinedTimestepSizeEmbeddings(
|
|
embedding_dim,
|
|
size_emb_dim=embedding_dim // 3,
|
|
)
|
|
self.silu = torch.nn.SiLU()
|
|
self.linear = torch.nn.Linear(embedding_dim, embedding_coefficient * embedding_dim, bias=True)
|
|
|
|
def forward(
|
|
self,
|
|
timestep: torch.Tensor,
|
|
hidden_dtype: Optional[torch.dtype] = None,
|
|
) -> Tuple[torch.Tensor, torch.Tensor]:
|
|
embedded_timestep = self.emb(timestep, hidden_dtype=hidden_dtype)
|
|
return self.linear(self.silu(embedded_timestep)), embedded_timestep
|
|
|
|
|
|
# Number of AdaLN scale/shift/gate rows. LTX-2.0 emits 6 (shift/scale/gate for
|
|
# self-attn and FFN). LTX-2.3 with cross_attention_adaln emits 3 extra rows for
|
|
# the text cross-attention shift/scale/gate.
|
|
ADALN_NUM_BASE_PARAMS = 6
|
|
ADALN_NUM_CROSS_ATTN_PARAMS = 3
|
|
|
|
|
|
def adaln_embedding_coefficient(cross_attention_adaln: bool) -> int:
|
|
return ADALN_NUM_BASE_PARAMS + (ADALN_NUM_CROSS_ATTN_PARAMS if cross_attention_adaln else 0)
|
|
|
|
|
|
class PixArtAlphaTextProjection(torch.nn.Module):
|
|
"""Caption projection MLP used by LTX-2."""
|
|
|
|
def __init__(self, in_features: int, hidden_size: int, out_features: int | None = None, act_fn: str = "gelu_tanh"):
|
|
super().__init__()
|
|
if out_features is None:
|
|
out_features = hidden_size
|
|
self.linear_1 = torch.nn.Linear(in_features=in_features, out_features=hidden_size, bias=True)
|
|
if act_fn == "gelu_tanh":
|
|
self.act_1 = torch.nn.GELU(approximate="tanh")
|
|
elif act_fn == "silu":
|
|
self.act_1 = torch.nn.SiLU()
|
|
else:
|
|
raise ValueError(f"Unknown activation function: {act_fn}")
|
|
self.linear_2 = torch.nn.Linear(in_features=hidden_size, out_features=out_features, bias=True)
|
|
|
|
def forward(self, caption: torch.Tensor) -> torch.Tensor:
|
|
hidden_states = self.linear_1(caption)
|
|
hidden_states = self.act_1(hidden_states)
|
|
hidden_states = self.linear_2(hidden_states)
|
|
return hidden_states
|
|
|
|
|
|
class GELUApprox(nn.Module):
|
|
"""Linear + tanh-approximate GELU used by LTX-2 FFN."""
|
|
|
|
def __init__(
|
|
self,
|
|
in_features: int,
|
|
out_features: int,
|
|
quant_config: QuantizationConfig | None = None,
|
|
prefix: str = "",
|
|
):
|
|
super().__init__()
|
|
self.proj = ReplicatedLinear(
|
|
in_features,
|
|
out_features,
|
|
quant_config=quant_config,
|
|
prefix=f"{prefix}.fc_in",
|
|
)
|
|
self.act = nn.GELU(approximate="tanh")
|
|
|
|
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
return self.act(self.proj(x)[0])
|
|
|
|
|
|
class FeedForward(nn.Module):
|
|
"""LTX-2 FFN: GELUApprox -> Identity -> Linear."""
|
|
|
|
def __init__(
|
|
self,
|
|
dim: int,
|
|
dim_out: int,
|
|
mult: int = 4,
|
|
quant_config: QuantizationConfig | None = None,
|
|
prefix: str = "",
|
|
) -> None:
|
|
super().__init__()
|
|
inner_dim = int(dim * mult)
|
|
project_in = GELUApprox(
|
|
dim,
|
|
inner_dim,
|
|
quant_config=quant_config,
|
|
prefix=f"{prefix}.ffn",
|
|
)
|
|
project_out = ReplicatedLinear(
|
|
inner_dim,
|
|
dim_out,
|
|
quant_config=quant_config,
|
|
prefix=f"{prefix}.ffn.fc_out",
|
|
)
|
|
self.net = nn.ModuleList([project_in, nn.Identity(), project_out])
|
|
|
|
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
x = self.net[0](x)
|
|
x = self.net[1](x)
|
|
x = self.net[2](x)[0]
|
|
return x
|
|
|
|
|
|
class VideoLatentShape(tuple):
|
|
"""Helper for (B, C, T, H, W) latent shapes."""
|
|
|
|
@property
|
|
def batch(self) -> int:
|
|
return self[0]
|
|
|
|
@property
|
|
def channels(self) -> int:
|
|
return self[1]
|
|
|
|
@property
|
|
def frames(self) -> int:
|
|
return self[2]
|
|
|
|
@property
|
|
def height(self) -> int:
|
|
return self[3]
|
|
|
|
@property
|
|
def width(self) -> int:
|
|
return self[4]
|
|
|
|
@staticmethod
|
|
def from_torch_shape(shape: torch.Size) -> "VideoLatentShape":
|
|
return VideoLatentShape(shape)
|
|
|
|
def to_torch_shape(self) -> torch.Size:
|
|
return torch.Size(self)
|
|
|
|
|
|
class AudioLatentShape(tuple):
|
|
"""Helper for (B, C, T, F) audio latent shapes."""
|
|
|
|
@property
|
|
def batch(self) -> int:
|
|
return self[0]
|
|
|
|
@property
|
|
def channels(self) -> int:
|
|
return self[1]
|
|
|
|
@property
|
|
def frames(self) -> int:
|
|
return self[2]
|
|
|
|
@property
|
|
def mel_bins(self) -> int:
|
|
return self[3]
|
|
|
|
def to_torch_shape(self) -> torch.Size:
|
|
return torch.Size(self)
|
|
|
|
@staticmethod
|
|
def from_torch_shape(shape: torch.Size) -> "AudioLatentShape":
|
|
return AudioLatentShape(shape)
|
|
|
|
@staticmethod
|
|
def from_duration(
|
|
batch: int,
|
|
duration: float,
|
|
channels: int,
|
|
mel_bins: int,
|
|
sample_rate: int,
|
|
hop_length: int,
|
|
audio_latent_downsample_factor: int,
|
|
) -> "AudioLatentShape":
|
|
latents_per_second = float(sample_rate) / float(hop_length) / float(audio_latent_downsample_factor)
|
|
return AudioLatentShape((batch, channels, round(duration * latents_per_second), mel_bins))
|
|
|
|
|
|
class VideoLatentPatchifier:
|
|
"""Patchify/unpatchify latent tokens for LTX-2."""
|
|
|
|
def __init__(self, patch_size: int):
|
|
self._patch_size = (1, patch_size, patch_size)
|
|
|
|
@property
|
|
def patch_size(self) -> Tuple[int, int, int]:
|
|
return self._patch_size
|
|
|
|
def get_token_count(self, tgt_shape: VideoLatentShape) -> int:
|
|
return math.prod(tgt_shape.to_torch_shape()[2:]) // math.prod(self._patch_size)
|
|
|
|
def patchify(self, latents: torch.Tensor) -> torch.Tensor:
|
|
return rearrange(
|
|
latents,
|
|
"b c (f p1) (h p2) (w p3) -> b (f h w) (c p1 p2 p3)",
|
|
p1=self._patch_size[0],
|
|
p2=self._patch_size[1],
|
|
p3=self._patch_size[2],
|
|
)
|
|
|
|
def unpatchify(self, latents: torch.Tensor, output_shape: VideoLatentShape) -> torch.Tensor:
|
|
patch_grid_frames = output_shape.frames // self._patch_size[0]
|
|
patch_grid_height = output_shape.height // self._patch_size[1]
|
|
patch_grid_width = output_shape.width // self._patch_size[2]
|
|
return rearrange(
|
|
latents,
|
|
"b (f h w) (c p q) -> b c f (h p) (w q)",
|
|
f=patch_grid_frames,
|
|
h=patch_grid_height,
|
|
w=patch_grid_width,
|
|
p=self._patch_size[1],
|
|
q=self._patch_size[2],
|
|
)
|
|
|
|
def get_patch_grid_bounds(
|
|
self,
|
|
output_shape: VideoLatentShape,
|
|
device: Optional[torch.device] = None,
|
|
) -> torch.Tensor:
|
|
"""Get patch grid bounds for RoPE computation.
|
|
|
|
Args:
|
|
output_shape: Shape of the video latent tensor
|
|
device: Device to create tensors on
|
|
"""
|
|
frames = output_shape.frames
|
|
height = output_shape.height
|
|
width = output_shape.width
|
|
batch_size = output_shape.batch
|
|
|
|
grid_coords = torch.meshgrid(
|
|
torch.arange(start=0, end=frames, step=self._patch_size[0], device=device),
|
|
torch.arange(start=0, end=height, step=self._patch_size[1], device=device),
|
|
torch.arange(start=0, end=width, step=self._patch_size[2], device=device),
|
|
indexing="ij",
|
|
)
|
|
|
|
patch_starts = torch.stack(grid_coords, dim=0)
|
|
patch_size_delta = torch.tensor(
|
|
self._patch_size,
|
|
device=patch_starts.device,
|
|
dtype=patch_starts.dtype,
|
|
).view(3, 1, 1, 1)
|
|
patch_ends = patch_starts + patch_size_delta
|
|
latent_coords = torch.stack((patch_starts, patch_ends), dim=-1)
|
|
latent_coords = repeat(
|
|
latent_coords,
|
|
"c f h w bounds -> b c (f h w) bounds",
|
|
b=batch_size,
|
|
bounds=2,
|
|
)
|
|
return latent_coords
|
|
|
|
|
|
class AudioLatentPatchifier:
|
|
"""Patchify/unpatchify audio latents and compute timing bounds."""
|
|
|
|
def __init__(
|
|
self,
|
|
patch_size: int,
|
|
sample_rate: int,
|
|
hop_length: int,
|
|
audio_latent_downsample_factor: int,
|
|
is_causal: bool = True,
|
|
shift: int = 0,
|
|
) -> None:
|
|
self.hop_length = hop_length
|
|
self.sample_rate = sample_rate
|
|
self.audio_latent_downsample_factor = audio_latent_downsample_factor
|
|
self.is_causal = is_causal
|
|
self.shift = shift
|
|
self._patch_size = (1, patch_size, patch_size)
|
|
|
|
def get_token_count(self, tgt_shape: AudioLatentShape) -> int:
|
|
return tgt_shape.frames
|
|
|
|
def patchify(self, latents: torch.Tensor) -> torch.Tensor:
|
|
return rearrange(latents, "b c t f -> b t (c f)")
|
|
|
|
def unpatchify(
|
|
self,
|
|
latents: torch.Tensor,
|
|
output_shape: AudioLatentShape,
|
|
) -> torch.Tensor:
|
|
return rearrange(
|
|
latents,
|
|
"b t (c f) -> b c t f",
|
|
c=output_shape.channels,
|
|
f=output_shape.mel_bins,
|
|
)
|
|
|
|
def get_patch_grid_bounds(
|
|
self,
|
|
output_shape: AudioLatentShape,
|
|
device: Optional[torch.device] = None,
|
|
) -> torch.Tensor:
|
|
"""Get patch grid bounds for audio RoPE computation.
|
|
|
|
Args:
|
|
output_shape: Shape of the audio latent tensor
|
|
device: Device to create tensors on
|
|
"""
|
|
start_timings = self._get_audio_latent_time_in_sec(
|
|
self.shift,
|
|
output_shape.frames + self.shift,
|
|
torch.float32,
|
|
device,
|
|
)
|
|
start_timings = start_timings.unsqueeze(0).expand(output_shape.batch, -1).unsqueeze(1)
|
|
|
|
end_timings = self._get_audio_latent_time_in_sec(
|
|
self.shift + 1,
|
|
output_shape.frames + self.shift + 1,
|
|
torch.float32,
|
|
device,
|
|
)
|
|
end_timings = end_timings.unsqueeze(0).expand(output_shape.batch, -1).unsqueeze(1)
|
|
|
|
return torch.stack([start_timings, end_timings], dim=-1)
|
|
|
|
def _get_audio_latent_time_in_sec(
|
|
self,
|
|
start_latent: int,
|
|
end_latent: int,
|
|
dtype: torch.dtype,
|
|
device: Optional[torch.device],
|
|
) -> torch.Tensor:
|
|
resolved_device = device or torch.device("cpu")
|
|
audio_latent_frame = torch.arange(start_latent, end_latent, dtype=dtype, device=resolved_device)
|
|
audio_mel_frame = audio_latent_frame * self.audio_latent_downsample_factor
|
|
if self.is_causal:
|
|
causal_offset = 1
|
|
audio_mel_frame = (audio_mel_frame + causal_offset - self.audio_latent_downsample_factor).clamp(min=0)
|
|
return audio_mel_frame * self.hop_length / self.sample_rate
|
|
|
|
|
|
def _get_pixel_coords(
|
|
latent_coords: torch.Tensor,
|
|
scale_factors: tuple[int, int, int],
|
|
fps: float | None,
|
|
causal_fix: bool = True,
|
|
) -> torch.Tensor:
|
|
broadcast_shape = [1] * latent_coords.ndim
|
|
broadcast_shape[1] = -1
|
|
scale_tensor = torch.tensor(
|
|
scale_factors,
|
|
device=latent_coords.device,
|
|
dtype=torch.float32,
|
|
).view(*broadcast_shape)
|
|
pixel_coords = latent_coords.to(torch.float32) * scale_tensor
|
|
if causal_fix:
|
|
pixel_coords[:, 0, ...] = (pixel_coords[:, 0, ...] + 1 - scale_factors[0]).clamp(min=0)
|
|
if fps:
|
|
pixel_coords[:, 0, ...] = pixel_coords[:, 0, ...] / fps
|
|
return pixel_coords
|
|
|
|
|
|
def _to_denoised(
|
|
sample: torch.Tensor,
|
|
velocity: torch.Tensor,
|
|
sigma: torch.Tensor,
|
|
calc_dtype: torch.dtype = torch.float32,
|
|
) -> torch.Tensor:
|
|
if isinstance(sigma, torch.Tensor):
|
|
sigma = sigma.to(calc_dtype)
|
|
while sigma.ndim < sample.ndim:
|
|
sigma = sigma.unsqueeze(-1)
|
|
return (sample.to(calc_dtype) - velocity.to(calc_dtype) * sigma).to(sample.dtype)
|
|
|
|
|
|
def _debug_block_log_line(message: str) -> None:
|
|
if os.getenv("LTX2_PIPELINE_DEBUG_LOG", "0") != "1":
|
|
return
|
|
log_path = os.getenv("LTX2_PIPELINE_DEBUG_PATH", "")
|
|
if not log_path:
|
|
return
|
|
log_dir = os.path.dirname(log_path)
|
|
if log_dir:
|
|
os.makedirs(log_dir, exist_ok=True)
|
|
with open(log_path, "a", encoding="utf-8") as f:
|
|
f.write(message + "\n")
|
|
|
|
|
|
def _debug_transformer_args(prefix: str, args: "TransformerArgs | None") -> None:
|
|
if os.getenv("LTX2_PIPELINE_DEBUG_LOG", "0") != "1" or args is None:
|
|
return
|
|
pe_cos, pe_sin = args.positional_embeddings
|
|
cross_cos = None
|
|
cross_sin = None
|
|
if args.cross_positional_embeddings is not None:
|
|
cross_cos, cross_sin = args.cross_positional_embeddings
|
|
mask = args.context_mask
|
|
if mask is None:
|
|
mask_summary = "mask=None"
|
|
else:
|
|
finite = torch.isfinite(mask)
|
|
finite_sum = mask[finite].sum().item() if finite.any() else 0.0
|
|
mask_summary = (f"mask_min={mask.min().item():.6f} "
|
|
f"mask_max={mask.max().item():.6f} "
|
|
f"mask_finite_sum={finite_sum:.6f} "
|
|
f"mask_finite_count={finite.sum().item()}")
|
|
_debug_block_log_line(f"{prefix}:x_sum={args.x.float().sum().item():.6f} "
|
|
f"context_sum={args.context.float().sum().item():.6f} "
|
|
f"t_sum={args.timesteps.float().sum().item():.6f} "
|
|
f"emb_sum={args.embedded_timestep.float().sum().item():.6f} "
|
|
f"pe_cos_sum={pe_cos.float().sum().item():.6f} "
|
|
f"pe_sin_sum={pe_sin.float().sum().item():.6f} "
|
|
f"cross_pe_cos_sum={(cross_cos.float().sum().item() if cross_cos is not None else 0.0):.6f} "
|
|
f"cross_pe_sin_sum={(cross_sin.float().sum().item() if cross_sin is not None else 0.0):.6f} "
|
|
f"{mask_summary}")
|
|
|
|
|
|
class LTXRopeType(Enum):
|
|
"""LTX-2 rotary variants (interleaved vs split)."""
|
|
INTERLEAVED = "interleaved"
|
|
SPLIT = "split"
|
|
|
|
|
|
DEFAULT_LTX2_SCALE_FACTORS = (8, 32, 32)
|
|
DEFAULT_LTX2_AUDIO_CHANNELS = 8
|
|
DEFAULT_LTX2_AUDIO_MEL_BINS = 16
|
|
DEFAULT_LTX2_AUDIO_SAMPLE_RATE = 16000
|
|
DEFAULT_LTX2_AUDIO_HOP_LENGTH = 160
|
|
DEFAULT_LTX2_AUDIO_DOWNSAMPLE = 4
|
|
# The vocoder upsamples from mel spectrograms (16kHz sample rate) to 24kHz audio
|
|
DEFAULT_LTX2_VOCODER_OUTPUT_SAMPLE_RATE = 24000
|
|
|
|
|
|
def apply_ltx_rotary_emb(
|
|
input_tensor: torch.Tensor,
|
|
freqs_cis: tuple[torch.Tensor, torch.Tensor],
|
|
rope_type: LTXRopeType = LTXRopeType.INTERLEAVED,
|
|
) -> torch.Tensor:
|
|
"""Apply LTX-2 rotary embeddings to a tensor."""
|
|
if rope_type == LTXRopeType.INTERLEAVED:
|
|
return _apply_ltx_interleaved_rotary_emb(input_tensor, *freqs_cis)
|
|
if rope_type == LTXRopeType.SPLIT:
|
|
return _apply_ltx_split_rotary_emb(input_tensor, *freqs_cis)
|
|
raise ValueError(f"Invalid rope type: {rope_type}")
|
|
|
|
|
|
def apply_ltx_rotary_emb_4d(
|
|
input_tensor: torch.Tensor,
|
|
freqs_cis: tuple[torch.Tensor, torch.Tensor],
|
|
rope_type: LTXRopeType = LTXRopeType.INTERLEAVED,
|
|
) -> torch.Tensor:
|
|
"""Apply LTX-2 rotary embeddings to a 4D tensor [B, H, T, D].
|
|
|
|
This is used for applying RoPE after all-to-all in distributed attention,
|
|
where the tensor is already in [B, H, T, D] format.
|
|
"""
|
|
cos_freqs, sin_freqs = freqs_cis
|
|
if rope_type == LTXRopeType.INTERLEAVED:
|
|
# For interleaved, cos/sin have shape [B, T, inner_dim]
|
|
# Need to reshape to [B, H, T, D] format
|
|
# Actually interleaved doesn't have per-head rotations, so we broadcast
|
|
t_dup = rearrange(input_tensor, "... (d r) -> ... d r", r=2)
|
|
t1, t2 = t_dup.unbind(dim=-1)
|
|
t_dup = torch.stack((-t2, t1), dim=-1)
|
|
input_tensor_rot = rearrange(t_dup, "... d r -> ... (d r)")
|
|
return input_tensor * cos_freqs + input_tensor_rot * sin_freqs
|
|
if rope_type == LTXRopeType.SPLIT:
|
|
# For split, cos/sin already have shape [B, H, T, D/2]
|
|
# input_tensor is [B, H, T, D]
|
|
split_input = rearrange(input_tensor, "... (d r) -> ... d r", d=2)
|
|
first_half_input = split_input[..., :1, :]
|
|
second_half_input = split_input[..., 1:, :]
|
|
|
|
output = split_input * cos_freqs.unsqueeze(-2)
|
|
first_half_output = output[..., :1, :]
|
|
second_half_output = output[..., 1:, :]
|
|
|
|
first_half_output.addcmul_(-sin_freqs.unsqueeze(-2), second_half_input)
|
|
second_half_output.addcmul_(sin_freqs.unsqueeze(-2), first_half_input)
|
|
|
|
return rearrange(output, "... d r -> ... (d r)")
|
|
raise ValueError(f"Invalid rope type: {rope_type}")
|
|
|
|
|
|
def _apply_ltx_interleaved_rotary_emb(input_tensor: torch.Tensor, cos_freqs: torch.Tensor,
|
|
sin_freqs: torch.Tensor) -> torch.Tensor:
|
|
t_dup = rearrange(input_tensor, "... (d r) -> ... d r", r=2)
|
|
t1, t2 = t_dup.unbind(dim=-1)
|
|
t_dup = torch.stack((-t2, t1), dim=-1)
|
|
input_tensor_rot = rearrange(t_dup, "... d r -> ... (d r)")
|
|
return input_tensor * cos_freqs + input_tensor_rot * sin_freqs
|
|
|
|
|
|
def _apply_ltx_split_rotary_emb(input_tensor: torch.Tensor, cos_freqs: torch.Tensor,
|
|
sin_freqs: torch.Tensor) -> torch.Tensor:
|
|
needs_reshape = False
|
|
if input_tensor.ndim != 4 and cos_freqs.ndim == 4:
|
|
b, h, t, _ = cos_freqs.shape
|
|
input_tensor = input_tensor.reshape(b, t, h, -1).swapaxes(1, 2)
|
|
needs_reshape = True
|
|
|
|
split_input = rearrange(input_tensor, "... (d r) -> ... d r", d=2)
|
|
first_half_input = split_input[..., :1, :]
|
|
second_half_input = split_input[..., 1:, :]
|
|
|
|
output = split_input * cos_freqs.unsqueeze(-2)
|
|
first_half_output = output[..., :1, :]
|
|
second_half_output = output[..., 1:, :]
|
|
|
|
first_half_output.addcmul_(-sin_freqs.unsqueeze(-2), second_half_input)
|
|
second_half_output.addcmul_(sin_freqs.unsqueeze(-2), first_half_input)
|
|
|
|
output = rearrange(output, "... d r -> ... (d r)")
|
|
if needs_reshape:
|
|
output = output.swapaxes(1, 2).reshape(b, t, -1)
|
|
return output
|
|
|
|
|
|
def _generate_ltx_freq_grid_float64(positional_embedding_theta: float, positional_embedding_max_pos_count: int,
|
|
inner_dim: int) -> torch.Tensor:
|
|
theta = positional_embedding_theta
|
|
start = 1
|
|
end = theta
|
|
n_elem = 2 * positional_embedding_max_pos_count
|
|
pow_indices = torch.pow(
|
|
theta,
|
|
torch.linspace(
|
|
math.log(start) / math.log(theta),
|
|
math.log(end) / math.log(theta),
|
|
inner_dim // n_elem,
|
|
dtype=torch.float64,
|
|
),
|
|
)
|
|
return (pow_indices * math.pi / 2).to(dtype=torch.float32)
|
|
|
|
|
|
_cached_generate_ltx_freq_grid_float64 = functools.lru_cache(maxsize=5)(_generate_ltx_freq_grid_float64)
|
|
|
|
|
|
def generate_ltx_freq_grid_float64(positional_embedding_theta: float, positional_embedding_max_pos_count: int,
|
|
inner_dim: int) -> torch.Tensor:
|
|
"""Generate an eager-cached, compile-safe float64 LTX-2 rotary grid."""
|
|
if torch.compiler.is_compiling():
|
|
return _generate_ltx_freq_grid_float64(positional_embedding_theta, positional_embedding_max_pos_count,
|
|
inner_dim)
|
|
return _cached_generate_ltx_freq_grid_float64(positional_embedding_theta, positional_embedding_max_pos_count,
|
|
inner_dim)
|
|
|
|
|
|
def _generate_ltx_freq_grid_pytorch(positional_embedding_theta: float, positional_embedding_max_pos_count: int,
|
|
inner_dim: int) -> torch.Tensor:
|
|
theta = positional_embedding_theta
|
|
start = 1
|
|
end = theta
|
|
n_elem = 2 * positional_embedding_max_pos_count
|
|
indices = theta**(torch.linspace(
|
|
math.log(start, theta),
|
|
math.log(end, theta),
|
|
inner_dim // n_elem,
|
|
dtype=torch.float32,
|
|
))
|
|
indices = indices.to(dtype=torch.float32)
|
|
return indices * math.pi / 2
|
|
|
|
|
|
_cached_generate_ltx_freq_grid_pytorch = functools.lru_cache(maxsize=5)(_generate_ltx_freq_grid_pytorch)
|
|
|
|
|
|
def generate_ltx_freq_grid_pytorch(positional_embedding_theta: float, positional_embedding_max_pos_count: int,
|
|
inner_dim: int) -> torch.Tensor:
|
|
"""Generate an eager-cached, compile-safe float32 LTX-2 rotary grid."""
|
|
if torch.compiler.is_compiling():
|
|
return _generate_ltx_freq_grid_pytorch(positional_embedding_theta, positional_embedding_max_pos_count,
|
|
inner_dim)
|
|
return _cached_generate_ltx_freq_grid_pytorch(positional_embedding_theta, positional_embedding_max_pos_count,
|
|
inner_dim)
|
|
|
|
|
|
def _ltx_get_fractional_positions(indices_grid: torch.Tensor, max_pos: list[int]) -> torch.Tensor:
|
|
n_pos_dims = indices_grid.shape[1]
|
|
if n_pos_dims != len(max_pos):
|
|
raise ValueError(f"Number of position dimensions ({n_pos_dims}) must match max_pos length ({len(max_pos)})")
|
|
return torch.stack(
|
|
[indices_grid[:, i] / max_pos[i] for i in range(n_pos_dims)],
|
|
dim=-1,
|
|
)
|
|
|
|
|
|
def _ltx_generate_freqs(indices: torch.Tensor, indices_grid: torch.Tensor, max_pos: list[int],
|
|
use_middle_indices_grid: bool) -> torch.Tensor:
|
|
if use_middle_indices_grid:
|
|
indices_grid_start, indices_grid_end = indices_grid[..., 0], indices_grid[..., 1]
|
|
indices_grid = (indices_grid_start + indices_grid_end) / 2.0
|
|
elif len(indices_grid.shape) == 4:
|
|
indices_grid = indices_grid[..., 0]
|
|
|
|
fractional_positions = _ltx_get_fractional_positions(indices_grid, max_pos)
|
|
indices = indices.to(device=fractional_positions.device)
|
|
freqs = (indices * (fractional_positions.unsqueeze(-1) * 2 - 1)).transpose(-1, -2).flatten(2)
|
|
return freqs
|
|
|
|
|
|
def _ltx_split_freqs_cis(freqs: torch.Tensor, pad_size: int,
|
|
num_attention_heads: int) -> tuple[torch.Tensor, torch.Tensor]:
|
|
cos_freq = freqs.cos()
|
|
sin_freq = freqs.sin()
|
|
|
|
if pad_size != 0:
|
|
cos_padding = torch.ones_like(cos_freq[:, :, :pad_size])
|
|
sin_padding = torch.zeros_like(sin_freq[:, :, :pad_size])
|
|
cos_freq = torch.concatenate([cos_padding, cos_freq], axis=-1)
|
|
sin_freq = torch.concatenate([sin_padding, sin_freq], axis=-1)
|
|
|
|
b = cos_freq.shape[0]
|
|
t = cos_freq.shape[1]
|
|
cos_freq = cos_freq.reshape(b, t, num_attention_heads, -1)
|
|
sin_freq = sin_freq.reshape(b, t, num_attention_heads, -1)
|
|
cos_freq = torch.swapaxes(cos_freq, 1, 2)
|
|
sin_freq = torch.swapaxes(sin_freq, 1, 2)
|
|
return cos_freq, sin_freq
|
|
|
|
|
|
def _ltx_interleaved_freqs_cis(freqs: torch.Tensor, pad_size: int) -> tuple[torch.Tensor, torch.Tensor]:
|
|
cos_freq = freqs.cos().repeat_interleave(2, dim=-1)
|
|
sin_freq = freqs.sin().repeat_interleave(2, dim=-1)
|
|
if pad_size != 0:
|
|
cos_padding = torch.ones_like(cos_freq[:, :, :pad_size])
|
|
sin_padding = torch.zeros_like(cos_freq[:, :, :pad_size])
|
|
cos_freq = torch.cat([cos_padding, cos_freq], dim=-1)
|
|
sin_freq = torch.cat([sin_padding, sin_freq], dim=-1)
|
|
return cos_freq, sin_freq
|
|
|
|
|
|
def precompute_ltx_freqs_cis(
|
|
indices_grid: torch.Tensor,
|
|
dim: int,
|
|
out_dtype: torch.dtype,
|
|
theta: float = 10000.0,
|
|
max_pos: list[int] | None = None,
|
|
use_middle_indices_grid: bool = False,
|
|
num_attention_heads: int = 32,
|
|
rope_type: LTXRopeType = LTXRopeType.INTERLEAVED,
|
|
freq_grid_generator: Callable[[float, int, int], torch.Tensor] = generate_ltx_freq_grid_pytorch,
|
|
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
"""Precompute LTX-2 rotary cos/sin grids for (t, x, y) positions."""
|
|
if max_pos is None:
|
|
max_pos = [20, 2048, 2048]
|
|
|
|
indices = freq_grid_generator(theta, indices_grid.shape[1], dim)
|
|
freqs = _ltx_generate_freqs(indices, indices_grid, max_pos, use_middle_indices_grid)
|
|
|
|
if rope_type == LTXRopeType.SPLIT:
|
|
expected_freqs = dim // 2
|
|
current_freqs = freqs.shape[-1]
|
|
pad_size = expected_freqs - current_freqs
|
|
cos_freq, sin_freq = _ltx_split_freqs_cis(freqs, pad_size, num_attention_heads)
|
|
else:
|
|
n_elem = 2 * indices_grid.shape[1]
|
|
cos_freq, sin_freq = _ltx_interleaved_freqs_cis(freqs, dim % n_elem)
|
|
return cos_freq.to(out_dtype), sin_freq.to(out_dtype)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TransformerArgs:
|
|
"""Pack transformer inputs for LTX-2 blocks."""
|
|
x: torch.Tensor
|
|
context: torch.Tensor
|
|
context_mask: torch.Tensor | None
|
|
timesteps: torch.Tensor
|
|
embedded_timestep: torch.Tensor
|
|
positional_embeddings: torch.Tensor
|
|
cross_positional_embeddings: torch.Tensor | None
|
|
cross_scale_shift_timestep: torch.Tensor | None
|
|
cross_gate_timestep: torch.Tensor | None
|
|
enabled: bool
|
|
# LTX-2.3 cross-attention AdaLN prompt timestep embedding (None for 2.0).
|
|
prompt_timestep: torch.Tensor | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Modality:
|
|
"""Lightweight modality container for LTX-2 inputs."""
|
|
enabled: bool
|
|
latent: torch.Tensor
|
|
timesteps: torch.Tensor
|
|
positions: torch.Tensor
|
|
context: torch.Tensor
|
|
context_mask: torch.Tensor | None = None
|
|
# LTX-2.3 cross-attention AdaLN sigma timestep (None for 2.0).
|
|
sigma: torch.Tensor | None = None
|
|
|
|
|
|
class TransformerArgsPreprocessor:
|
|
"""Prepare LTX-2 transformer inputs (patchify, AdaLN, rope)."""
|
|
|
|
def __init__( # noqa: PLR0913
|
|
self,
|
|
patchify_proj: torch.nn.Linear,
|
|
adaln: AdaLayerNormSingle,
|
|
caption_projection: PixArtAlphaTextProjection,
|
|
inner_dim: int,
|
|
max_pos: list[int],
|
|
num_attention_heads: int,
|
|
use_middle_indices_grid: bool,
|
|
timestep_scale_multiplier: int,
|
|
double_precision_rope: bool,
|
|
positional_embedding_theta: float,
|
|
rope_type: LTXRopeType,
|
|
prompt_adaln: AdaLayerNormSingle | None = None,
|
|
) -> None:
|
|
self.patchify_proj = patchify_proj
|
|
self.adaln = adaln
|
|
self.caption_projection = caption_projection
|
|
self.inner_dim = inner_dim
|
|
self.max_pos = max_pos
|
|
self.num_attention_heads = num_attention_heads
|
|
self.use_middle_indices_grid = use_middle_indices_grid
|
|
self.timestep_scale_multiplier = timestep_scale_multiplier
|
|
self.double_precision_rope = double_precision_rope
|
|
self.positional_embedding_theta = positional_embedding_theta
|
|
self.rope_type = rope_type
|
|
# LTX-2.3 cross-attention AdaLN prompt timestep embedder (None for 2.0).
|
|
self.prompt_adaln = prompt_adaln
|
|
|
|
def _prepare_timestep(
|
|
self,
|
|
timestep: torch.Tensor,
|
|
batch_size: int,
|
|
hidden_dtype: torch.dtype,
|
|
adaln: AdaLayerNormSingle | None = None,
|
|
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
adaln = self.adaln if adaln is None else adaln
|
|
timestep = timestep * self.timestep_scale_multiplier
|
|
timestep, embedded_timestep = adaln(timestep.flatten(), hidden_dtype=hidden_dtype)
|
|
timestep = timestep.view(batch_size, -1, timestep.shape[-1])
|
|
embedded_timestep = embedded_timestep.view(batch_size, -1, embedded_timestep.shape[-1])
|
|
return timestep, embedded_timestep
|
|
|
|
def _prepare_context(
|
|
self,
|
|
context: torch.Tensor,
|
|
x: torch.Tensor,
|
|
attention_mask: torch.Tensor | None = None,
|
|
) -> tuple[torch.Tensor, torch.Tensor | None]:
|
|
batch_size = x.shape[0]
|
|
if context.device != x.device:
|
|
context = context.to(x.device)
|
|
if context.dtype != x.dtype:
|
|
context = context.to(x.dtype)
|
|
if attention_mask is not None and attention_mask.device != x.device:
|
|
attention_mask = attention_mask.to(x.device)
|
|
# When ``caption_proj_before_connector`` is set the caption projection
|
|
# lives in the text encoder, so it is None here and we skip it.
|
|
if self.caption_projection is not None:
|
|
context = self.caption_projection(context)
|
|
context = context.view(batch_size, -1, x.shape[-1])
|
|
return context, attention_mask
|
|
|
|
def _prepare_attention_mask(self, attention_mask: torch.Tensor | None, x_dtype: torch.dtype) -> torch.Tensor | None:
|
|
if attention_mask is None or torch.is_floating_point(attention_mask):
|
|
return attention_mask
|
|
return (attention_mask - 1).to(x_dtype).reshape(
|
|
(attention_mask.shape[0], 1, -1, attention_mask.shape[-1])) * torch.finfo(x_dtype).max
|
|
|
|
def _prepare_positional_embeddings(
|
|
self,
|
|
positions: torch.Tensor,
|
|
inner_dim: int,
|
|
max_pos: list[int],
|
|
use_middle_indices_grid: bool,
|
|
num_attention_heads: int,
|
|
x_dtype: torch.dtype,
|
|
) -> torch.Tensor:
|
|
if self.double_precision_rope:
|
|
freq_grid_generator = generate_ltx_freq_grid_float64
|
|
else:
|
|
freq_grid_generator = generate_ltx_freq_grid_pytorch
|
|
return precompute_ltx_freqs_cis(
|
|
positions,
|
|
dim=inner_dim,
|
|
out_dtype=x_dtype,
|
|
theta=self.positional_embedding_theta,
|
|
max_pos=max_pos,
|
|
use_middle_indices_grid=use_middle_indices_grid,
|
|
num_attention_heads=num_attention_heads,
|
|
rope_type=self.rope_type,
|
|
freq_grid_generator=freq_grid_generator,
|
|
)
|
|
|
|
def prepare(self, modality: Modality) -> TransformerArgs:
|
|
# `rearrange`-produced latent tokens can be non-contiguous with large
|
|
# leading strides. Materialize a contiguous view before Linear to avoid
|
|
# invalid BF16 GEMM descriptors on some CUDA/cuBLAS stacks.
|
|
latent = modality.latent
|
|
if not latent.is_contiguous():
|
|
latent = latent.contiguous()
|
|
x = self.patchify_proj(latent)
|
|
timestep, embedded_timestep = self._prepare_timestep(modality.timesteps, x.shape[0], modality.latent.dtype,
|
|
self.adaln)
|
|
prompt_timestep = None
|
|
if self.prompt_adaln is not None and modality.sigma is not None:
|
|
prompt_timestep, _ = self._prepare_timestep(
|
|
modality.sigma,
|
|
x.shape[0],
|
|
modality.latent.dtype,
|
|
self.prompt_adaln,
|
|
)
|
|
context, attention_mask = self._prepare_context(modality.context, x, modality.context_mask)
|
|
attention_mask = self._prepare_attention_mask(attention_mask, modality.latent.dtype)
|
|
pe = self._prepare_positional_embeddings(
|
|
positions=modality.positions,
|
|
inner_dim=self.inner_dim,
|
|
max_pos=self.max_pos,
|
|
use_middle_indices_grid=self.use_middle_indices_grid,
|
|
num_attention_heads=self.num_attention_heads,
|
|
x_dtype=modality.latent.dtype,
|
|
)
|
|
return TransformerArgs(
|
|
x=x,
|
|
context=context,
|
|
context_mask=attention_mask,
|
|
timesteps=timestep,
|
|
embedded_timestep=embedded_timestep,
|
|
positional_embeddings=pe,
|
|
cross_positional_embeddings=None,
|
|
cross_scale_shift_timestep=None,
|
|
cross_gate_timestep=None,
|
|
enabled=modality.enabled,
|
|
prompt_timestep=prompt_timestep,
|
|
)
|
|
|
|
|
|
class MultiModalTransformerArgsPreprocessor:
|
|
"""Prepare transformer args for audio/video cross-attention paths."""
|
|
|
|
def __init__( # noqa: PLR0913
|
|
self,
|
|
patchify_proj: torch.nn.Linear,
|
|
adaln: AdaLayerNormSingle,
|
|
caption_projection: PixArtAlphaTextProjection,
|
|
cross_scale_shift_adaln: AdaLayerNormSingle,
|
|
cross_gate_adaln: AdaLayerNormSingle,
|
|
inner_dim: int,
|
|
max_pos: list[int],
|
|
num_attention_heads: int,
|
|
cross_pe_max_pos: int,
|
|
use_middle_indices_grid: bool,
|
|
audio_cross_attention_dim: int,
|
|
timestep_scale_multiplier: int,
|
|
double_precision_rope: bool,
|
|
positional_embedding_theta: float,
|
|
rope_type: LTXRopeType,
|
|
av_ca_timestep_scale_multiplier: int,
|
|
prompt_adaln: AdaLayerNormSingle | None = None,
|
|
) -> None:
|
|
self.simple_preprocessor = TransformerArgsPreprocessor(
|
|
patchify_proj=patchify_proj,
|
|
adaln=adaln,
|
|
caption_projection=caption_projection,
|
|
inner_dim=inner_dim,
|
|
max_pos=max_pos,
|
|
num_attention_heads=num_attention_heads,
|
|
use_middle_indices_grid=use_middle_indices_grid,
|
|
timestep_scale_multiplier=timestep_scale_multiplier,
|
|
double_precision_rope=double_precision_rope,
|
|
positional_embedding_theta=positional_embedding_theta,
|
|
rope_type=rope_type,
|
|
prompt_adaln=prompt_adaln,
|
|
)
|
|
self.cross_scale_shift_adaln = cross_scale_shift_adaln
|
|
self.cross_gate_adaln = cross_gate_adaln
|
|
self.cross_pe_max_pos = cross_pe_max_pos
|
|
self.audio_cross_attention_dim = audio_cross_attention_dim
|
|
self.av_ca_timestep_scale_multiplier = av_ca_timestep_scale_multiplier
|
|
|
|
def prepare(self, modality: Modality) -> TransformerArgs:
|
|
transformer_args = self.simple_preprocessor.prepare(modality)
|
|
cross_pe = self.simple_preprocessor._prepare_positional_embeddings(
|
|
positions=modality.positions[:, 0:1, :],
|
|
inner_dim=self.audio_cross_attention_dim,
|
|
max_pos=[self.cross_pe_max_pos],
|
|
use_middle_indices_grid=True,
|
|
num_attention_heads=self.simple_preprocessor.num_attention_heads,
|
|
x_dtype=modality.latent.dtype,
|
|
)
|
|
|
|
cross_scale_shift_timestep, cross_gate_timestep = self._prepare_cross_attention_timestep(
|
|
timestep=modality.timesteps,
|
|
timestep_scale_multiplier=self.simple_preprocessor.timestep_scale_multiplier,
|
|
batch_size=transformer_args.x.shape[0],
|
|
hidden_dtype=modality.latent.dtype,
|
|
)
|
|
|
|
return replace(
|
|
transformer_args,
|
|
cross_positional_embeddings=cross_pe,
|
|
cross_scale_shift_timestep=cross_scale_shift_timestep,
|
|
cross_gate_timestep=cross_gate_timestep,
|
|
)
|
|
|
|
def _prepare_cross_attention_timestep(
|
|
self,
|
|
timestep: torch.Tensor,
|
|
timestep_scale_multiplier: int,
|
|
batch_size: int,
|
|
hidden_dtype: torch.dtype,
|
|
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
timestep = timestep * timestep_scale_multiplier
|
|
|
|
av_ca_factor = self.av_ca_timestep_scale_multiplier / timestep_scale_multiplier
|
|
|
|
scale_shift_timestep, _ = self.cross_scale_shift_adaln(
|
|
timestep.flatten(),
|
|
hidden_dtype=hidden_dtype,
|
|
)
|
|
scale_shift_timestep = scale_shift_timestep.view(batch_size, -1, scale_shift_timestep.shape[-1])
|
|
gate_noise_timestep, _ = self.cross_gate_adaln(
|
|
timestep.flatten() * av_ca_factor,
|
|
hidden_dtype=hidden_dtype,
|
|
)
|
|
gate_noise_timestep = gate_noise_timestep.view(batch_size, -1, gate_noise_timestep.shape[-1])
|
|
|
|
return scale_shift_timestep, gate_noise_timestep
|
|
|
|
|
|
@dataclass
|
|
class TransformerConfig:
|
|
"""Attention/FFN dims for LTX-2 transformer blocks."""
|
|
dim: int
|
|
heads: int
|
|
d_head: int
|
|
context_dim: int
|
|
# LTX-2.3 gated extensions (default OFF == LTX-2.0 behavior).
|
|
apply_gated_attention: bool = False
|
|
cross_attention_adaln: bool = False
|
|
|
|
|
|
class LTXDistributedAttention(DistributedAttention):
|
|
"""LTX-2 specialized DistributedAttention that handles LTX-style RoPE internally."""
|
|
|
|
def __init__(
|
|
self,
|
|
num_heads: int,
|
|
head_size: int,
|
|
rope_type: LTXRopeType,
|
|
num_kv_heads: int | None = None,
|
|
softmax_scale: float | None = None,
|
|
causal: bool = False,
|
|
supported_attention_backends: tuple[AttentionBackendEnum, ...] | None = None,
|
|
prefix: str = "",
|
|
**extra_impl_args,
|
|
) -> None:
|
|
super().__init__(
|
|
num_heads=num_heads,
|
|
head_size=head_size,
|
|
num_kv_heads=num_kv_heads,
|
|
softmax_scale=softmax_scale,
|
|
causal=causal,
|
|
supported_attention_backends=supported_attention_backends,
|
|
prefix=prefix,
|
|
**extra_impl_args,
|
|
)
|
|
self.rope_type = rope_type
|
|
|
|
def forward(
|
|
self,
|
|
q: torch.Tensor,
|
|
k: torch.Tensor,
|
|
v: torch.Tensor,
|
|
original_seq_len: int,
|
|
replicated_q: torch.Tensor | None = None,
|
|
replicated_k: torch.Tensor | None = None,
|
|
replicated_v: torch.Tensor | None = None,
|
|
gate_compress: torch.Tensor | None = None,
|
|
ltx_freqs_cis: tuple[torch.Tensor, torch.Tensor] | None = None,
|
|
) -> tuple[torch.Tensor, torch.Tensor | None]:
|
|
"""Forward pass with LTX-2 style RoPE application.
|
|
|
|
Args:
|
|
q: Query tensor [batch_size, seq_len, num_heads, head_dim]
|
|
k: Key tensor [batch_size, seq_len, num_heads, head_dim]
|
|
v: Value tensor [batch_size, seq_len, num_heads, head_dim]
|
|
replicated_q: Replicated query tensor for text tokens
|
|
replicated_k: Replicated key tensor
|
|
replicated_v: Replicated value tensor
|
|
original_seq_len: Original (unpadded) full sequence length
|
|
ltx_freqs_cis: LTX-2 style RoPE (cos, sin) with shape [B, H, T, D]
|
|
|
|
Returns:
|
|
Tuple of output tensor and optional replicated output
|
|
"""
|
|
assert q.dim() == 4 and k.dim() == 4 and v.dim() == 4, "Expected 4D tensors"
|
|
batch_size, _, num_heads, _ = q.shape
|
|
local_rank = get_sp_parallel_rank()
|
|
world_size = get_sp_world_size()
|
|
|
|
forward_context: ForwardContext = get_forward_context()
|
|
ctx_attn_metadata = forward_context.attn_metadata
|
|
|
|
use_vsa = self.backend == AttentionBackendEnum.VIDEO_SPARSE_ATTN
|
|
|
|
if use_vsa:
|
|
assert (replicated_q is None and replicated_k is None
|
|
and replicated_v is None), "Replicated QKV is not supported for VSA now"
|
|
if gate_compress is None:
|
|
raise ValueError("gate_compress must be provided when using VIDEO_SPARSE_ATTN")
|
|
|
|
qkvg = torch.cat([q, k, v, gate_compress], dim=0) # [4*batch, seq_len, heads, head_dim]
|
|
qkvg = sequence_model_parallel_all_to_all_4D(qkvg, scatter_dim=2, gather_dim=1)
|
|
pad_seq_len = qkvg.shape[1] - original_seq_len
|
|
qkvg = qkvg[:, :original_seq_len, :, :]
|
|
|
|
if ltx_freqs_cis is not None:
|
|
cos, sin = ltx_freqs_cis
|
|
heads_per_rank = num_heads // world_size
|
|
head_start = local_rank * heads_per_rank
|
|
head_end = head_start + heads_per_rank
|
|
cos_local = cos[:, head_start:head_end, :original_seq_len, :]
|
|
sin_local = sin[:, head_start:head_end, :original_seq_len, :]
|
|
qk_part = qkvg[:batch_size * 2].transpose(1, 2)
|
|
qk_part = apply_ltx_rotary_emb_4d(qk_part, (cos_local, sin_local), self.rope_type)
|
|
qkvg[:batch_size * 2] = qk_part.transpose(1, 2)
|
|
|
|
qkvg = self.attn_impl.preprocess_qkv(qkvg, ctx_attn_metadata)
|
|
q, k, v, gate_compress = qkvg.chunk(4, dim=0)
|
|
output = self.attn_impl.forward( # type: ignore[call-arg]
|
|
q, k, v, gate_compress, ctx_attn_metadata)
|
|
output = self.attn_impl.postprocess_output(output, ctx_attn_metadata)
|
|
output = torch.nn.functional.pad(output, (0, 0, 0, 0, 0, pad_seq_len))
|
|
|
|
output = sequence_model_parallel_all_to_all_4D(output, scatter_dim=1, gather_dim=2)
|
|
return output, None
|
|
|
|
# Stack QKV
|
|
qkv = torch.cat([q, k, v], dim=0) # [3*batch, seq_len, num_heads, head_dim]
|
|
|
|
# Redistribute heads across sequence dimension
|
|
qkv = sequence_model_parallel_all_to_all_4D(qkv, scatter_dim=2, gather_dim=1)
|
|
|
|
# After all-to-all, each rank has the full sequence but only a subset of heads
|
|
pad_seq_len = qkv.shape[1] - original_seq_len
|
|
qkv = qkv[:, :original_seq_len, :, :]
|
|
|
|
# Apply LTX-2 style RoPE after all-to-all (when we have full sequence)
|
|
if ltx_freqs_cis is not None:
|
|
cos, sin = ltx_freqs_cis
|
|
heads_per_rank = num_heads // world_size
|
|
head_start = local_rank * heads_per_rank
|
|
head_end = head_start + heads_per_rank
|
|
# Slice to this rank's heads: [B, H/SP, T, D]
|
|
cos_local = cos[:, head_start:head_end, :original_seq_len, :]
|
|
sin_local = sin[:, head_start:head_end, :original_seq_len, :]
|
|
|
|
# Apply RoPE to Q and K together (first 2*batch_size in dim 0)
|
|
qk_part = qkv[:batch_size * 2]
|
|
# Transpose to [2*B, H/SP, T, D] for LTX RoPE application
|
|
qk_part = qk_part.transpose(1, 2)
|
|
qk_part = apply_ltx_rotary_emb_4d(qk_part, (cos_local, sin_local), self.rope_type)
|
|
# Transpose back to [2*B, T, H/SP, D]
|
|
qkv[:batch_size * 2] = qk_part.transpose(1, 2)
|
|
|
|
# Apply backend-specific preprocess_qkv
|
|
qkv = self.attn_impl.preprocess_qkv(qkv, ctx_attn_metadata)
|
|
|
|
# Concatenate with replicated QKV if provided
|
|
if replicated_q is not None:
|
|
assert replicated_k is not None and replicated_v is not None
|
|
replicated_qkv = torch.cat([replicated_q, replicated_k, replicated_v],
|
|
dim=0) # [3, seq_len, num_heads, head_dim]
|
|
heads_per_rank = num_heads // world_size
|
|
replicated_qkv = replicated_qkv[:, :, local_rank * heads_per_rank:(local_rank + 1) * heads_per_rank]
|
|
qkv = torch.cat([qkv, replicated_qkv], dim=1)
|
|
|
|
q, k, v = qkv.chunk(3, dim=0)
|
|
|
|
output = self.attn_impl.forward(q, k, v, ctx_attn_metadata)
|
|
|
|
# Redistribute back if using sequence parallelism
|
|
replicated_output = None
|
|
if replicated_q is not None:
|
|
split_idx = original_seq_len
|
|
replicated_output = output[:, split_idx:]
|
|
output = output[:, :split_idx]
|
|
replicated_output = sequence_model_parallel_all_gather(replicated_output.contiguous(), dim=2)
|
|
|
|
# Apply backend-specific postprocess_output
|
|
output = self.attn_impl.postprocess_output(output, ctx_attn_metadata)
|
|
|
|
output = torch.nn.functional.pad(output, (0, 0, 0, 0, 0, pad_seq_len))
|
|
|
|
output = sequence_model_parallel_all_to_all_4D(output, scatter_dim=1, gather_dim=2)
|
|
|
|
return output, replicated_output
|
|
|
|
|
|
class LTXLocalAttention(LocalAttention):
|
|
"""LTX-2 specialized LocalAttention that handles LTX-style RoPE internally."""
|
|
|
|
def __init__(
|
|
self,
|
|
num_heads: int,
|
|
head_size: int,
|
|
rope_type: LTXRopeType,
|
|
num_kv_heads: int | None = None,
|
|
softmax_scale: float | None = None,
|
|
causal: bool = False,
|
|
supported_attention_backends: tuple[AttentionBackendEnum, ...] | None = None,
|
|
**extra_impl_args,
|
|
) -> None:
|
|
super().__init__(
|
|
num_heads=num_heads,
|
|
head_size=head_size,
|
|
num_kv_heads=num_kv_heads,
|
|
softmax_scale=softmax_scale,
|
|
causal=causal,
|
|
supported_attention_backends=supported_attention_backends,
|
|
**extra_impl_args,
|
|
)
|
|
self.rope_type = rope_type
|
|
|
|
def forward(
|
|
self,
|
|
q: torch.Tensor,
|
|
k: torch.Tensor,
|
|
v: torch.Tensor,
|
|
gate_compress: torch.Tensor | None = None,
|
|
ltx_freqs_cis: tuple[torch.Tensor, torch.Tensor] | None = None,
|
|
ltx_k_freqs_cis: tuple[torch.Tensor, torch.Tensor] | None = None,
|
|
) -> torch.Tensor:
|
|
"""Forward pass with LTX-2 style RoPE application.
|
|
|
|
Args:
|
|
q: Query tensor [batch_size, seq_len, num_heads, head_dim]
|
|
k: Key tensor [batch_size, seq_len, num_heads, head_dim]
|
|
v: Value tensor [batch_size, seq_len, num_heads, head_dim]
|
|
ltx_freqs_cis: LTX-2 style RoPE (cos, sin) for Q with shape [B, H, T, D]
|
|
ltx_k_freqs_cis: LTX-2 style RoPE (cos, sin) for K (if different from Q)
|
|
|
|
Returns:
|
|
Output tensor after local attention
|
|
"""
|
|
assert q.dim() == 4 and k.dim() == 4 and v.dim() == 4, "Expected 4D tensors"
|
|
|
|
forward_context: ForwardContext = get_forward_context()
|
|
ctx_attn_metadata = forward_context.attn_metadata
|
|
|
|
# Apply LTX-2 style RoPE
|
|
if ltx_freqs_cis is not None:
|
|
# Use separate K RoPE if provided (for cross-attention), otherwise use same as Q
|
|
k_freqs = ltx_k_freqs_cis if ltx_k_freqs_cis is not None else ltx_freqs_cis
|
|
# Transpose to [B, H, T, D] for RoPE application
|
|
q = q.transpose(1, 2)
|
|
k = k.transpose(1, 2)
|
|
q = apply_ltx_rotary_emb_4d(q, ltx_freqs_cis, self.rope_type)
|
|
k = apply_ltx_rotary_emb_4d(k, k_freqs, self.rope_type)
|
|
# Transpose back to [B, T, H, D]
|
|
q = q.transpose(1, 2)
|
|
k = k.transpose(1, 2)
|
|
|
|
if self.backend == AttentionBackendEnum.VIDEO_SPARSE_ATTN:
|
|
if gate_compress is None:
|
|
raise ValueError("gate_compress must be provided when using VIDEO_SPARSE_ATTN")
|
|
output = self.attn_impl.forward( # type: ignore[call-arg]
|
|
q, k, v, gate_compress, ctx_attn_metadata)
|
|
else:
|
|
output = self.attn_impl.forward(q, k, v, ctx_attn_metadata)
|
|
return output
|
|
|
|
|
|
class LTXSelfAttention(nn.Module):
|
|
"""LTX-2 attention block with RMSNorm + FastVideo LocalAttention."""
|
|
|
|
def __init__(
|
|
self,
|
|
query_dim: int,
|
|
context_dim: int | None,
|
|
heads: int,
|
|
dim_head: int,
|
|
norm_eps: float,
|
|
rope_type: LTXRopeType,
|
|
supported_attention_backends: tuple[AttentionBackendEnum, ...],
|
|
apply_gated_attention: bool = False,
|
|
quant_config: QuantizationConfig | None = None,
|
|
prefix: str = "",
|
|
) -> None:
|
|
super().__init__()
|
|
inner_dim = dim_head * heads
|
|
context_dim = query_dim if context_dim is None else context_dim
|
|
|
|
self.heads = heads
|
|
self.dim_head = dim_head
|
|
self.rope_type = rope_type
|
|
|
|
# StageAwareRMSNorm is identical to torch.nn.RMSNorm outside the
|
|
# LTX-2.3 refine stage, so default-off behavior matches LTX-2.0.
|
|
self.q_norm = StageAwareRMSNorm(inner_dim, eps=norm_eps)
|
|
self.k_norm = StageAwareRMSNorm(inner_dim, eps=norm_eps)
|
|
self.to_q = ReplicatedLinear(
|
|
query_dim,
|
|
inner_dim,
|
|
bias=True,
|
|
quant_config=quant_config,
|
|
prefix=f"{prefix}.to_q",
|
|
)
|
|
self.to_k = ReplicatedLinear(
|
|
context_dim,
|
|
inner_dim,
|
|
bias=True,
|
|
quant_config=quant_config,
|
|
prefix=f"{prefix}.to_k",
|
|
)
|
|
self.to_v = ReplicatedLinear(
|
|
context_dim,
|
|
inner_dim,
|
|
bias=True,
|
|
quant_config=quant_config,
|
|
prefix=f"{prefix}.to_v",
|
|
)
|
|
# LTX-2.3 gated attention. DISTINCT from the VSA-QAT to_gate_compress
|
|
# gate below: this gate multiplies the *self-attention output* by
|
|
# 2*sigmoid(to_gate_logits(x)). None (default) == LTX-2.0 behavior.
|
|
self.to_gate_logits: ReplicatedLinear | None = None
|
|
if apply_gated_attention:
|
|
self.to_gate_logits = ReplicatedLinear(
|
|
query_dim,
|
|
heads,
|
|
bias=True,
|
|
quant_config=quant_config,
|
|
prefix=f"{prefix}.to_gate_logits",
|
|
)
|
|
self.to_out = nn.ModuleList([
|
|
ReplicatedLinear(
|
|
inner_dim,
|
|
query_dim,
|
|
bias=True,
|
|
quant_config=quant_config,
|
|
prefix=f"{prefix}.to_out",
|
|
),
|
|
nn.Identity(),
|
|
])
|
|
|
|
self.attn = LTXLocalAttention(
|
|
num_heads=heads,
|
|
head_size=dim_head,
|
|
rope_type=rope_type,
|
|
dropout_rate=0,
|
|
softmax_scale=None,
|
|
causal=False,
|
|
supported_attention_backends=supported_attention_backends,
|
|
)
|
|
self.to_gate_compress: ReplicatedLinear | None = None
|
|
if self.attn.backend == AttentionBackendEnum.VIDEO_SPARSE_ATTN:
|
|
self.to_gate_compress = ReplicatedLinear(
|
|
context_dim,
|
|
inner_dim,
|
|
bias=True,
|
|
quant_config=quant_config,
|
|
prefix=f"{prefix}.to_gate_compress",
|
|
)
|
|
self.attn_masked = LTXLocalAttention(
|
|
num_heads=heads,
|
|
head_size=dim_head,
|
|
rope_type=rope_type,
|
|
dropout_rate=0,
|
|
softmax_scale=None,
|
|
causal=False,
|
|
supported_attention_backends=(AttentionBackendEnum.TORCH_SDPA, ),
|
|
default_backend=AttentionBackendEnum.TORCH_SDPA,
|
|
)
|
|
|
|
def forward(
|
|
self,
|
|
x: torch.Tensor,
|
|
context: torch.Tensor | None = None,
|
|
mask: torch.Tensor | None = None,
|
|
pe: tuple[torch.Tensor, torch.Tensor] | None = None,
|
|
k_pe: tuple[torch.Tensor, torch.Tensor] | None = None,
|
|
) -> torch.Tensor:
|
|
context = x if context is None else context
|
|
|
|
q_pre_quantized = (
|
|
self.to_q.quant_method.quantize_input(x) # type: ignore[union-attr]
|
|
if _supports_prequantized_input(self.to_q) else None)
|
|
kv_pre_quantized = None
|
|
if (_supports_prequantized_input(self.to_k) and _supports_prequantized_input(self.to_v)):
|
|
if context is x and q_pre_quantized is not None:
|
|
kv_pre_quantized = q_pre_quantized
|
|
else:
|
|
kv_pre_quantized = self.to_k.quant_method.quantize_input( # type: ignore[union-attr]
|
|
context)
|
|
|
|
q = _linear_project_with_optional_prequant(self.to_q, x, q_pre_quantized)
|
|
k = _linear_project_with_optional_prequant(self.to_k, context, kv_pre_quantized)
|
|
v = _linear_project_with_optional_prequant(self.to_v, context, kv_pre_quantized)
|
|
gate_logits = (self.to_gate_logits(x)[0] if self.to_gate_logits is not None else None)
|
|
gate_compress = (self.to_gate_compress(context)[0] if self.to_gate_compress is not None else None)
|
|
|
|
q = self.q_norm(q)
|
|
k = self.k_norm(k)
|
|
|
|
# RoPE is applied inside LTXLocalAttention
|
|
b, q_len, _ = q.shape
|
|
k_len = k.shape[1]
|
|
q = q.view(b, q_len, self.heads, self.dim_head)
|
|
k = k.view(b, k_len, self.heads, self.dim_head)
|
|
v = v.view(b, k_len, self.heads, self.dim_head)
|
|
if gate_compress is not None:
|
|
gate_compress = gate_compress.view(b, k_len, self.heads, self.dim_head)
|
|
|
|
if mask is not None:
|
|
if mask.ndim == 2:
|
|
mask = mask.unsqueeze(0)
|
|
if mask.ndim == 3:
|
|
mask = mask.unsqueeze(1)
|
|
try:
|
|
forward_ctx = get_forward_context()
|
|
current_timestep = forward_ctx.current_timestep
|
|
forward_batch = forward_ctx.forward_batch
|
|
except AssertionError:
|
|
current_timestep = 0
|
|
forward_batch = None
|
|
attn_metadata = SDPAMetadata(
|
|
current_timestep=current_timestep,
|
|
attn_mask=mask,
|
|
)
|
|
with set_forward_context(
|
|
current_timestep=current_timestep,
|
|
attn_metadata=attn_metadata,
|
|
forward_batch=forward_batch,
|
|
):
|
|
out = self.attn_masked(q, k, v, ltx_freqs_cis=pe, ltx_k_freqs_cis=k_pe)
|
|
else:
|
|
out = self.attn(q, k, v, gate_compress=gate_compress, ltx_freqs_cis=pe, ltx_k_freqs_cis=k_pe)
|
|
# LTX-2.3 gated attention: scale the attention output per-head by
|
|
# 2*sigmoid(gate_logits). No-op for LTX-2.0 (gate_logits is None).
|
|
if gate_logits is not None:
|
|
out = out.view(b, q_len, self.heads, self.dim_head)
|
|
gates = 2.0 * torch.sigmoid(gate_logits).to(out.dtype)
|
|
out = out * gates.unsqueeze(-1)
|
|
out = out.reshape(b, q_len, -1)
|
|
out = self.to_out[0](out)[0]
|
|
return self.to_out[1](out)
|
|
|
|
|
|
class LTXDistributedSelfAttention(nn.Module):
|
|
"""LTX-2 attention block with RMSNorm + LTXDistributedAttention for SP."""
|
|
|
|
def __init__(
|
|
self,
|
|
query_dim: int,
|
|
context_dim: int | None,
|
|
heads: int,
|
|
dim_head: int,
|
|
norm_eps: float,
|
|
rope_type: LTXRopeType,
|
|
supported_attention_backends: tuple[AttentionBackendEnum, ...],
|
|
apply_gated_attention: bool = False,
|
|
quant_config: QuantizationConfig | None = None,
|
|
prefix: str = "",
|
|
) -> None:
|
|
super().__init__()
|
|
inner_dim = dim_head * heads
|
|
context_dim = query_dim if context_dim is None else context_dim
|
|
|
|
self.heads = heads
|
|
self.dim_head = dim_head
|
|
self.rope_type = rope_type
|
|
|
|
# StageAwareRMSNorm == torch.nn.RMSNorm outside the LTX-2.3 refine
|
|
# stage, preserving LTX-2.0 numerics by default.
|
|
self.q_norm = StageAwareRMSNorm(inner_dim, eps=norm_eps)
|
|
self.k_norm = StageAwareRMSNorm(inner_dim, eps=norm_eps)
|
|
self.to_q = ReplicatedLinear(
|
|
query_dim,
|
|
inner_dim,
|
|
bias=True,
|
|
quant_config=quant_config,
|
|
prefix=f"{prefix}.to_q",
|
|
)
|
|
self.to_k = ReplicatedLinear(
|
|
context_dim,
|
|
inner_dim,
|
|
bias=True,
|
|
quant_config=quant_config,
|
|
prefix=f"{prefix}.to_k",
|
|
)
|
|
self.to_v = ReplicatedLinear(
|
|
context_dim,
|
|
inner_dim,
|
|
bias=True,
|
|
quant_config=quant_config,
|
|
prefix=f"{prefix}.to_v",
|
|
)
|
|
# LTX-2.3 gated attention (distinct from VSA-QAT to_gate_compress).
|
|
self.to_gate_logits: ReplicatedLinear | None = None
|
|
if apply_gated_attention:
|
|
self.to_gate_logits = ReplicatedLinear(
|
|
query_dim,
|
|
heads,
|
|
bias=True,
|
|
quant_config=quant_config,
|
|
prefix=f"{prefix}.to_gate_logits",
|
|
)
|
|
self.to_out = nn.ModuleList([
|
|
ReplicatedLinear(
|
|
inner_dim,
|
|
query_dim,
|
|
bias=True,
|
|
quant_config=quant_config,
|
|
prefix=f"{prefix}.to_out",
|
|
),
|
|
nn.Identity(),
|
|
])
|
|
|
|
self.attn = LTXDistributedAttention(
|
|
num_heads=heads,
|
|
head_size=dim_head,
|
|
rope_type=rope_type,
|
|
causal=False,
|
|
supported_attention_backends=supported_attention_backends,
|
|
prefix=f"{prefix}.attn",
|
|
)
|
|
self.to_gate_compress: ReplicatedLinear | None = None
|
|
if self.attn.backend == AttentionBackendEnum.VIDEO_SPARSE_ATTN:
|
|
self.to_gate_compress = ReplicatedLinear(
|
|
context_dim,
|
|
inner_dim,
|
|
bias=True,
|
|
quant_config=quant_config,
|
|
prefix=f"{prefix}.to_gate_compress",
|
|
)
|
|
|
|
def forward(
|
|
self,
|
|
x: torch.Tensor,
|
|
context: torch.Tensor | None = None,
|
|
pe: tuple[torch.Tensor, torch.Tensor] | None = None,
|
|
k_pe: tuple[torch.Tensor, torch.Tensor] | None = None,
|
|
original_seq_len: int | None = None,
|
|
) -> torch.Tensor:
|
|
"""Forward pass for distributed self-attention.
|
|
|
|
Args:
|
|
x: Query tensor [B, L, C]
|
|
context: Key/Value tensor [B, L, C], or None for self-attention
|
|
pe: Rotary position embeddings for Q (cos, sin) with shape [B, H, T_full, D]
|
|
NOTE: For SP, this must be the FULL sequence RoPE, not sharded!
|
|
k_pe: Rotary position embeddings for K (cos, sin), or None to use pe
|
|
original_seq_len: Original (unpadded) full sequence length
|
|
"""
|
|
context = x if context is None else context
|
|
|
|
q_pre_quantized = (
|
|
self.to_q.quant_method.quantize_input(x) # type: ignore[union-attr]
|
|
if _supports_prequantized_input(self.to_q) else None)
|
|
kv_pre_quantized = None
|
|
if (_supports_prequantized_input(self.to_k) and _supports_prequantized_input(self.to_v)):
|
|
if context is x and q_pre_quantized is not None:
|
|
kv_pre_quantized = q_pre_quantized
|
|
else:
|
|
kv_pre_quantized = self.to_k.quant_method.quantize_input( # type: ignore[union-attr]
|
|
context)
|
|
|
|
q = _linear_project_with_optional_prequant(self.to_q, x, q_pre_quantized)
|
|
k = _linear_project_with_optional_prequant(self.to_k, context, kv_pre_quantized)
|
|
v = _linear_project_with_optional_prequant(self.to_v, context, kv_pre_quantized)
|
|
gate_logits = (self.to_gate_logits(x)[0] if self.to_gate_logits is not None else None)
|
|
gate_compress = (self.to_gate_compress(context)[0] if self.to_gate_compress is not None else None)
|
|
|
|
q = self.q_norm(q)
|
|
k = self.k_norm(k)
|
|
|
|
# RoPE is applied inside LTXDistributedAttention AFTER the all-to-all,
|
|
# when each rank has the full sequence (but subset of heads).
|
|
b, q_len, _ = q.shape
|
|
k_len = k.shape[1]
|
|
q = q.view(b, q_len, self.heads, self.dim_head)
|
|
k = k.view(b, k_len, self.heads, self.dim_head)
|
|
v = v.view(b, k_len, self.heads, self.dim_head)
|
|
if gate_compress is not None:
|
|
gate_compress = gate_compress.view(b, k_len, self.heads, self.dim_head)
|
|
|
|
# Pass full RoPE to distributed attention - it will apply after all-to-all
|
|
# and slice to this rank's heads
|
|
if original_seq_len is None:
|
|
raise ValueError("original_seq_len must be provided for distributed attention")
|
|
out, _ = self.attn(
|
|
q,
|
|
k,
|
|
v,
|
|
original_seq_len,
|
|
gate_compress=gate_compress,
|
|
ltx_freqs_cis=pe,
|
|
)
|
|
|
|
# LTX-2.3 gated attention (no-op for LTX-2.0).
|
|
if gate_logits is not None:
|
|
out = out.view(b, q_len, self.heads, self.dim_head)
|
|
gates = 2.0 * torch.sigmoid(gate_logits).to(out.dtype)
|
|
out = out * gates.unsqueeze(-1)
|
|
out = out.reshape(b, q_len, -1)
|
|
out = self.to_out[0](out)[0]
|
|
return self.to_out[1](out)
|
|
|
|
|
|
class BasicAVTransformerBlock(torch.nn.Module):
|
|
"""LTX-2 transformer block (audio/video + cross attention + FFN)."""
|
|
|
|
def __init__(
|
|
self,
|
|
idx: int,
|
|
video: TransformerConfig | None = None,
|
|
audio: TransformerConfig | None = None,
|
|
rope_type: LTXRopeType = LTXRopeType.INTERLEAVED,
|
|
norm_eps: float = 1e-6,
|
|
use_distributed_attention: bool = False,
|
|
cross_attention_adaln: bool = False,
|
|
stg_block_idx: int = 29,
|
|
quant_config: QuantizationConfig | None = None,
|
|
prefix: str = "",
|
|
):
|
|
super().__init__()
|
|
self.idx = idx
|
|
self.use_distributed_attention = use_distributed_attention
|
|
# LTX-2.3 cross-attention AdaLN + per-sample STG (defaults reproduce 2.0).
|
|
self.cross_attention_adaln = cross_attention_adaln
|
|
self.stg_block_idx = stg_block_idx
|
|
|
|
# Choose attention class based on SP mode
|
|
# Self-attention and audio-video cross-attention use DistributedAttention when SP > 1
|
|
# Text cross-attention uses LocalAttention (text embeddings are replicated)
|
|
SelfAttnCls = LTXDistributedSelfAttention if use_distributed_attention else LTXSelfAttention
|
|
CrossAttnCls = LTXSelfAttention # Text cross-attention is always local
|
|
video_attn_backends = (
|
|
AttentionBackendEnum.FLASH_ATTN,
|
|
AttentionBackendEnum.TORCH_SDPA,
|
|
AttentionBackendEnum.ATTN_QAT_INFER,
|
|
AttentionBackendEnum.ATTN_QAT_TRAIN,
|
|
)
|
|
video_self_attn_backends = video_attn_backends + (AttentionBackendEnum.VIDEO_SPARSE_ATTN, )
|
|
audio_attn_backends = (
|
|
AttentionBackendEnum.FLASH_ATTN,
|
|
AttentionBackendEnum.TORCH_SDPA,
|
|
)
|
|
|
|
if video is not None:
|
|
# Video self-attention - use distributed when SP > 1
|
|
self.attn1 = SelfAttnCls(
|
|
query_dim=video.dim,
|
|
context_dim=None,
|
|
heads=video.heads,
|
|
dim_head=video.d_head,
|
|
norm_eps=norm_eps,
|
|
rope_type=rope_type,
|
|
supported_attention_backends=video_self_attn_backends,
|
|
apply_gated_attention=video.apply_gated_attention,
|
|
quant_config=quant_config,
|
|
prefix=f"{prefix}.blocks.{idx}.attn1",
|
|
)
|
|
# Text cross-attention - always local (text is replicated)
|
|
self.attn2 = CrossAttnCls(
|
|
query_dim=video.dim,
|
|
context_dim=video.context_dim,
|
|
heads=video.heads,
|
|
dim_head=video.d_head,
|
|
norm_eps=norm_eps,
|
|
rope_type=rope_type,
|
|
supported_attention_backends=video_attn_backends,
|
|
apply_gated_attention=video.apply_gated_attention,
|
|
quant_config=quant_config,
|
|
prefix=f"{prefix}.blocks.{idx}.attn2",
|
|
)
|
|
self.ff = FeedForward(
|
|
video.dim,
|
|
dim_out=video.dim,
|
|
quant_config=quant_config,
|
|
prefix=f"{prefix}.blocks.{idx}",
|
|
)
|
|
# 6 rows for LTX-2.0; 9 rows when cross_attention_adaln adds the
|
|
# text cross-attention shift/scale/gate.
|
|
video_sst_size = adaln_embedding_coefficient(cross_attention_adaln)
|
|
self.scale_shift_table = torch.nn.Parameter(torch.empty(video_sst_size, video.dim))
|
|
|
|
if audio is not None:
|
|
# Audio self-attention - use distributed when SP > 1
|
|
self.audio_attn1 = SelfAttnCls(
|
|
query_dim=audio.dim,
|
|
context_dim=None,
|
|
heads=audio.heads,
|
|
dim_head=audio.d_head,
|
|
norm_eps=norm_eps,
|
|
rope_type=rope_type,
|
|
supported_attention_backends=audio_attn_backends,
|
|
apply_gated_attention=audio.apply_gated_attention,
|
|
quant_config=quant_config,
|
|
prefix=f"{prefix}.blocks.{idx}.audio_attn1",
|
|
)
|
|
# Text cross-attention - always local (text is replicated)
|
|
self.audio_attn2 = CrossAttnCls(
|
|
query_dim=audio.dim,
|
|
context_dim=audio.context_dim,
|
|
heads=audio.heads,
|
|
dim_head=audio.d_head,
|
|
norm_eps=norm_eps,
|
|
rope_type=rope_type,
|
|
supported_attention_backends=audio_attn_backends,
|
|
apply_gated_attention=audio.apply_gated_attention,
|
|
quant_config=quant_config,
|
|
prefix=f"{prefix}.blocks.{idx}.audio_attn2",
|
|
)
|
|
self.audio_ff = FeedForward(
|
|
audio.dim,
|
|
dim_out=audio.dim,
|
|
quant_config=quant_config,
|
|
prefix=f"{prefix}.blocks.{idx}.audio",
|
|
)
|
|
audio_sst_size = adaln_embedding_coefficient(cross_attention_adaln)
|
|
self.audio_scale_shift_table = torch.nn.Parameter(torch.empty(audio_sst_size, audio.dim))
|
|
|
|
if audio is not None and video is not None:
|
|
# Audio-to-video cross-attention
|
|
# Uses local attention - context is gathered from all SP ranks in forward()
|
|
self.audio_to_video_attn = LTXSelfAttention(
|
|
query_dim=video.dim,
|
|
context_dim=audio.dim,
|
|
heads=audio.heads,
|
|
dim_head=audio.d_head,
|
|
norm_eps=norm_eps,
|
|
rope_type=rope_type,
|
|
supported_attention_backends=audio_attn_backends,
|
|
apply_gated_attention=video.apply_gated_attention,
|
|
quant_config=quant_config,
|
|
prefix=f"{prefix}.blocks.{idx}.audio_to_video_attn",
|
|
)
|
|
# Video-to-audio cross-attention
|
|
# Uses local attention - context is gathered from all SP ranks in forward()
|
|
self.video_to_audio_attn = LTXSelfAttention(
|
|
query_dim=audio.dim,
|
|
context_dim=video.dim,
|
|
heads=audio.heads,
|
|
dim_head=audio.d_head,
|
|
norm_eps=norm_eps,
|
|
rope_type=rope_type,
|
|
supported_attention_backends=audio_attn_backends,
|
|
apply_gated_attention=audio.apply_gated_attention,
|
|
quant_config=quant_config,
|
|
prefix=f"{prefix}.blocks.{idx}.video_to_audio_attn",
|
|
)
|
|
self.scale_shift_table_a2v_ca_audio = torch.nn.Parameter(torch.empty(5, audio.dim))
|
|
self.scale_shift_table_a2v_ca_video = torch.nn.Parameter(torch.empty(5, video.dim))
|
|
|
|
# LTX-2.3 cross-attention AdaLN prompt scale/shift tables (only created
|
|
# when cross_attention_adaln is enabled, so absent for LTX-2.0).
|
|
if self.cross_attention_adaln and video is not None:
|
|
self.prompt_scale_shift_table = torch.nn.Parameter(torch.empty(2, video.dim))
|
|
if self.cross_attention_adaln and audio is not None:
|
|
self.audio_prompt_scale_shift_table = torch.nn.Parameter(torch.empty(2, audio.dim))
|
|
|
|
self.norm_eps = norm_eps
|
|
|
|
def _register_fsdp_backward_hooks_on_output(self, vx, ax):
|
|
"""Register backward hooks on output tensors to trigger FSDP2 unshard.
|
|
|
|
FSDP2's module-level backward hooks don't fire when the module returns
|
|
dataclass outputs. We must register hooks directly on the output tensors.
|
|
"""
|
|
if not hasattr(self, 'unshard'):
|
|
return # Not wrapped by FSDP2
|
|
|
|
def make_unshard_hook():
|
|
|
|
def hook(grad):
|
|
self.unshard()
|
|
return grad
|
|
|
|
return hook
|
|
|
|
if vx is not None and vx.requires_grad:
|
|
vx.register_hook(make_unshard_hook())
|
|
if ax is not None and ax.requires_grad:
|
|
ax.register_hook(make_unshard_hook())
|
|
|
|
def get_ada_values(self, scale_shift_table: torch.Tensor, batch_size: int, timestep: torch.Tensor,
|
|
indices: slice) -> tuple[torch.Tensor, ...]:
|
|
num_ada_params = scale_shift_table.shape[0]
|
|
ada_values = (
|
|
scale_shift_table[indices].unsqueeze(0).unsqueeze(0).to(device=timestep.device, dtype=timestep.dtype) +
|
|
timestep.reshape(batch_size, timestep.shape[1], num_ada_params, -1)[:, :, indices, :]).unbind(dim=2)
|
|
return ada_values
|
|
|
|
def get_av_ca_ada_values(
|
|
self,
|
|
scale_shift_table: torch.Tensor,
|
|
batch_size: int,
|
|
scale_shift_timestep: torch.Tensor,
|
|
gate_timestep: torch.Tensor,
|
|
num_scale_shift_values: int = 4,
|
|
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
|
|
scale_shift_ada_values = self.get_ada_values(scale_shift_table[:num_scale_shift_values, :], batch_size,
|
|
scale_shift_timestep, slice(None, None))
|
|
gate_ada_values = self.get_ada_values(scale_shift_table[num_scale_shift_values:, :], batch_size, gate_timestep,
|
|
slice(None, None))
|
|
|
|
scale_shift_chunks = [t.squeeze(2) for t in scale_shift_ada_values]
|
|
gate_ada_values = [t.squeeze(2) for t in gate_ada_values]
|
|
|
|
return (*scale_shift_chunks, *gate_ada_values)
|
|
|
|
def _apply_text_cross_attention(
|
|
self,
|
|
x: torch.Tensor,
|
|
context: torch.Tensor,
|
|
attn: Callable,
|
|
scale_shift_table: torch.Tensor,
|
|
prompt_scale_shift_table: torch.Tensor | None,
|
|
timestep: torch.Tensor,
|
|
prompt_timestep: torch.Tensor | None,
|
|
context_mask: torch.Tensor | None,
|
|
) -> torch.Tensor:
|
|
"""Text cross-attention with optional LTX-2.3 cross-attention AdaLN.
|
|
|
|
When ``cross_attention_adaln`` is False this is exactly the LTX-2.0
|
|
path: ``attn(rms_norm(x), context=context, mask=context_mask)``.
|
|
"""
|
|
if self.cross_attention_adaln:
|
|
# The 3 extra AdaLN rows (6..9) carry text cross-attn shift/scale/gate.
|
|
shift_q, scale_q, gate = self.get_ada_values(scale_shift_table, x.shape[0], timestep, slice(6, 9))
|
|
return apply_cross_attention_adaln(
|
|
x=x,
|
|
context=context,
|
|
attn=attn,
|
|
q_shift=shift_q,
|
|
q_scale=scale_q,
|
|
q_gate=gate,
|
|
prompt_scale_shift_table=prompt_scale_shift_table,
|
|
prompt_timestep=prompt_timestep,
|
|
context_mask=context_mask,
|
|
norm_eps=self.norm_eps,
|
|
)
|
|
return attn(
|
|
_rms_norm_dispatch(x, eps=self.norm_eps),
|
|
context=context,
|
|
mask=context_mask,
|
|
)
|
|
|
|
def forward(
|
|
self,
|
|
video: TransformerArgs | None,
|
|
audio: TransformerArgs | None,
|
|
video_original_seq_len: int | None = None,
|
|
audio_original_seq_len: int | None = None,
|
|
skip_cross_modal_attn: bool = False,
|
|
skip_video_self_attn: bool | torch.Tensor = False,
|
|
skip_audio_self_attn: bool | torch.Tensor = False,
|
|
) -> tuple[TransformerArgs | None, TransformerArgs | None]:
|
|
"""Forward pass for transformer block.
|
|
|
|
Args:
|
|
video: Video transformer args
|
|
audio: Audio transformer args
|
|
video_original_seq_len: Original unpadded video sequence length
|
|
audio_original_seq_len: Original unpadded audio sequence length
|
|
skip_cross_modal_attn: If True, skip A2V and V2A cross-modal
|
|
attention (used for the modality-isolated CFG pass).
|
|
skip_video_self_attn: If True, skip video self-attention
|
|
in this block (used for STG perturbed pass).
|
|
skip_audio_self_attn: If True, skip audio self-attention
|
|
in this block (used for STG perturbed pass).
|
|
"""
|
|
vx = video.x if video is not None else None
|
|
ax = audio.x if audio is not None else None
|
|
|
|
run_vx = video is not None and video.enabled and vx.numel() > 0
|
|
run_ax = audio is not None and audio.enabled and ax.numel() > 0
|
|
run_a2v = run_vx and run_ax
|
|
run_v2a = run_ax and run_vx
|
|
|
|
def _build_attn_keep_mask(
|
|
values: torch.Tensor,
|
|
skip_flag: bool | torch.Tensor,
|
|
) -> torch.Tensor:
|
|
"""STG keep-mask for self-attention.
|
|
|
|
Returns a per-sample multiplier (1 keep, 0 perturb) broadcastable
|
|
over ``values``. Applied unconditionally: the eager caller
|
|
(``_process_transformer_blocks``) enforces that only the
|
|
configured ``stg_block_idx`` block receives a truthy flag.
|
|
Supports both the LTX-2.0 bool flag and an LTX-2.3 per-sample
|
|
tensor ``[B]`` (1/True == perturb). When skip_flag is False/0 the
|
|
multiplier is all ones, reproducing the un-skipped LTX-2.0 path.
|
|
"""
|
|
bsz = values.shape[0]
|
|
keep = torch.ones((bsz, ), device=values.device, dtype=values.dtype)
|
|
# The ``idx == stg_block_idx`` gate lives in the eager
|
|
# ``_process_transformer_blocks`` caller, not here: reading the
|
|
# per-instance ``self.idx`` inside this compiled forward forces
|
|
# torch.compile to specialize a distinct graph per block (48x),
|
|
# blowing the Dynamo recompile limit and defeating regional
|
|
# compilation. The caller only passes a truthy ``skip_flag`` to the
|
|
# configured STG block, so applying it unconditionally here is
|
|
# equivalent while keeping the graph identical across all 48 blocks.
|
|
if torch.is_tensor(skip_flag):
|
|
if skip_flag.ndim == 0:
|
|
perturb = skip_flag.reshape(1).expand(bsz)
|
|
else:
|
|
if skip_flag.shape[0] != bsz:
|
|
raise ValueError("Per-sample STG mask batch size mismatch: "
|
|
f"got {skip_flag.shape[0]}, expected {bsz}")
|
|
perturb = skip_flag.reshape(bsz)
|
|
keep = 1.0 - perturb.to(device=values.device, dtype=values.dtype)
|
|
elif bool(skip_flag):
|
|
keep.zero_()
|
|
return keep.view(bsz, *([1] * (values.ndim - 1)))
|
|
|
|
if run_vx:
|
|
vshift_msa, vscale_msa, vgate_msa = self.get_ada_values(self.scale_shift_table, vx.shape[0],
|
|
video.timesteps, slice(0, 3))
|
|
norm_vx = _rms_norm_dispatch(vx, eps=self.norm_eps) * (1 + vscale_msa) + vshift_msa
|
|
video_self_attn_mask = _build_attn_keep_mask(vx, skip_video_self_attn)
|
|
if self.use_distributed_attention:
|
|
vx_attn1_out = self.attn1(
|
|
norm_vx,
|
|
pe=video.positional_embeddings,
|
|
original_seq_len=video_original_seq_len,
|
|
)
|
|
else:
|
|
vx_attn1_out = self.attn1(norm_vx, pe=video.positional_embeddings)
|
|
vx = vx + vx_attn1_out * vgate_msa * video_self_attn_mask
|
|
# Text cross-attention: no SP mask needed (text is replicated).
|
|
vx = vx + self._apply_text_cross_attention(
|
|
x=vx,
|
|
context=video.context,
|
|
attn=self.attn2,
|
|
scale_shift_table=self.scale_shift_table,
|
|
prompt_scale_shift_table=getattr(self, "prompt_scale_shift_table", None),
|
|
timestep=video.timesteps,
|
|
prompt_timestep=video.prompt_timestep,
|
|
context_mask=video.context_mask,
|
|
)
|
|
|
|
if run_ax:
|
|
ashift_msa, ascale_msa, agate_msa = self.get_ada_values(self.audio_scale_shift_table, ax.shape[0],
|
|
audio.timesteps, slice(0, 3))
|
|
norm_ax = _rms_norm_dispatch(ax, eps=self.norm_eps) * (1 + ascale_msa) + ashift_msa
|
|
audio_self_attn_mask = _build_attn_keep_mask(ax, skip_audio_self_attn)
|
|
if self.use_distributed_attention:
|
|
ax_attn1_out = self.audio_attn1(
|
|
norm_ax,
|
|
pe=audio.positional_embeddings,
|
|
original_seq_len=audio_original_seq_len,
|
|
)
|
|
else:
|
|
ax_attn1_out = self.audio_attn1(norm_ax, pe=audio.positional_embeddings)
|
|
ax = ax + ax_attn1_out * agate_msa * audio_self_attn_mask
|
|
# Text cross-attention: no SP mask needed (text is replicated).
|
|
ax = ax + self._apply_text_cross_attention(
|
|
x=ax,
|
|
context=audio.context,
|
|
attn=self.audio_attn2,
|
|
scale_shift_table=self.audio_scale_shift_table,
|
|
prompt_scale_shift_table=getattr(self, "audio_prompt_scale_shift_table", None),
|
|
timestep=audio.timesteps,
|
|
prompt_timestep=audio.prompt_timestep,
|
|
context_mask=audio.context_mask,
|
|
)
|
|
|
|
if (run_a2v or run_v2a) and not skip_cross_modal_attn:
|
|
vx_norm3 = _rms_norm_dispatch(vx, eps=self.norm_eps)
|
|
ax_norm3 = _rms_norm_dispatch(ax, eps=self.norm_eps)
|
|
|
|
(
|
|
scale_ca_audio_hidden_states_a2v,
|
|
shift_ca_audio_hidden_states_a2v,
|
|
scale_ca_audio_hidden_states_v2a,
|
|
shift_ca_audio_hidden_states_v2a,
|
|
gate_out_v2a,
|
|
) = self.get_av_ca_ada_values(
|
|
self.scale_shift_table_a2v_ca_audio,
|
|
ax.shape[0],
|
|
audio.cross_scale_shift_timestep,
|
|
audio.cross_gate_timestep,
|
|
)
|
|
|
|
(
|
|
scale_ca_video_hidden_states_a2v,
|
|
shift_ca_video_hidden_states_a2v,
|
|
scale_ca_video_hidden_states_v2a,
|
|
shift_ca_video_hidden_states_v2a,
|
|
gate_out_a2v,
|
|
) = self.get_av_ca_ada_values(
|
|
self.scale_shift_table_a2v_ca_video,
|
|
vx.shape[0],
|
|
video.cross_scale_shift_timestep,
|
|
video.cross_gate_timestep,
|
|
)
|
|
|
|
if run_a2v:
|
|
vx_scaled = vx_norm3 * (1 + scale_ca_video_hidden_states_a2v) + shift_ca_video_hidden_states_a2v
|
|
ax_scaled = ax_norm3 * (1 + scale_ca_audio_hidden_states_a2v) + shift_ca_audio_hidden_states_a2v
|
|
# Audio-to-video cross-attention: Q from video (sharded), K/V from audio
|
|
# For SP, gather audio context from all ranks before cross-attention
|
|
if self.use_distributed_attention:
|
|
# Gather audio context from all SP ranks
|
|
ax_context = sequence_model_parallel_all_gather(ax_scaled, dim=1)
|
|
# Video Q: video tokens divide evenly (adjusted upstream), so simple slicing works
|
|
video_q_pe = None
|
|
if video.cross_positional_embeddings is not None:
|
|
sp_rank = get_sp_parallel_rank()
|
|
local_seq_len = vx_scaled.shape[1]
|
|
pe_tuple = video.cross_positional_embeddings
|
|
start_idx = sp_rank * local_seq_len
|
|
end_idx = start_idx + local_seq_len
|
|
video_q_pe = tuple(pe[:, :, start_idx:end_idx, :] for pe in pe_tuple)
|
|
# Audio K: gathered context may have padding, trim to original length
|
|
audio_k_pe = audio.cross_positional_embeddings
|
|
if audio_k_pe is not None:
|
|
original_audio_len = audio_k_pe[0].shape[2]
|
|
ax_context = ax_context[:, :original_audio_len, :]
|
|
vx = vx + (self.audio_to_video_attn(
|
|
vx_scaled,
|
|
context=ax_context,
|
|
pe=video_q_pe,
|
|
k_pe=audio_k_pe,
|
|
) * gate_out_a2v)
|
|
else:
|
|
ax_context = ax_scaled
|
|
video_q_pe = video.cross_positional_embeddings
|
|
audio_k_pe = audio.cross_positional_embeddings
|
|
vx = vx + (self.audio_to_video_attn(
|
|
vx_scaled,
|
|
context=ax_context,
|
|
pe=video_q_pe,
|
|
k_pe=audio_k_pe,
|
|
) * gate_out_a2v)
|
|
|
|
if run_v2a:
|
|
ax_scaled = ax_norm3 * (1 + scale_ca_audio_hidden_states_v2a) + shift_ca_audio_hidden_states_v2a
|
|
vx_scaled = vx_norm3 * (1 + scale_ca_video_hidden_states_v2a) + shift_ca_video_hidden_states_v2a
|
|
# Video-to-audio cross-attention: Q from audio, K/V from video
|
|
# For SP, gather both modalities and run cross-attention on full sequences
|
|
# (audio is short, so no need to keep it sharded for cross-attention)
|
|
if self.use_distributed_attention:
|
|
# Gather both modalities to full sequences
|
|
ax_full = sequence_model_parallel_all_gather(ax_scaled, dim=1)
|
|
vx_context = sequence_model_parallel_all_gather(vx_scaled, dim=1)
|
|
# Use full positional embeddings directly
|
|
audio_q_pe = audio.cross_positional_embeddings
|
|
video_k_pe = video.cross_positional_embeddings
|
|
# Trim gathered tensors to original lengths (may have padding from sharding)
|
|
if audio_q_pe is not None:
|
|
original_audio_len = audio_q_pe[0].shape[2]
|
|
ax_full = ax_full[:, :original_audio_len, :]
|
|
if video_k_pe is not None:
|
|
original_video_len = video_k_pe[0].shape[2]
|
|
vx_context = vx_context[:, :original_video_len, :]
|
|
# Run cross-attention on full audio
|
|
v2a_out_full = self.video_to_audio_attn(
|
|
ax_full,
|
|
context=vx_context,
|
|
pe=audio_q_pe,
|
|
k_pe=video_k_pe,
|
|
)
|
|
# Shard the output back to match ax's sharding
|
|
v2a_out, _ = sequence_model_parallel_shard(v2a_out_full, dim=1)
|
|
ax = ax + v2a_out * gate_out_v2a
|
|
else:
|
|
vx_context = vx_scaled
|
|
audio_q_pe = audio.cross_positional_embeddings
|
|
video_k_pe = video.cross_positional_embeddings
|
|
ax = ax + (self.video_to_audio_attn(
|
|
ax_scaled,
|
|
context=vx_context,
|
|
pe=audio_q_pe,
|
|
k_pe=video_k_pe,
|
|
) * gate_out_v2a)
|
|
|
|
if run_vx:
|
|
# slice(3, 6) selects the FFN shift/scale/gate. Identical to
|
|
# slice(3, None) for the 6-row LTX-2.0 table, but correct for the
|
|
# 9-row cross_attention_adaln table (rows 6..9 are the cross-attn).
|
|
vshift_mlp, vscale_mlp, vgate_mlp = self.get_ada_values(self.scale_shift_table, vx.shape[0],
|
|
video.timesteps, slice(3, 6))
|
|
vx_scaled = _rms_norm_dispatch(vx, eps=self.norm_eps) * (1 + vscale_mlp) + vshift_mlp
|
|
vx = vx + self.ff(vx_scaled) * vgate_mlp
|
|
|
|
if run_ax:
|
|
ashift_mlp, ascale_mlp, agate_mlp = self.get_ada_values(self.audio_scale_shift_table, ax.shape[0],
|
|
audio.timesteps, slice(3, 6))
|
|
ax_scaled = _rms_norm_dispatch(ax, eps=self.norm_eps) * (1 + ascale_mlp) + ashift_mlp
|
|
ax = ax + self.audio_ff(ax_scaled) * agate_mlp
|
|
|
|
# Debug-only: reading ``self.idx`` (and .item() syncs) here means
|
|
# enabling this env var re-specializes the compiled graph per block,
|
|
# defeating the shared-graph regional compilation.
|
|
if os.getenv("LTX2_PIPELINE_DEBUG_LOG", "0") == "1":
|
|
video_sum = vx.float().sum().item() if vx is not None else 0.0
|
|
audio_sum = ax.float().sum().item() if ax is not None else 0.0
|
|
_debug_block_log_line(f"fastvideo:block={self.idx}:video_sum={video_sum:.6f} "
|
|
f"audio_sum={audio_sum:.6f}")
|
|
|
|
# Register FSDP2 backward hooks on output tensors (module-level hooks don't
|
|
# fire for dataclass outputs, so we must hook the tensors directly)
|
|
self._register_fsdp_backward_hooks_on_output(vx, ax)
|
|
|
|
return (
|
|
replace(video, x=vx) if video is not None else None,
|
|
replace(audio, x=ax) if audio is not None else None,
|
|
)
|
|
|
|
|
|
def apply_cross_attention_adaln(
|
|
x: torch.Tensor,
|
|
context: torch.Tensor,
|
|
attn: Callable,
|
|
q_shift: torch.Tensor,
|
|
q_scale: torch.Tensor,
|
|
q_gate: torch.Tensor,
|
|
prompt_scale_shift_table: torch.Tensor | None,
|
|
prompt_timestep: torch.Tensor | None,
|
|
context_mask: torch.Tensor | None = None,
|
|
norm_eps: float = 1e-6,
|
|
) -> torch.Tensor:
|
|
"""LTX-2.3 cross-attention AdaLN: modulate query (x) and key/value
|
|
(context) by AdaLN scale/shift and gate the output.
|
|
|
|
Only reached when ``cross_attention_adaln`` is enabled, so it never
|
|
affects the LTX-2.0 path.
|
|
"""
|
|
if prompt_scale_shift_table is None or prompt_timestep is None:
|
|
raise ValueError("cross_attention_adaln requires prompt scale/shift tables and prompt_timestep.")
|
|
batch_size = x.shape[0]
|
|
shift_kv, scale_kv = (prompt_scale_shift_table[None, None].to(device=x.device, dtype=x.dtype) +
|
|
prompt_timestep.reshape(batch_size, prompt_timestep.shape[1], 2, -1)).unbind(dim=2)
|
|
attn_input = _rms_norm_dispatch(x, eps=norm_eps) * (1 + q_scale) + q_shift
|
|
encoder_hidden_states = context * (1 + scale_kv) + shift_kv
|
|
return attn(attn_input, context=encoder_hidden_states, mask=context_mask) * q_gate
|
|
|
|
|
|
class LTXModelType(Enum):
|
|
"""Model type flags for LTX-2."""
|
|
AudioVideo = "ltx av model"
|
|
VideoOnly = "ltx video only model"
|
|
AudioOnly = "ltx audio only model"
|
|
|
|
def is_video_enabled(self) -> bool:
|
|
return self in (LTXModelType.AudioVideo, LTXModelType.VideoOnly)
|
|
|
|
def is_audio_enabled(self) -> bool:
|
|
return self in (LTXModelType.AudioVideo, LTXModelType.AudioOnly)
|
|
|
|
|
|
class LTXModel(torch.nn.Module):
|
|
"""Core LTX-2 transformer stack for audio/video latents."""
|
|
|
|
def __init__( # noqa: PLR0913
|
|
self,
|
|
*,
|
|
model_type: LTXModelType = LTXModelType.AudioVideo,
|
|
num_attention_heads: int = 32,
|
|
attention_head_dim: int = 128,
|
|
in_channels: int = 128,
|
|
out_channels: int = 128,
|
|
num_layers: int = 48,
|
|
cross_attention_dim: int = 4096,
|
|
norm_eps: float = 1e-06,
|
|
caption_channels: int = 3840,
|
|
positional_embedding_theta: float = 10000.0,
|
|
positional_embedding_max_pos: list[int] | None = None,
|
|
timestep_scale_multiplier: int = 1000,
|
|
use_middle_indices_grid: bool = True,
|
|
audio_num_attention_heads: int = 32,
|
|
audio_attention_head_dim: int = 64,
|
|
audio_in_channels: int = 128,
|
|
audio_out_channels: int = 128,
|
|
audio_cross_attention_dim: int = 2048,
|
|
audio_positional_embedding_max_pos: list[int] | None = None,
|
|
av_ca_timestep_scale_multiplier: int = 1,
|
|
rope_type: LTXRopeType = LTXRopeType.INTERLEAVED,
|
|
double_precision_rope: bool = False,
|
|
cross_attention_adaln: bool = False,
|
|
caption_proj_before_connector: bool = False,
|
|
apply_gated_attention: bool = False,
|
|
stg_block_idx: int = 29,
|
|
use_distributed_attention: bool = False,
|
|
quant_config: QuantizationConfig | None = None,
|
|
prefix: str = "",
|
|
):
|
|
super().__init__()
|
|
self._enable_gradient_checkpointing = False
|
|
# LTX-2.3 gated extensions (all default OFF == LTX-2.0 behavior).
|
|
self.cross_attention_adaln = cross_attention_adaln
|
|
self.caption_proj_before_connector = caption_proj_before_connector
|
|
self.apply_gated_attention = apply_gated_attention
|
|
self.stg_block_idx = stg_block_idx
|
|
self.use_middle_indices_grid = use_middle_indices_grid
|
|
self.rope_type = rope_type
|
|
self.double_precision_rope = double_precision_rope
|
|
self.timestep_scale_multiplier = timestep_scale_multiplier
|
|
self.positional_embedding_theta = positional_embedding_theta
|
|
self.model_type = model_type
|
|
cross_pe_max_pos = None
|
|
|
|
if model_type.is_video_enabled():
|
|
if positional_embedding_max_pos is None:
|
|
positional_embedding_max_pos = [20, 2048, 2048]
|
|
self.positional_embedding_max_pos = positional_embedding_max_pos
|
|
self.num_attention_heads = num_attention_heads
|
|
self.inner_dim = num_attention_heads * attention_head_dim
|
|
self._init_video(
|
|
in_channels=in_channels,
|
|
out_channels=out_channels,
|
|
caption_channels=caption_channels,
|
|
norm_eps=norm_eps,
|
|
create_caption_projection=not caption_proj_before_connector,
|
|
)
|
|
|
|
if model_type.is_audio_enabled():
|
|
if audio_positional_embedding_max_pos is None:
|
|
audio_positional_embedding_max_pos = [20]
|
|
self.audio_positional_embedding_max_pos = audio_positional_embedding_max_pos
|
|
self.audio_num_attention_heads = audio_num_attention_heads
|
|
self.audio_inner_dim = self.audio_num_attention_heads * audio_attention_head_dim
|
|
self._init_audio(
|
|
in_channels=audio_in_channels,
|
|
out_channels=audio_out_channels,
|
|
caption_channels=caption_channels,
|
|
norm_eps=norm_eps,
|
|
create_caption_projection=not caption_proj_before_connector,
|
|
)
|
|
|
|
if model_type.is_video_enabled() and model_type.is_audio_enabled():
|
|
cross_pe_max_pos = max(self.positional_embedding_max_pos[0], self.audio_positional_embedding_max_pos[0])
|
|
self.av_ca_timestep_scale_multiplier = av_ca_timestep_scale_multiplier
|
|
self.audio_cross_attention_dim = audio_cross_attention_dim
|
|
self._init_audio_video(num_scale_shift_values=4)
|
|
|
|
self._init_preprocessors(cross_pe_max_pos)
|
|
self._init_transformer_blocks(
|
|
num_layers=num_layers,
|
|
attention_head_dim=attention_head_dim if model_type.is_video_enabled() else 0,
|
|
cross_attention_dim=cross_attention_dim,
|
|
audio_attention_head_dim=audio_attention_head_dim if model_type.is_audio_enabled() else 0,
|
|
audio_cross_attention_dim=audio_cross_attention_dim,
|
|
norm_eps=norm_eps,
|
|
use_distributed_attention=use_distributed_attention,
|
|
quant_config=quant_config,
|
|
prefix=prefix,
|
|
)
|
|
|
|
def _init_video(
|
|
self,
|
|
in_channels: int,
|
|
out_channels: int,
|
|
caption_channels: int,
|
|
norm_eps: float,
|
|
create_caption_projection: bool = True,
|
|
) -> None:
|
|
self.patchify_proj = torch.nn.Linear(in_channels, self.inner_dim, bias=True)
|
|
self.adaln_single = AdaLayerNormSingle(
|
|
self.inner_dim,
|
|
embedding_coefficient=adaln_embedding_coefficient(self.cross_attention_adaln),
|
|
)
|
|
# When caption_proj_before_connector is set the caption projection
|
|
# lives in the text encoder, so it is omitted here (LTX-2.3 layout).
|
|
if create_caption_projection:
|
|
self.caption_projection = PixArtAlphaTextProjection(
|
|
in_features=caption_channels,
|
|
hidden_size=self.inner_dim,
|
|
)
|
|
if self.cross_attention_adaln:
|
|
self.prompt_adaln_single = AdaLayerNormSingle(
|
|
self.inner_dim,
|
|
embedding_coefficient=2,
|
|
)
|
|
self.scale_shift_table = torch.nn.Parameter(torch.empty(2, self.inner_dim))
|
|
self.norm_out = torch.nn.LayerNorm(self.inner_dim, elementwise_affine=False, eps=norm_eps)
|
|
self.proj_out = torch.nn.Linear(self.inner_dim, out_channels)
|
|
|
|
def _init_audio(
|
|
self,
|
|
in_channels: int,
|
|
out_channels: int,
|
|
caption_channels: int,
|
|
norm_eps: float,
|
|
create_caption_projection: bool = True,
|
|
) -> None:
|
|
self.audio_patchify_proj = torch.nn.Linear(in_channels, self.audio_inner_dim, bias=True)
|
|
self.audio_adaln_single = AdaLayerNormSingle(
|
|
self.audio_inner_dim,
|
|
embedding_coefficient=adaln_embedding_coefficient(self.cross_attention_adaln),
|
|
)
|
|
if create_caption_projection:
|
|
self.audio_caption_projection = PixArtAlphaTextProjection(
|
|
in_features=caption_channels,
|
|
hidden_size=self.audio_inner_dim,
|
|
)
|
|
if self.cross_attention_adaln:
|
|
self.audio_prompt_adaln_single = AdaLayerNormSingle(
|
|
self.audio_inner_dim,
|
|
embedding_coefficient=2,
|
|
)
|
|
self.audio_scale_shift_table = torch.nn.Parameter(torch.empty(2, self.audio_inner_dim))
|
|
self.audio_norm_out = torch.nn.LayerNorm(self.audio_inner_dim, elementwise_affine=False, eps=norm_eps)
|
|
self.audio_proj_out = torch.nn.Linear(self.audio_inner_dim, out_channels)
|
|
|
|
def _init_audio_video(self, num_scale_shift_values: int) -> None:
|
|
self.av_ca_video_scale_shift_adaln_single = AdaLayerNormSingle(
|
|
self.inner_dim,
|
|
embedding_coefficient=num_scale_shift_values,
|
|
)
|
|
self.av_ca_audio_scale_shift_adaln_single = AdaLayerNormSingle(
|
|
self.audio_inner_dim,
|
|
embedding_coefficient=num_scale_shift_values,
|
|
)
|
|
self.av_ca_a2v_gate_adaln_single = AdaLayerNormSingle(
|
|
self.inner_dim,
|
|
embedding_coefficient=1,
|
|
)
|
|
self.av_ca_v2a_gate_adaln_single = AdaLayerNormSingle(
|
|
self.audio_inner_dim,
|
|
embedding_coefficient=1,
|
|
)
|
|
|
|
def _init_preprocessors(self, cross_pe_max_pos: int | None = None) -> None:
|
|
if self.model_type.is_video_enabled() and self.model_type.is_audio_enabled():
|
|
self.video_args_preprocessor = MultiModalTransformerArgsPreprocessor(
|
|
patchify_proj=self.patchify_proj,
|
|
adaln=self.adaln_single,
|
|
caption_projection=getattr(self, "caption_projection", None),
|
|
cross_scale_shift_adaln=self.av_ca_video_scale_shift_adaln_single,
|
|
cross_gate_adaln=self.av_ca_a2v_gate_adaln_single,
|
|
inner_dim=self.inner_dim,
|
|
max_pos=self.positional_embedding_max_pos,
|
|
num_attention_heads=self.num_attention_heads,
|
|
cross_pe_max_pos=cross_pe_max_pos,
|
|
use_middle_indices_grid=self.use_middle_indices_grid,
|
|
audio_cross_attention_dim=self.audio_cross_attention_dim,
|
|
timestep_scale_multiplier=self.timestep_scale_multiplier,
|
|
double_precision_rope=self.double_precision_rope,
|
|
positional_embedding_theta=self.positional_embedding_theta,
|
|
rope_type=self.rope_type,
|
|
av_ca_timestep_scale_multiplier=self.av_ca_timestep_scale_multiplier,
|
|
prompt_adaln=getattr(self, "prompt_adaln_single", None),
|
|
)
|
|
self.audio_args_preprocessor = MultiModalTransformerArgsPreprocessor(
|
|
patchify_proj=self.audio_patchify_proj,
|
|
adaln=self.audio_adaln_single,
|
|
caption_projection=getattr(self, "audio_caption_projection", None),
|
|
cross_scale_shift_adaln=self.av_ca_audio_scale_shift_adaln_single,
|
|
cross_gate_adaln=self.av_ca_v2a_gate_adaln_single,
|
|
inner_dim=self.audio_inner_dim,
|
|
max_pos=self.audio_positional_embedding_max_pos,
|
|
num_attention_heads=self.audio_num_attention_heads,
|
|
cross_pe_max_pos=cross_pe_max_pos,
|
|
use_middle_indices_grid=self.use_middle_indices_grid,
|
|
audio_cross_attention_dim=self.audio_cross_attention_dim,
|
|
timestep_scale_multiplier=self.timestep_scale_multiplier,
|
|
double_precision_rope=self.double_precision_rope,
|
|
positional_embedding_theta=self.positional_embedding_theta,
|
|
rope_type=self.rope_type,
|
|
av_ca_timestep_scale_multiplier=self.av_ca_timestep_scale_multiplier,
|
|
prompt_adaln=getattr(self, "audio_prompt_adaln_single", None),
|
|
)
|
|
elif self.model_type.is_video_enabled():
|
|
self.video_args_preprocessor = TransformerArgsPreprocessor(
|
|
patchify_proj=self.patchify_proj,
|
|
adaln=self.adaln_single,
|
|
caption_projection=getattr(self, "caption_projection", None),
|
|
inner_dim=self.inner_dim,
|
|
max_pos=self.positional_embedding_max_pos,
|
|
num_attention_heads=self.num_attention_heads,
|
|
use_middle_indices_grid=self.use_middle_indices_grid,
|
|
timestep_scale_multiplier=self.timestep_scale_multiplier,
|
|
double_precision_rope=self.double_precision_rope,
|
|
positional_embedding_theta=self.positional_embedding_theta,
|
|
rope_type=self.rope_type,
|
|
prompt_adaln=getattr(self, "prompt_adaln_single", None),
|
|
)
|
|
elif self.model_type.is_audio_enabled():
|
|
self.audio_args_preprocessor = TransformerArgsPreprocessor(
|
|
patchify_proj=self.audio_patchify_proj,
|
|
adaln=self.audio_adaln_single,
|
|
caption_projection=getattr(self, "audio_caption_projection", None),
|
|
inner_dim=self.audio_inner_dim,
|
|
max_pos=self.audio_positional_embedding_max_pos,
|
|
num_attention_heads=self.audio_num_attention_heads,
|
|
use_middle_indices_grid=self.use_middle_indices_grid,
|
|
timestep_scale_multiplier=self.timestep_scale_multiplier,
|
|
double_precision_rope=self.double_precision_rope,
|
|
positional_embedding_theta=self.positional_embedding_theta,
|
|
rope_type=self.rope_type,
|
|
prompt_adaln=getattr(self, "audio_prompt_adaln_single", None),
|
|
)
|
|
|
|
def _init_transformer_blocks(
|
|
self,
|
|
num_layers: int,
|
|
attention_head_dim: int,
|
|
cross_attention_dim: int,
|
|
audio_attention_head_dim: int,
|
|
audio_cross_attention_dim: int,
|
|
norm_eps: float,
|
|
use_distributed_attention: bool = False,
|
|
quant_config: QuantizationConfig | None = None,
|
|
prefix: str = "",
|
|
) -> None:
|
|
video_config = (TransformerConfig(
|
|
dim=self.inner_dim,
|
|
heads=self.num_attention_heads,
|
|
d_head=attention_head_dim,
|
|
context_dim=cross_attention_dim,
|
|
apply_gated_attention=self.apply_gated_attention,
|
|
cross_attention_adaln=self.cross_attention_adaln,
|
|
) if self.model_type.is_video_enabled() else None)
|
|
audio_config = (TransformerConfig(
|
|
dim=self.audio_inner_dim,
|
|
heads=self.audio_num_attention_heads,
|
|
d_head=audio_attention_head_dim,
|
|
context_dim=audio_cross_attention_dim,
|
|
apply_gated_attention=self.apply_gated_attention,
|
|
cross_attention_adaln=self.cross_attention_adaln,
|
|
) if self.model_type.is_audio_enabled() else None)
|
|
self.use_distributed_attention = use_distributed_attention
|
|
self.transformer_blocks = torch.nn.ModuleList([
|
|
BasicAVTransformerBlock(
|
|
idx=idx,
|
|
video=video_config,
|
|
audio=audio_config,
|
|
rope_type=self.rope_type,
|
|
norm_eps=norm_eps,
|
|
use_distributed_attention=use_distributed_attention,
|
|
cross_attention_adaln=self.cross_attention_adaln,
|
|
stg_block_idx=self.stg_block_idx,
|
|
quant_config=quant_config,
|
|
prefix=prefix,
|
|
) for idx in range(num_layers)
|
|
])
|
|
|
|
def _process_transformer_blocks(
|
|
self,
|
|
video: TransformerArgs | None,
|
|
audio: TransformerArgs | None,
|
|
video_original_seq_len: int | None = None,
|
|
audio_original_seq_len: int | None = None,
|
|
skip_cross_modal_attn: bool = False,
|
|
skip_video_self_attn_blocks: list[int] | None = None,
|
|
skip_audio_self_attn_blocks: list[int] | None = None,
|
|
) -> tuple[TransformerArgs | None, TransformerArgs | None]:
|
|
# Convert once so per-block membership checks stay O(1).
|
|
skip_video_self_attn_block_set = set(skip_video_self_attn_blocks or [])
|
|
skip_audio_self_attn_block_set = set(skip_audio_self_attn_blocks or [])
|
|
|
|
for idx, block in enumerate(self.transformer_blocks):
|
|
# Preserve the original STG block condition in eager code so it does not introduce a block index guard in the compiled forward
|
|
is_stg_block = idx == block.stg_block_idx
|
|
skip_v_sa = is_stg_block and idx in skip_video_self_attn_block_set
|
|
skip_a_sa = is_stg_block and idx in skip_audio_self_attn_block_set
|
|
video, audio = block(
|
|
video=video,
|
|
audio=audio,
|
|
video_original_seq_len=video_original_seq_len,
|
|
audio_original_seq_len=audio_original_seq_len,
|
|
skip_cross_modal_attn=skip_cross_modal_attn,
|
|
skip_video_self_attn=skip_v_sa,
|
|
skip_audio_self_attn=skip_a_sa,
|
|
)
|
|
return video, audio
|
|
|
|
def _process_output(
|
|
self,
|
|
scale_shift_table: torch.Tensor,
|
|
norm_out: torch.nn.LayerNorm,
|
|
proj_out: torch.nn.Linear,
|
|
x: torch.Tensor,
|
|
embedded_timestep: torch.Tensor,
|
|
) -> torch.Tensor:
|
|
scale_shift_values = (scale_shift_table[None, None].to(device=x.device, dtype=x.dtype) +
|
|
embedded_timestep[:, :, None])
|
|
shift, scale = scale_shift_values[:, :, 0], scale_shift_values[:, :, 1]
|
|
x = norm_out(x)
|
|
x = x * (1 + scale) + shift
|
|
x = proj_out(x)
|
|
return x
|
|
|
|
def forward(
|
|
self,
|
|
video: Modality | None,
|
|
audio: Modality | None,
|
|
video_original_seq_len: int | None = None,
|
|
audio_original_seq_len: int | None = None,
|
|
skip_cross_modal_attn: bool = False,
|
|
skip_video_self_attn_blocks: list[int] | None = None,
|
|
skip_audio_self_attn_blocks: list[int] | None = None,
|
|
) -> tuple[torch.Tensor | None, torch.Tensor | None]:
|
|
"""Forward pass through the LTX model.
|
|
|
|
Args:
|
|
video: Video modality input
|
|
audio: Audio modality input
|
|
video_original_seq_len: Original unpadded video sequence length
|
|
audio_original_seq_len: Original unpadded audio sequence length
|
|
skip_cross_modal_attn: If True, skip A2V and V2A cross-modal
|
|
attention in all transformer blocks (modality-isolated
|
|
CFG pass).
|
|
skip_video_self_attn_blocks: Block indices where video
|
|
self-attention is skipped (STG perturbed pass).
|
|
skip_audio_self_attn_blocks: Block indices where audio
|
|
self-attention is skipped (STG perturbed pass).
|
|
"""
|
|
if os.getenv("LTX2_PIPELINE_DEBUG_LOG", "0") == "1":
|
|
_debug_block_log_line("fastvideo:patchify_proj"
|
|
f":video_w_sum={self.patchify_proj.weight.float().sum().item():.6f} "
|
|
f"video_b_sum={self.patchify_proj.bias.float().sum().item():.6f} "
|
|
f"audio_w_sum={self.audio_patchify_proj.weight.float().sum().item():.6f} "
|
|
f"audio_b_sum={self.audio_patchify_proj.bias.float().sum().item():.6f}")
|
|
if not self.model_type.is_video_enabled() and video is not None:
|
|
raise ValueError("Video is not enabled for this model")
|
|
if not self.model_type.is_audio_enabled() and audio is not None:
|
|
raise ValueError("Audio is not enabled for this model")
|
|
|
|
video_args = self.video_args_preprocessor.prepare(video) if video is not None else None
|
|
audio_args = self.audio_args_preprocessor.prepare(audio) if audio is not None else None
|
|
_debug_transformer_args("fastvideo:prep_video", video_args)
|
|
_debug_transformer_args("fastvideo:prep_audio", audio_args)
|
|
video_out, audio_out = self._process_transformer_blocks(
|
|
video_args,
|
|
audio_args,
|
|
video_original_seq_len=video_original_seq_len,
|
|
audio_original_seq_len=audio_original_seq_len,
|
|
skip_cross_modal_attn=skip_cross_modal_attn,
|
|
skip_video_self_attn_blocks=skip_video_self_attn_blocks,
|
|
skip_audio_self_attn_blocks=skip_audio_self_attn_blocks,
|
|
)
|
|
|
|
vx = (self._process_output(self.scale_shift_table, self.norm_out, self.proj_out, video_out.x,
|
|
video_out.embedded_timestep) if video_out is not None else None)
|
|
ax = (self._process_output(
|
|
self.audio_scale_shift_table,
|
|
self.audio_norm_out,
|
|
self.audio_proj_out,
|
|
audio_out.x,
|
|
audio_out.embedded_timestep,
|
|
) if audio_out is not None else None)
|
|
return vx, ax
|
|
|
|
|
|
class LTX2Transformer3DModel(BaseDiT):
|
|
"""
|
|
LTX-2 transformer using native FastVideo LTX-2 modules.
|
|
"""
|
|
|
|
param_names_mapping = LTX2VideoConfig().param_names_mapping
|
|
reverse_param_names_mapping = LTX2VideoConfig().reverse_param_names_mapping
|
|
lora_param_names_mapping = LTX2VideoConfig().lora_param_names_mapping
|
|
_fsdp_shard_conditions = LTX2VideoConfig()._fsdp_shard_conditions
|
|
_compile_conditions = LTX2VideoConfig()._compile_conditions
|
|
|
|
def __init__(self, config: LTX2VideoConfig, hf_config: dict[str, Any]):
|
|
super().__init__(config=config, hf_config=hf_config)
|
|
|
|
arch = config.arch_config
|
|
|
|
# Get SP world size for distributed attention
|
|
sp_world_size = get_sp_world_size()
|
|
use_vsa_backend = os.getenv("FASTVIDEO_ATTENTION_BACKEND", "") == "VIDEO_SPARSE_ATTN"
|
|
use_distributed_attention = sp_world_size > 1 or use_vsa_backend
|
|
|
|
# Validate that attention heads are divisible by SP world size
|
|
if sp_world_size > 1:
|
|
assert arch.num_attention_heads % sp_world_size == 0, (
|
|
f"The number of video attention heads ({arch.num_attention_heads}) "
|
|
f"must be divisible by the sequence parallel size ({sp_world_size})")
|
|
assert arch.audio_num_attention_heads % sp_world_size == 0, (
|
|
f"The number of audio attention heads ({arch.audio_num_attention_heads}) "
|
|
f"must be divisible by the sequence parallel size ({sp_world_size})")
|
|
logger.info(f"LTX2 sequence parallelism enabled with SP world size {sp_world_size}")
|
|
elif use_vsa_backend:
|
|
logger.info("LTX2 VSA enabled with SP world size 1; using distributed "
|
|
"attention path for VSA compatibility")
|
|
|
|
model_type = LTXModelType.AudioVideo
|
|
self.model = LTXModel(
|
|
model_type=model_type,
|
|
num_attention_heads=arch.num_attention_heads,
|
|
attention_head_dim=arch.attention_head_dim,
|
|
in_channels=arch.in_channels,
|
|
out_channels=arch.out_channels,
|
|
num_layers=arch.num_layers,
|
|
cross_attention_dim=arch.cross_attention_dim,
|
|
norm_eps=arch.norm_eps,
|
|
caption_channels=arch.caption_channels,
|
|
positional_embedding_theta=arch.positional_embedding_theta,
|
|
positional_embedding_max_pos=arch.positional_embedding_max_pos,
|
|
timestep_scale_multiplier=arch.timestep_scale_multiplier,
|
|
use_middle_indices_grid=arch.use_middle_indices_grid,
|
|
rope_type=LTXRopeType(arch.rope_type),
|
|
double_precision_rope=arch.double_precision_rope,
|
|
audio_num_attention_heads=arch.audio_num_attention_heads,
|
|
audio_attention_head_dim=arch.audio_attention_head_dim,
|
|
audio_in_channels=arch.audio_in_channels,
|
|
audio_out_channels=arch.audio_out_channels,
|
|
audio_cross_attention_dim=arch.audio_cross_attention_dim,
|
|
audio_positional_embedding_max_pos=arch.audio_positional_embedding_max_pos,
|
|
av_ca_timestep_scale_multiplier=arch.av_ca_timestep_scale_multiplier,
|
|
cross_attention_adaln=arch.cross_attention_adaln,
|
|
caption_proj_before_connector=arch.caption_proj_before_connector,
|
|
apply_gated_attention=arch.apply_gated_attention,
|
|
stg_block_idx=arch.stg_block_idx,
|
|
use_distributed_attention=use_distributed_attention,
|
|
quant_config=config.quant_config,
|
|
prefix=config.prefix,
|
|
)
|
|
|
|
self.patchifier = VideoLatentPatchifier(patch_size=arch.patch_size[1])
|
|
self.audio_patchifier = AudioLatentPatchifier(
|
|
patch_size=DEFAULT_LTX2_AUDIO_MEL_BINS,
|
|
sample_rate=DEFAULT_LTX2_AUDIO_SAMPLE_RATE,
|
|
hop_length=DEFAULT_LTX2_AUDIO_HOP_LENGTH,
|
|
audio_latent_downsample_factor=DEFAULT_LTX2_AUDIO_DOWNSAMPLE,
|
|
is_causal=True,
|
|
shift=0,
|
|
)
|
|
|
|
self.hidden_size = arch.num_attention_heads * arch.attention_head_dim
|
|
self.num_attention_heads = arch.num_attention_heads
|
|
self.num_channels_latents = arch.num_channels_latents
|
|
|
|
if os.getenv("LTX2_DEBUG_DETAIL", "0") == "1":
|
|
detail_path = os.getenv("LTX2_PIPELINE_DEBUG_DETAIL_PATH", "")
|
|
if detail_path:
|
|
self._attach_debug_detail_hooks(detail_path)
|
|
|
|
def _attach_debug_detail_hooks(self, log_path: str) -> None:
|
|
path = Path(log_path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
if path.exists():
|
|
path.unlink()
|
|
|
|
def _format_sum(tensor: torch.Tensor | None) -> str:
|
|
if tensor is None:
|
|
return "None"
|
|
return f"{tensor.float().sum().item():.6f}"
|
|
|
|
def _hook_factory(block_idx: int, name: str):
|
|
|
|
def _hook(_module, _inputs, outputs): # noqa: ANN001
|
|
out = outputs[0] if isinstance(outputs, tuple) else outputs
|
|
out_sum = _format_sum(out if torch.is_tensor(out) else None)
|
|
with path.open("a", encoding="utf-8") as f:
|
|
f.write(f"fastvideo:{block_idx}:{name}:out_sum={out_sum}\n")
|
|
|
|
return _hook
|
|
|
|
for block in self.model.transformer_blocks:
|
|
idx = block.idx
|
|
for name in (
|
|
"attn1",
|
|
"attn2",
|
|
"ff",
|
|
"audio_attn1",
|
|
"audio_attn2",
|
|
"audio_ff",
|
|
"audio_to_video_attn",
|
|
"video_to_audio_attn",
|
|
):
|
|
if hasattr(block, name):
|
|
getattr(block, name).register_forward_hook(_hook_factory(idx, name))
|
|
|
|
def _output_hook(label: str):
|
|
|
|
def _hook(_module, _inputs, outputs): # noqa: ANN001
|
|
out = outputs[0] if isinstance(outputs, tuple) else outputs
|
|
out_sum = _format_sum(out if torch.is_tensor(out) else None)
|
|
with path.open("a", encoding="utf-8") as f:
|
|
f.write(f"fastvideo:output:{label}:out_sum={out_sum}\n")
|
|
|
|
return _hook
|
|
|
|
if hasattr(self.model, "proj_out"):
|
|
self.model.proj_out.register_forward_hook(_output_hook("proj_out"))
|
|
if hasattr(self.model, "audio_proj_out"):
|
|
self.model.audio_proj_out.register_forward_hook(_output_hook("audio_proj_out"))
|
|
|
|
def forward(
|
|
self,
|
|
hidden_states: torch.Tensor,
|
|
encoder_hidden_states: torch.Tensor | list[torch.Tensor],
|
|
timestep: torch.LongTensor,
|
|
encoder_hidden_states_image: torch.Tensor | list[torch.Tensor] | None = None,
|
|
encoder_attention_mask: torch.Tensor | None = None,
|
|
guidance=None,
|
|
audio_hidden_states: torch.Tensor | None = None,
|
|
audio_encoder_hidden_states: torch.Tensor | None = None,
|
|
audio_timestep: torch.Tensor | None = None,
|
|
audio_encoder_attention_mask: torch.Tensor | None = None,
|
|
video_sigma: torch.Tensor | None = None,
|
|
audio_sigma: torch.Tensor | None = None,
|
|
skip_cross_modal_attn: bool = False,
|
|
skip_video_self_attn_blocks: list[int] | None = None,
|
|
skip_audio_self_attn_blocks: list[int] | None = None,
|
|
video_position_offset_sec: float = 0.0,
|
|
**kwargs,
|
|
) -> torch.Tensor:
|
|
if isinstance(encoder_hidden_states, list):
|
|
encoder_hidden_states = encoder_hidden_states[0]
|
|
# Get SP parameters
|
|
sp_world_size = get_sp_world_size()
|
|
|
|
# Get fps for position computation
|
|
fps = None
|
|
try:
|
|
forward_ctx = get_forward_context()
|
|
except AssertionError:
|
|
forward_ctx = None
|
|
if forward_ctx is not None and forward_ctx.forward_batch is not None:
|
|
fps_value = forward_ctx.forward_batch.fps
|
|
if isinstance(fps_value, list):
|
|
fps_value = fps_value[0] if fps_value else None
|
|
if fps_value is not None:
|
|
fps = float(fps_value)
|
|
|
|
# Patchify video latents
|
|
video_shape = VideoLatentShape.from_torch_shape(hidden_states.shape)
|
|
latents = self.patchifier.patchify(hidden_states)
|
|
# LTX-2.3 cross-attention AdaLN consumes a per-sample sigma timestep.
|
|
# When the denoising stage does not supply one (LTX-2.0, or any caller
|
|
# that omits it) derive it from the video timestep. The downstream
|
|
# ``prompt_adaln`` is None for LTX-2.0 so this value is ignored there.
|
|
if video_sigma is None:
|
|
video_sigma = timestep[:, 0] if timestep.ndim > 1 else timestep
|
|
|
|
# Shard video latents and timestep across SP ranks
|
|
video_original_seq_len = latents.shape[1]
|
|
video_padded_seq_len = video_original_seq_len
|
|
video_timestep = timestep
|
|
if sp_world_size > 1:
|
|
latents, video_original_seq_len = sequence_model_parallel_shard(latents, dim=1)
|
|
# Shard timestep along sequence dimension (timestep has shape [batch, seq_len])
|
|
video_timestep, _ = sequence_model_parallel_shard(timestep, dim=1)
|
|
current_seq_len = latents.shape[1]
|
|
video_padded_seq_len = current_seq_len * sp_world_size
|
|
# Compute RoPE positions for the FULL sequence (before sharding)
|
|
# This is necessary because sequence sharding may not align to frame boundaries.
|
|
# The full RoPE will be passed to DistributedAttention which applies it after
|
|
# the all-to-all (when each rank has the full sequence but subset of heads).
|
|
positions = self.patchifier.get_patch_grid_bounds(video_shape, device=hidden_states.device)
|
|
positions = _get_pixel_coords(
|
|
positions,
|
|
DEFAULT_LTX2_SCALE_FACTORS,
|
|
fps=fps,
|
|
causal_fix=True,
|
|
)
|
|
if video_position_offset_sec:
|
|
positions[:, 0, ...] = positions[:, 0, ...] + float(video_position_offset_sec)
|
|
positions = positions.to(hidden_states.dtype)
|
|
|
|
# Pad positions to match padded sequence length for SP cross-attention
|
|
if sp_world_size > 1 and video_padded_seq_len > video_original_seq_len:
|
|
padding_needed = video_padded_seq_len - video_original_seq_len
|
|
last_pos = positions[:, :, -1:, :].expand(-1, -1, padding_needed, -1)
|
|
positions = torch.cat([positions, last_pos], dim=2)
|
|
|
|
video_modality = Modality(
|
|
enabled=True,
|
|
latent=latents,
|
|
timesteps=video_timestep,
|
|
positions=positions,
|
|
context=encoder_hidden_states,
|
|
context_mask=encoder_attention_mask,
|
|
sigma=video_sigma,
|
|
)
|
|
if os.getenv("LTX2_PIPELINE_DEBUG_LOG", "0") == "1":
|
|
video_head = latents.flatten()[:8].float().tolist()
|
|
video_flat = latents.float().flatten()
|
|
video_checksum = (video_flat * torch.arange(video_flat.numel(), device=video_flat.device)).sum().item()
|
|
_debug_block_log_line("fastvideo:modality_video"
|
|
f":latent_sum={latents.float().sum().item():.6f} "
|
|
f"latent_shape={tuple(latents.shape)} "
|
|
f"positions_sum={positions.float().sum().item():.6f} "
|
|
f"positions_shape={tuple(positions.shape)} "
|
|
f"latent_head={video_head} "
|
|
f"latent_checksum={video_checksum:.6f}")
|
|
|
|
# Process audio modality if provided
|
|
audio_modality = None
|
|
audio_shape = None
|
|
audio_original_seq_len = 0
|
|
|
|
if audio_hidden_states is not None and audio_encoder_hidden_states is not None and audio_timestep is not None:
|
|
audio_shape = AudioLatentShape.from_torch_shape(audio_hidden_states.shape)
|
|
audio_latents = self.audio_patchifier.patchify(audio_hidden_states)
|
|
if audio_sigma is None:
|
|
audio_sigma = (audio_timestep[:, 0] if audio_timestep.ndim > 1 else audio_timestep)
|
|
|
|
# Shard audio latents and timestep across SP ranks
|
|
audio_original_seq_len = audio_latents.shape[1]
|
|
sharded_audio_timestep = audio_timestep
|
|
if sp_world_size > 1:
|
|
audio_latents, audio_original_seq_len = sequence_model_parallel_shard(audio_latents, dim=1)
|
|
# Shard audio timestep along sequence dimension
|
|
sharded_audio_timestep, _ = sequence_model_parallel_shard(audio_timestep, dim=1)
|
|
|
|
# Compute audio RoPE positions for the FULL sequence (before sharding)
|
|
# Same as video: full RoPE is applied after all-to-all in DistributedAttention
|
|
audio_positions = self.audio_patchifier.get_patch_grid_bounds(audio_shape,
|
|
device=audio_hidden_states.device)
|
|
|
|
audio_modality = Modality(
|
|
enabled=True,
|
|
latent=audio_latents,
|
|
timesteps=sharded_audio_timestep,
|
|
positions=audio_positions,
|
|
context=audio_encoder_hidden_states,
|
|
context_mask=audio_encoder_attention_mask,
|
|
sigma=audio_sigma,
|
|
)
|
|
if os.getenv("LTX2_PIPELINE_DEBUG_LOG", "0") == "1":
|
|
audio_head = audio_latents.flatten()[:8].float().tolist()
|
|
audio_flat = audio_latents.float().flatten()
|
|
audio_checksum = (audio_flat * torch.arange(audio_flat.numel(), device=audio_flat.device)).sum().item()
|
|
_debug_block_log_line("fastvideo:modality_audio"
|
|
f":latent_sum={audio_latents.float().sum().item():.6f} "
|
|
f"latent_shape={tuple(audio_latents.shape)} "
|
|
f"positions_sum={audio_positions.float().sum().item():.6f} "
|
|
f"positions_shape={tuple(audio_positions.shape)} "
|
|
f"latent_head={audio_head} "
|
|
f"latent_checksum={audio_checksum:.6f}")
|
|
|
|
# Run transformer with original sequence lengths
|
|
video_out, audio_out = self.model(
|
|
video=video_modality,
|
|
audio=audio_modality,
|
|
video_original_seq_len=video_original_seq_len,
|
|
audio_original_seq_len=audio_original_seq_len,
|
|
skip_cross_modal_attn=skip_cross_modal_attn,
|
|
skip_video_self_attn_blocks=skip_video_self_attn_blocks,
|
|
skip_audio_self_attn_blocks=skip_audio_self_attn_blocks,
|
|
)
|
|
|
|
# Denoised prediction
|
|
if video_out is not None and video_modality is not None:
|
|
video_out = _to_denoised(
|
|
video_modality.latent,
|
|
video_out,
|
|
video_modality.timesteps,
|
|
)
|
|
if audio_out is not None and audio_modality is not None:
|
|
audio_out = _to_denoised(
|
|
audio_modality.latent,
|
|
audio_out,
|
|
audio_modality.timesteps,
|
|
)
|
|
|
|
# Gather and unpad video output
|
|
if sp_world_size > 1 and video_out is not None:
|
|
video_out = sequence_model_parallel_all_gather_with_unpad(video_out, video_original_seq_len, dim=1)
|
|
|
|
# Gather and unpad audio output
|
|
if sp_world_size > 1 and audio_out is not None:
|
|
audio_out = sequence_model_parallel_all_gather_with_unpad(audio_out, audio_original_seq_len, dim=1)
|
|
|
|
# Unpatchify
|
|
video_out = self.patchifier.unpatchify(video_out, output_shape=video_shape)
|
|
if audio_out is None or audio_shape is None:
|
|
return video_out
|
|
audio_out = self.audio_patchifier.unpatchify(audio_out, output_shape=audio_shape)
|
|
return video_out, audio_out
|
|
|
|
|
|
# Entry point for model registry
|
|
EntryClass = LTX2Transformer3DModel
|