diff --git a/docs/diffusion/compatibility_matrix.md b/docs/diffusion/compatibility_matrix.md
index 9f5f13978..de1083c01 100644
--- a/docs/diffusion/compatibility_matrix.md
+++ b/docs/diffusion/compatibility_matrix.md
@@ -33,16 +33,16 @@ default parameters when initializing and generating videos.
| TurboWan2.1 T2V 14B | `IPostYellow/TurboWan2.1-T2V-14B-Diffusers` | 480p | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ | ⭕ |
| TurboWan2.1 T2V 14B 720P | `IPostYellow/TurboWan2.1-T2V-14B-720P-Diffusers` | 720p | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ | ⭕ |
| TurboWan2.2 I2V A14B | `IPostYellow/TurboWan2.2-I2V-A14B-Diffusers` | 720p | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ | ⭕ |
-| LTX-2 | `Lightricks/LTX-2` | 768×512
1536×1024 | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
-| LTX-2.3 | `Lightricks/LTX-2.3` | 768×512
1536×1024 | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
+| LTX-2 (one and two stages) | `Lightricks/LTX-2` | 768×512
1536×1024 | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
+| LTX-2.3 (one and two stages) | `Lightricks/LTX-2.3` | 768×512
1536×1024 | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
**Note**:
1. Wan2.2 TI2V 5B has some quality issues when performing I2V generation. We are working on fixing this issue.
2. SageSLA is based on SpargeAttn. Install it first with `pip install git+https://github.com/thu-ml/SpargeAttn.git --no-build-isolation`
3. LTX-2 two-stage generation uses `--pipeline-class-name LTX2TwoStagePipeline`. The spatial upsampler and distilled LoRA are auto-resolved from the model snapshot by default, and can still be overridden with `--spatial-upsampler-path` and `--distilled-lora-path`.
-4. `Lightricks/LTX-2.3` is supported through the bundled native overlay materialization path. One-stage generation uses the default `LTX2Pipeline`; two-stage generation uses `--pipeline-class-name LTX2TwoStagePipeline`.
-5. For LTX models, the `Resolutions` column uses output video `width×height` semantics, matching `sglang generate --width ... --height ...`. One-stage generation is validated at `768×512`; two-stage generation is validated at `1536×1024`.
+3. LTX-2 and LTX-2.3 two-stage generation uses `--pipeline-class-name LTX2TwoStagePipeline`. The spatial upsampler and distilled LoRA are auto-resolved from the model snapshot by default, and can still be overridden with `--spatial-upsampler-path` and `--distilled-lora-path`.
+ - For LTX models, the `Resolutions` column uses output video `width×height` semantics, matching `sglang generate --width ... --height ...`.
### Image Generation Models
diff --git a/python/sglang/multimodal_gen/configs/pipeline_configs/base.py b/python/sglang/multimodal_gen/configs/pipeline_configs/base.py
index 902c2a1cd..b149b9cfb 100644
--- a/python/sglang/multimodal_gen/configs/pipeline_configs/base.py
+++ b/python/sglang/multimodal_gen/configs/pipeline_configs/base.py
@@ -365,6 +365,42 @@ class PipelineConfig:
latents = sequence_model_parallel_all_gather(latents, dim=2)
return latents
+ def can_shard_audio_latents_for_sp(self, audio_latents) -> bool:
+ """Return whether this pipeline uses packed audio latents that can be SP-sharded."""
+ return False
+
+ def shard_audio_latents_for_sp(self, batch, audio_latents):
+ """Shard packed audio latents for SP. Pipelines without packed audio latents should return the input unchanged."""
+ return audio_latents, False
+
+ def gather_audio_latents_for_sp(self, audio_latents, batch):
+ """Gather SP-sharded audio latents back to full sequence length."""
+ return audio_latents
+
+ def prepare_video_rope_coords_for_sp(
+ self,
+ model,
+ batch,
+ latent_model_input,
+ *,
+ num_frames,
+ height,
+ width,
+ ):
+ """Prepare model-side video RoPE coordinates for the local SP shard when the pipeline requires them."""
+ return None
+
+ def prepare_audio_rope_coords_for_sp(
+ self,
+ model,
+ batch,
+ audio_latent_model_input,
+ *,
+ num_frames,
+ ):
+ """Prepare model-side audio RoPE coordinates for the local SP shard when the pipeline requires them."""
+ return None
+
def gather_noise_pred_for_sp(self, batch, noise_pred):
noise_pred = self.gather_latents_for_sp(noise_pred)
raw_latent_shape = getattr(batch, "raw_latent_shape", None)
diff --git a/python/sglang/multimodal_gen/configs/pipeline_configs/ltx_2.py b/python/sglang/multimodal_gen/configs/pipeline_configs/ltx_2.py
index 5029e68ba..126cf1a54 100644
--- a/python/sglang/multimodal_gen/configs/pipeline_configs/ltx_2.py
+++ b/python/sglang/multimodal_gen/configs/pipeline_configs/ltx_2.py
@@ -345,6 +345,7 @@ class LTX2PipelineConfig(PipelineConfig):
latent_frames, tokens_per_frame = (
self._infer_video_latent_frames_and_tokens_per_frame(batch, seq_len)
)
+ orig_latent_frames = int(latent_frames)
# Pad whole frames so `latent_frames` is divisible by `sp_world_size`.
pad_frames = (sp_world_size - (latent_frames % sp_world_size)) % sp_world_size
@@ -360,6 +361,9 @@ class LTX2PipelineConfig(PipelineConfig):
local_frames = int(latent_frames) // int(sp_world_size)
start_frame = int(sp_rank) * int(local_frames)
+ valid_local_frames = max(
+ min(int(orig_latent_frames) - int(start_frame), int(local_frames)), 0
+ )
start = int(start_frame) * int(tokens_per_frame)
end = int(start) + int(local_frames) * int(tokens_per_frame)
latents = latents[:, start:end, :]
@@ -368,6 +372,9 @@ class LTX2PipelineConfig(PipelineConfig):
batch.sp_video_latent_num_frames = int(local_frames)
batch.sp_video_start_frame = int(start_frame)
batch.sp_video_tokens_per_frame = int(tokens_per_frame)
+ batch.sp_video_valid_token_count = int(valid_local_frames) * int(
+ tokens_per_frame
+ )
return latents, True
@@ -379,6 +386,104 @@ class LTX2PipelineConfig(PipelineConfig):
return sequence_model_parallel_all_gather(latents.contiguous(), dim=1)
return super().gather_latents_for_sp(latents, batch=batch)
+ def shard_audio_latents_for_sp(self, batch, audio_latents):
+ sp_world_size = get_sp_world_size()
+ if sp_world_size <= 1:
+ return audio_latents, False
+ if not (isinstance(audio_latents, torch.Tensor) and audio_latents.ndim == 3):
+ return audio_latents, False
+
+ sp_rank = get_sp_parallel_rank()
+ seq_len = int(audio_latents.shape[1])
+ batch.sp_audio_orig_num_frames = int(seq_len)
+
+ pad_frames = (sp_world_size - (seq_len % sp_world_size)) % sp_world_size
+ if pad_frames:
+ pad = torch.zeros(
+ (audio_latents.shape[0], pad_frames, audio_latents.shape[2]),
+ device=audio_latents.device,
+ dtype=audio_latents.dtype,
+ )
+ audio_latents = torch.cat([audio_latents, pad], dim=1)
+ seq_len += int(pad_frames)
+
+ local_frames = seq_len // sp_world_size
+ start_frame = sp_rank * local_frames
+ end_frame = start_frame + local_frames
+ valid_local_frames = max(
+ min(
+ int(batch.sp_audio_orig_num_frames) - int(start_frame),
+ int(local_frames),
+ ),
+ 0,
+ )
+ audio_latents = audio_latents[:, start_frame:end_frame, :]
+
+ batch.sp_audio_latent_num_frames = int(local_frames)
+ batch.sp_audio_start_frame = int(start_frame)
+ batch.sp_audio_valid_token_count = int(valid_local_frames)
+ return audio_latents, True
+
+ def can_shard_audio_latents_for_sp(self, audio_latents) -> bool:
+ return (
+ get_sp_world_size() > 1
+ and isinstance(audio_latents, torch.Tensor)
+ and audio_latents.ndim == 3
+ )
+
+ def gather_audio_latents_for_sp(self, audio_latents, batch):
+ if get_sp_world_size() <= 1:
+ return audio_latents
+ if not (isinstance(audio_latents, torch.Tensor) and audio_latents.ndim == 3):
+ return audio_latents
+
+ audio_latents = sequence_model_parallel_all_gather(
+ audio_latents.contiguous(), dim=1
+ )
+ orig_num_frames = int(batch.sp_audio_orig_num_frames)
+ if orig_num_frames > 0:
+ audio_latents = audio_latents[:, :orig_num_frames, :]
+ return audio_latents
+
+ def prepare_video_rope_coords_for_sp(
+ self,
+ model,
+ batch,
+ latent_model_input,
+ *,
+ num_frames,
+ height,
+ width,
+ ):
+ if not batch.did_sp_shard_latents:
+ return None
+ return model.rope.prepare_video_coords(
+ batch_size=int(latent_model_input.shape[0]),
+ num_frames=num_frames,
+ height=height,
+ width=width,
+ device=latent_model_input.device,
+ fps=batch.fps,
+ start_frame=int(batch.sp_video_start_frame),
+ )
+
+ def prepare_audio_rope_coords_for_sp(
+ self,
+ model,
+ batch,
+ audio_latent_model_input,
+ *,
+ num_frames,
+ ):
+ if not batch.did_sp_shard_audio_latents:
+ return None
+ return model.audio_rope.prepare_audio_coords(
+ batch_size=int(audio_latent_model_input.shape[0]),
+ num_frames=num_frames,
+ device=audio_latent_model_input.device,
+ start_frame=int(batch.sp_audio_start_frame),
+ )
+
def maybe_pack_audio_latents(self, latents, batch_size, batch):
# If already packed (3D shape [B, T, C*F]), skip packing
if latents.dim() == 3:
diff --git a/python/sglang/multimodal_gen/model_overlays/ltx_2_3/_overlay/materialize.py b/python/sglang/multimodal_gen/model_overlays/ltx_2_3/_overlay/materialize.py
deleted file mode 100644
index 73fadfc22..000000000
--- a/python/sglang/multimodal_gen/model_overlays/ltx_2_3/_overlay/materialize.py
+++ /dev/null
@@ -1,302 +0,0 @@
-import json
-import os
-
-from huggingface_hub import snapshot_download
-from safetensors import safe_open
-from safetensors.torch import save_file
-
-from sglang.multimodal_gen.runtime.utils.model_overlay import (
- _copytree_link_or_copy,
- _ensure_dir,
- _link_or_copy_file,
-)
-
-AUXILIARY_MODEL_ID = "Lightricks/LTX-2"
-CONFIG_DONOR_MODEL_ID = "FastVideo/LTX-2.3-Distilled-Diffusers"
-
-AUXILIARY_PATTERNS = [
- "audio_vae/**",
- "scheduler/**",
- "text_encoder/**",
- "tokenizer/**",
- "vae/config.json",
- "vae/diffusion_pytorch_model.safetensors",
-]
-
-CONFIG_DONOR_PATTERNS = [
- "transformer/config.json",
- "text_encoder/config.json",
- "vae/**",
- "vocoder/**",
-]
-
-MONOLITH_PREFIX = "model.diffusion_model."
-VIDEO_CONNECTOR_PREFIX = f"{MONOLITH_PREFIX}video_embeddings_connector."
-AUDIO_CONNECTOR_PREFIX = f"{MONOLITH_PREFIX}audio_embeddings_connector."
-TEXT_PROJ_IN_PREFIX = f"{MONOLITH_PREFIX}text_proj_in."
-VIDEO_AGGREGATE_PREFIX = "text_embedding_projection.video_aggregate_embed."
-AUDIO_AGGREGATE_PREFIX = "text_embedding_projection.audio_aggregate_embed."
-
-
-def _load_json(path: str) -> dict:
- with open(path) as f:
- return json.load(f)
-
-
-def _write_json(path: str, payload: dict) -> None:
- with open(path, "w") as f:
- json.dump(payload, f, indent=2)
- f.write("\n")
-
-
-def _rename_connector_key(key: str) -> str | None:
- if key.startswith(VIDEO_CONNECTOR_PREFIX):
- suffix = key[len(VIDEO_CONNECTOR_PREFIX) :]
- suffix = suffix.replace("transformer_1d_blocks", "transformer_blocks")
- suffix = suffix.replace(".attn1.q_norm.", ".attn1.norm_q.")
- suffix = suffix.replace(".attn1.k_norm.", ".attn1.norm_k.")
- return f"video_connector.{suffix}"
- if key.startswith(AUDIO_CONNECTOR_PREFIX):
- suffix = key[len(AUDIO_CONNECTOR_PREFIX) :]
- suffix = suffix.replace("transformer_1d_blocks", "transformer_blocks")
- suffix = suffix.replace(".attn1.q_norm.", ".attn1.norm_q.")
- suffix = suffix.replace(".attn1.k_norm.", ".attn1.norm_k.")
- return f"audio_connector.{suffix}"
- if key.startswith(TEXT_PROJ_IN_PREFIX):
- return key[len(MONOLITH_PREFIX) :]
- if key.startswith(VIDEO_AGGREGATE_PREFIX):
- return f"video_aggregate_embed.{key[len(VIDEO_AGGREGATE_PREFIX):]}"
- if key.startswith(AUDIO_AGGREGATE_PREFIX):
- return f"audio_aggregate_embed.{key[len(AUDIO_AGGREGATE_PREFIX):]}"
- return None
-
-
-def _repack_transformer_weights(source_path: str, output_path: str) -> None:
- tensors = {}
- with safe_open(source_path, framework="pt") as f:
- for key in f.keys():
- if not key.startswith(MONOLITH_PREFIX):
- continue
- if key.startswith(VIDEO_CONNECTOR_PREFIX):
- continue
- if key.startswith(AUDIO_CONNECTOR_PREFIX):
- continue
- if key.startswith(TEXT_PROJ_IN_PREFIX):
- continue
- tensors[key[len(MONOLITH_PREFIX) :]] = f.get_tensor(key)
- if not tensors:
- raise ValueError("No transformer tensors found in LTX-2.3 source checkpoint.")
- save_file(tensors, output_path)
-
-
-def _repack_connectors_weights(source_path: str, output_path: str) -> None:
- tensors = {}
- with safe_open(source_path, framework="pt") as f:
- for key in f.keys():
- renamed = _rename_connector_key(key)
- if renamed is None:
- continue
- tensors[renamed] = f.get_tensor(key)
- if not tensors:
- raise ValueError("No connector tensors found in LTX-2.3 source checkpoint.")
- save_file(tensors, output_path)
-
-
-def _build_transformer_config(config_donor_dir: str) -> dict:
- config = _load_json(os.path.join(config_donor_dir, "transformer", "config.json"))
- config["_class_name"] = "LTX2VideoTransformer3DModel"
- config["force_sdpa_v2a_cross_attention"] = True
- config["quantize_video_rope_coords_to_hidden_dtype"] = True
- return config
-
-
-def _build_connectors_config(config_donor_dir: str) -> dict:
- text_encoder_config = _load_json(
- os.path.join(config_donor_dir, "text_encoder", "config.json")
- )
- return {
- "_class_name": "LTX2TextConnectors",
- "_diffusers_version": "0.37.0.dev0",
- "audio_connector_attention_head_dim": text_encoder_config[
- "audio_connector_attention_head_dim"
- ],
- "audio_connector_num_attention_heads": text_encoder_config[
- "audio_connector_num_attention_heads"
- ],
- "audio_connector_num_layers": text_encoder_config["audio_connector_num_layers"],
- "audio_connector_num_learnable_registers": text_encoder_config[
- "connector_num_learnable_registers"
- ],
- "audio_feature_extractor_out_features": text_encoder_config[
- "audio_feature_extractor_out_features"
- ],
- "caption_channels": text_encoder_config["hidden_size"],
- "causal_temporal_positioning": False,
- "connector_apply_gated_attention": text_encoder_config[
- "connector_apply_gated_attention"
- ],
- "feature_extractor_in_features": text_encoder_config[
- "feature_extractor_in_features"
- ],
- "connector_rope_base_seq_len": text_encoder_config[
- "connector_positional_embedding_max_pos"
- ][0],
- "rope_double_precision": text_encoder_config["connector_double_precision_rope"],
- "rope_theta": text_encoder_config["connector_positional_embedding_theta"],
- "rope_type": text_encoder_config["connector_rope_type"],
- "text_proj_in_factor": text_encoder_config["feature_extractor_in_features"]
- // text_encoder_config["hidden_size"],
- "video_feature_extractor_out_features": text_encoder_config[
- "video_feature_extractor_out_features"
- ],
- "video_connector_attention_head_dim": text_encoder_config[
- "connector_attention_head_dim"
- ],
- "video_connector_num_attention_heads": text_encoder_config[
- "connector_num_attention_heads"
- ],
- "video_connector_num_layers": text_encoder_config["connector_num_layers"],
- "video_connector_num_learnable_registers": text_encoder_config[
- "connector_num_learnable_registers"
- ],
- }
-
-
-def _build_vae_config(auxiliary_dir: str, config_donor_dir: str) -> dict:
- config = _load_json(os.path.join(auxiliary_dir, "vae", "config.json"))
- config["ltx_variant"] = "ltx_2_3"
- config["condition_encoder_subdir"] = "ltx23_image_encoder"
- config["video_decoder_variant"] = "ltx_2_3"
- config["video_decoder_config"] = _load_json(
- os.path.join(config_donor_dir, "vae", "config.json")
- )["vae"]
- return config
-
-
-def _repack_ltx23_image_encoder_weights(source_path: str, output_path: str) -> None:
- tensors = {}
- with safe_open(source_path, framework="pt") as f:
- for key in f.keys():
- if key.startswith("encoder."):
- tensors[key[len("encoder.") :]] = f.get_tensor(key)
- continue
- if key.startswith("per_channel_statistics."):
- tensors[key] = f.get_tensor(key)
- if not tensors:
- raise ValueError("No LTX-2.3 image-encoder tensors found in donor checkpoint.")
- save_file(tensors, output_path)
-
-
-def _repack_ltx23_video_decoder_weights(
- auxiliary_encoder_path: str,
- donor_decoder_path: str,
- output_path: str,
-) -> None:
- tensors = {}
- with safe_open(auxiliary_encoder_path, framework="pt") as f:
- for key in f.keys():
- if key.startswith("encoder."):
- tensors[key] = f.get_tensor(key)
- with safe_open(donor_decoder_path, framework="pt") as f:
- for key in f.keys():
- if key.startswith("decoder."):
- tensors[key] = f.get_tensor(key)
- continue
- if key == "per_channel_statistics.mean-of-means":
- tensor = f.get_tensor(key)
- tensors["decoder.per_channel_statistics.mean_of_means"] = tensor
- tensors["latents_mean"] = tensor.clone()
- continue
- if key == "per_channel_statistics.std-of-means":
- tensor = f.get_tensor(key)
- tensors["decoder.per_channel_statistics.std_of_means"] = tensor
- tensors["latents_std"] = tensor.clone()
- continue
- if not tensors:
- raise ValueError("No LTX-2.3 decoder tensors found in donor checkpoint.")
- save_file(tensors, output_path)
-
-
-def materialize(
- *,
- overlay_dir: str,
- source_dir: str,
- output_dir: str,
- manifest: dict,
-) -> None:
- _ = overlay_dir, manifest
-
- auxiliary_dir = snapshot_download(
- repo_id=AUXILIARY_MODEL_ID,
- allow_patterns=AUXILIARY_PATTERNS,
- max_workers=8,
- )
- config_donor_dir = snapshot_download(
- repo_id=CONFIG_DONOR_MODEL_ID,
- allow_patterns=CONFIG_DONOR_PATTERNS,
- max_workers=8,
- )
-
- for component_name in ("audio_vae", "scheduler", "text_encoder", "tokenizer"):
- _copytree_link_or_copy(
- os.path.join(auxiliary_dir, component_name),
- os.path.join(output_dir, component_name),
- )
- _copytree_link_or_copy(
- os.path.join(config_donor_dir, "vocoder"),
- os.path.join(output_dir, "vocoder"),
- )
-
- source_checkpoint = os.path.join(source_dir, "ltx-2.3-22b-dev.safetensors")
-
- transformer_dir = os.path.join(output_dir, "transformer")
- _ensure_dir(transformer_dir)
- _write_json(
- os.path.join(transformer_dir, "config.json"),
- _build_transformer_config(config_donor_dir),
- )
- _repack_transformer_weights(
- source_checkpoint, os.path.join(transformer_dir, "model.safetensors")
- )
-
- connectors_dir = os.path.join(output_dir, "connectors")
- _ensure_dir(connectors_dir)
- _write_json(
- os.path.join(connectors_dir, "config.json"),
- _build_connectors_config(config_donor_dir),
- )
- _repack_connectors_weights(
- source_checkpoint, os.path.join(connectors_dir, "model.safetensors")
- )
-
- vae_dir = os.path.join(output_dir, "vae")
- _ensure_dir(vae_dir)
- _write_json(
- os.path.join(vae_dir, "config.json"),
- _build_vae_config(auxiliary_dir, config_donor_dir),
- )
- _repack_ltx23_video_decoder_weights(
- os.path.join(auxiliary_dir, "vae", "diffusion_pytorch_model.safetensors"),
- os.path.join(config_donor_dir, "vae", "model.safetensors"),
- os.path.join(vae_dir, "model.safetensors"),
- )
-
- image_encoder_dir = os.path.join(vae_dir, "ltx23_image_encoder")
- _ensure_dir(image_encoder_dir)
- _link_or_copy_file(
- os.path.join(config_donor_dir, "vae", "config.json"),
- os.path.join(image_encoder_dir, "config.json"),
- )
- _repack_ltx23_image_encoder_weights(
- os.path.join(config_donor_dir, "vae", "model.safetensors"),
- os.path.join(image_encoder_dir, "model.safetensors"),
- )
-
- _link_or_copy_file(
- os.path.join(source_dir, "ltx-2.3-22b-distilled-lora-384.safetensors"),
- os.path.join(output_dir, "ltx-2.3-22b-distilled-lora-384.safetensors"),
- )
- _link_or_copy_file(
- os.path.join(source_dir, "ltx-2.3-spatial-upscaler-x2-1.1.safetensors"),
- os.path.join(output_dir, "ltx-2.3-spatial-upscaler-x2-1.1.safetensors"),
- )
diff --git a/python/sglang/multimodal_gen/runtime/layers/attention/layer.py b/python/sglang/multimodal_gen/runtime/layers/attention/layer.py
index c98604be7..bf1518513 100644
--- a/python/sglang/multimodal_gen/runtime/layers/attention/layer.py
+++ b/python/sglang/multimodal_gen/runtime/layers/attention/layer.py
@@ -398,8 +398,10 @@ class USPAttention(nn.Module):
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
+ attn_mask: torch.Tensor | None = None,
num_replicated_prefix: int = 0,
num_replicated_suffix: int = 0,
+ skip_sequence_parallel_override: bool = False,
) -> torch.Tensor:
"""
Forward pass for USPAttention.
@@ -421,7 +423,82 @@ class USPAttention(nn.Module):
"""
forward_context: ForwardContext = get_forward_context()
ctx_attn_metadata = forward_context.attn_metadata
- if self.skip_sequence_parallel or get_sequence_parallel_world_size() == 1:
+ effective_skip_sp = (
+ self.skip_sequence_parallel or skip_sequence_parallel_override
+ )
+ if attn_mask is not None:
+
+ def _prepare_sdpa_mask(
+ mask: torch.Tensor, *, dtype: torch.dtype, device: torch.device
+ ) -> torch.Tensor:
+ mask = mask.to(device=device)
+ if torch.is_floating_point(mask):
+ mask = mask.to(dtype=dtype)
+ if mask.dim() == 2:
+ mask = mask[:, None, None, :]
+ elif mask.dim() == 3:
+ mask = mask[:, None, :, :]
+ return mask
+
+ mask = mask.to(dtype=dtype)
+ if mask.dim() == 2:
+ mask = mask[:, None, None, :]
+ elif mask.dim() == 3:
+ mask = mask[:, None, :, :]
+ return (mask - 1.0) * torch.finfo(dtype).max
+
+ sp_world_size = get_sequence_parallel_world_size()
+ if effective_skip_sp or sp_world_size == 1:
+ q_ = q.transpose(1, 2)
+ k_ = k.transpose(1, 2)
+ v_ = v.transpose(1, 2)
+ mask = _prepare_sdpa_mask(attn_mask, dtype=q_.dtype, device=q_.device)
+ return torch.nn.functional.scaled_dot_product_attention(
+ q_,
+ k_,
+ v_,
+ attn_mask=mask,
+ dropout_p=0.0,
+ is_causal=False,
+ scale=self.softmax_scale,
+ ).transpose(1, 2)
+
+ if get_ring_parallel_world_size() > 1:
+ raise NotImplementedError(
+ "USPAttention masked path does not support ring parallelism yet."
+ )
+ if attn_mask.dim() != 2:
+ raise NotImplementedError(
+ "USPAttention masked SP path currently expects a [B, S_local] key mask."
+ )
+
+ sp_size = get_ulysses_parallel_world_size()
+ if sp_size > 1:
+ q = _usp_input_all_to_all(q, head_dim=2)
+ k = _usp_input_all_to_all(k, head_dim=2)
+ v = _usp_input_all_to_all(v, head_dim=2)
+
+ gathered_mask = sequence_model_parallel_all_gather(
+ attn_mask.contiguous(), dim=1
+ )
+ q_ = q.transpose(1, 2)
+ k_ = k.transpose(1, 2)
+ v_ = v.transpose(1, 2)
+ mask = _prepare_sdpa_mask(gathered_mask, dtype=q_.dtype, device=q_.device)
+ out = torch.nn.functional.scaled_dot_product_attention(
+ q_,
+ k_,
+ v_,
+ attn_mask=mask,
+ dropout_p=0.0,
+ is_causal=False,
+ scale=self.softmax_scale,
+ ).transpose(1, 2)
+ if sp_size > 1:
+ out = _usp_output_all_to_all(out, head_dim=2)
+ return out
+
+ if effective_skip_sp or get_sequence_parallel_world_size() == 1:
# No sequence parallelism, just run local attention.
out = self.attn_impl.forward(q, k, v, ctx_attn_metadata)
return out
diff --git a/python/sglang/multimodal_gen/runtime/models/dits/ltx_2.py b/python/sglang/multimodal_gen/runtime/models/dits/ltx_2.py
index c2de1f643..18dee2ddf 100644
--- a/python/sglang/multimodal_gen/runtime/models/dits/ltx_2.py
+++ b/python/sglang/multimodal_gen/runtime/models/dits/ltx_2.py
@@ -19,6 +19,7 @@ from sglang.multimodal_gen.runtime.distributed import (
model_parallel_is_initialized,
)
from sglang.multimodal_gen.runtime.distributed.communication_op import (
+ sequence_model_parallel_all_gather,
tensor_model_parallel_all_reduce,
)
from sglang.multimodal_gen.runtime.layers.attention import LocalAttention, USPAttention
@@ -581,6 +582,8 @@ class LTX2Attention(nn.Module):
k_pe: tuple[torch.Tensor, torch.Tensor] | None = None,
perturbation_mask: torch.Tensor | None = None,
all_perturbed: bool = False,
+ skip_sequence_parallel_override: bool = False,
+ gather_context_kv_for_sp: bool = False,
) -> torch.Tensor:
gate_input = x
context_ = x if context is None else context
@@ -620,10 +623,34 @@ class LTX2Attention(nn.Module):
q = q.view(*q.shape[:-1], self.local_heads, self.dim_head)
k = k.view(*k.shape[:-1], self.local_heads, self.dim_head)
- if self.use_local_attention:
+ if gather_context_kv_for_sp:
+ k_full = sequence_model_parallel_all_gather(k.contiguous(), dim=1)
+ v_full = sequence_model_parallel_all_gather(v.contiguous(), dim=1)
+ gathered_mask = None
+ if mask is not None:
+ gathered_mask = sequence_model_parallel_all_gather(
+ mask.contiguous(), dim=1
+ )
+ if self.use_local_attention:
+ out = self.attn(q, k_full, v_full, attn_mask=gathered_mask)
+ else:
+ out = self.attn(
+ q,
+ k_full,
+ v_full,
+ attn_mask=gathered_mask,
+ skip_sequence_parallel_override=True,
+ )
+ elif self.use_local_attention:
out = self.attn(q, k, v, attn_mask=mask)
else:
- out = self.attn(q, k, v)
+ out = self.attn(
+ q,
+ k,
+ v,
+ attn_mask=mask,
+ skip_sequence_parallel_override=skip_sequence_parallel_override,
+ )
if perturbation_mask is not None:
out = out * perturbation_mask + v * (1 - perturbation_mask)
@@ -883,12 +910,15 @@ class LTX2TransformerBlock(nn.Module):
ca_audio_rotary_emb: Optional[Tuple[torch.Tensor, torch.Tensor]] = None,
encoder_attention_mask: Optional[torch.Tensor] = None,
audio_encoder_attention_mask: Optional[torch.Tensor] = None,
+ video_self_attention_mask: Optional[torch.Tensor] = None,
+ audio_self_attention_mask: Optional[torch.Tensor] = None,
a2v_cross_attention_mask: Optional[torch.Tensor] = None,
v2a_cross_attention_mask: Optional[torch.Tensor] = None,
skip_video_self_attn: bool = False,
skip_audio_self_attn: bool = False,
skip_a2v_cross_attn: bool = False,
skip_v2a_cross_attn: bool = False,
+ audio_replicated_for_sp: bool = False,
) -> tuple[torch.Tensor, torch.Tensor]:
batch_size = hidden_states.size(0)
@@ -902,8 +932,10 @@ class LTX2TransformerBlock(nn.Module):
)
attn_hidden_states = self.attn1(
norm_hidden_states,
+ mask=video_self_attention_mask,
pe=video_rotary_emb,
all_perturbed=skip_video_self_attn,
+ gather_context_kv_for_sp=audio_replicated_for_sp,
)
hidden_states = hidden_states + attn_hidden_states * vgate_msa
@@ -915,8 +947,10 @@ class LTX2TransformerBlock(nn.Module):
)
attn_audio_hidden_states = self.audio_attn1(
norm_audio_hidden_states,
+ mask=audio_self_attention_mask,
pe=audio_rotary_emb,
all_perturbed=skip_audio_self_attn,
+ skip_sequence_parallel_override=audio_replicated_for_sp,
)
audio_hidden_states = audio_hidden_states + attn_audio_hidden_states * agate_msa
# 2. Prompt Cross-Attention
@@ -1061,6 +1095,7 @@ class LTX2TransformerBlock(nn.Module):
pe=ca_video_rotary_emb,
k_pe=ca_audio_rotary_emb,
mask=a2v_cross_attention_mask,
+ skip_sequence_parallel_override=audio_replicated_for_sp,
)
hidden_states = hidden_states + a2v_gate * a2v_attn_hidden_states
@@ -1079,6 +1114,7 @@ class LTX2TransformerBlock(nn.Module):
pe=ca_audio_rotary_emb,
k_pe=ca_video_rotary_emb,
mask=v2a_cross_attention_mask,
+ gather_context_kv_for_sp=audio_replicated_for_sp,
)
audio_hidden_states = (
audio_hidden_states + v2a_gate * v2a_attn_hidden_states
@@ -1415,6 +1451,45 @@ class LTX2VideoTransformer3DModel(CachableDiT, OffloadableDiTMixin):
self.layer_names = ["transformer_blocks"]
+ def _maybe_quantize_video_rope_coords(
+ self,
+ video_coords: torch.Tensor,
+ hidden_device: torch.device,
+ hidden_dtype: torch.dtype,
+ ) -> torch.Tensor:
+ if self.quantize_video_rope_coords_to_hidden_dtype:
+ return video_coords.to(device=hidden_device, dtype=hidden_dtype)
+ return video_coords.to(device=hidden_device)
+
+ def _get_av_ca_gate_timestep_factor(self) -> float:
+ ltx_variant = str(getattr(self.config.arch_config, "ltx_variant", "ltx_2"))
+ if ltx_variant == "ltx_2_3":
+ return self.av_ca_timestep_scale_multiplier / self.timestep_scale_multiplier
+ return float(self.av_ca_timestep_scale_multiplier)
+
+ def _get_av_ca_timesteps(
+ self,
+ timestep: torch.Tensor,
+ audio_timestep: torch.Tensor,
+ prompt_timestep: torch.Tensor | None,
+ audio_prompt_timestep: torch.Tensor | None,
+ ) -> tuple[torch.Tensor, torch.Tensor]:
+ ltx_variant = str(getattr(self.config.arch_config, "ltx_variant", "ltx_2"))
+ if ltx_variant != "ltx_2_3":
+ return timestep, audio_timestep
+
+ video_timestep = (
+ self._collapse_prompt_timestep(timestep)
+ if prompt_timestep is None
+ else prompt_timestep
+ )
+ audio_timestep_for_ca = (
+ self._collapse_prompt_timestep(audio_timestep)
+ if audio_prompt_timestep is None
+ else audio_prompt_timestep
+ )
+ return video_timestep, audio_timestep_for_ca
+
def forward(
self,
hidden_states: torch.Tensor,
@@ -1423,6 +1498,8 @@ class LTX2VideoTransformer3DModel(CachableDiT, OffloadableDiTMixin):
audio_encoder_hidden_states: torch.Tensor,
timestep: torch.LongTensor,
audio_timestep: Optional[torch.LongTensor] = None,
+ prompt_timestep: Optional[torch.Tensor] = None,
+ audio_prompt_timestep: Optional[torch.Tensor] = None,
encoder_attention_mask: Optional[torch.Tensor] = None,
audio_encoder_attention_mask: Optional[torch.Tensor] = None,
num_frames: Optional[int] = None,
@@ -1432,10 +1509,15 @@ class LTX2VideoTransformer3DModel(CachableDiT, OffloadableDiTMixin):
audio_num_frames: Optional[int] = None,
video_coords: Optional[torch.Tensor] = None,
audio_coords: Optional[torch.Tensor] = None,
+ video_self_attention_mask: Optional[torch.Tensor] = None,
+ audio_self_attention_mask: Optional[torch.Tensor] = None,
+ a2v_cross_attention_mask: Optional[torch.Tensor] = None,
+ v2a_cross_attention_mask: Optional[torch.Tensor] = None,
skip_video_self_attn_blocks: Optional[tuple[int, ...]] = None,
skip_audio_self_attn_blocks: Optional[tuple[int, ...]] = None,
disable_a2v_cross_attn: bool = False,
disable_v2a_cross_attn: bool = False,
+ audio_replicated_for_sp: bool = False,
**kwargs,
) -> tuple[torch.Tensor | None, torch.Tensor | None]:
@@ -1480,14 +1562,10 @@ class LTX2VideoTransformer3DModel(CachableDiT, OffloadableDiTMixin):
device=audio_hidden_states.device,
)
- if self.quantize_video_rope_coords_to_hidden_dtype:
- video_coords = video_coords.to(
- device=hidden_states.device, dtype=hidden_states.dtype
- )
- else:
- video_coords = video_coords.to(device=hidden_states.device)
+ video_coords = self._maybe_quantize_video_rope_coords(
+ video_coords, hidden_states.device, hidden_states.dtype
+ )
audio_coords = audio_coords.to(device=audio_hidden_states.device)
-
video_rotary_emb = self.rope(video_coords, device=hidden_states.device)
audio_rotary_emb = self.audio_rope(
audio_coords, device=audio_hidden_states.device
@@ -1506,6 +1584,7 @@ class LTX2VideoTransformer3DModel(CachableDiT, OffloadableDiTMixin):
# 3.1. Prepare global modality (video and audio) timestep embedding and modulation parameters
temb, embedded_timestep = self.adaln_single(
timestep.flatten(),
+ hidden_dtype=hidden_states.dtype,
)
temb = temb.view(batch_size, -1, temb.size(-1))
embedded_timestep = embedded_timestep.view(
@@ -1513,7 +1592,8 @@ class LTX2VideoTransformer3DModel(CachableDiT, OffloadableDiTMixin):
)
temb_audio, audio_embedded_timestep = self.audio_adaln_single(
- audio_timestep.flatten()
+ audio_timestep.flatten(),
+ hidden_dtype=audio_hidden_states.dtype,
)
temb_audio = temb_audio.view(batch_size, -1, temb_audio.size(-1))
audio_embedded_timestep = audio_embedded_timestep.view(
@@ -1522,13 +1602,21 @@ class LTX2VideoTransformer3DModel(CachableDiT, OffloadableDiTMixin):
temb_prompt = None
temb_audio_prompt = None
if self.prompt_adaln_single is not None:
- prompt_timestep = self._collapse_prompt_timestep(timestep)
+ prompt_timestep = (
+ self._collapse_prompt_timestep(timestep)
+ if prompt_timestep is None
+ else prompt_timestep
+ )
temb_prompt, _ = self.prompt_adaln_single(
prompt_timestep.flatten(), hidden_dtype=hidden_states.dtype
)
temb_prompt = temb_prompt.view(batch_size, -1, temb_prompt.size(-1))
if self.audio_prompt_adaln_single is not None:
- audio_prompt_timestep = self._collapse_prompt_timestep(audio_timestep)
+ audio_prompt_timestep = (
+ self._collapse_prompt_timestep(audio_timestep)
+ if audio_prompt_timestep is None
+ else audio_prompt_timestep
+ )
temb_audio_prompt, _ = self.audio_prompt_adaln_single(
audio_prompt_timestep.flatten(),
hidden_dtype=audio_hidden_states.dtype,
@@ -1539,28 +1627,35 @@ class LTX2VideoTransformer3DModel(CachableDiT, OffloadableDiTMixin):
# 3.2. Prepare global modality cross attention modulation parameters
hidden_dtype = hidden_states.dtype
+ av_ca_video_timestep, av_ca_audio_timestep = self._get_av_ca_timesteps(
+ timestep,
+ audio_timestep,
+ prompt_timestep,
+ audio_prompt_timestep,
+ )
temb_ca_scale_shift, _ = self.av_ca_video_scale_shift_adaln_single(
- timestep.flatten(), hidden_dtype=hidden_dtype
+ av_ca_video_timestep.flatten(), hidden_dtype=hidden_dtype
)
temb_ca_scale_shift = temb_ca_scale_shift.view(
batch_size, -1, temb_ca_scale_shift.shape[-1]
)
+ av_ca_gate_factor = self._get_av_ca_gate_timestep_factor()
temb_ca_gate, _ = self.av_ca_a2v_gate_adaln_single(
- timestep.flatten() * self.av_ca_timestep_scale_multiplier,
+ av_ca_video_timestep.flatten() * av_ca_gate_factor,
hidden_dtype=hidden_dtype,
)
temb_ca_gate = temb_ca_gate.view(batch_size, -1, temb_ca_gate.shape[-1])
temb_ca_audio_scale_shift, _ = self.av_ca_audio_scale_shift_adaln_single(
- audio_timestep.flatten(), hidden_dtype=audio_hidden_states.dtype
+ av_ca_audio_timestep.flatten(), hidden_dtype=audio_hidden_states.dtype
)
temb_ca_audio_scale_shift = temb_ca_audio_scale_shift.view(
batch_size, -1, temb_ca_audio_scale_shift.shape[-1]
)
temb_ca_audio_gate, _ = self.av_ca_v2a_gate_adaln_single(
- audio_timestep.flatten() * self.av_ca_timestep_scale_multiplier,
+ av_ca_audio_timestep.flatten() * av_ca_gate_factor,
hidden_dtype=audio_hidden_states.dtype,
)
temb_ca_audio_gate = temb_ca_audio_gate.view(
@@ -1600,10 +1695,15 @@ class LTX2VideoTransformer3DModel(CachableDiT, OffloadableDiTMixin):
ca_audio_rotary_emb=ca_audio_rotary_emb,
encoder_attention_mask=encoder_attention_mask,
audio_encoder_attention_mask=audio_encoder_attention_mask,
+ video_self_attention_mask=video_self_attention_mask,
+ audio_self_attention_mask=audio_self_attention_mask,
+ a2v_cross_attention_mask=a2v_cross_attention_mask,
+ v2a_cross_attention_mask=v2a_cross_attention_mask,
skip_video_self_attn=block.idx in skip_video_self_attn_blocks,
skip_audio_self_attn=block.idx in skip_audio_self_attn_blocks,
skip_a2v_cross_attn=disable_a2v_cross_attn,
skip_v2a_cross_attn=disable_v2a_cross_attn,
+ audio_replicated_for_sp=audio_replicated_for_sp,
)
# 6. Output layers
diff --git a/python/sglang/multimodal_gen/runtime/pipelines/ltx_2_pipeline.py b/python/sglang/multimodal_gen/runtime/pipelines/ltx_2_pipeline.py
index 80173fe13..406a9e9c1 100644
--- a/python/sglang/multimodal_gen/runtime/pipelines/ltx_2_pipeline.py
+++ b/python/sglang/multimodal_gen/runtime/pipelines/ltx_2_pipeline.py
@@ -46,9 +46,9 @@ def _resolve_ltx2_two_stage_component_paths(
if "spatial_upsampler" not in resolved:
spatial_candidates = [
- os.path.join(model_path, "latent_upsampler"),
- os.path.join(model_path, "ltx-2.3-spatial-upscaler-x2-1.1.safetensors"),
os.path.join(model_path, "ltx-2.3-spatial-upscaler-x2-1.0.safetensors"),
+ os.path.join(model_path, "ltx-2.3-spatial-upscaler-x2-1.1.safetensors"),
+ os.path.join(model_path, "latent_upsampler"),
os.path.join(model_path, "ltx-2-spatial-upscaler-x2-1.0.safetensors"),
]
for candidate in spatial_candidates:
@@ -59,6 +59,7 @@ def _resolve_ltx2_two_stage_component_paths(
if "distilled_lora" not in resolved:
distilled_lora_candidates = [
+ os.path.join(model_path, "ltx-2.3-20b-distilled-lora-384.safetensors"),
os.path.join(model_path, "ltx-2.3-22b-distilled-lora-384.safetensors"),
os.path.join(model_path, "ltx-2-19b-distilled-lora-384.safetensors"),
]
@@ -264,6 +265,12 @@ class LTX2TwoStagePipeline(_BaseLTX2Pipeline):
pipeline_name = "LTX2TwoStagePipeline"
STAGE_2_DISTILLED_SIGMA_VALUES = [0.909375, 0.725, 0.421875, 0.0]
+ @staticmethod
+ def _should_merge_stage2_distilled_lora(server_args: ServerArgs) -> bool:
+ return is_ltx23_native_variant(
+ server_args.pipeline_config.vae_config.arch_config
+ )
+
def initialize_pipeline(self, server_args: ServerArgs):
super().initialize_pipeline(server_args)
server_args.component_paths = _resolve_ltx2_two_stage_component_paths(
@@ -332,10 +339,12 @@ class LTX2TwoStagePipeline(_BaseLTX2Pipeline):
lora_path=lora_paths,
target=lora_targets,
strength=lora_strengths,
- # Keep the distilled adapter unmerged when it is the only active LoRA.
- # Merging it into the base weights makes the subsequent switch back to
- # stage 1 depend on unmerge bookkeeping instead of the original base.
- merge_weights=self._stage1_lora_path is not None,
+ # Official LTX-2.3 two-stage builds stage 2 with distilled LoRA fused
+ # into the transformer weights. Legacy LTX-2 should keep the
+ # preexisting unmerged behavior to avoid regressing stage 2 quality.
+ merge_weights=self._should_merge_stage2_distilled_lora(
+ self.server_args
+ ),
)
else:
raise ValueError(f"Unknown LTX2 two-stage LoRA phase: {phase}")
diff --git a/python/sglang/multimodal_gen/runtime/pipelines_core/schedule_batch.py b/python/sglang/multimodal_gen/runtime/pipelines_core/schedule_batch.py
index 516445b9e..674cc1934 100644
--- a/python/sglang/multimodal_gen/runtime/pipelines_core/schedule_batch.py
+++ b/python/sglang/multimodal_gen/runtime/pipelines_core/schedule_batch.py
@@ -102,11 +102,16 @@ class Req:
audio_latents: torch.Tensor | None = None
audio_noise: torch.Tensor | None = None
raw_audio_latent_shape: tuple[int, ...] | None = None
+ did_sp_shard_audio_latents: bool = False
+ sp_audio_start_frame: int = 0
+ sp_audio_orig_num_frames: int = 0
# Audio Parameters
generate_audio: bool = True
raw_latent_shape: torch.Tensor | None = None
+ did_sp_shard_latents: bool = False
+ sp_video_start_frame: int = 0
noise_pred: torch.Tensor | None = None
# vae-encoded condition image
image_latent: torch.Tensor | list[torch.Tensor] | None = None
diff --git a/python/sglang/multimodal_gen/runtime/pipelines_core/stages/denoising.py b/python/sglang/multimodal_gen/runtime/pipelines_core/stages/denoising.py
index 014f31079..7f385c967 100644
--- a/python/sglang/multimodal_gen/runtime/pipelines_core/stages/denoising.py
+++ b/python/sglang/multimodal_gen/runtime/pipelines_core/stages/denoising.py
@@ -855,9 +855,21 @@ class DenoisingStage(PipelineStage):
# image_latent must be sharded consistently with latents when it is
# concatenated along the sequence dimension in the denoising loop.
if batch.image_latent is not None:
+ sp_video_metadata = {
+ name: getattr(batch, name)
+ for name in (
+ "sp_video_latent_num_frames",
+ "sp_video_start_frame",
+ "sp_video_tokens_per_frame",
+ "sp_video_valid_token_count",
+ )
+ if hasattr(batch, name)
+ }
batch.image_latent, _ = server_args.pipeline_config.shard_latents_for_sp(
batch, batch.image_latent
)
+ for name, value in sp_video_metadata.items():
+ setattr(batch, name, value)
def _postprocess_sp_latents(
self,
diff --git a/python/sglang/multimodal_gen/runtime/pipelines_core/stages/denoising_av.py b/python/sglang/multimodal_gen/runtime/pipelines_core/stages/denoising_av.py
index e42c0ae9d..a688d8f7e 100644
--- a/python/sglang/multimodal_gen/runtime/pipelines_core/stages/denoising_av.py
+++ b/python/sglang/multimodal_gen/runtime/pipelines_core/stages/denoising_av.py
@@ -17,6 +17,7 @@ from safetensors.torch import load_file as safetensors_load_file
from sglang.multimodal_gen.configs.pipeline_configs.ltx_2 import (
is_ltx23_native_variant,
)
+from sglang.multimodal_gen.runtime.distributed import get_sp_world_size
from sglang.multimodal_gen.runtime.managers.forward_context import set_forward_context
from sglang.multimodal_gen.runtime.models.vaes.ltx_2_3_condition_encoder import (
LTX23VideoConditionEncoder,
@@ -195,6 +196,71 @@ class LTX2AVDenoisingStage(DenoisingStage):
repeat_factor = int(target_batch_size) // int(tensor.shape[0])
return tensor.repeat(repeat_factor, *([1] * (tensor.ndim - 1)))
+ @staticmethod
+ def _build_ltx2_sp_padding_mask(
+ batch: Req,
+ *,
+ seq_len: int,
+ batch_size: int,
+ key: str,
+ device: torch.device,
+ ) -> torch.Tensor | None:
+ valid = getattr(batch, key, None)
+ if valid is None:
+ return None
+ valid = int(valid)
+ if valid <= 0 or valid >= int(seq_len):
+ return None
+ mask = torch.ones(
+ (batch_size, int(seq_len)), device=device, dtype=torch.float32
+ )
+ mask[:, valid:] = 0.0
+ return mask
+
+ @staticmethod
+ def _get_ltx_prompt_attention_mask(
+ batch: Req,
+ *,
+ is_ltx23_variant: bool,
+ negative: bool = False,
+ ) -> torch.Tensor | None:
+ if is_ltx23_variant:
+ return None
+ return (
+ batch.negative_attention_mask if negative else batch.prompt_attention_mask
+ )
+
+ @classmethod
+ def _should_use_ltx23_legacy_one_stage(
+ cls,
+ server_args: ServerArgs,
+ pipeline_name: str | None,
+ ) -> bool:
+ if not is_ltx23_native_variant(
+ server_args.pipeline_config.vae_config.arch_config
+ ):
+ return False
+ if server_args.pipeline_class_name == "LTX2TwoStagePipeline":
+ return False
+ return pipeline_name != "LTX2TwoStagePipeline"
+
+ @classmethod
+ def _should_shard_ltx23_legacy_one_stage_audio_latents(
+ cls,
+ batch: Req,
+ server_args: ServerArgs,
+ ) -> bool:
+ return bool(
+ get_sp_world_size() > 1
+ and is_ltx23_native_variant(
+ server_args.pipeline_config.vae_config.arch_config
+ )
+ and cls._should_use_ltx23_legacy_one_stage(server_args, None)
+ and server_args.pipeline_config.can_shard_audio_latents_for_sp(
+ batch.audio_latents
+ )
+ )
+
@classmethod
def _ltx2_calculate_guided_x0(
cls,
@@ -306,6 +372,15 @@ class LTX2AVDenoisingStage(DenoisingStage):
return True
return int(getattr(batch, "sp_video_start_frame", 0)) == 0
+ @staticmethod
+ def _should_replicate_ltx23_audio_for_sp(
+ batch: Req,
+ server_args: ServerArgs,
+ *,
+ is_ltx23_variant: bool,
+ ) -> bool:
+ return False
+
def _get_condition_image_encoder(
self,
server_args: ServerArgs,
@@ -477,23 +552,12 @@ class LTX2AVDenoisingStage(DenoisingStage):
self._condition_image_encoder = condition_image_encoder.to("cpu")
@torch.no_grad()
- def forward(self, batch: Req, server_args: ServerArgs) -> Req:
- """
- Run the denoising loop.
-
- Args:
- batch: The current batch information.
- server_args: The inference arguments.
-
- Returns:
- The batch with denoised latents.
- """
- # Disable cache-dit for image-conditioned requests (TI2V-style) for correctness/debuggability.
- self._disable_cache_dit_for_request = batch.image_path is not None
-
- # Prepare variables for the denoising loop
-
- prepared_vars = self._prepare_denoising_loop(batch, server_args)
+ def _forward_ltx23_legacy_one_stage(
+ self,
+ batch: Req,
+ server_args: ServerArgs,
+ prepared_vars: dict[str, object],
+ ) -> Req:
target_dtype = prepared_vars["target_dtype"]
autocast_enabled = prepared_vars["autocast_enabled"]
timesteps = prepared_vars["timesteps"]
@@ -503,16 +567,12 @@ class LTX2AVDenoisingStage(DenoisingStage):
boundary_timestep = prepared_vars["boundary_timestep"]
z = prepared_vars["z"]
reserved_frames_mask = prepared_vars["reserved_frames_mask"]
- stage = batch.extra.get("ltx2_phase", "stage1")
+ stage = "stage1"
audio_latents = batch.audio_latents
audio_scheduler = copy.deepcopy(self.scheduler)
+ batch.ltx23_audio_replicated_for_sp = False
+ batch.did_sp_shard_audio_latents = False
- # Prepare TI2V conditioning once (encode image -> patchify tokens).
- self._prepare_ltx2_image_latent(batch, server_args)
-
- # For LTX-2 packed token latents, SP sharding happens on the time dimension
- # (frames). The model must see local latent frames (RoPE offset is applied
- # inside the model using SP rank).
latent_num_frames_for_model = self._get_video_latent_num_frames_for_model(
batch=batch, server_args=server_args, latents=latents
)
@@ -525,15 +585,12 @@ class LTX2AVDenoisingStage(DenoisingStage):
// server_args.pipeline_config.vae_config.arch_config.spatial_compression_ratio
)
- # Initialize lists for ODE trajectory
trajectory_timesteps: list[torch.Tensor] = []
trajectory_latents: list[torch.Tensor] = []
trajectory_audio_latents: list[torch.Tensor] = []
- # Run denoising loop
denoising_start_time = time.time()
- # to avoid device-sync caused by timestep comparison
is_warmup = batch.is_warmup
self.scheduler.set_begin_index(0)
audio_scheduler.set_begin_index(0)
@@ -547,15 +604,13 @@ class LTX2AVDenoisingStage(DenoisingStage):
if do_ti2v:
if not (isinstance(latents, torch.Tensor) and latents.ndim == 3):
raise ValueError("LTX-2 TI2V expects packed token latents [B, S, D].")
- use_zero_clean_latent = is_ltx23_native_variant(
- server_args.pipeline_config.vae_config.arch_config
- )
latents, denoise_mask, clean_latent = self._prepare_ltx2_ti2v_clean_state(
latents=latents,
image_latent=batch.image_latent,
num_img_tokens=num_img_tokens,
- zero_clean_latent=use_zero_clean_latent,
+ zero_clean_latent=True,
)
+
with torch.autocast(
device_type=current_platform.device_type,
dtype=target_dtype,
@@ -572,20 +627,18 @@ class LTX2AVDenoisingStage(DenoisingStage):
):
t_int = int(t_host.item())
t_device = timesteps[i]
- current_model, current_guidance_scale = (
- self._select_and_manage_model(
- t_int=t_int,
- boundary_timestep=boundary_timestep,
- server_args=server_args,
- batch=batch,
- )
+ (
+ current_model,
+ current_guidance_scale,
+ ) = self._select_and_manage_model(
+ t_int=t_int,
+ boundary_timestep=boundary_timestep,
+ server_args=server_args,
+ batch=batch,
)
- # Predict noise residual
attn_metadata = self._build_attn_metadata(i, batch, server_args)
- # === LTX-2 sigma-space Euler step (flow matching) ===
- # Use scheduler-generated sigmas (includes terminal sigma=0).
sigmas = getattr(self.scheduler, "sigmas", None)
if sigmas is None or not isinstance(sigmas, torch.Tensor):
raise ValueError(
@@ -604,7 +657,6 @@ class LTX2AVDenoisingStage(DenoisingStage):
)
latent_num_frames = latent_num_frames_for_model
- # Audio latent dims
if audio_latent_model_input.ndim == 3:
audio_num_frames_latent = int(
audio_latent_model_input.shape[1]
@@ -618,7 +670,6 @@ class LTX2AVDenoisingStage(DenoisingStage):
f"Unexpected audio latents rank: {audio_latent_model_input.ndim}, shape={tuple(audio_latent_model_input.shape)}"
)
- # LTX-2 model can generate coords internally.
video_coords = None
audio_coords = None
@@ -698,12 +749,14 @@ class LTX2AVDenoisingStage(DenoisingStage):
model_video = model_video.float()
model_audio = model_audio.float()
if batch.do_classifier_free_guidance:
- model_video_uncond, model_video_text = (
- model_video.chunk(2)
- )
- model_audio_uncond, model_audio_text = (
- model_audio.chunk(2)
- )
+ (
+ model_video_uncond,
+ model_video_text,
+ ) = model_video.chunk(2)
+ (
+ model_audio_uncond,
+ model_audio_text,
+ ) = model_audio.chunk(2)
model_video = model_video_uncond + (
batch.guidance_scale
* (model_video_text - model_video_uncond)
@@ -714,8 +767,6 @@ class LTX2AVDenoisingStage(DenoisingStage):
)
v_pos = model_video
a_v_pos = model_audio
- v_neg = None
- a_v_neg = None
latents = self.scheduler.step(
v_pos, t_device, latents, return_dict=False
@@ -743,24 +794,56 @@ class LTX2AVDenoisingStage(DenoisingStage):
if not is_warmup:
self.step_profile()
continue
- else:
- # Follow ltx-pipelines structure: separate pos/neg forward passes,
- # then apply CFG on denoised (x0) predictions.
- encoder_hidden_states = batch.prompt_embeds[0]
- audio_encoder_hidden_states = batch.audio_prompt_embeds[0]
- encoder_attention_mask = batch.prompt_attention_mask
- with set_forward_context(
- current_timestep=i, attn_metadata=attn_metadata
+
+ encoder_hidden_states = batch.prompt_embeds[0]
+ audio_encoder_hidden_states = batch.audio_prompt_embeds[0]
+ encoder_attention_mask = batch.prompt_attention_mask
+ with set_forward_context(
+ current_timestep=i, attn_metadata=attn_metadata
+ ):
+ v_pos, a_v_pos = current_model(
+ hidden_states=latent_model_input,
+ audio_hidden_states=audio_latent_model_input,
+ encoder_hidden_states=encoder_hidden_states,
+ audio_encoder_hidden_states=audio_encoder_hidden_states,
+ timestep=timestep_video,
+ audio_timestep=timestep_audio,
+ encoder_attention_mask=encoder_attention_mask,
+ audio_encoder_attention_mask=encoder_attention_mask,
+ num_frames=latent_num_frames,
+ height=latent_height,
+ width=latent_width,
+ fps=batch.fps,
+ audio_num_frames=audio_num_frames_latent,
+ video_coords=video_coords,
+ audio_coords=audio_coords,
+ return_latents=False,
+ return_dict=False,
+ )
+
+ if (
+ stage1_guider_params is not None
+ or batch.do_classifier_free_guidance
):
- v_pos, a_v_pos = current_model(
+ neg_encoder_hidden_states = (
+ batch.negative_prompt_embeds[0]
+ )
+ neg_audio_encoder_hidden_states = (
+ batch.negative_audio_prompt_embeds[0]
+ )
+ neg_encoder_attention_mask = (
+ batch.negative_attention_mask
+ )
+
+ v_neg, a_v_neg = current_model(
hidden_states=latent_model_input,
audio_hidden_states=audio_latent_model_input,
- encoder_hidden_states=encoder_hidden_states,
- audio_encoder_hidden_states=audio_encoder_hidden_states,
+ encoder_hidden_states=neg_encoder_hidden_states,
+ audio_encoder_hidden_states=neg_audio_encoder_hidden_states,
timestep=timestep_video,
audio_timestep=timestep_audio,
- encoder_attention_mask=encoder_attention_mask,
- audio_encoder_attention_mask=encoder_attention_mask,
+ encoder_attention_mask=neg_encoder_attention_mask,
+ audio_encoder_attention_mask=neg_encoder_attention_mask,
num_frames=latent_num_frames,
height=latent_height,
width=latent_width,
@@ -771,50 +854,16 @@ class LTX2AVDenoisingStage(DenoisingStage):
return_latents=False,
return_dict=False,
)
+ else:
+ v_neg = None
+ a_v_neg = None
- if (
- stage1_guider_params is not None
- or batch.do_classifier_free_guidance
- ):
- neg_encoder_hidden_states = (
- batch.negative_prompt_embeds[0]
- )
- neg_audio_encoder_hidden_states = (
- batch.negative_audio_prompt_embeds[0]
- )
- neg_encoder_attention_mask = (
- batch.negative_attention_mask
- )
-
- v_neg, a_v_neg = current_model(
- hidden_states=latent_model_input,
- audio_hidden_states=audio_latent_model_input,
- encoder_hidden_states=neg_encoder_hidden_states,
- audio_encoder_hidden_states=neg_audio_encoder_hidden_states,
- timestep=timestep_video,
- audio_timestep=timestep_audio,
- encoder_attention_mask=neg_encoder_attention_mask,
- audio_encoder_attention_mask=neg_encoder_attention_mask,
- num_frames=latent_num_frames,
- height=latent_height,
- width=latent_width,
- fps=batch.fps,
- audio_num_frames=audio_num_frames_latent,
- video_coords=video_coords,
- audio_coords=audio_coords,
- return_latents=False,
- return_dict=False,
- )
- else:
- v_neg = None
- a_v_neg = None
-
- v_pos = v_pos.float()
- a_v_pos = a_v_pos.float()
- if v_neg is not None:
- v_neg = v_neg.float()
- if a_v_neg is not None:
- a_v_neg = a_v_neg.float()
+ v_pos = v_pos.float()
+ a_v_pos = a_v_pos.float()
+ if v_neg is not None:
+ v_neg = v_neg.float()
+ if a_v_neg is not None:
+ a_v_neg = a_v_neg.float()
sigma_val = float(sigma.item())
video_sigma_for_x0: float | torch.Tensor = sigma_val
@@ -828,8 +877,6 @@ class LTX2AVDenoisingStage(DenoisingStage):
denoised_audio = self._ltx2_velocity_to_x0(
audio_latents, a_v_pos, sigma_val
)
- denoised_video_cond = denoised_video
- denoised_audio_cond = denoised_audio
denoised_video_neg = None
denoised_audio_neg = None
denoised_video_perturbed = None
@@ -1011,6 +1058,787 @@ class LTX2AVDenoisingStage(DenoisingStage):
batch.guidance_scale - 1.0
) * (denoised_audio - denoised_audio_neg)
+ if (
+ do_ti2v
+ and denoise_mask is not None
+ and clean_latent is not None
+ ):
+ denoised_video = (
+ denoised_video * denoise_mask
+ + clean_latent.float() * (1.0 - denoise_mask)
+ )
+ if sigma_val == 0.0:
+ v_video = torch.zeros_like(denoised_video)
+ v_audio = torch.zeros_like(denoised_audio)
+ else:
+ v_video = (
+ (latents.float() - denoised_video.float()) / sigma_val
+ ).to(latents.dtype)
+ v_audio = (
+ (audio_latents.float() - denoised_audio.float())
+ / sigma_val
+ ).to(audio_latents.dtype)
+
+ latents = (latents.float() + v_video.float() * dt).to(
+ dtype=latents.dtype
+ )
+ audio_latents = (
+ audio_latents.float() + v_audio.float() * dt
+ ).to(dtype=audio_latents.dtype)
+
+ latents = self.post_forward_for_ti2v_task(
+ batch, server_args, reserved_frames_mask, latents, z
+ )
+
+ if batch.return_trajectory_latents:
+ trajectory_timesteps.append(t_host)
+ trajectory_latents.append(latents)
+ if audio_latents is not None:
+ trajectory_audio_latents.append(audio_latents)
+
+ if i == num_timesteps - 1 or (
+ (i + 1) > num_warmup_steps
+ and (i + 1) % self.scheduler.order == 0
+ and progress_bar is not None
+ ):
+ progress_bar.update()
+
+ if not is_warmup:
+ self.step_profile()
+
+ denoising_end_time = time.time()
+
+ if num_timesteps > 0 and not is_warmup:
+ self.log_info(
+ "average time per step: %.4f seconds",
+ (denoising_end_time - denoising_start_time) / len(timesteps),
+ )
+
+ batch.audio_latents = audio_latents
+ self._post_denoising_loop(
+ batch=batch,
+ latents=latents,
+ trajectory_latents=trajectory_latents,
+ trajectory_timesteps=trajectory_timesteps,
+ trajectory_audio_latents=trajectory_audio_latents,
+ server_args=server_args,
+ is_warmup=is_warmup,
+ )
+
+ return batch
+
+ @torch.no_grad()
+ def forward(self, batch: Req, server_args: ServerArgs) -> Req:
+ """
+ Run the denoising loop.
+
+ Args:
+ batch: The current batch information.
+ server_args: The inference arguments.
+
+ Returns:
+ The batch with denoised latents.
+ """
+ # Disable cache-dit for image-conditioned requests (TI2V-style) for correctness/debuggability.
+ self._disable_cache_dit_for_request = batch.image_path is not None
+
+ # Prepare variables for the denoising loop
+ prepared_vars = self._prepare_denoising_loop(batch, server_args)
+ target_dtype = prepared_vars["target_dtype"]
+ autocast_enabled = prepared_vars["autocast_enabled"]
+ timesteps = prepared_vars["timesteps"]
+ num_inference_steps = prepared_vars["num_inference_steps"]
+ num_warmup_steps = prepared_vars["num_warmup_steps"]
+ latents = prepared_vars["latents"]
+ boundary_timestep = prepared_vars["boundary_timestep"]
+ z = prepared_vars["z"]
+ reserved_frames_mask = prepared_vars["reserved_frames_mask"]
+ is_ltx23_variant = is_ltx23_native_variant(
+ server_args.pipeline_config.vae_config.arch_config
+ )
+ phase = batch.extra.get("ltx2_phase")
+ pipeline = self.pipeline() if self.pipeline else None
+ pipeline_name = pipeline.pipeline_name if pipeline is not None else None
+ use_ltx23_legacy_one_stage = self._should_use_ltx23_legacy_one_stage(
+ server_args, pipeline_name
+ )
+ stage = (
+ phase
+ if phase is not None
+ else ("stage1" if use_ltx23_legacy_one_stage else "one_stage")
+ )
+ audio_latents = batch.audio_latents
+ audio_scheduler = copy.deepcopy(self.scheduler)
+
+ self._prepare_ltx2_image_latent(batch, server_args)
+ if use_ltx23_legacy_one_stage:
+ return self._forward_ltx23_legacy_one_stage(
+ batch, server_args, prepared_vars
+ )
+ do_ti2v = self._should_apply_ltx2_ti2v(batch)
+ replicate_audio_for_sp = self._should_replicate_ltx23_audio_for_sp(
+ batch,
+ server_args,
+ is_ltx23_variant=is_ltx23_variant,
+ )
+ batch.ltx23_audio_replicated_for_sp = bool(replicate_audio_for_sp)
+
+ if (
+ is_ltx23_variant
+ and get_sp_world_size() > 1
+ and server_args.pipeline_config.can_shard_audio_latents_for_sp(
+ batch.audio_latents
+ )
+ and not replicate_audio_for_sp
+ and not use_ltx23_legacy_one_stage
+ ):
+ (
+ batch.audio_latents,
+ batch.did_sp_shard_audio_latents,
+ ) = server_args.pipeline_config.shard_audio_latents_for_sp(
+ batch, batch.audio_latents
+ )
+ audio_latents = batch.audio_latents
+ else:
+ batch.did_sp_shard_audio_latents = False
+
+ # For LTX-2 packed token latents, SP sharding happens on the time dimension
+ # (frames). The model must see local latent frames (RoPE offset is applied
+ # inside the model using SP rank).
+ latent_num_frames_for_model = self._get_video_latent_num_frames_for_model(
+ batch=batch, server_args=server_args, latents=latents
+ )
+ latent_height = (
+ batch.height
+ // server_args.pipeline_config.vae_config.arch_config.spatial_compression_ratio
+ )
+ latent_width = (
+ batch.width
+ // server_args.pipeline_config.vae_config.arch_config.spatial_compression_ratio
+ )
+
+ # Initialize lists for ODE trajectory
+ trajectory_timesteps: list[torch.Tensor] = []
+ trajectory_latents: list[torch.Tensor] = []
+ trajectory_audio_latents: list[torch.Tensor] = []
+
+ # Run denoising loop
+ denoising_start_time = time.time()
+
+ # to avoid device-sync caused by timestep comparison
+ is_warmup = batch.is_warmup
+ self.scheduler.set_begin_index(0)
+ audio_scheduler.set_begin_index(0)
+ timesteps_cpu = timesteps.cpu()
+ num_timesteps = timesteps_cpu.shape[0]
+
+ num_img_tokens = int(getattr(batch, "ltx2_num_image_tokens", 0))
+ denoise_mask = None
+ clean_latent = None
+ if do_ti2v:
+ if not (isinstance(latents, torch.Tensor) and latents.ndim == 3):
+ raise ValueError("LTX-2 TI2V expects packed token latents [B, S, D].")
+ use_zero_clean_latent = is_ltx23_native_variant(
+ server_args.pipeline_config.vae_config.arch_config
+ )
+ latents, denoise_mask, clean_latent = self._prepare_ltx2_ti2v_clean_state(
+ latents=latents,
+ image_latent=batch.image_latent,
+ num_img_tokens=num_img_tokens,
+ zero_clean_latent=use_zero_clean_latent,
+ )
+ with torch.autocast(
+ device_type=current_platform.device_type,
+ dtype=target_dtype,
+ enabled=autocast_enabled,
+ ):
+ with self.progress_bar(total=num_inference_steps) as progress_bar:
+ for i, t_host in enumerate(timesteps_cpu):
+ with StageProfiler(
+ f"denoising_step_{i}",
+ logger=logger,
+ metrics=batch.metrics,
+ perf_dump_path_provided=batch.perf_dump_path is not None,
+ record_as_step=True,
+ ):
+ t_int = int(t_host.item())
+ t_device = timesteps[i]
+ (
+ current_model,
+ current_guidance_scale,
+ ) = self._select_and_manage_model(
+ t_int=t_int,
+ boundary_timestep=boundary_timestep,
+ server_args=server_args,
+ batch=batch,
+ )
+
+ # Predict noise residual
+ attn_metadata = self._build_attn_metadata(i, batch, server_args)
+
+ # === LTX-2 sigma-space Euler step (flow matching) ===
+ # Use scheduler-generated sigmas (includes terminal sigma=0).
+ sigmas = getattr(self.scheduler, "sigmas", None)
+ if sigmas is None or not isinstance(sigmas, torch.Tensor):
+ raise ValueError(
+ "Expected scheduler.sigmas to be a tensor for LTX-2."
+ )
+ sigma = sigmas[i].to(device=latents.device, dtype=torch.float32)
+ sigma_next = sigmas[i + 1].to(
+ device=latents.device, dtype=torch.float32
+ )
+ dt = sigma_next - sigma
+
+ latent_model_input = latents.to(target_dtype)
+ audio_latent_model_input = audio_latents.to(target_dtype)
+ stage1_guider_params = self._get_ltx2_stage1_guider_params(
+ batch, server_args, stage
+ )
+ latent_num_frames = latent_num_frames_for_model
+
+ # Audio latent dims
+ if audio_latent_model_input.ndim == 3:
+ audio_num_frames_latent = int(
+ audio_latent_model_input.shape[1]
+ )
+ elif audio_latent_model_input.ndim == 4:
+ audio_num_frames_latent = int(
+ audio_latent_model_input.shape[2]
+ )
+ else:
+ raise ValueError(
+ f"Unexpected audio latents rank: {audio_latent_model_input.ndim}, shape={tuple(audio_latent_model_input.shape)}"
+ )
+
+ video_coords = None
+ audio_coords = None
+ if not use_ltx23_legacy_one_stage:
+ video_coords = server_args.pipeline_config.prepare_video_rope_coords_for_sp(
+ current_model,
+ batch,
+ latent_model_input,
+ num_frames=latent_num_frames,
+ height=latent_height,
+ width=latent_width,
+ )
+ audio_coords = server_args.pipeline_config.prepare_audio_rope_coords_for_sp(
+ current_model,
+ batch,
+ audio_latent_model_input,
+ num_frames=audio_num_frames_latent,
+ )
+
+ batch_size = int(latent_model_input.shape[0])
+ video_num_tokens = int(latent_model_input.shape[1])
+ is_ltx23_variant = is_ltx23_native_variant(
+ server_args.pipeline_config.vae_config.arch_config
+ )
+ timestep = t_device.expand(batch_size)
+ if do_ti2v and denoise_mask is not None:
+ timestep_video = timestep.unsqueeze(
+ -1
+ ) * denoise_mask.squeeze(-1)
+ elif is_ltx23_variant and not use_ltx23_legacy_one_stage:
+ timestep_video = timestep.view(batch_size, 1).expand(
+ batch_size, video_num_tokens
+ )
+ else:
+ timestep_video = timestep
+
+ if (
+ is_ltx23_variant
+ and not use_ltx23_legacy_one_stage
+ and audio_latent_model_input.ndim == 3
+ ):
+ audio_num_tokens = int(audio_latent_model_input.shape[1])
+ timestep_audio = timestep.view(batch_size, 1).expand(
+ batch_size, audio_num_tokens
+ )
+ else:
+ timestep_audio = timestep
+ prompt_timestep_video = None
+ prompt_timestep_audio = None
+ if is_ltx23_variant and not use_ltx23_legacy_one_stage:
+ timestep_scale_multiplier = float(
+ getattr(
+ current_model, "timestep_scale_multiplier", 1000
+ )
+ )
+ prompt_timestep_video = (
+ sigma.to(
+ device=latent_model_input.device,
+ dtype=torch.float32,
+ )
+ * timestep_scale_multiplier
+ ).expand(batch_size)
+ prompt_timestep_audio = (
+ sigma.to(
+ device=audio_latent_model_input.device,
+ dtype=torch.float32,
+ )
+ * timestep_scale_multiplier
+ ).expand(batch_size)
+
+ if use_ltx23_legacy_one_stage:
+ video_self_attention_mask = None
+ audio_self_attention_mask = None
+ a2v_cross_attention_mask = None
+ v2a_cross_attention_mask = None
+ else:
+ video_self_attention_mask = (
+ self._build_ltx2_sp_padding_mask(
+ batch,
+ seq_len=video_num_tokens,
+ batch_size=batch_size,
+ key="sp_video_valid_token_count",
+ device=latent_model_input.device,
+ )
+ )
+ audio_self_attention_mask = (
+ self._build_ltx2_sp_padding_mask(
+ batch,
+ seq_len=audio_num_frames_latent,
+ batch_size=batch_size,
+ key="sp_audio_valid_token_count",
+ device=audio_latent_model_input.device,
+ )
+ )
+ a2v_cross_attention_mask = audio_self_attention_mask
+ v2a_cross_attention_mask = video_self_attention_mask
+
+ def build_model_kwargs(
+ *,
+ encoder_hidden_states: torch.Tensor,
+ audio_encoder_hidden_states: torch.Tensor,
+ encoder_attention_mask: torch.Tensor | None,
+ skip_video_self_attn_blocks: tuple[int, ...] | None = None,
+ skip_audio_self_attn_blocks: tuple[int, ...] | None = None,
+ disable_a2v_cross_attn: bool = False,
+ disable_v2a_cross_attn: bool = False,
+ ) -> dict[str, object]:
+ kwargs: dict[str, object] = {
+ "hidden_states": latent_model_input,
+ "audio_hidden_states": audio_latent_model_input,
+ "encoder_hidden_states": encoder_hidden_states,
+ "audio_encoder_hidden_states": audio_encoder_hidden_states,
+ "timestep": timestep_video,
+ "audio_timestep": timestep_audio,
+ "encoder_attention_mask": encoder_attention_mask,
+ "audio_encoder_attention_mask": encoder_attention_mask,
+ "num_frames": latent_num_frames,
+ "height": latent_height,
+ "width": latent_width,
+ "fps": batch.fps,
+ "audio_num_frames": audio_num_frames_latent,
+ "video_coords": video_coords,
+ "audio_coords": audio_coords,
+ "return_latents": False,
+ "return_dict": False,
+ }
+ if not use_ltx23_legacy_one_stage:
+ kwargs.update(
+ {
+ "prompt_timestep": prompt_timestep_video,
+ "audio_prompt_timestep": prompt_timestep_audio,
+ "video_self_attention_mask": video_self_attention_mask,
+ "audio_self_attention_mask": audio_self_attention_mask,
+ "a2v_cross_attention_mask": a2v_cross_attention_mask,
+ "v2a_cross_attention_mask": v2a_cross_attention_mask,
+ "audio_replicated_for_sp": replicate_audio_for_sp,
+ "legacy_ltx23_one_stage_semantics": False,
+ }
+ )
+ if skip_video_self_attn_blocks is not None:
+ kwargs["skip_video_self_attn_blocks"] = (
+ skip_video_self_attn_blocks
+ )
+ if skip_audio_self_attn_blocks is not None:
+ kwargs["skip_audio_self_attn_blocks"] = (
+ skip_audio_self_attn_blocks
+ )
+ if disable_a2v_cross_attn:
+ kwargs["disable_a2v_cross_attn"] = True
+ if disable_v2a_cross_attn:
+ kwargs["disable_v2a_cross_attn"] = True
+ return kwargs
+
+ use_official_cfg_path = stage1_guider_params is None
+ if use_official_cfg_path:
+ encoder_hidden_states = batch.prompt_embeds[0]
+ audio_encoder_hidden_states = batch.audio_prompt_embeds[0]
+ encoder_attention_mask = (
+ self._get_ltx_prompt_attention_mask(
+ batch,
+ is_ltx23_variant=(
+ is_ltx23_variant
+ and not use_ltx23_legacy_one_stage
+ ),
+ )
+ )
+ if batch.do_classifier_free_guidance:
+ latent_model_input = torch.cat(
+ [latent_model_input] * 2, dim=0
+ )
+ audio_latent_model_input = torch.cat(
+ [audio_latent_model_input] * 2, dim=0
+ )
+ encoder_hidden_states = torch.cat(
+ [
+ batch.negative_prompt_embeds[0],
+ encoder_hidden_states,
+ ],
+ dim=0,
+ )
+ audio_encoder_hidden_states = torch.cat(
+ [
+ batch.negative_audio_prompt_embeds[0],
+ audio_encoder_hidden_states,
+ ],
+ dim=0,
+ )
+ if encoder_attention_mask is not None:
+ encoder_attention_mask = torch.cat(
+ [
+ self._get_ltx_prompt_attention_mask(
+ batch,
+ is_ltx23_variant=(
+ is_ltx23_variant
+ and not use_ltx23_legacy_one_stage
+ ),
+ negative=True,
+ ),
+ encoder_attention_mask,
+ ],
+ dim=0,
+ )
+ cfg_batch_size = int(latent_model_input.shape[0])
+ timestep_video = self._repeat_batch_dim(
+ timestep_video, cfg_batch_size
+ )
+ timestep_audio = self._repeat_batch_dim(
+ timestep_audio, cfg_batch_size
+ )
+ if prompt_timestep_video is not None:
+ prompt_timestep_video = self._repeat_batch_dim(
+ prompt_timestep_video, cfg_batch_size
+ )
+ if prompt_timestep_audio is not None:
+ prompt_timestep_audio = self._repeat_batch_dim(
+ prompt_timestep_audio, cfg_batch_size
+ )
+ if video_self_attention_mask is not None:
+ video_self_attention_mask = self._repeat_batch_dim(
+ video_self_attention_mask, cfg_batch_size
+ )
+ if audio_self_attention_mask is not None:
+ audio_self_attention_mask = self._repeat_batch_dim(
+ audio_self_attention_mask, cfg_batch_size
+ )
+ if a2v_cross_attention_mask is not None:
+ a2v_cross_attention_mask = self._repeat_batch_dim(
+ a2v_cross_attention_mask, cfg_batch_size
+ )
+ if v2a_cross_attention_mask is not None:
+ v2a_cross_attention_mask = self._repeat_batch_dim(
+ v2a_cross_attention_mask, cfg_batch_size
+ )
+
+ with set_forward_context(
+ current_timestep=i, attn_metadata=attn_metadata
+ ):
+ model_video, model_audio = current_model(
+ **build_model_kwargs(
+ encoder_hidden_states=encoder_hidden_states,
+ audio_encoder_hidden_states=audio_encoder_hidden_states,
+ encoder_attention_mask=encoder_attention_mask,
+ )
+ )
+
+ model_video = model_video.float()
+ model_audio = model_audio.float()
+ if batch.do_classifier_free_guidance:
+ (
+ model_video_uncond,
+ model_video_text,
+ ) = model_video.chunk(2)
+ (
+ model_audio_uncond,
+ model_audio_text,
+ ) = model_audio.chunk(2)
+ model_video = model_video_uncond + (
+ batch.guidance_scale
+ * (model_video_text - model_video_uncond)
+ )
+ model_audio = model_audio_uncond + (
+ batch.guidance_scale
+ * (model_audio_text - model_audio_uncond)
+ )
+ v_pos = model_video
+ a_v_pos = model_audio
+ v_neg = None
+ a_v_neg = None
+
+ latents = self.scheduler.step(
+ v_pos, t_device, latents, return_dict=False
+ )[0]
+ audio_latents = audio_scheduler.step(
+ a_v_pos, t_device, audio_latents, return_dict=False
+ )[0]
+ latents = self.post_forward_for_ti2v_task(
+ batch, server_args, reserved_frames_mask, latents, z
+ )
+
+ if batch.return_trajectory_latents:
+ trajectory_timesteps.append(t_host)
+ trajectory_latents.append(latents)
+ if audio_latents is not None:
+ trajectory_audio_latents.append(audio_latents)
+
+ if i == num_timesteps - 1 or (
+ (i + 1) > num_warmup_steps
+ and (i + 1) % self.scheduler.order == 0
+ and progress_bar is not None
+ ):
+ progress_bar.update()
+
+ if not is_warmup:
+ self.step_profile()
+ continue
+ else:
+ # Follow ltx-pipelines structure: separate pos/neg forward passes,
+ # then apply CFG on denoised (x0) predictions.
+ encoder_hidden_states = batch.prompt_embeds[0]
+ audio_encoder_hidden_states = batch.audio_prompt_embeds[0]
+ encoder_attention_mask = (
+ self._get_ltx_prompt_attention_mask(
+ batch,
+ is_ltx23_variant=(
+ is_ltx23_variant
+ and not use_ltx23_legacy_one_stage
+ ),
+ )
+ )
+ with set_forward_context(
+ current_timestep=i, attn_metadata=attn_metadata
+ ):
+ v_pos, a_v_pos = current_model(
+ **build_model_kwargs(
+ encoder_hidden_states=encoder_hidden_states,
+ audio_encoder_hidden_states=audio_encoder_hidden_states,
+ encoder_attention_mask=encoder_attention_mask,
+ )
+ )
+
+ if (
+ stage1_guider_params is not None
+ or batch.do_classifier_free_guidance
+ ):
+ neg_encoder_hidden_states = (
+ batch.negative_prompt_embeds[0]
+ )
+ neg_audio_encoder_hidden_states = (
+ batch.negative_audio_prompt_embeds[0]
+ )
+ neg_encoder_attention_mask = (
+ self._get_ltx_prompt_attention_mask(
+ batch,
+ is_ltx23_variant=(
+ is_ltx23_variant
+ and not use_ltx23_legacy_one_stage
+ ),
+ negative=True,
+ )
+ )
+
+ v_neg, a_v_neg = current_model(
+ **build_model_kwargs(
+ encoder_hidden_states=neg_encoder_hidden_states,
+ audio_encoder_hidden_states=neg_audio_encoder_hidden_states,
+ encoder_attention_mask=neg_encoder_attention_mask,
+ )
+ )
+ else:
+ v_neg = None
+ a_v_neg = None
+
+ v_pos = v_pos.float()
+ a_v_pos = a_v_pos.float()
+ if v_neg is not None:
+ v_neg = v_neg.float()
+ if a_v_neg is not None:
+ a_v_neg = a_v_neg.float()
+
+ sigma_val = float(sigma.item())
+ video_sigma_for_x0: float | torch.Tensor = sigma_val
+ if do_ti2v and denoise_mask is not None:
+ video_sigma_for_x0 = sigma.to(
+ device=latents.device, dtype=torch.float32
+ ) * denoise_mask.squeeze(-1)
+ denoised_video = self._ltx2_velocity_to_x0(
+ latents, v_pos, video_sigma_for_x0
+ )
+ denoised_audio = self._ltx2_velocity_to_x0(
+ audio_latents, a_v_pos, sigma_val
+ )
+ denoised_video_cond = denoised_video
+ denoised_audio_cond = denoised_audio
+ denoised_video_neg = None
+ denoised_audio_neg = None
+ denoised_video_perturbed = None
+ denoised_audio_perturbed = None
+ denoised_video_modality = None
+ denoised_audio_modality = None
+
+ if (
+ (
+ stage1_guider_params is not None
+ or batch.do_classifier_free_guidance
+ )
+ and v_neg is not None
+ and a_v_neg is not None
+ ):
+ denoised_video_neg = self._ltx2_velocity_to_x0(
+ latents, v_neg, video_sigma_for_x0
+ )
+ denoised_audio_neg = self._ltx2_velocity_to_x0(
+ audio_latents, a_v_neg, sigma_val
+ )
+ if stage1_guider_params is not None:
+ video_skip = self._ltx2_should_skip_step(
+ i, int(stage1_guider_params["video_skip_step"])
+ )
+ audio_skip = self._ltx2_should_skip_step(
+ i, int(stage1_guider_params["audio_skip_step"])
+ )
+
+ need_perturbed = (
+ float(stage1_guider_params["video_stg_scale"]) != 0.0
+ or float(stage1_guider_params["audio_stg_scale"]) != 0.0
+ )
+ if need_perturbed:
+ with set_forward_context(
+ current_timestep=i, attn_metadata=attn_metadata
+ ):
+ v_ptb, a_v_ptb = current_model(
+ **build_model_kwargs(
+ encoder_hidden_states=encoder_hidden_states,
+ audio_encoder_hidden_states=audio_encoder_hidden_states,
+ encoder_attention_mask=encoder_attention_mask,
+ skip_video_self_attn_blocks=tuple(
+ stage1_guider_params["video_stg_blocks"]
+ ),
+ skip_audio_self_attn_blocks=tuple(
+ stage1_guider_params["audio_stg_blocks"]
+ ),
+ )
+ )
+ denoised_video_perturbed = self._ltx2_velocity_to_x0(
+ latents, v_ptb.float(), video_sigma_for_x0
+ )
+ denoised_audio_perturbed = self._ltx2_velocity_to_x0(
+ audio_latents, a_v_ptb.float(), sigma_val
+ )
+
+ need_modality = (
+ float(stage1_guider_params["video_modality_scale"])
+ != 1.0
+ or float(stage1_guider_params["audio_modality_scale"])
+ != 1.0
+ )
+ if need_modality:
+ with set_forward_context(
+ current_timestep=i, attn_metadata=attn_metadata
+ ):
+ v_mod, a_v_mod = current_model(
+ **build_model_kwargs(
+ encoder_hidden_states=encoder_hidden_states,
+ audio_encoder_hidden_states=audio_encoder_hidden_states,
+ encoder_attention_mask=encoder_attention_mask,
+ disable_a2v_cross_attn=True,
+ disable_v2a_cross_attn=True,
+ )
+ )
+ denoised_video_modality = self._ltx2_velocity_to_x0(
+ latents, v_mod.float(), video_sigma_for_x0
+ )
+ denoised_audio_modality = self._ltx2_velocity_to_x0(
+ audio_latents, a_v_mod.float(), sigma_val
+ )
+
+ if not video_skip:
+ denoised_video = self._ltx2_calculate_guided_x0(
+ cond=denoised_video,
+ uncond_text=(
+ denoised_video_neg
+ if denoised_video_neg is not None
+ else denoised_video
+ ),
+ uncond_perturbed=(
+ denoised_video_perturbed
+ if denoised_video_perturbed is not None
+ else 0.0
+ ),
+ uncond_modality=(
+ denoised_video_modality
+ if denoised_video_modality is not None
+ else 0.0
+ ),
+ cfg_scale=float(
+ stage1_guider_params["video_cfg_scale"]
+ ),
+ stg_scale=float(
+ stage1_guider_params["video_stg_scale"]
+ ),
+ rescale_scale=float(
+ stage1_guider_params["video_rescale_scale"]
+ ),
+ modality_scale=float(
+ stage1_guider_params["video_modality_scale"]
+ ),
+ )
+ if not audio_skip:
+ denoised_audio = self._ltx2_calculate_guided_x0(
+ cond=denoised_audio,
+ uncond_text=(
+ denoised_audio_neg
+ if denoised_audio_neg is not None
+ else denoised_audio
+ ),
+ uncond_perturbed=(
+ denoised_audio_perturbed
+ if denoised_audio_perturbed is not None
+ else 0.0
+ ),
+ uncond_modality=(
+ denoised_audio_modality
+ if denoised_audio_modality is not None
+ else 0.0
+ ),
+ cfg_scale=float(
+ stage1_guider_params["audio_cfg_scale"]
+ ),
+ stg_scale=float(
+ stage1_guider_params["audio_stg_scale"]
+ ),
+ rescale_scale=float(
+ stage1_guider_params["audio_rescale_scale"]
+ ),
+ modality_scale=float(
+ stage1_guider_params["audio_modality_scale"]
+ ),
+ )
+ elif (
+ batch.do_classifier_free_guidance
+ and denoised_video_neg is not None
+ and denoised_audio_neg is not None
+ ):
+ denoised_video = denoised_video + (
+ batch.guidance_scale - 1.0
+ ) * (denoised_video - denoised_video_neg)
+ denoised_audio = denoised_audio + (
+ batch.guidance_scale - 1.0
+ ) * (denoised_audio - denoised_audio_neg)
+
# Apply conditioning mask (keep conditioned tokens clean).
if (
do_ti2v
@@ -1129,6 +1957,11 @@ class LTX2AVDenoisingStage(DenoisingStage):
# batch.audio_latents is audio latents.
audio_latents = batch.audio_latents
+ if batch.did_sp_shard_audio_latents and isinstance(audio_latents, torch.Tensor):
+ audio_latents = server_args.pipeline_config.gather_audio_latents_for_sp(
+ audio_latents, batch
+ )
+ batch.audio_latents = audio_latents
# NOTE: self.vae and self.audio_vae should be populated via __init__ or manual setting
if self.vae is None or self.audio_vae is None:
@@ -1138,10 +1971,11 @@ class LTX2AVDenoisingStage(DenoisingStage):
batch.latents = latents
batch.audio_latents = audio_latents
else:
- latents, audio_latents = (
- server_args.pipeline_config._unpad_and_unpack_latents(
- latents, audio_latents, batch, self.vae, self.audio_vae
- )
+ (
+ latents,
+ audio_latents,
+ ) = server_args.pipeline_config._unpad_and_unpack_latents(
+ latents, audio_latents, batch, self.vae, self.audio_vae
)
batch.latents = latents
@@ -1249,9 +2083,22 @@ class LTX2RefinementStage(LTX2AVDenoisingStage):
for seed in seeds
]
+ @staticmethod
+ def _should_reset_stage2_generators(server_args: ServerArgs) -> bool:
+ # Official LTX-2.3 two-stage refinement continues from the generator state
+ # after stage 1. Resetting back to the request seed changes the distilled
+ # noise injection immediately at stage 2 step 0.
+ arch_config = getattr(
+ server_args.pipeline_config.vae_config, "arch_config", None
+ )
+ if arch_config is not None and is_ltx23_native_variant(arch_config):
+ return False
+ return "LTX-2.3" not in str(getattr(server_args, "model_path", ""))
+
def forward(self, batch: Req, server_args: ServerArgs) -> Req:
batch.extra["ltx2_phase"] = "stage2"
- self._reset_stage2_generators(batch)
+ if self._should_reset_stage2_generators(server_args):
+ self._reset_stage2_generators(batch)
noise_scale = self.distilled_sigmas[0].to(batch.latents.device)
video_noise = self._randn_like_with_batch_generators(batch.latents, batch)
batch.latents = video_noise * noise_scale + batch.latents * (1 - noise_scale)
@@ -1267,13 +2114,16 @@ class LTX2RefinementStage(LTX2AVDenoisingStage):
audio_noise * audio_noise_scale
+ batch.audio_latents * (1 - audio_noise_scale)
)
- batch.latents = batch.latents.to(
- device=batch.latents.device, dtype=torch.float32
- )
- if isinstance(batch.audio_latents, torch.Tensor):
- batch.audio_latents = batch.audio_latents.to(
- device=batch.audio_latents.device, dtype=torch.float32
+ if not is_ltx23_native_variant(
+ server_args.pipeline_config.vae_config.arch_config
+ ):
+ batch.latents = batch.latents.to(
+ device=batch.latents.device, dtype=torch.float32
)
+ if isinstance(batch.audio_latents, torch.Tensor):
+ batch.audio_latents = batch.audio_latents.to(
+ device=batch.audio_latents.device, dtype=torch.float32
+ )
# Stage 2 runs at full resolution, so Stage 1 TI2V conditioning is invalid.
batch.image_latent = None
diff --git a/python/sglang/multimodal_gen/runtime/pipelines_core/stages/latent_preparation_av.py b/python/sglang/multimodal_gen/runtime/pipelines_core/stages/latent_preparation_av.py
index 6bd26554c..10f58c70c 100644
--- a/python/sglang/multimodal_gen/runtime/pipelines_core/stages/latent_preparation_av.py
+++ b/python/sglang/multimodal_gen/runtime/pipelines_core/stages/latent_preparation_av.py
@@ -61,6 +61,12 @@ class LTX2AVLatentPreparationStage(LatentPreparationStage):
batch: Req,
server_args: ServerArgs,
):
+ if is_ltx23_native_variant(server_args.pipeline_config.vae_config.arch_config):
+ if server_args.pipeline_class_name == "LTX2TwoStagePipeline":
+ return server_args.pipeline_config.get_latent_dtype(
+ batch.prompt_embeds[0].dtype
+ )
+ return torch.float32
return torch.float32
@staticmethod
diff --git a/python/sglang/multimodal_gen/runtime/server_args.py b/python/sglang/multimodal_gen/runtime/server_args.py
index 4402c6eb8..7d934910e 100644
--- a/python/sglang/multimodal_gen/runtime/server_args.py
+++ b/python/sglang/multimodal_gen/runtime/server_args.py
@@ -375,6 +375,21 @@ class ServerArgs:
)
if self.attention_backend is None and self.backend != Backend.DIFFUSERS:
+ if (
+ current_platform.is_cuda()
+ and self.pipeline_class_name is None
+ and self.num_gpus == 1
+ and self.tp_size == 1
+ and self.sp_degree == 1
+ and self.ulysses_degree == 1
+ and self.ring_degree == 1
+ and self._is_ltx23_model_path(self.model_path)
+ ):
+ self.attention_backend = "fa"
+ logger.info(
+ "Automatically set attention_backend=fa for LTX-2.3 one-stage on 1 GPU to preserve precision"
+ )
+ return
self._set_default_attention_backend()
def _adjust_warmup(self):
@@ -409,12 +424,17 @@ class ServerArgs:
self.master_port = self.settle_port(self.master_port, 37)
def _adjust_parallelism(self):
- if self.tp_size is None:
- self.tp_size = 1
+ tp_unspecified = self.tp_size is None
+ sp_unspecified = self.sp_degree is None
+ ulysses_unspecified = self.ulysses_degree is None
+ ring_unspecified = self.ring_degree is None
if self.hsdp_shard_dim is None:
self.hsdp_shard_dim = self.num_gpus
+ if self.tp_size is None:
+ self.tp_size = 1
+
# adjust sp_degree: allocate all remaining GPUs after TP and DP
if self.sp_degree is None:
num_gpus_per_group = self.dp_size * self.tp_size
@@ -446,6 +466,20 @@ class ServerArgs:
self.ring_degree = 1
logger.debug(f"Ring degree not set, using default value {self.ring_degree}")
+ @staticmethod
+ def _is_ltx23_model_path(model_path: str | None) -> bool:
+ if not model_path:
+ return False
+ normalized = model_path.lower()
+ return any(
+ token in normalized
+ for token in (
+ "lightricks/ltx-2.3",
+ "models--lightricks--ltx-2.3",
+ "lightricks__ltx-2.3",
+ )
+ )
+
def _adjust_platform_specific(self):
if current_platform.is_mps():
self.use_fsdp_inference = False
diff --git a/python/sglang/multimodal_gen/runtime/utils/model_overlay.py b/python/sglang/multimodal_gen/runtime/utils/model_overlay.py
index 53176b351..2decf5a30 100644
--- a/python/sglang/multimodal_gen/runtime/utils/model_overlay.py
+++ b/python/sglang/multimodal_gen/runtime/utils/model_overlay.py
@@ -26,6 +26,7 @@ logger = init_logger(__name__)
# Built-in diffusion model overlay registry.
BUILTIN_MODEL_OVERLAY_REGISTRY: dict[str, dict[str, Any]] = {
"Lightricks/LTX-2.3": {
+ # TODO: consider move to lmsys hf repo
"overlay_repo_id": "MickJ/LTX-2.3-overlay",
"overlay_revision": "main",
"bundled_overlay_subdir": "ltx_2_3",
diff --git a/python/sglang/multimodal_gen/test/server/accuracy_config.py b/python/sglang/multimodal_gen/test/server/accuracy_config.py
index 1ec0b487b..1f052264f 100644
--- a/python/sglang/multimodal_gen/test/server/accuracy_config.py
+++ b/python/sglang/multimodal_gen/test/server/accuracy_config.py
@@ -73,14 +73,6 @@ SKIP_COMPONENTS: Dict[str, Dict[ComponentType, ComponentSkip]] = {
"HF reference transformer cannot be materialized from the video_dit repo layout"
)
},
- "ltx_2.3_one_stage_ti2v": {
- ComponentType.VAE: ComponentSkip(
- "LTX-2.3 VAE component diverges from the HF reference after local overlay materialization; weight transfer matched 96/176 (54.55%), below the minimum threshold for trustworthy comparison"
- ),
- ComponentType.TRANSFORMER: ComponentSkip(
- "LTX-2.3 transformer component does not match the HF reference architecture after local overlay materialization; scale_shift_table parameters load as [9, ...] in the checkpoint but [6, ...] in the reference model"
- ),
- },
"qwen_image_t2i_cache_dit_enabled": {
ComponentType.VAE: ComponentSkip(
"Representative VAE accuracy is already covered by qwen_image_t2i for the same source component and topology"
diff --git a/python/sglang/multimodal_gen/test/server/perf_baselines.json b/python/sglang/multimodal_gen/test/server/perf_baselines.json
index b01abbb66..04c93c430 100644
--- a/python/sglang/multimodal_gen/test/server/perf_baselines.json
+++ b/python/sglang/multimodal_gen/test/server/perf_baselines.json
@@ -2562,6 +2562,61 @@
"expected_e2e_ms": 26916.58,
"expected_avg_denoise_ms": 715.73,
"expected_median_denoise_ms": 707.35
+ },
+ "ltx_2.3_two_stage_t2v_2gpus": {
+ "stages_ms": {
+ "InputValidationStage": 0.05,
+ "TextEncodingStage": 2020.14,
+ "LTX2TextConnectorStage": 26.56,
+ "LTX2HalveResolutionStage": 0.06,
+ "LTX2LoRASwitchStage": 104.32,
+ "LTX2SigmaPreparationStage": 0.37,
+ "TimestepPreparationStage": 26.33,
+ "LTX2AVLatentPreparationStage": 0.13,
+ "LTX2AVDenoisingStage": 25176.87,
+ "LTX2UpsampleStage": 549.01,
+ "LTX2RefinementStage": 663.05,
+ "LTX2AVDecodingStage": 391.25,
+ "per_frame_generation": null
+ },
+ "denoise_step_ms": {
+ "0": 1744.42,
+ "1": 817.18,
+ "2": 854.8,
+ "3": 836.55,
+ "4": 808.83,
+ "5": 809.81,
+ "6": 796.47,
+ "7": 767.67,
+ "8": 802.66,
+ "9": 805.93,
+ "10": 808.6,
+ "11": 820.85,
+ "12": 846.88,
+ "13": 852.86,
+ "14": 844.04,
+ "15": 833.44,
+ "16": 803.25,
+ "17": 807.18,
+ "18": 815.48,
+ "19": 811.04,
+ "20": 804.2,
+ "21": 781.2,
+ "22": 767.35,
+ "23": 772.59,
+ "24": 785.54,
+ "25": 770.46,
+ "26": 779.59,
+ "27": 817.82,
+ "28": 806.95,
+ "29": 798.13,
+ "30": 222.32,
+ "31": 214.83,
+ "32": 222.11
+ },
+ "expected_e2e_ms": 34384.39,
+ "expected_avg_denoise_ms": 782.76,
+ "expected_median_denoise_ms": 806.95
}
}
}
diff --git a/python/sglang/multimodal_gen/test/server/test_accuracy_1_gpu_a.py b/python/sglang/multimodal_gen/test/server/test_accuracy_1_gpu_a.py
index 409acab1e..16aaa4a13 100644
--- a/python/sglang/multimodal_gen/test/server/test_accuracy_1_gpu_a.py
+++ b/python/sglang/multimodal_gen/test/server/test_accuracy_1_gpu_a.py
@@ -10,10 +10,10 @@ from sglang.multimodal_gen.test.server.accuracy_utils import (
run_text_encoder_accuracy_case,
)
from sglang.multimodal_gen.test.server.component_accuracy import AccuracyEngine
-from sglang.multimodal_gen.test.server.testcase_configs import ONE_GPU_CASES_A
+from sglang.multimodal_gen.test.server.testcase_configs import ACCURACY_ONE_GPU_CASES_A
-@pytest.mark.parametrize("case", ONE_GPU_CASES_A, ids=lambda x: x.id)
+@pytest.mark.parametrize("case", ACCURACY_ONE_GPU_CASES_A, ids=lambda x: x.id)
class TestAccuracy1GPU_A:
"""1-GPU Component Accuracy Suite (Set A)."""
diff --git a/python/sglang/multimodal_gen/test/server/test_accuracy_1_gpu_b.py b/python/sglang/multimodal_gen/test/server/test_accuracy_1_gpu_b.py
index 0c0c7cb51..810809f33 100644
--- a/python/sglang/multimodal_gen/test/server/test_accuracy_1_gpu_b.py
+++ b/python/sglang/multimodal_gen/test/server/test_accuracy_1_gpu_b.py
@@ -10,10 +10,10 @@ from sglang.multimodal_gen.test.server.accuracy_utils import (
run_text_encoder_accuracy_case,
)
from sglang.multimodal_gen.test.server.component_accuracy import AccuracyEngine
-from sglang.multimodal_gen.test.server.testcase_configs import ONE_GPU_CASES_B
+from sglang.multimodal_gen.test.server.testcase_configs import ACCURACY_ONE_GPU_CASES_B
-@pytest.mark.parametrize("case", ONE_GPU_CASES_B, ids=lambda x: x.id)
+@pytest.mark.parametrize("case", ACCURACY_ONE_GPU_CASES_B, ids=lambda x: x.id)
class TestAccuracy1GPU_B:
"""1-GPU Component Accuracy Suite (Set B)."""
diff --git a/python/sglang/multimodal_gen/test/server/test_accuracy_2_gpu_a.py b/python/sglang/multimodal_gen/test/server/test_accuracy_2_gpu_a.py
index c40929bf4..cd0677a58 100644
--- a/python/sglang/multimodal_gen/test/server/test_accuracy_2_gpu_a.py
+++ b/python/sglang/multimodal_gen/test/server/test_accuracy_2_gpu_a.py
@@ -10,10 +10,10 @@ from sglang.multimodal_gen.test.server.accuracy_utils import (
run_text_encoder_accuracy_case,
)
from sglang.multimodal_gen.test.server.component_accuracy import AccuracyEngine
-from sglang.multimodal_gen.test.server.testcase_configs import TWO_GPU_CASES_A
+from sglang.multimodal_gen.test.server.testcase_configs import ACCURACY_TWO_GPU_CASES_A
-@pytest.mark.parametrize("case", TWO_GPU_CASES_A, ids=lambda x: x.id)
+@pytest.mark.parametrize("case", ACCURACY_TWO_GPU_CASES_A, ids=lambda x: x.id)
class TestAccuracy2GPU_A:
"""2-GPU Component Accuracy Suite (Set A)."""
diff --git a/python/sglang/multimodal_gen/test/server/test_accuracy_2_gpu_b.py b/python/sglang/multimodal_gen/test/server/test_accuracy_2_gpu_b.py
index 9b3cc1190..5d75cbd93 100644
--- a/python/sglang/multimodal_gen/test/server/test_accuracy_2_gpu_b.py
+++ b/python/sglang/multimodal_gen/test/server/test_accuracy_2_gpu_b.py
@@ -10,10 +10,10 @@ from sglang.multimodal_gen.test.server.accuracy_utils import (
run_text_encoder_accuracy_case,
)
from sglang.multimodal_gen.test.server.component_accuracy import AccuracyEngine
-from sglang.multimodal_gen.test.server.testcase_configs import TWO_GPU_CASES_B
+from sglang.multimodal_gen.test.server.testcase_configs import ACCURACY_TWO_GPU_CASES_B
-@pytest.mark.parametrize("case", TWO_GPU_CASES_B, ids=lambda x: x.id)
+@pytest.mark.parametrize("case", ACCURACY_TWO_GPU_CASES_B, ids=lambda x: x.id)
class TestAccuracy2GPU_B:
"""2-GPU Component Accuracy Suite (Set B)."""
diff --git a/python/sglang/multimodal_gen/test/server/testcase_configs.py b/python/sglang/multimodal_gen/test/server/testcase_configs.py
index 00f9f8661..cc7bf4da6 100644
--- a/python/sglang/multimodal_gen/test/server/testcase_configs.py
+++ b/python/sglang/multimodal_gen/test/server/testcase_configs.py
@@ -953,6 +953,16 @@ TWO_GPU_CASES_B = [
),
TI2V_sampling_params,
),
+ DiffusionTestCase(
+ "ltx_2.3_two_stage_t2v_2gpus",
+ DiffusionServerArgs(
+ model_path="Lightricks/LTX-2.3",
+ modality="video",
+ num_gpus=2,
+ extras=["--pipeline-class-name LTX2TwoStagePipeline"],
+ ),
+ T2V_sampling_params,
+ ),
# I2V LoRA test case
DiffusionTestCase(
"wan2_1_i2v_14b_lora_2gpu",
@@ -1065,6 +1075,90 @@ if not current_platform.is_hip():
)
)
+
+def _select_accuracy_cases(
+ cases: list[DiffusionTestCase], enabled_ids: tuple[str, ...]
+) -> list[DiffusionTestCase]:
+ enabled = set(enabled_ids)
+ return [case for case in cases if case.id in enabled]
+
+
+ACCURACY_ONE_GPU_CASES_A_IDS = (
+ "qwen_image_t2i",
+ "qwen_image_t2i_cache_dit_enabled",
+ "flux_image_t2i",
+ "flux_2_image_t2i",
+ "flux_2_klein_image_t2i",
+ "layerwise_offload",
+ "zimage_image_t2i",
+ "zimage_image_t2i_fp8",
+ "zimage_image_t2i_multi_lora",
+ "qwen_image_edit_ti2i",
+ "qwen_image_edit_2509_ti2i",
+ "qwen_image_edit_2511_ti2i",
+ "qwen_image_layered_i2i",
+ "flux_2_image_t2i_upscaling_4x",
+ "mova_360p_1gpu",
+)
+
+ACCURACY_ONE_GPU_CASES_B_IDS = (
+ "wan2_1_t2v_1.3b",
+ "wan2_1_t2v_1.3b_text_encoder_cpu_offload",
+ "wan2_1_t2v_1.3b_teacache_enabled",
+ "wan2_1_t2v_1.3b_frame_interp_2x",
+ "wan2_1_t2v_1.3b_upscaling_4x",
+ "wan2_1_t2v_1.3b_frame_interp_2x_upscaling_4x",
+ "wan2_1_t2v_1_3b_lora_1gpu",
+ "flux_2_ti2i",
+ "flux_2_t2i_customized_vae_path",
+ "fast_hunyuan_video",
+ "wan2_2_ti2v_5b",
+ "fastwan2_2_ti2v_5b",
+ "hunyuan3d_shape_gen",
+ "turbo_wan2_1_t2v_1.3b",
+ "flux_2_nvfp4_t2i",
+ "flux_2_ti2i_multi_image_cache_dit",
+)
+
+ACCURACY_TWO_GPU_CASES_A_IDS = (
+ "wan2_2_i2v_a14b_2gpu",
+ "wan2_2_t2v_a14b_2gpu",
+ "wan2_2_t2v_a14b_teacache_2gpu",
+ "wan2_2_t2v_a14b_lora_2gpu",
+ "wan2_1_t2v_14b_2gpu",
+ "wan2_1_t2v_1.3b_cfg_parallel",
+ "fsdp-inference",
+ "mova_360p_tp2",
+ "mova_360p_ring1_uly2",
+ "mova_360p_ring2_uly1",
+ "ltx_2_two_stage_t2v",
+)
+
+ACCURACY_TWO_GPU_CASES_B_IDS = (
+ "wan2_1_i2v_14b_480P_2gpu",
+ "wan2_1_i2v_14b_lora_2gpu",
+ "wan2_1_i2v_14b_720P_2gpu",
+ "qwen_image_t2i_2_gpus",
+ "zimage_image_t2i_2_gpus",
+ "zimage_image_t2i_2_gpus_non_square",
+ "flux_image_t2i_2_gpus",
+ "flux_2_image_t2i_2_gpus",
+ "flux_2_klein_ti2i_2_gpus",
+)
+
+ACCURACY_ONE_GPU_CASES_A = _select_accuracy_cases(
+ ONE_GPU_CASES_A, ACCURACY_ONE_GPU_CASES_A_IDS
+)
+ACCURACY_ONE_GPU_CASES_B = _select_accuracy_cases(
+ ONE_GPU_CASES_B, ACCURACY_ONE_GPU_CASES_B_IDS
+)
+ACCURACY_TWO_GPU_CASES_A = _select_accuracy_cases(
+ TWO_GPU_CASES_A, ACCURACY_TWO_GPU_CASES_A_IDS
+)
+ACCURACY_TWO_GPU_CASES_B = _select_accuracy_cases(
+ TWO_GPU_CASES_B, ACCURACY_TWO_GPU_CASES_B_IDS
+)
+
# Load global configuration
BASELINE_CONFIG = BaselineConfig.load(
Path(__file__).with_name("perf_baselines.json")
diff --git a/python/sglang/multimodal_gen/test/unit/test_model_overlay_ltx23.py b/python/sglang/multimodal_gen/test/unit/test_model_overlay_ltx23.py
deleted file mode 100644
index 62bd1f4fb..000000000
--- a/python/sglang/multimodal_gen/test/unit/test_model_overlay_ltx23.py
+++ /dev/null
@@ -1,392 +0,0 @@
-import json
-import os
-import tempfile
-from types import SimpleNamespace
-
-import pytest
-import torch
-from safetensors import safe_open
-from safetensors.torch import save_file
-
-pytest.importorskip("triton.compiler")
-
-from sglang.multimodal_gen.configs.pipeline_configs.ltx_2 import (
- is_ltx23_native_variant,
-)
-from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
-from sglang.multimodal_gen.model_overlays.ltx_2_3._overlay.materialize import (
- _build_transformer_config,
- _build_vae_config,
- _rename_connector_key,
- _repack_ltx23_image_encoder_weights,
- _repack_ltx23_video_decoder_weights,
-)
-from sglang.multimodal_gen.registry import get_model_info
-from sglang.multimodal_gen.runtime.pipelines.ltx_2_pipeline import (
- _resolve_ltx2_two_stage_component_paths,
- build_official_ltx2_sigmas,
- prepare_ltx2_mu,
-)
-from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
-from sglang.multimodal_gen.runtime.pipelines_core.stages.decoding_av import (
- LTX2AVDecodingStage,
-)
-from sglang.multimodal_gen.runtime.pipelines_core.stages.denoising_av import (
- LTX2AVDenoisingStage,
-)
-from sglang.multimodal_gen.runtime.utils.model_overlay import (
- resolve_model_overlay_target,
-)
-
-
-def _make_req(**sampling_kwargs) -> Req:
- return Req(
- sampling_params=SamplingParams(**sampling_kwargs),
- prompt="prompt",
- prompt_embeds=[torch.zeros(1, 1, 1)],
- )
-
-
-def test_ltx23_builtin_overlay_target_is_hf_repo():
- target = resolve_model_overlay_target("Lightricks/LTX-2.3")
- assert target is not None
-
- source_model_id, overlay_spec = target
- assert source_model_id == "Lightricks/LTX-2.3"
- assert str(overlay_spec["overlay_repo_id"]) == "MickJ/LTX-2.3-overlay"
- assert str(overlay_spec["overlay_revision"]) == "main"
- assert str(overlay_spec["bundled_overlay_subdir"]) == "ltx_2_3"
-
-
-def test_ltx23_model_info_resolves_to_native_pipeline_and_sampling_params():
- model_info = get_model_info("Lightricks/LTX-2.3", backend="sglang")
-
- assert model_info is not None
- assert model_info.pipeline_cls.__name__ == "LTX2Pipeline"
- assert model_info.sampling_param_cls.__name__ == "LTX23SamplingParams"
-
-
-def test_ltx23_sampling_defaults_use_cuda_generator():
- sampling_params = SamplingParams.from_pretrained(
- "Lightricks/LTX-2.3",
- backend="sglang",
- )
-
- assert sampling_params.generator_device == "cuda"
- assert sampling_params.guidance_scale == 3.0
- assert sampling_params.num_inference_steps == 30
-
-
-def test_ltx2_sampling_defaults_keep_cpu_generator():
- sampling_params = SamplingParams.from_pretrained(
- "Lightricks/LTX-2",
- backend="sglang",
- )
-
- assert sampling_params.generator_device == "cpu"
-
-
-def test_ltx23_build_request_extra_sets_stage1_guider_defaults():
- sampling_params = SamplingParams.from_pretrained(
- "Lightricks/LTX-2.3",
- backend="sglang",
- )
-
- assert sampling_params.build_request_extra()["ltx2_stage1_guider_params"] == {
- "video_cfg_scale": 3.0,
- "video_stg_scale": 1.0,
- "video_rescale_scale": 0.7,
- "video_modality_scale": 3.0,
- "video_skip_step": 0,
- "video_stg_blocks": [28],
- "audio_cfg_scale": 7.0,
- "audio_stg_scale": 1.0,
- "audio_rescale_scale": 0.7,
- "audio_modality_scale": 3.0,
- "audio_skip_step": 0,
- "audio_stg_blocks": [28],
- }
-
-
-def test_sampling_params_apply_request_extra_populates_req_extra():
- sampling_params = SamplingParams.from_pretrained(
- "Lightricks/LTX-2.3",
- backend="sglang",
- )
- req = Req(sampling_params=sampling_params, prompt="prompt")
-
- sampling_params.apply_request_extra(req)
-
- assert req.extra["ltx2_stage1_guider_params"]["video_cfg_scale"] == 3.0
- assert req.extra["ltx2_stage1_guider_params"]["audio_cfg_scale"] == 7.0
-
-
-def test_ltx23_uses_official_sigma_schedule():
- sigmas = build_official_ltx2_sigmas(30)
-
- assert len(sigmas) == 30
- assert sigmas[0] == pytest.approx(1.0)
- assert sigmas[1] == pytest.approx(0.99495703, abs=1e-6)
- assert sigmas[-1] == pytest.approx(0.1, abs=1e-6)
-
-
-def test_ltx23_native_variant_uses_explicit_marker_only():
- assert is_ltx23_native_variant(SimpleNamespace(ltx_variant="ltx_2_3")) is True
- assert is_ltx23_native_variant(SimpleNamespace(ltx_variant="ltx_2")) is False
-
-
-def test_prepare_ltx2_mu_respects_variant_marker():
- ltx23_server_args = SimpleNamespace(
- pipeline_config=SimpleNamespace(
- vae_config=SimpleNamespace(
- arch_config=SimpleNamespace(ltx_variant="ltx_2_3")
- )
- )
- )
- legacy_server_args = SimpleNamespace(
- pipeline_config=SimpleNamespace(
- vae_config=SimpleNamespace(
- arch_config=SimpleNamespace(ltx_variant="ltx_2")
- ),
- vae_temporal_compression=8,
- vae_scale_factor=32,
- )
- )
-
- assert prepare_ltx2_mu(
- _make_req(num_frames=121, height=512, width=768),
- ltx23_server_args,
- ) == ("mu", None)
-
- key, mu = prepare_ltx2_mu(
- _make_req(num_frames=121, height=512, width=768),
- legacy_server_args,
- )
- assert key == "mu"
- assert isinstance(mu, float)
- assert mu > 0.0
-
-
-def test_ltx23_ti2v_clean_latent_uses_zero_background():
- latents = torch.arange(24, dtype=torch.float32).view(1, 6, 4)
- image_latent = torch.full((1, 2, 4), 99.0)
-
- conditioned, denoise_mask, clean_latent = (
- LTX2AVDenoisingStage._prepare_ltx2_ti2v_clean_state(
- latents=latents,
- image_latent=image_latent,
- num_img_tokens=2,
- zero_clean_latent=True,
- )
- )
-
- assert torch.equal(conditioned[:, :2], image_latent)
- assert torch.equal(clean_latent[:, :2], image_latent)
- assert torch.equal(clean_latent[:, 2:], torch.zeros_like(clean_latent[:, 2:]))
- assert torch.equal(denoise_mask[:, :2], torch.zeros_like(denoise_mask[:, :2]))
- assert torch.equal(denoise_mask[:, 2:], torch.ones_like(denoise_mask[:, 2:]))
-
-
-def test_ltx2_ti2v_clean_latent_keeps_legacy_background_when_requested():
- latents = torch.arange(24, dtype=torch.float32).view(1, 6, 4)
- image_latent = torch.full((1, 2, 4), 99.0)
-
- conditioned, _, clean_latent = LTX2AVDenoisingStage._prepare_ltx2_ti2v_clean_state(
- latents=latents,
- image_latent=image_latent,
- num_img_tokens=2,
- zero_clean_latent=False,
- )
-
- assert torch.equal(conditioned[:, :2], image_latent)
- assert torch.equal(clean_latent[:, :2], image_latent)
- assert torch.equal(clean_latent[:, 2:], latents[:, 2:])
-
-
-def test_ltx23_velocity_to_x0_supports_tokenwise_sigma():
- sample = torch.tensor([[[1.0, 2.0], [3.0, 4.0]]], dtype=torch.float32)
- velocity = torch.tensor([[[0.5, 0.5], [1.0, 1.0]]], dtype=torch.float32)
- sigma = torch.tensor([[0.0, 0.5]], dtype=torch.float32)
-
- denoised = LTX2AVDenoisingStage._ltx2_velocity_to_x0(sample, velocity, sigma)
-
- expected = torch.tensor([[[1.0, 2.0], [2.5, 3.5]]], dtype=torch.float32)
- assert torch.allclose(denoised, expected)
-
-
-def test_ltx23_connector_repack_renames_qk_norm_keys():
- assert (
- _rename_connector_key(
- "model.diffusion_model.video_embeddings_connector.transformer_1d_blocks.0.attn1.q_norm.weight"
- )
- == "video_connector.transformer_blocks.0.attn1.norm_q.weight"
- )
- assert (
- _rename_connector_key(
- "model.diffusion_model.audio_embeddings_connector.transformer_1d_blocks.1.attn1.k_norm.weight"
- )
- == "audio_connector.transformer_blocks.1.attn1.norm_k.weight"
- )
-
-
-def test_ltx23_transformer_config_forces_sdpa_for_v2a_cross_attention():
- with tempfile.TemporaryDirectory() as tmpdir:
- donor_dir = os.path.join(tmpdir, "donor")
- os.makedirs(os.path.join(donor_dir, "transformer"), exist_ok=True)
- with open(os.path.join(donor_dir, "transformer", "config.json"), "w") as f:
- json.dump({"_class_name": "OldClass", "num_layers": 1}, f)
-
- config = _build_transformer_config(donor_dir)
-
- assert config["_class_name"] == "LTX2VideoTransformer3DModel"
- assert config["force_sdpa_v2a_cross_attention"] is True
-
-
-def test_ltx23_vae_config_adds_required_markers():
- with tempfile.TemporaryDirectory() as tmpdir:
- auxiliary_dir = os.path.join(tmpdir, "aux")
- config_donor_dir = os.path.join(tmpdir, "donor")
- os.makedirs(os.path.join(auxiliary_dir, "vae"), exist_ok=True)
- os.makedirs(os.path.join(config_donor_dir, "vae"), exist_ok=True)
-
- with open(os.path.join(auxiliary_dir, "vae", "config.json"), "w") as f:
- json.dump(
- {
- "_class_name": "AutoencoderKLLTX2Video",
- "scaling_factor": 1.0,
- "patch_size": 4,
- "decoder_causal": False,
- "timestep_conditioning": False,
- "encoder_spatial_padding_mode": "zeros",
- "decoder_spatial_padding_mode": "reflect",
- },
- f,
- )
- with open(os.path.join(config_donor_dir, "vae", "config.json"), "w") as f:
- json.dump(
- {
- "vae": {
- "decoder_blocks": [["res_x", {"num_layers": 2}]],
- "decoder_base_channels": 128,
- "patch_size": 4,
- "spatial_padding_mode": "zeros",
- }
- },
- f,
- )
-
- config = _build_vae_config(auxiliary_dir, config_donor_dir)
-
- assert config["ltx_variant"] == "ltx_2_3"
- assert config["condition_encoder_subdir"] == "ltx23_image_encoder"
- assert config["video_decoder_variant"] == "ltx_2_3"
- assert config["video_decoder_config"]["decoder_base_channels"] == 128
-
-
-def test_ltx23_repack_image_encoder_keeps_only_encoder_tensors():
- with tempfile.TemporaryDirectory() as tmpdir:
- source_path = os.path.join(tmpdir, "source.safetensors")
- output_path = os.path.join(tmpdir, "output.safetensors")
- save_file(
- {
- "encoder.conv_in.conv.weight": torch.ones(1),
- "decoder.conv_in.conv.weight": torch.full((1,), 2.0),
- "per_channel_statistics.mean-of-means": torch.full((2,), 3.0),
- },
- source_path,
- )
-
- _repack_ltx23_image_encoder_weights(source_path, output_path)
-
- with safe_open(output_path, framework="pt") as f:
- assert sorted(f.keys()) == [
- "conv_in.conv.weight",
- "per_channel_statistics.mean-of-means",
- ]
-
-
-def test_ltx23_repack_video_decoder_keeps_decoder_and_stats():
- with tempfile.TemporaryDirectory() as tmpdir:
- auxiliary_path = os.path.join(tmpdir, "aux.safetensors")
- donor_path = os.path.join(tmpdir, "donor.safetensors")
- output_path = os.path.join(tmpdir, "output.safetensors")
- save_file(
- {
- "encoder.conv_in.conv.weight": torch.full((1,), 5.0),
- },
- auxiliary_path,
- )
- save_file(
- {
- "decoder.conv_in.conv.weight": torch.ones(1),
- "per_channel_statistics.mean-of-means": torch.full((2,), 3.0),
- "per_channel_statistics.std-of-means": torch.full((2,), 4.0),
- },
- donor_path,
- )
-
- _repack_ltx23_video_decoder_weights(auxiliary_path, donor_path, output_path)
-
- with safe_open(output_path, framework="pt") as f:
- assert sorted(f.keys()) == [
- "decoder.conv_in.conv.weight",
- "decoder.per_channel_statistics.mean_of_means",
- "decoder.per_channel_statistics.std_of_means",
- "encoder.conv_in.conv.weight",
- "latents_mean",
- "latents_std",
- ]
-
-
-def test_ltx23_decode_skips_external_denorm():
- ltx23_server_args = SimpleNamespace(
- pipeline_config=SimpleNamespace(
- vae_config=SimpleNamespace(
- arch_config=SimpleNamespace(video_decoder_variant="ltx_2_3")
- )
- )
- )
- legacy_server_args = SimpleNamespace(
- pipeline_config=SimpleNamespace(
- vae_config=SimpleNamespace(
- arch_config=SimpleNamespace(video_decoder_variant="ltx_2")
- )
- )
- )
-
- assert (
- LTX2AVDecodingStage._ltx2_should_externally_denorm_video_latents(
- ltx23_server_args
- )
- is False
- )
- assert (
- LTX2AVDecodingStage._ltx2_should_externally_denorm_video_latents(
- legacy_server_args
- )
- is True
- )
-
-
-def test_ltx2_two_stage_component_auto_resolution_preserves_legacy_candidates(tmp_path):
- legacy_spatial = tmp_path / "ltx-2-spatial-upscaler-x2-1.0.safetensors"
- legacy_lora = tmp_path / "ltx-2-19b-distilled-lora-384.safetensors"
- legacy_spatial.touch()
- legacy_lora.touch()
-
- resolved = _resolve_ltx2_two_stage_component_paths(str(tmp_path), {})
-
- assert resolved["spatial_upsampler"] == str(legacy_spatial)
- assert resolved["distilled_lora"] == str(legacy_lora)
-
-
-def test_ltx23_two_stage_component_auto_resolution_prefers_23_assets(tmp_path):
- spatial = tmp_path / "ltx-2.3-spatial-upscaler-x2-1.1.safetensors"
- lora = tmp_path / "ltx-2.3-22b-distilled-lora-384.safetensors"
- spatial.touch()
- lora.touch()
-
- resolved = _resolve_ltx2_two_stage_component_paths(str(tmp_path), {})
-
- assert resolved["spatial_upsampler"] == str(spatial)
- assert resolved["distilled_lora"] == str(lora)