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)