[diffusion] model: support two stage pipeline of LTX-2 (#20707)
Co-authored-by: daiweitao <dwti614707404@163.com> Co-authored-by: Mick <mickjagger19@icloud.com> Co-authored-by: GMI Xiao Jin <xiao.j@gmicloud.ai>
This commit is contained in:
co-authored by
daiweitao
Mick
GMI Xiao Jin
parent
95cdbce34f
commit
db3d4f4b76
@@ -27,12 +27,12 @@ def _is_overlay_diffusion_model(model_path: str) -> bool:
|
||||
|
||||
def _is_registered_diffusion_model(model_path: str) -> bool:
|
||||
try:
|
||||
# if diffusion dependencies are not installed
|
||||
from sglang.multimodal_gen.registry import get_model_info
|
||||
from sglang.multimodal_gen.registry import has_registered_diffusion_model_path
|
||||
except ImportError:
|
||||
# if diffusion dependencies are not installed
|
||||
return False
|
||||
|
||||
return get_model_info(model_path, backend="sglang") is not None
|
||||
return has_registered_diffusion_model_path(model_path)
|
||||
|
||||
|
||||
def _is_diffusers_model_dir(model_dir: str) -> bool:
|
||||
@@ -93,9 +93,7 @@ def get_is_diffusion_model(model_path: str) -> bool:
|
||||
return _is_diffusers_model_dir(os.path.dirname(file_path))
|
||||
except Exception as e:
|
||||
logger.debug("Failed to auto-detect diffusion model for %s: %s", model_path, e)
|
||||
# For gated repos, file download fails but model card is still accessible.
|
||||
# Check library_name from HF metadata as a fallback.
|
||||
return _is_gated_diffusion_repo(model_path)
|
||||
return False
|
||||
|
||||
|
||||
def get_model_path(extra_argv):
|
||||
|
||||
@@ -9,7 +9,7 @@ SGLang diffusion features an end-to-end unified pipeline for accelerating diffus
|
||||
## Key Features
|
||||
|
||||
SGLang Diffusion has the following features:
|
||||
- Broad model support: Wan series, FastWan series, Hunyuan, Qwen-Image, Qwen-Image-Edit, Flux, Z-Image, GLM-Image
|
||||
- Broad model support: Wan series, FastWan series, Hunyuan, LTX-2, Qwen-Image, Qwen-Image-Edit, Flux, Z-Image, GLM-Image
|
||||
- Fast inference speed: enpowered by highly optimized kernel from sgl-kernel and efficient scheduler loop
|
||||
- Ease of use: OpenAI-compatible api, CLI, and python sdk support
|
||||
- Multi-platform support:
|
||||
@@ -76,6 +76,11 @@ sglang generate --model-path Wan-AI/Wan2.1-T2V-1.3B-Diffusers \
|
||||
--save-output
|
||||
```
|
||||
|
||||
For LTX-2 two-stage generation, use `--pipeline-class-name LTX2TwoStagePipeline`. The
|
||||
spatial upsampler and distilled LoRA are auto-resolved from the same model snapshot by
|
||||
default, and can still be overridden with `--spatial-upsampler-path` and
|
||||
`--distilled-lora-path` when needed.
|
||||
|
||||
### LoRA support
|
||||
|
||||
Apply LoRA adapters via `--lora-path`:
|
||||
|
||||
@@ -8,6 +8,7 @@ from sglang.multimodal_gen.configs.models.vaes.base import VAEArchConfig, VAECon
|
||||
@dataclass
|
||||
class LTXAudioVAEArchConfig(VAEArchConfig):
|
||||
# Architecture params
|
||||
temporal_compression_ratio: int = 4
|
||||
causality_axis: str = "height"
|
||||
attn_resolutions: Optional[Tuple[int, ...]] = None
|
||||
base_channels: int = 128
|
||||
@@ -20,6 +21,7 @@ class LTXAudioVAEArchConfig(VAEArchConfig):
|
||||
mid_block_add_attention: bool = False
|
||||
sample_rate: int = 16000
|
||||
mel_hop_length: int = 160
|
||||
mel_compression_ratio: int = 4
|
||||
is_causal: bool = True
|
||||
mel_bins: Optional[int] = 64
|
||||
double_z: bool = True
|
||||
|
||||
@@ -11,6 +11,8 @@ class LTXVideoVAEArchConfig(VAEArchConfig):
|
||||
in_channels: int = 3
|
||||
latent_channels: int = 128
|
||||
out_channels: int = 3
|
||||
temporal_compression_ratio: int = 8
|
||||
spatial_compression_ratio: int = 32
|
||||
block_out_channels: List[int] = field(
|
||||
default_factory=lambda: [256, 512, 1024, 2048]
|
||||
)
|
||||
|
||||
@@ -169,6 +169,7 @@ class PipelineConfig:
|
||||
# controls the timestep embedding generation
|
||||
should_use_guidance: bool = True
|
||||
embedded_cfg_scale: float = 6.0
|
||||
generator_device: str | None = None
|
||||
flow_shift: float | None = None
|
||||
disable_autocast: bool = False
|
||||
|
||||
@@ -420,6 +421,9 @@ class PipelineConfig:
|
||||
def prepare_neg_cond_kwargs(self, batch, device, rotary_emb, dtype):
|
||||
return {}
|
||||
|
||||
def _unpad_and_unpack_latents(self, latents, audio_latents, batch, vae, audio_vae):
|
||||
raise NotImplementedError("not yet implemented")
|
||||
|
||||
@staticmethod
|
||||
def add_cli_args(
|
||||
parser: FlexibleArgumentParser, prefix: str = ""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import dataclasses
|
||||
from dataclasses import field
|
||||
from typing import Callable
|
||||
from typing import Callable, Optional
|
||||
|
||||
import torch
|
||||
|
||||
@@ -11,6 +11,7 @@ from sglang.multimodal_gen.configs.models.encoders import (
|
||||
)
|
||||
from sglang.multimodal_gen.configs.models.encoders.gemma_3 import Gemma3Config
|
||||
from sglang.multimodal_gen.configs.models.vaes.ltx_audio import LTXAudioVAEConfig
|
||||
from sglang.multimodal_gen.configs.models.vaes.ltx_video import LTXVideoVAEConfig
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.base import (
|
||||
ModelTaskType,
|
||||
PipelineConfig,
|
||||
@@ -93,8 +94,11 @@ def pack_text_embeds(
|
||||
|
||||
|
||||
def _gemma_postprocess_func(
|
||||
outputs: BaseEncoderOutput, text_inputs: dict
|
||||
outputs: BaseEncoderOutput,
|
||||
text_inputs: dict,
|
||||
pipeline_config: Optional["LTX2PipelineConfig"] = None,
|
||||
) -> torch.Tensor:
|
||||
_ = pipeline_config
|
||||
# LTX-2 requires all hidden states concatenated for the connector
|
||||
if hasattr(outputs, "hidden_states") and outputs.hidden_states is not None:
|
||||
# outputs.hidden_states is a tuple of tensors
|
||||
@@ -116,6 +120,7 @@ class LTX2PipelineConfig(PipelineConfig):
|
||||
|
||||
task_type: ModelTaskType = ModelTaskType.TI2V
|
||||
skip_input_image_preprocess: bool = True
|
||||
generator_device: str = "cpu"
|
||||
dit_config: LTX2Config = field(default_factory=LTX2Config)
|
||||
|
||||
# Model architecture
|
||||
@@ -125,35 +130,23 @@ class LTX2PipelineConfig(PipelineConfig):
|
||||
patch_size_t: int = 1
|
||||
|
||||
# Audio VAE configuration
|
||||
vae_config: LTXVideoVAEConfig = field(default_factory=LTXVideoVAEConfig)
|
||||
audio_vae_config: LTXAudioVAEConfig = field(default_factory=LTXAudioVAEConfig)
|
||||
audio_vae_precision: str = "fp32"
|
||||
audio_vae_temporal_compression_ratio: int = 4
|
||||
audio_vae_mel_compression_ratio: int = 4
|
||||
|
||||
@property
|
||||
def vae_scale_factor(self):
|
||||
return getattr(self.vae_config.arch_config, "spatial_compression_ratio", 32)
|
||||
return self.vae_config.arch_config.spatial_compression_ratio
|
||||
|
||||
@property
|
||||
def vae_temporal_compression(self):
|
||||
return getattr(self.vae_config.arch_config, "temporal_compression_ratio", 8)
|
||||
return self.vae_config.arch_config.temporal_compression_ratio
|
||||
|
||||
def prepare_latent_shape(self, batch, batch_size, num_frames):
|
||||
"""Return packed latent shape [B, seq, C] directly."""
|
||||
"""Return unpacked latent shape [B, C, F, H, W]."""
|
||||
height = batch.height // self.vae_scale_factor
|
||||
width = batch.width // self.vae_scale_factor
|
||||
|
||||
post_patch_num_frames = num_frames // self.patch_size_t
|
||||
post_patch_height = height // self.patch_size
|
||||
post_patch_width = width // self.patch_size
|
||||
seq_len = post_patch_num_frames * post_patch_height * post_patch_width
|
||||
|
||||
num_channels = (
|
||||
self.in_channels * self.patch_size_t * self.patch_size * self.patch_size
|
||||
)
|
||||
|
||||
shape = (batch_size, seq_len, num_channels)
|
||||
return shape
|
||||
return (batch_size, self.in_channels, num_frames, height, width)
|
||||
|
||||
def prepare_audio_latent_shape(self, batch, batch_size, num_frames):
|
||||
# Adapted from diffusers pipeline prepare_audio_latents
|
||||
@@ -161,7 +154,9 @@ class LTX2PipelineConfig(PipelineConfig):
|
||||
|
||||
sample_rate = self.audio_vae_config.arch_config.sample_rate
|
||||
hop_length = self.audio_vae_config.arch_config.mel_hop_length
|
||||
temporal_compression = self.audio_vae_temporal_compression_ratio
|
||||
temporal_compression = (
|
||||
self.audio_vae_config.arch_config.temporal_compression_ratio
|
||||
)
|
||||
|
||||
latents_per_second = (
|
||||
float(sample_rate) / float(hop_length) / float(temporal_compression)
|
||||
@@ -169,15 +164,13 @@ class LTX2PipelineConfig(PipelineConfig):
|
||||
latent_length = round(duration_s * latents_per_second)
|
||||
|
||||
num_mel_bins = self.audio_vae_config.arch_config.mel_bins
|
||||
mel_compression_ratio = self.audio_vae_mel_compression_ratio
|
||||
mel_compression_ratio = self.audio_vae_config.arch_config.mel_compression_ratio
|
||||
latent_mel_bins = num_mel_bins // mel_compression_ratio
|
||||
|
||||
# Default to 8
|
||||
num_channels_latents = self.audio_vae_config.arch_config.latent_channels
|
||||
|
||||
shape = (batch_size, latent_length, num_channels_latents * latent_mel_bins)
|
||||
|
||||
return shape
|
||||
return (batch_size, num_channels_latents, latent_length, latent_mel_bins)
|
||||
|
||||
# Text encoding stage (Gemma)
|
||||
# LTX-2 needs separate contexts for video/audio streams. We model this as
|
||||
@@ -221,6 +214,7 @@ class LTX2PipelineConfig(PipelineConfig):
|
||||
padding="max_length",
|
||||
max_length=max_sequence_length,
|
||||
truncation=True,
|
||||
add_special_tokens=True,
|
||||
return_tensors="pt",
|
||||
)
|
||||
return text_inputs
|
||||
@@ -524,7 +518,9 @@ class LTX2PipelineConfig(PipelineConfig):
|
||||
|
||||
sample_rate = self.audio_vae_config.arch_config.sample_rate
|
||||
hop_length = self.audio_vae_config.arch_config.mel_hop_length
|
||||
temporal_compression = self.audio_vae_temporal_compression_ratio
|
||||
temporal_compression = (
|
||||
self.audio_vae_config.arch_config.temporal_compression_ratio
|
||||
)
|
||||
duration_s = num_frames / batch.fps
|
||||
|
||||
latents_per_second = (
|
||||
@@ -533,43 +529,9 @@ class LTX2PipelineConfig(PipelineConfig):
|
||||
audio_num_frames = round(duration_s * latents_per_second)
|
||||
|
||||
num_mel_bins = self.audio_vae_config.arch_config.mel_bins
|
||||
mel_compression_ratio = self.audio_vae_mel_compression_ratio
|
||||
mel_compression_ratio = self.audio_vae_config.arch_config.mel_compression_ratio
|
||||
latent_mel_bins = num_mel_bins // mel_compression_ratio
|
||||
|
||||
audio_latents_mean = getattr(audio_vae, "latents_mean", None)
|
||||
audio_latents_std = getattr(audio_vae, "latents_std", None)
|
||||
if (
|
||||
isinstance(audio_latents_mean, torch.Tensor)
|
||||
and isinstance(audio_latents_std, torch.Tensor)
|
||||
and audio_latents_mean.numel() == audio_latents_std.numel()
|
||||
):
|
||||
audio_latents_mean = audio_latents_mean.to(
|
||||
device=audio_latents.device, dtype=audio_latents.dtype
|
||||
)
|
||||
audio_latents_std = audio_latents_std.to(
|
||||
device=audio_latents.device, dtype=audio_latents.dtype
|
||||
)
|
||||
if audio_latents.ndim == 3:
|
||||
if audio_latents.shape[-1] != audio_latents_mean.numel():
|
||||
raise ValueError(
|
||||
f"audio_latents last dim {audio_latents.shape[-1]} "
|
||||
f"does not match audio_vae stats {audio_latents_mean.numel()}"
|
||||
)
|
||||
audio_latents = audio_latents * audio_latents_std.view(
|
||||
1, 1, -1
|
||||
) + audio_latents_mean.view(1, 1, -1)
|
||||
elif audio_latents.ndim == 2:
|
||||
if audio_latents.shape[-1] != audio_latents_mean.numel():
|
||||
raise ValueError(
|
||||
f"audio_latents last dim {audio_latents.shape[-1]} "
|
||||
f"does not match audio_vae stats {audio_latents_mean.numel()}"
|
||||
)
|
||||
audio_latents = audio_latents * audio_latents_std.view(
|
||||
1, -1
|
||||
) + audio_latents_mean.view(1, -1)
|
||||
else:
|
||||
audio_latents = audio_latents * audio_latents_std + audio_latents_mean
|
||||
|
||||
audio_latents = self._unpack_audio_latents(
|
||||
audio_latents, audio_num_frames, num_mel_bins=latent_mel_bins
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ class LTX2SamplingParams(SamplingParams):
|
||||
# Match the reference defaults used by ltx-pipelines (one-stage).
|
||||
# See: LTX-2/packages/ltx-pipelines/src/ltx_pipelines/utils/constants.py
|
||||
seed: int = 10
|
||||
generator_device: str = "cpu"
|
||||
|
||||
# Video parameters
|
||||
height: int = 512
|
||||
|
||||
@@ -128,7 +128,7 @@ class SamplingParams:
|
||||
# Batch info
|
||||
num_outputs_per_prompt: int = 1
|
||||
seed: int = 42
|
||||
generator_device: str = "cuda" # Device for random generator: "cuda" or "cpu"
|
||||
generator_device: str | None = None # None means use the pipeline/model default
|
||||
|
||||
# Original dimensions (before VAE scaling)
|
||||
num_frames: int = 1 # Default for image models
|
||||
@@ -685,7 +685,7 @@ class SamplingParams:
|
||||
"--generator-device",
|
||||
type=str,
|
||||
choices=["cuda", "musa", "cpu"],
|
||||
help="Device for random generator (cuda, musa or cpu). Default: cuda",
|
||||
help="Device for random generator (cuda, musa or cpu). Default: use the model-specific setting.",
|
||||
)
|
||||
add_argument(
|
||||
"--num-frames",
|
||||
|
||||
@@ -264,6 +264,29 @@ def _normalize_hf_cache_path(path: str) -> str:
|
||||
return os.path.normpath(path).lower().replace("\\", "/")
|
||||
|
||||
|
||||
def has_registered_diffusion_model_path(model_path: str) -> bool:
|
||||
all_model_hf_paths = sorted(_MODEL_HF_PATH_TO_NAME.keys(), key=len, reverse=True)
|
||||
|
||||
if model_path in _MODEL_HF_PATH_TO_NAME:
|
||||
return True
|
||||
|
||||
model_short_name = get_model_short_name(model_path.lower())
|
||||
for registered_model_hf_id in all_model_hf_paths:
|
||||
registered_model_name = get_model_short_name(registered_model_hf_id.lower())
|
||||
if registered_model_name in model_short_name:
|
||||
return True
|
||||
|
||||
normalized_model_path = _normalize_hf_cache_path(model_path)
|
||||
for registered_model_hf_id in all_model_hf_paths:
|
||||
cache_repo_fragment = (
|
||||
f"models--{registered_model_hf_id.lower().replace('/', '--')}"
|
||||
)
|
||||
if cache_repo_fragment in normalized_model_path:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _get_config_info(
|
||||
model_path: str, model_id: Optional[str] = None
|
||||
@@ -570,6 +593,9 @@ def _register_configs():
|
||||
register_configs(
|
||||
sampling_param_cls=LTX2SamplingParams,
|
||||
pipeline_config_cls=LTX2PipelineConfig,
|
||||
hf_model_paths=[
|
||||
"Lightricks/LTX-2",
|
||||
],
|
||||
model_detectors=[
|
||||
lambda path: "ltx" in path.lower() and "video" in path.lower(),
|
||||
lambda path: "ltx-2" in path.lower(),
|
||||
|
||||
@@ -270,7 +270,6 @@ def _maybe_mux_audio_into_mp4(
|
||||
sample_rate=selected_sr,
|
||||
ffmpeg_exe=ffmpeg_exe,
|
||||
)
|
||||
logger.info(f"Merged video saved to {CYAN}{save_file_path}{RESET}")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to mux audio into mp4 (saved silent video): %s",
|
||||
|
||||
@@ -266,6 +266,7 @@ class LocalAttention(nn.Module):
|
||||
q: torch.Tensor,
|
||||
k: torch.Tensor,
|
||||
v: torch.Tensor,
|
||||
attn_mask: torch.Tensor | None = None,
|
||||
) -> torch.Tensor:
|
||||
"""
|
||||
Apply local attention between query, key and value tensors.
|
||||
@@ -284,6 +285,35 @@ class LocalAttention(nn.Module):
|
||||
forward_context: ForwardContext = get_forward_context()
|
||||
ctx_attn_metadata = forward_context.attn_metadata
|
||||
|
||||
if attn_mask is not None:
|
||||
q_ = q.transpose(1, 2)
|
||||
k_ = k.transpose(1, 2)
|
||||
v_ = v.transpose(1, 2)
|
||||
|
||||
if torch.is_floating_point(attn_mask):
|
||||
mask = attn_mask.to(dtype=q_.dtype, device=q_.device)
|
||||
if mask.dim() == 2:
|
||||
mask = mask[:, None, None, :]
|
||||
elif mask.dim() == 3:
|
||||
mask = mask[:, None, :, :]
|
||||
else:
|
||||
mask = attn_mask.to(dtype=q_.dtype, device=q_.device)
|
||||
if mask.dim() == 2:
|
||||
mask = mask[:, None, None, :]
|
||||
elif mask.dim() == 3:
|
||||
mask = mask[:, None, :, :]
|
||||
mask = (mask - 1.0) * torch.finfo(q_.dtype).max
|
||||
|
||||
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)
|
||||
|
||||
output = self.attn_impl.forward(q, k, v, attn_metadata=ctx_attn_metadata)
|
||||
return output
|
||||
|
||||
|
||||
@@ -84,18 +84,22 @@ class BaseLayerWithLoRA(nn.Module):
|
||||
|
||||
# TODO: Support multiple LoRA adapters when use not merged mode
|
||||
if not self.merged and not self.disable_lora:
|
||||
lora_A_sliced = self.slice_lora_a_weights(lora_A.to(x, non_blocking=True))
|
||||
lora_B_sliced = self.slice_lora_b_weights(lora_B.to(x, non_blocking=True))
|
||||
delta = x @ lora_A_sliced.T @ lora_B_sliced.T
|
||||
lora_dtype = lora_A.dtype
|
||||
x_lora = x.to(dtype=lora_dtype)
|
||||
lora_A_sliced = self.slice_lora_a_weights(
|
||||
lora_A.to(device=x.device, non_blocking=True)
|
||||
)
|
||||
lora_B_sliced = self.slice_lora_b_weights(
|
||||
lora_B.to(device=x.device, non_blocking=True)
|
||||
)
|
||||
delta = x_lora @ lora_A_sliced.T @ lora_B_sliced.T
|
||||
if self.lora_alpha != self.lora_rank:
|
||||
delta = delta * (
|
||||
self.lora_alpha / self.lora_rank # type: ignore
|
||||
) # type: ignore
|
||||
delta = delta * self.strength
|
||||
if delta.dim() > 2:
|
||||
delta = delta.reshape(-1, delta.shape[-1])
|
||||
out, output_bias = self.base_layer(x)
|
||||
return out + delta, output_bias
|
||||
return out + delta.to(dtype=out.dtype), output_bias
|
||||
else:
|
||||
out, output_bias = self.base_layer(x)
|
||||
return out, output_bias
|
||||
@@ -113,6 +117,7 @@ class BaseLayerWithLoRA(nn.Module):
|
||||
lora_path: str | None = None,
|
||||
strength: float = 1.0,
|
||||
clear_existing: bool = False,
|
||||
merge_weights: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Set LoRA weights. Supports multiple LoRA adapters.
|
||||
@@ -149,7 +154,10 @@ class BaseLayerWithLoRA(nn.Module):
|
||||
self.strength = strength
|
||||
|
||||
self.disable_lora = False
|
||||
self.merge_lora_weights()
|
||||
if merge_weights:
|
||||
self.merge_lora_weights()
|
||||
elif self.merged:
|
||||
self.unmerge_lora_weights()
|
||||
|
||||
@torch.no_grad()
|
||||
def _merge_lora_into_data(
|
||||
@@ -309,11 +317,34 @@ class ColumnParallelLinearWithLoRA(BaseLayerWithLoRA):
|
||||
super().__init__(base_layer, lora_rank, lora_alpha)
|
||||
|
||||
def forward(self, input_: torch.Tensor) -> torch.Tensor:
|
||||
# duplicate the logic in ColumnParallelLinear
|
||||
lora_A = self.lora_A
|
||||
lora_B = self.lora_B
|
||||
if isinstance(self.lora_B, DTensor):
|
||||
lora_B = self.lora_B.to_local()
|
||||
lora_A = self.lora_A.to_local()
|
||||
|
||||
bias = self.base_layer.bias if not self.base_layer.skip_bias_add else None
|
||||
output_parallel = self.base_layer.quant_method.apply(
|
||||
self.base_layer, input_, bias
|
||||
)
|
||||
if not self.merged and not self.disable_lora:
|
||||
lora_dtype = lora_A.dtype
|
||||
input_lora = input_.to(dtype=lora_dtype)
|
||||
lora_A_sliced = self.slice_lora_a_weights(
|
||||
lora_A.to(device=input_.device, non_blocking=True)
|
||||
)
|
||||
lora_B_sliced = self.slice_lora_b_weights(
|
||||
lora_B.to(device=input_.device, non_blocking=True)
|
||||
)
|
||||
delta_parallel = input_lora @ lora_A_sliced.T @ lora_B_sliced.T
|
||||
if self.lora_alpha != self.lora_rank:
|
||||
delta_parallel = delta_parallel * (
|
||||
self.lora_alpha / self.lora_rank # type: ignore
|
||||
) # type: ignore
|
||||
delta_parallel = delta_parallel * self.strength
|
||||
output_parallel = output_parallel + delta_parallel.to(
|
||||
dtype=output_parallel.dtype
|
||||
)
|
||||
if self.base_layer.gather_output:
|
||||
output = tensor_model_parallel_all_gather(output_parallel)
|
||||
else:
|
||||
@@ -399,7 +430,12 @@ class RowParallelLinearWithLoRA(BaseLayerWithLoRA):
|
||||
super().__init__(base_layer, lora_rank, lora_alpha)
|
||||
|
||||
def forward(self, input_: torch.Tensor):
|
||||
# duplicate the logic in RowParallelLinear
|
||||
lora_A = self.lora_A
|
||||
lora_B = self.lora_B
|
||||
if isinstance(self.lora_B, DTensor):
|
||||
lora_B = self.lora_B.to_local()
|
||||
lora_A = self.lora_A.to_local()
|
||||
|
||||
if self.base_layer.input_is_parallel:
|
||||
input_parallel = input_
|
||||
else:
|
||||
@@ -411,6 +447,24 @@ class RowParallelLinearWithLoRA(BaseLayerWithLoRA):
|
||||
output_parallel = self.base_layer.quant_method.apply(
|
||||
self.base_layer, input_parallel
|
||||
)
|
||||
if not self.merged and not self.disable_lora:
|
||||
lora_dtype = lora_A.dtype
|
||||
input_parallel_lora = input_parallel.to(dtype=lora_dtype)
|
||||
lora_A_sliced = self.slice_lora_a_weights(
|
||||
lora_A.to(device=input_parallel.device, non_blocking=True)
|
||||
)
|
||||
lora_B_sliced = self.slice_lora_b_weights(
|
||||
lora_B.to(device=input_parallel.device, non_blocking=True)
|
||||
)
|
||||
delta_parallel = input_parallel_lora @ lora_A_sliced.T @ lora_B_sliced.T
|
||||
if self.lora_alpha != self.lora_rank:
|
||||
delta_parallel = delta_parallel * (
|
||||
self.lora_alpha / self.lora_rank # type: ignore
|
||||
) # type: ignore
|
||||
delta_parallel = delta_parallel * self.strength
|
||||
output_parallel = output_parallel + delta_parallel.to(
|
||||
dtype=output_parallel.dtype
|
||||
)
|
||||
|
||||
if self.base_layer.reduce_results and self.base_layer.tp_size > 1:
|
||||
output_ = tensor_model_parallel_all_reduce(output_parallel)
|
||||
@@ -466,19 +520,23 @@ class LinearWithLoRA(BaseLayerWithLoRA):
|
||||
|
||||
# TODO: Support multiple LoRA adapters when use not merged mode
|
||||
if not self.merged and not self.disable_lora:
|
||||
lora_A_sliced = self.slice_lora_a_weights(lora_A.to(x, non_blocking=True))
|
||||
lora_B_sliced = self.slice_lora_b_weights(lora_B.to(x, non_blocking=True))
|
||||
delta = x @ lora_A_sliced.T @ lora_B_sliced.T
|
||||
lora_dtype = lora_A.dtype
|
||||
x_lora = x.to(dtype=lora_dtype)
|
||||
lora_A_sliced = self.slice_lora_a_weights(
|
||||
lora_A.to(device=x.device, non_blocking=True)
|
||||
)
|
||||
lora_B_sliced = self.slice_lora_b_weights(
|
||||
lora_B.to(device=x.device, non_blocking=True)
|
||||
)
|
||||
delta = x_lora @ lora_A_sliced.T @ lora_B_sliced.T
|
||||
if self.lora_alpha != self.lora_rank:
|
||||
delta = delta * (
|
||||
self.lora_alpha / self.lora_rank # type: ignore
|
||||
) # type: ignore
|
||||
delta = delta * self.strength
|
||||
if delta.dim() > 2:
|
||||
delta = delta.reshape(-1, delta.shape[-1])
|
||||
# nn.Linear.forward() returns a single tensor, not a tuple
|
||||
out = self.base_layer(x)
|
||||
return out + delta
|
||||
return out + delta.to(dtype=out.dtype)
|
||||
else:
|
||||
# nn.Linear.forward() returns a single tensor
|
||||
out = self.base_layer(x)
|
||||
|
||||
@@ -15,6 +15,7 @@ from torch import nn
|
||||
from transformers import AutoImageProcessor, AutoProcessor, AutoTokenizer
|
||||
|
||||
from sglang.multimodal_gen.configs.models import ModelConfig
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.flux import Flux2PipelineConfig
|
||||
from sglang.multimodal_gen.runtime.distributed import get_local_torch_device
|
||||
from sglang.multimodal_gen.runtime.loader.utils import (
|
||||
_normalize_component_type,
|
||||
@@ -294,9 +295,15 @@ class TokenizerLoader(ComponentLoader):
|
||||
def load_customized(
|
||||
self, component_model_path: str, server_args: ServerArgs, component_name: str
|
||||
) -> Any:
|
||||
# Flux.2 aligns to the tokenizer defaults from the original baseline.
|
||||
# TODO: abstract this
|
||||
if isinstance(server_args.pipeline_config, Flux2PipelineConfig):
|
||||
return AutoTokenizer.from_pretrained(component_model_path)
|
||||
|
||||
return AutoTokenizer.from_pretrained(
|
||||
component_model_path,
|
||||
padding_size="right",
|
||||
padding_side="right",
|
||||
use_fast=True,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
import safetensors
|
||||
import torch
|
||||
from safetensors.torch import load_file as safetensors_load_file
|
||||
|
||||
from sglang.multimodal_gen.runtime.loader.component_loaders.component_loader import (
|
||||
ComponentLoader,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.models.upsampler.latent_upsampler import (
|
||||
LatentUpsampler,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||
from sglang.multimodal_gen.runtime.utils.hf_diffusers_utils import maybe_download_model
|
||||
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
UPSAMPLER_CONSTRUCTOR_KEYS = {
|
||||
"in_channels",
|
||||
"mid_channels",
|
||||
"num_blocks_per_stage",
|
||||
"dims",
|
||||
"spatial_upsample",
|
||||
"temporal_upsample",
|
||||
"spatial_scale",
|
||||
"rational_resampler",
|
||||
}
|
||||
|
||||
_HF_BLOB_URL_RE = re.compile(
|
||||
r"https?://huggingface\.co/([^/]+/[^/]+)/blob/([^/]+)/(.*)"
|
||||
)
|
||||
_HF_RESOLVE_URL_RE = re.compile(
|
||||
r"https?://huggingface\.co/([^/]+/[^/]+)/resolve/([^/]+)/(.*)"
|
||||
)
|
||||
|
||||
|
||||
def _parse_hf_url(path: str):
|
||||
m = _HF_BLOB_URL_RE.match(path) or _HF_RESOLVE_URL_RE.match(path)
|
||||
if m:
|
||||
return m.group(1), m.group(2), m.group(3)
|
||||
return None
|
||||
|
||||
|
||||
def _download_hf_file(repo_id: str, filename: str, revision: str = "main") -> str:
|
||||
from huggingface_hub import hf_hub_download
|
||||
|
||||
logger.info("Downloading %s from %s (revision=%s)", filename, repo_id, revision)
|
||||
return hf_hub_download(repo_id=repo_id, filename=filename, revision=revision)
|
||||
|
||||
|
||||
def _find_safetensors_file(path: str) -> str:
|
||||
"""Resolve path to a single safetensors file (local path, directory, HF URL, or HF repo id)."""
|
||||
if os.path.isfile(path) and path.endswith(".safetensors"):
|
||||
return path
|
||||
|
||||
if os.path.isdir(path):
|
||||
files = sorted(glob.glob(os.path.join(path, "*.safetensors")))
|
||||
if len(files) == 1:
|
||||
return files[0]
|
||||
elif len(files) > 1:
|
||||
raise ValueError(
|
||||
f"Found {len(files)} safetensors files in {path}, expected 1"
|
||||
)
|
||||
|
||||
hf = _parse_hf_url(path)
|
||||
if hf:
|
||||
repo_id, revision, filename = hf
|
||||
return _download_hf_file(repo_id, filename, revision)
|
||||
|
||||
try:
|
||||
maybe_downloaded = maybe_download_model(path)
|
||||
if os.path.isdir(maybe_downloaded):
|
||||
files = sorted(glob.glob(os.path.join(maybe_downloaded, "*.safetensors")))
|
||||
if len(files) == 1:
|
||||
return files[0]
|
||||
elif len(files) > 1:
|
||||
raise ValueError(
|
||||
f"Found {len(files)} safetensors files in {maybe_downloaded}, expected 1"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise FileNotFoundError(
|
||||
f"No safetensors file found at {path}. "
|
||||
"Provide a local .safetensors file, a directory containing one, "
|
||||
"a HuggingFace URL (https://huggingface.co/<repo>/blob/main/<path>), "
|
||||
"or a HuggingFace repo id."
|
||||
)
|
||||
|
||||
|
||||
def _normalize_config(raw: dict) -> dict:
|
||||
"""Map diffusers / original-repo config fields to LatentUpsampler kwargs."""
|
||||
config = {k: v for k, v in raw.items() if k in UPSAMPLER_CONSTRUCTOR_KEYS}
|
||||
|
||||
# diffusers uses rational_spatial_scale instead of rational_resampler + spatial_scale
|
||||
if "rational_spatial_scale" in raw and "rational_resampler" not in config:
|
||||
config["rational_resampler"] = True
|
||||
config.setdefault("spatial_scale", raw["rational_spatial_scale"])
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def _infer_config_from_state_dict(state_dict: dict[str, torch.Tensor]) -> dict:
|
||||
"""Infer LatentUpsampler kwargs from weight shapes and key names.
|
||||
|
||||
Works even when no config.json or safetensors metadata is available.
|
||||
"""
|
||||
config: dict = {}
|
||||
|
||||
w = state_dict.get("initial_conv.weight")
|
||||
if w is not None:
|
||||
config["mid_channels"] = w.shape[0]
|
||||
config["in_channels"] = w.shape[1]
|
||||
config["dims"] = 3 if w.ndim == 5 else 2
|
||||
|
||||
num_blocks = sum(
|
||||
1
|
||||
for k in state_dict
|
||||
if k.startswith("res_blocks.") and k.endswith(".conv1.weight")
|
||||
)
|
||||
if num_blocks > 0:
|
||||
config["num_blocks_per_stage"] = num_blocks
|
||||
|
||||
# Detect upsampler type from key patterns
|
||||
has_rational = any(k.startswith("upsampler.blur_down.") for k in state_dict)
|
||||
if has_rational:
|
||||
config["rational_resampler"] = True
|
||||
config["spatial_upsample"] = True
|
||||
config["temporal_upsample"] = False
|
||||
config["spatial_scale"] = 2.0
|
||||
else:
|
||||
up_w = state_dict.get("upsampler.0.weight")
|
||||
if up_w is not None and up_w.ndim == 5:
|
||||
ratio = up_w.shape[0] // up_w.shape[1]
|
||||
if ratio == 8:
|
||||
config["spatial_upsample"] = True
|
||||
config["temporal_upsample"] = True
|
||||
elif ratio == 2:
|
||||
config["spatial_upsample"] = False
|
||||
config["temporal_upsample"] = True
|
||||
else:
|
||||
config["spatial_upsample"] = True
|
||||
config["temporal_upsample"] = False
|
||||
else:
|
||||
config["spatial_upsample"] = True
|
||||
config["temporal_upsample"] = False
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def _load_config(
|
||||
safetensors_path: str,
|
||||
original_path: str,
|
||||
state_dict: dict[str, torch.Tensor],
|
||||
) -> dict:
|
||||
"""Load upsampler config with fallback chain:
|
||||
1. safetensors metadata ("config" key) - original LTX-2 repo format
|
||||
2. sibling config.json - diffusers format
|
||||
3. config.json from HF (if original_path was a URL)
|
||||
4. infer from state dict shapes (always works)
|
||||
"""
|
||||
with safetensors.safe_open(safetensors_path, framework="pt") as f:
|
||||
meta = f.metadata()
|
||||
if meta and "config" in meta:
|
||||
logger.info("Using config from safetensors metadata")
|
||||
return _normalize_config(json.loads(meta["config"]))
|
||||
|
||||
config_json_path = os.path.join(os.path.dirname(safetensors_path), "config.json")
|
||||
if os.path.isfile(config_json_path):
|
||||
with open(config_json_path) as fp:
|
||||
logger.info("Using config from sibling config.json")
|
||||
return _normalize_config(json.load(fp))
|
||||
|
||||
hf = _parse_hf_url(original_path)
|
||||
if hf:
|
||||
repo_id, revision, filename = hf
|
||||
config_filename = os.path.dirname(filename) + "/config.json"
|
||||
try:
|
||||
local = _download_hf_file(repo_id, config_filename, revision)
|
||||
with open(local) as fp:
|
||||
logger.info("Using config from HF config.json")
|
||||
return _normalize_config(json.load(fp))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info("No explicit config found, inferring from state dict")
|
||||
return _infer_config_from_state_dict(state_dict)
|
||||
|
||||
|
||||
class UpsamplerLoader(ComponentLoader):
|
||||
component_names = ["spatial_upsampler"]
|
||||
expected_library = "diffusers"
|
||||
|
||||
def should_offload(self, server_args: ServerArgs, model_config=None):
|
||||
return server_args.vae_cpu_offload
|
||||
|
||||
def load_customized(
|
||||
self,
|
||||
component_model_path: str,
|
||||
server_args: ServerArgs,
|
||||
component_name: str,
|
||||
):
|
||||
safetensors_path = _find_safetensors_file(component_model_path)
|
||||
state_dict = safetensors_load_file(safetensors_path)
|
||||
config = _load_config(safetensors_path, component_model_path, state_dict)
|
||||
|
||||
logger.info("Loading LatentUpsampler with config: %s", config)
|
||||
|
||||
should_offload = self.should_offload(server_args)
|
||||
target_device = self.target_device(should_offload)
|
||||
|
||||
with torch.device("meta"):
|
||||
model = LatentUpsampler(**config)
|
||||
|
||||
model.load_state_dict(state_dict, assign=True)
|
||||
model = model.to(device=target_device, dtype=torch.bfloat16).eval()
|
||||
|
||||
logger.info("Loaded LatentUpsampler to %s", target_device)
|
||||
return model
|
||||
@@ -46,7 +46,7 @@ def apply_split_rotary_emb(
|
||||
r = last // 2
|
||||
|
||||
# (..., 2, r)
|
||||
split_x = x.reshape(*x.shape[:-1], 2, r)
|
||||
split_x = x.reshape(*x.shape[:-1], 2, r).float()
|
||||
first_x = split_x[..., :1, :] # (..., 1, r)
|
||||
second_x = split_x[..., 1:, :] # (..., 1, r)
|
||||
|
||||
@@ -153,12 +153,6 @@ class LTX2Attention(torch.nn.Module):
|
||||
query_rotary_emb: Optional[Tuple[torch.Tensor, torch.Tensor]] = None,
|
||||
key_rotary_emb: Optional[Tuple[torch.Tensor, torch.Tensor]] = None,
|
||||
) -> torch.Tensor:
|
||||
batch_size, sequence_length, _ = (
|
||||
hidden_states.shape
|
||||
if encoder_hidden_states is None
|
||||
else encoder_hidden_states.shape
|
||||
)
|
||||
|
||||
if encoder_hidden_states is None:
|
||||
encoder_hidden_states = hidden_states
|
||||
|
||||
@@ -183,16 +177,26 @@ class LTX2Attention(torch.nn.Module):
|
||||
key_rotary_emb if key_rotary_emb is not None else query_rotary_emb,
|
||||
)
|
||||
|
||||
query = query.unflatten(2, (self.heads, -1))
|
||||
key = key.unflatten(2, (self.heads, -1))
|
||||
value = value.unflatten(2, (self.heads, -1))
|
||||
query = query.unflatten(2, (self.heads, -1)).transpose(1, 2)
|
||||
key = key.unflatten(2, (self.heads, -1)).transpose(1, 2)
|
||||
value = value.unflatten(2, (self.heads, -1)).transpose(1, 2)
|
||||
|
||||
hidden_states = self.attn(
|
||||
if attention_mask is not None:
|
||||
if attention_mask.ndim == 2:
|
||||
attention_mask = attention_mask[:, None, None, :]
|
||||
elif attention_mask.ndim == 3:
|
||||
attention_mask = attention_mask[:, None, :, :]
|
||||
attention_mask = attention_mask.to(dtype=query.dtype)
|
||||
|
||||
hidden_states = F.scaled_dot_product_attention(
|
||||
query,
|
||||
key,
|
||||
value,
|
||||
attn_mask=attention_mask,
|
||||
dropout_p=0.0,
|
||||
is_causal=False,
|
||||
)
|
||||
hidden_states = hidden_states.flatten(2, 3)
|
||||
hidden_states = hidden_states.transpose(1, 2).flatten(2, 3)
|
||||
hidden_states = hidden_states.to(query.dtype)
|
||||
|
||||
hidden_states = self.to_out[0](hidden_states)
|
||||
@@ -464,9 +468,7 @@ class LTX2ConnectorTransformer1d(nn.Module):
|
||||
attention_mask = torch.zeros_like(attention_mask)
|
||||
|
||||
# 2. Calculate 1D RoPE positional embeddings
|
||||
rotary_emb = self.rope(
|
||||
batch_size, seq_len, device=hidden_states.device, dtype=hidden_states.dtype
|
||||
)
|
||||
rotary_emb = self.rope(batch_size, seq_len, device=hidden_states.device)
|
||||
|
||||
# 3. Run 1D transformer blocks
|
||||
for block in self.transformer_blocks:
|
||||
|
||||
@@ -21,7 +21,7 @@ from sglang.multimodal_gen.runtime.distributed import (
|
||||
from sglang.multimodal_gen.runtime.distributed.communication_op import (
|
||||
tensor_model_parallel_all_reduce,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.layers.attention import USPAttention
|
||||
from sglang.multimodal_gen.runtime.layers.attention import LocalAttention, USPAttention
|
||||
from sglang.multimodal_gen.runtime.layers.linear import (
|
||||
ColumnParallelLinear,
|
||||
RowParallelLinear,
|
||||
@@ -66,7 +66,7 @@ def apply_split_rotary_emb(
|
||||
)
|
||||
r = last // 2
|
||||
|
||||
split_x = x.reshape(*x.shape[:-1], 2, r)
|
||||
split_x = x.reshape(*x.shape[:-1], 2, r).float()
|
||||
first_x = split_x[..., :1, :]
|
||||
second_x = split_x[..., 1:, :]
|
||||
|
||||
@@ -137,7 +137,6 @@ class LTX2AudioVideoRotaryPosEmbed(nn.Module):
|
||||
self.causal_offset = int(causal_offset)
|
||||
|
||||
self.modality = modality
|
||||
self.coords_dtype = torch.bfloat16 if modality == "video" else torch.float32
|
||||
if self.modality not in ["video", "audio"]:
|
||||
raise ValueError(
|
||||
f"Modality {modality} is not supported. Supported modalities are `video` and `audio`."
|
||||
@@ -244,7 +243,6 @@ class LTX2AudioVideoRotaryPosEmbed(nn.Module):
|
||||
device = device or coords.device
|
||||
num_pos_dims = coords.shape[1]
|
||||
|
||||
coords = coords.to(self.coords_dtype)
|
||||
if coords.ndim == 4:
|
||||
coords_start, coords_end = coords.chunk(2, dim=-1)
|
||||
coords = (coords_start + coords_end) / 2.0
|
||||
@@ -309,9 +307,7 @@ class LTX2AudioVideoRotaryPosEmbed(nn.Module):
|
||||
cos_freqs = torch.swapaxes(cos_freq, 1, 2)
|
||||
sin_freqs = torch.swapaxes(sin_freq, 1, 2)
|
||||
|
||||
# Cast to bf16 to match model weights dtype. coords_dtype controls
|
||||
# intermediate coordinate precision (fp32 for audio) and differs.
|
||||
return cos_freqs.to(torch.bfloat16), sin_freqs.to(torch.bfloat16)
|
||||
return cos_freqs, sin_freqs
|
||||
|
||||
|
||||
def rms_norm(x: torch.Tensor, eps: float) -> torch.Tensor:
|
||||
@@ -450,6 +446,7 @@ class LTX2Attention(nn.Module):
|
||||
dim_head: int = 64,
|
||||
norm_eps: float = 1e-6,
|
||||
qk_norm: bool = True,
|
||||
use_local_attention: bool = False,
|
||||
supported_attention_backends: set[AttentionBackendEnum] | None = None,
|
||||
prefix: str = "",
|
||||
quant_config: QuantizationConfig | None = None,
|
||||
@@ -463,6 +460,7 @@ class LTX2Attention(nn.Module):
|
||||
self.inner_dim = self.heads * self.dim_head
|
||||
self.norm_eps = float(norm_eps)
|
||||
self.qk_norm = bool(qk_norm)
|
||||
self.use_local_attention = bool(use_local_attention)
|
||||
|
||||
tp_size = get_tp_world_size()
|
||||
if tp_size <= 0:
|
||||
@@ -531,16 +529,27 @@ class LTX2Attention(nn.Module):
|
||||
nn.Identity(),
|
||||
)
|
||||
|
||||
self.attn = USPAttention(
|
||||
num_heads=self.local_heads,
|
||||
head_size=self.dim_head,
|
||||
num_kv_heads=self.local_heads,
|
||||
dropout_rate=0,
|
||||
softmax_scale=None,
|
||||
causal=False,
|
||||
supported_attention_backends=supported_attention_backends,
|
||||
prefix=f"{prefix}.attn",
|
||||
)
|
||||
if self.use_local_attention:
|
||||
self.attn = LocalAttention(
|
||||
num_heads=self.local_heads,
|
||||
head_size=self.dim_head,
|
||||
num_kv_heads=self.local_heads,
|
||||
softmax_scale=None,
|
||||
causal=False,
|
||||
supported_attention_backends=supported_attention_backends,
|
||||
prefix=f"{prefix}.attn",
|
||||
)
|
||||
else:
|
||||
self.attn = USPAttention(
|
||||
num_heads=self.local_heads,
|
||||
head_size=self.dim_head,
|
||||
num_kv_heads=self.local_heads,
|
||||
dropout_rate=0,
|
||||
softmax_scale=None,
|
||||
causal=False,
|
||||
supported_attention_backends=supported_attention_backends,
|
||||
prefix=f"{prefix}.attn",
|
||||
)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
@@ -549,66 +558,56 @@ class LTX2Attention(nn.Module):
|
||||
mask: torch.Tensor | None = None,
|
||||
pe: tuple[torch.Tensor, torch.Tensor] | None = None,
|
||||
k_pe: tuple[torch.Tensor, torch.Tensor] | None = None,
|
||||
perturbation_mask: torch.Tensor | None = None,
|
||||
all_perturbed: bool = False,
|
||||
) -> torch.Tensor:
|
||||
q, _ = self.to_q(x)
|
||||
context_ = x if context is None else context
|
||||
k, _ = self.to_k(context_)
|
||||
v, _ = self.to_v(context_)
|
||||
use_attention = not all_perturbed
|
||||
|
||||
if self.qk_norm:
|
||||
assert self.q_norm is not None and self.k_norm is not None
|
||||
q = self.q_norm(q)
|
||||
k = self.k_norm(k)
|
||||
if use_attention:
|
||||
q, _ = self.to_q(x)
|
||||
k, _ = self.to_k(context_)
|
||||
|
||||
if pe is not None:
|
||||
cos, sin = pe
|
||||
k_cos, k_sin = pe if k_pe is None else k_pe
|
||||
tp_size = get_tp_world_size()
|
||||
if tp_size > 1:
|
||||
tp_rank = get_tp_rank()
|
||||
cos, sin = self._slice_rope_for_tp(
|
||||
cos, sin, tp_rank=tp_rank, tp_size=tp_size
|
||||
)
|
||||
k_cos, k_sin = self._slice_rope_for_tp(
|
||||
k_cos, k_sin, tp_rank=tp_rank, tp_size=tp_size
|
||||
)
|
||||
if cos.dim() == 3:
|
||||
q = apply_interleaved_rotary_emb(q, (cos, sin))
|
||||
k = apply_interleaved_rotary_emb(k, (k_cos, k_sin))
|
||||
else:
|
||||
q = apply_split_rotary_emb(q, (cos, sin))
|
||||
k = apply_split_rotary_emb(k, (k_cos, k_sin))
|
||||
if self.qk_norm:
|
||||
assert self.q_norm is not None and self.k_norm is not None
|
||||
q = self.q_norm(q)
|
||||
k = self.k_norm(k)
|
||||
|
||||
if pe is not None:
|
||||
cos, sin = pe
|
||||
k_cos, k_sin = pe if k_pe is None else k_pe
|
||||
tp_size = get_tp_world_size()
|
||||
if tp_size > 1:
|
||||
tp_rank = get_tp_rank()
|
||||
cos, sin = self._slice_rope_for_tp(
|
||||
cos, sin, tp_rank=tp_rank, tp_size=tp_size
|
||||
)
|
||||
k_cos, k_sin = self._slice_rope_for_tp(
|
||||
k_cos, k_sin, tp_rank=tp_rank, tp_size=tp_size
|
||||
)
|
||||
if cos.dim() == 3:
|
||||
q = apply_interleaved_rotary_emb(q, (cos, sin))
|
||||
k = apply_interleaved_rotary_emb(k, (k_cos, k_sin))
|
||||
else:
|
||||
q = apply_split_rotary_emb(q, (cos, sin))
|
||||
k = apply_split_rotary_emb(k, (k_cos, k_sin))
|
||||
|
||||
q = q.view(*q.shape[:-1], self.local_heads, self.dim_head)
|
||||
k = k.view(*k.shape[:-1], self.local_heads, self.dim_head)
|
||||
v = v.view(*v.shape[:-1], self.local_heads, self.dim_head)
|
||||
if use_attention:
|
||||
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 mask is not None:
|
||||
# Fallback to SDPA for masked attention
|
||||
q_ = q.transpose(1, 2)
|
||||
k_ = k.transpose(1, 2)
|
||||
v_ = v.transpose(1, 2)
|
||||
|
||||
if torch.is_floating_point(mask):
|
||||
m = mask
|
||||
if m.dim() == 2:
|
||||
m = m[:, None, None, :]
|
||||
elif m.dim() == 3:
|
||||
m = m[:, None, :, :]
|
||||
sdpa_mask = m.to(dtype=q_.dtype, device=q_.device)
|
||||
if self.use_local_attention:
|
||||
out = self.attn(q, k, v, attn_mask=mask)
|
||||
else:
|
||||
m = mask.to(dtype=q_.dtype, device=q_.device)
|
||||
if m.dim() == 2:
|
||||
m = m[:, None, None, :]
|
||||
elif m.dim() == 3:
|
||||
m = m[:, None, :, :]
|
||||
sdpa_mask = (m - 1.0) * torch.finfo(q_.dtype).max
|
||||
out = self.attn(q, k, v)
|
||||
|
||||
out = torch.nn.functional.scaled_dot_product_attention(
|
||||
q_, k_, v_, attn_mask=sdpa_mask, dropout_p=0.0, is_causal=False
|
||||
).transpose(1, 2)
|
||||
else:
|
||||
out = self.attn(q, k, v)
|
||||
if perturbation_mask is not None:
|
||||
out = out * perturbation_mask + v * (1 - perturbation_mask)
|
||||
|
||||
if not use_attention:
|
||||
out = v
|
||||
|
||||
out = out.flatten(2)
|
||||
out, _ = self.to_out[0](out)
|
||||
@@ -720,6 +719,8 @@ class LTX2TransformerBlock(nn.Module):
|
||||
)
|
||||
|
||||
# 2. Prompt Cross-Attention
|
||||
# Prompt KV is replicated across SP ranks, so prompt cross-attn should
|
||||
# stay local and preserve the explicit KV mask semantics from official.
|
||||
self.attn2 = LTX2Attention(
|
||||
query_dim=dim,
|
||||
context_dim=cross_attention_dim,
|
||||
@@ -727,6 +728,7 @@ class LTX2TransformerBlock(nn.Module):
|
||||
dim_head=attention_head_dim,
|
||||
norm_eps=norm_eps,
|
||||
qk_norm=qk_norm,
|
||||
use_local_attention=True,
|
||||
supported_attention_backends=supported_attention_backends,
|
||||
prefix=f"{prefix}.attn2",
|
||||
quant_config=quant_config,
|
||||
@@ -738,6 +740,7 @@ class LTX2TransformerBlock(nn.Module):
|
||||
dim_head=audio_attention_head_dim,
|
||||
norm_eps=norm_eps,
|
||||
qk_norm=qk_norm,
|
||||
use_local_attention=True,
|
||||
supported_attention_backends=supported_attention_backends,
|
||||
prefix=f"{prefix}.audio_attn2",
|
||||
quant_config=quant_config,
|
||||
@@ -822,6 +825,10 @@ class LTX2TransformerBlock(nn.Module):
|
||||
audio_encoder_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,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
|
||||
batch_size = hidden_states.size(0)
|
||||
@@ -833,7 +840,11 @@ class LTX2TransformerBlock(nn.Module):
|
||||
norm_hidden_states = (
|
||||
rms_norm(hidden_states, self.norm_eps) * (1 + vscale_msa) + vshift_msa
|
||||
)
|
||||
attn_hidden_states = self.attn1(norm_hidden_states, pe=video_rotary_emb)
|
||||
attn_hidden_states = self.attn1(
|
||||
norm_hidden_states,
|
||||
pe=video_rotary_emb,
|
||||
all_perturbed=skip_video_self_attn,
|
||||
)
|
||||
hidden_states = hidden_states + attn_hidden_states * vgate_msa
|
||||
|
||||
ashift_msa, ascale_msa, agate_msa = self.get_ada_values(
|
||||
@@ -843,10 +854,11 @@ class LTX2TransformerBlock(nn.Module):
|
||||
rms_norm(audio_hidden_states, self.norm_eps) * (1 + ascale_msa) + ashift_msa
|
||||
)
|
||||
attn_audio_hidden_states = self.audio_attn1(
|
||||
norm_audio_hidden_states, pe=audio_rotary_emb
|
||||
norm_audio_hidden_states,
|
||||
pe=audio_rotary_emb,
|
||||
all_perturbed=skip_audio_self_attn,
|
||||
)
|
||||
audio_hidden_states = audio_hidden_states + attn_audio_hidden_states * agate_msa
|
||||
|
||||
# 2. Prompt Cross-Attention
|
||||
norm_hidden_states = rms_norm(hidden_states, self.norm_eps)
|
||||
attn_hidden_states = self.attn2(
|
||||
@@ -863,7 +875,6 @@ class LTX2TransformerBlock(nn.Module):
|
||||
mask=audio_encoder_attention_mask,
|
||||
)
|
||||
audio_hidden_states = audio_hidden_states + attn_audio_hidden_states
|
||||
|
||||
# 3. Audio-to-Video and Video-to-Audio Cross-Attention
|
||||
norm_hidden_states = rms_norm(hidden_states, self.norm_eps)
|
||||
norm_audio_hidden_states = rms_norm(audio_hidden_states, self.norm_eps)
|
||||
@@ -934,14 +945,15 @@ class LTX2TransformerBlock(nn.Module):
|
||||
norm_audio_hidden_states * (1 + audio_a2v_ca_scale) + audio_a2v_ca_shift
|
||||
)
|
||||
|
||||
a2v_attn_hidden_states = self.audio_to_video_attn(
|
||||
mod_norm_hidden_states,
|
||||
context=mod_norm_audio_hidden_states,
|
||||
pe=ca_video_rotary_emb,
|
||||
k_pe=ca_audio_rotary_emb,
|
||||
mask=a2v_cross_attention_mask,
|
||||
)
|
||||
hidden_states = hidden_states + a2v_gate * a2v_attn_hidden_states
|
||||
if not skip_a2v_cross_attn:
|
||||
a2v_attn_hidden_states = self.audio_to_video_attn(
|
||||
mod_norm_hidden_states,
|
||||
context=mod_norm_audio_hidden_states,
|
||||
pe=ca_video_rotary_emb,
|
||||
k_pe=ca_audio_rotary_emb,
|
||||
mask=a2v_cross_attention_mask,
|
||||
)
|
||||
hidden_states = hidden_states + a2v_gate * a2v_attn_hidden_states
|
||||
|
||||
# V2A
|
||||
mod_norm_hidden_states = (
|
||||
@@ -951,15 +963,17 @@ class LTX2TransformerBlock(nn.Module):
|
||||
norm_audio_hidden_states * (1 + audio_v2a_ca_scale) + audio_v2a_ca_shift
|
||||
)
|
||||
|
||||
v2a_attn_hidden_states = self.video_to_audio_attn(
|
||||
mod_norm_audio_hidden_states,
|
||||
context=mod_norm_hidden_states,
|
||||
pe=ca_audio_rotary_emb,
|
||||
k_pe=ca_video_rotary_emb,
|
||||
mask=v2a_cross_attention_mask,
|
||||
)
|
||||
audio_hidden_states = audio_hidden_states + v2a_gate * v2a_attn_hidden_states
|
||||
|
||||
if not skip_v2a_cross_attn:
|
||||
v2a_attn_hidden_states = self.video_to_audio_attn(
|
||||
mod_norm_audio_hidden_states,
|
||||
context=mod_norm_hidden_states,
|
||||
pe=ca_audio_rotary_emb,
|
||||
k_pe=ca_video_rotary_emb,
|
||||
mask=v2a_cross_attention_mask,
|
||||
)
|
||||
audio_hidden_states = (
|
||||
audio_hidden_states + v2a_gate * v2a_attn_hidden_states
|
||||
)
|
||||
# 4. Feedforward
|
||||
vshift_mlp, vscale_mlp, vgate_mlp = self.get_ada_values(
|
||||
self.scale_shift_table, batch_size, temb, slice(3, None)
|
||||
@@ -978,7 +992,6 @@ class LTX2TransformerBlock(nn.Module):
|
||||
)
|
||||
audio_ff_output = self.audio_ff(norm_audio_hidden_states)
|
||||
audio_hidden_states = audio_hidden_states + audio_ff_output * agate_mlp
|
||||
|
||||
return hidden_states, audio_hidden_states
|
||||
|
||||
|
||||
@@ -1275,6 +1288,10 @@ class LTX2VideoTransformer3DModel(CachableDiT, OffloadableDiTMixin):
|
||||
audio_num_frames: Optional[int] = None,
|
||||
video_coords: Optional[torch.Tensor] = None,
|
||||
audio_coords: 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,
|
||||
**kwargs,
|
||||
) -> tuple[torch.Tensor | None, torch.Tensor | None]:
|
||||
|
||||
@@ -1333,7 +1350,6 @@ class LTX2VideoTransformer3DModel(CachableDiT, OffloadableDiTMixin):
|
||||
# 2. Patchify input projections
|
||||
hidden_states, _ = self.patchify_proj(hidden_states)
|
||||
audio_hidden_states, _ = self.audio_patchify_proj(audio_hidden_states)
|
||||
|
||||
# 3. Prepare timestep embeddings
|
||||
# 3.1. Prepare global modality (video and audio) timestep embedding and modulation parameters
|
||||
temb, embedded_timestep = self.adaln_single(
|
||||
@@ -1391,8 +1407,9 @@ class LTX2VideoTransformer3DModel(CachableDiT, OffloadableDiTMixin):
|
||||
audio_encoder_hidden_states = self.audio_caption_projection(
|
||||
audio_encoder_hidden_states
|
||||
)
|
||||
|
||||
# 5. Run blocks
|
||||
skip_video_self_attn_blocks = set(skip_video_self_attn_blocks or ())
|
||||
skip_audio_self_attn_blocks = set(skip_audio_self_attn_blocks or ())
|
||||
for block in self.transformer_blocks:
|
||||
hidden_states, audio_hidden_states = block(
|
||||
hidden_states,
|
||||
@@ -1414,6 +1431,10 @@ 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,
|
||||
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,
|
||||
)
|
||||
|
||||
# 6. Output layers
|
||||
@@ -1439,7 +1460,6 @@ class LTX2VideoTransformer3DModel(CachableDiT, OffloadableDiTMixin):
|
||||
audio_hidden_states = self.audio_norm_out(audio_hidden_states)
|
||||
audio_hidden_states = audio_hidden_states * (1 + audio_scale) + audio_shift
|
||||
audio_hidden_states, _ = self.audio_proj_out(audio_hidden_states)
|
||||
|
||||
# Unpatchify if requested (default True for pipeline compatibility)
|
||||
return_latents = kwargs.get("return_latents", True)
|
||||
|
||||
|
||||
@@ -146,23 +146,61 @@ class Gemma3Attention(nn.Module):
|
||||
prefix=f"{prefix}.o_proj",
|
||||
)
|
||||
|
||||
self.layer_type = (
|
||||
config.text_config.layer_types[layer_id]
|
||||
if hasattr(config.text_config, "layer_types")
|
||||
else None
|
||||
)
|
||||
self.is_sliding = (
|
||||
config.text_config.layer_types[layer_id] == "sliding_attention"
|
||||
)
|
||||
|
||||
rope_parameters = getattr(config.text_config, "rope_parameters", None) or {}
|
||||
layer_rope_params = {}
|
||||
if self.layer_type is not None and isinstance(rope_parameters, dict):
|
||||
layer_rope_params = dict(rope_parameters.get(self.layer_type) or {})
|
||||
|
||||
# Initialize the rotary embedding.
|
||||
if self.is_sliding:
|
||||
# Local attention.
|
||||
self.rope_theta = config.text_config.rope_local_base_freq
|
||||
rope_scaling = None # Default
|
||||
self.rope_theta = float(
|
||||
layer_rope_params.get(
|
||||
"rope_theta",
|
||||
getattr(
|
||||
config.text_config,
|
||||
"rope_local_base_freq",
|
||||
getattr(
|
||||
getattr(config.text_config, "default_theta", {}),
|
||||
"get",
|
||||
lambda *_: 10_000.0,
|
||||
)("local", 10_000.0),
|
||||
),
|
||||
)
|
||||
)
|
||||
rope_scaling = layer_rope_params or None
|
||||
# sliding window
|
||||
self.sliding_window = get_attention_sliding_window_size(config.text_config)
|
||||
# (left, right) = (window, 0) effectively for causal
|
||||
self.window_size = (self.sliding_window, 0)
|
||||
else:
|
||||
# Global attention.
|
||||
self.rope_theta = config.text_config.rope_theta
|
||||
rope_scaling = config.text_config.rope_scaling
|
||||
self.rope_theta = float(
|
||||
layer_rope_params.get(
|
||||
"rope_theta",
|
||||
getattr(
|
||||
config.text_config,
|
||||
"rope_theta",
|
||||
getattr(
|
||||
getattr(config.text_config, "default_theta", {}),
|
||||
"get",
|
||||
lambda *_: 1_000_000.0,
|
||||
)("global", 1_000_000.0),
|
||||
),
|
||||
)
|
||||
)
|
||||
rope_scaling = layer_rope_params or getattr(
|
||||
config.text_config, "rope_scaling", None
|
||||
)
|
||||
self.sliding_window = None
|
||||
self.window_size = (-1, -1)
|
||||
|
||||
@@ -734,7 +772,9 @@ class Gemma3TextModel(nn.Module):
|
||||
layer_id=i,
|
||||
config=config,
|
||||
quant_config=self.quant_config,
|
||||
prefix=f"{config.text_config.prefix}.layers.{i}",
|
||||
prefix=add_prefix(
|
||||
f"layers.{i}", getattr(config.text_config, "prefix", "")
|
||||
),
|
||||
)
|
||||
for i in range(config.text_config.num_hidden_layers)
|
||||
]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
from sglang.multimodal_gen.runtime.models.upsampler.latent_upsampler import (
|
||||
LatentUpsampler,
|
||||
)
|
||||
|
||||
__all__ = ["LatentUpsampler"]
|
||||
@@ -0,0 +1,268 @@
|
||||
# Ported from https://github.com/Lightricks/LTX-2
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import math
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from einops import rearrange
|
||||
|
||||
|
||||
class BlurDownsample(torch.nn.Module):
|
||||
"""Anti-aliased spatial downsampling by integer stride using a fixed separable binomial kernel."""
|
||||
|
||||
def __init__(self, dims: int, stride: int, kernel_size: int = 5) -> None:
|
||||
super().__init__()
|
||||
assert dims in (2, 3)
|
||||
assert isinstance(stride, int) and stride >= 1
|
||||
assert kernel_size >= 3 and kernel_size % 2 == 1
|
||||
self.dims = dims
|
||||
self.stride = stride
|
||||
self.kernel_size = kernel_size
|
||||
|
||||
k = torch.tensor([math.comb(kernel_size - 1, i) for i in range(kernel_size)])
|
||||
k2d = k[:, None] @ k[None, :]
|
||||
k2d = (k2d / k2d.sum()).float()
|
||||
self.register_buffer("kernel", k2d[None, None, :, :])
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
if self.stride == 1:
|
||||
return x
|
||||
if self.dims == 2:
|
||||
return self._apply_2d(x)
|
||||
b, _, f, _, _ = x.shape
|
||||
x = rearrange(x, "b c f h w -> (b f) c h w")
|
||||
x = self._apply_2d(x)
|
||||
h2, w2 = x.shape[-2:]
|
||||
x = rearrange(x, "(b f) c h w -> b c f h w", b=b, f=f, h=h2, w=w2)
|
||||
return x
|
||||
|
||||
def _apply_2d(self, x2d: torch.Tensor) -> torch.Tensor:
|
||||
c = x2d.shape[1]
|
||||
weight = self.kernel.expand(c, 1, self.kernel_size, self.kernel_size)
|
||||
x2d = F.conv2d(
|
||||
x2d,
|
||||
weight=weight,
|
||||
bias=None,
|
||||
stride=self.stride,
|
||||
padding=self.kernel_size // 2,
|
||||
groups=c,
|
||||
)
|
||||
return x2d
|
||||
|
||||
|
||||
class PixelShuffleND(torch.nn.Module):
|
||||
"""N-dimensional pixel shuffle for upsampling tensors."""
|
||||
|
||||
def __init__(self, dims: int, upscale_factors: Tuple[int, int, int] = (2, 2, 2)):
|
||||
super().__init__()
|
||||
assert dims in [1, 2, 3]
|
||||
self.dims = dims
|
||||
self.upscale_factors = upscale_factors
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
if self.dims == 3:
|
||||
return rearrange(
|
||||
x,
|
||||
"b (c p1 p2 p3) d h w -> b c (d p1) (h p2) (w p3)",
|
||||
p1=self.upscale_factors[0],
|
||||
p2=self.upscale_factors[1],
|
||||
p3=self.upscale_factors[2],
|
||||
)
|
||||
elif self.dims == 2:
|
||||
return rearrange(
|
||||
x,
|
||||
"b (c p1 p2) h w -> b c (h p1) (w p2)",
|
||||
p1=self.upscale_factors[0],
|
||||
p2=self.upscale_factors[1],
|
||||
)
|
||||
elif self.dims == 1:
|
||||
return rearrange(
|
||||
x,
|
||||
"b (c p1) f h w -> b c (f p1) h w",
|
||||
p1=self.upscale_factors[0],
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported dims: {self.dims}")
|
||||
|
||||
|
||||
class ResBlock(torch.nn.Module):
|
||||
"""Residual block with two conv layers, group norm, and SiLU activation."""
|
||||
|
||||
def __init__(
|
||||
self, channels: int, mid_channels: Optional[int] = None, dims: int = 3
|
||||
):
|
||||
super().__init__()
|
||||
if mid_channels is None:
|
||||
mid_channels = channels
|
||||
conv = torch.nn.Conv2d if dims == 2 else torch.nn.Conv3d
|
||||
self.conv1 = conv(channels, mid_channels, kernel_size=3, padding=1)
|
||||
self.norm1 = torch.nn.GroupNorm(32, mid_channels)
|
||||
self.conv2 = conv(mid_channels, channels, kernel_size=3, padding=1)
|
||||
self.norm2 = torch.nn.GroupNorm(32, channels)
|
||||
self.activation = torch.nn.SiLU()
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
residual = x
|
||||
x = self.conv1(x)
|
||||
x = self.norm1(x)
|
||||
x = self.activation(x)
|
||||
x = self.conv2(x)
|
||||
x = self.norm2(x)
|
||||
x = self.activation(x + residual)
|
||||
return x
|
||||
|
||||
|
||||
def _rational_for_scale(scale: float) -> Tuple[int, int]:
|
||||
mapping = {0.75: (3, 4), 1.5: (3, 2), 2.0: (2, 1), 4.0: (4, 1)}
|
||||
if float(scale) not in mapping:
|
||||
raise ValueError(
|
||||
f"Unsupported scale {scale}. Choose from {list(mapping.keys())}"
|
||||
)
|
||||
return mapping[float(scale)]
|
||||
|
||||
|
||||
class SpatialRationalResampler(torch.nn.Module):
|
||||
"""Fully-learned rational spatial scaling via PixelShuffle + anti-aliased downsample."""
|
||||
|
||||
def __init__(self, mid_channels: int, scale: float):
|
||||
super().__init__()
|
||||
self.scale = float(scale)
|
||||
self.num, self.den = _rational_for_scale(self.scale)
|
||||
self.conv = torch.nn.Conv2d(
|
||||
mid_channels, (self.num**2) * mid_channels, kernel_size=3, padding=1
|
||||
)
|
||||
self.pixel_shuffle = PixelShuffleND(2, upscale_factors=(self.num, self.num))
|
||||
self.blur_down = BlurDownsample(dims=2, stride=self.den)
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
b, _, f, _, _ = x.shape
|
||||
x = rearrange(x, "b c f h w -> (b f) c h w")
|
||||
x = self.conv(x)
|
||||
x = self.pixel_shuffle(x)
|
||||
x = self.blur_down(x)
|
||||
x = rearrange(x, "(b f) c h w -> b c f h w", b=b, f=f)
|
||||
return x
|
||||
|
||||
|
||||
class LatentUpsampler(torch.nn.Module):
|
||||
"""
|
||||
Upsample VAE latents spatially and/or temporally.
|
||||
|
||||
Args:
|
||||
in_channels: Number of channels in the input latent.
|
||||
mid_channels: Number of channels in the middle layers.
|
||||
num_blocks_per_stage: Number of ResBlocks per stage (pre/post upsampling).
|
||||
dims: Dimensionality of convolutions (2 or 3).
|
||||
spatial_upsample: Whether to spatially upsample.
|
||||
temporal_upsample: Whether to temporally upsample.
|
||||
spatial_scale: Scale factor for spatial upsampling.
|
||||
rational_resampler: Whether to use rational resampler for spatial upsampling.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
in_channels: int = 128,
|
||||
mid_channels: int = 512,
|
||||
num_blocks_per_stage: int = 4,
|
||||
dims: int = 3,
|
||||
spatial_upsample: bool = True,
|
||||
temporal_upsample: bool = False,
|
||||
spatial_scale: float = 2.0,
|
||||
rational_resampler: bool = False,
|
||||
):
|
||||
super().__init__()
|
||||
self.in_channels = in_channels
|
||||
self.mid_channels = mid_channels
|
||||
self.num_blocks_per_stage = num_blocks_per_stage
|
||||
self.dims = dims
|
||||
self.spatial_upsample = spatial_upsample
|
||||
self.temporal_upsample = temporal_upsample
|
||||
self.spatial_scale = float(spatial_scale)
|
||||
self.rational_resampler = rational_resampler
|
||||
|
||||
conv = torch.nn.Conv2d if dims == 2 else torch.nn.Conv3d
|
||||
|
||||
self.initial_conv = conv(in_channels, mid_channels, kernel_size=3, padding=1)
|
||||
self.initial_norm = torch.nn.GroupNorm(32, mid_channels)
|
||||
self.initial_activation = torch.nn.SiLU()
|
||||
|
||||
self.res_blocks = torch.nn.ModuleList(
|
||||
[ResBlock(mid_channels, dims=dims) for _ in range(num_blocks_per_stage)]
|
||||
)
|
||||
|
||||
if spatial_upsample and temporal_upsample:
|
||||
self.upsampler = torch.nn.Sequential(
|
||||
torch.nn.Conv3d(
|
||||
mid_channels, 8 * mid_channels, kernel_size=3, padding=1
|
||||
),
|
||||
PixelShuffleND(3),
|
||||
)
|
||||
elif spatial_upsample:
|
||||
if rational_resampler:
|
||||
self.upsampler = SpatialRationalResampler(
|
||||
mid_channels=mid_channels, scale=self.spatial_scale
|
||||
)
|
||||
else:
|
||||
self.upsampler = torch.nn.Sequential(
|
||||
torch.nn.Conv2d(
|
||||
mid_channels, 4 * mid_channels, kernel_size=3, padding=1
|
||||
),
|
||||
PixelShuffleND(2),
|
||||
)
|
||||
elif temporal_upsample:
|
||||
self.upsampler = torch.nn.Sequential(
|
||||
torch.nn.Conv3d(
|
||||
mid_channels, 2 * mid_channels, kernel_size=3, padding=1
|
||||
),
|
||||
PixelShuffleND(1),
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
"Either spatial_upsample or temporal_upsample must be True"
|
||||
)
|
||||
|
||||
self.post_upsample_res_blocks = torch.nn.ModuleList(
|
||||
[ResBlock(mid_channels, dims=dims) for _ in range(num_blocks_per_stage)]
|
||||
)
|
||||
|
||||
self.final_conv = conv(mid_channels, in_channels, kernel_size=3, padding=1)
|
||||
|
||||
def forward(self, latent: torch.Tensor) -> torch.Tensor:
|
||||
b, _, f, _, _ = latent.shape
|
||||
|
||||
if self.dims == 2:
|
||||
x = rearrange(latent, "b c f h w -> (b f) c h w")
|
||||
x = self.initial_conv(x)
|
||||
x = self.initial_norm(x)
|
||||
x = self.initial_activation(x)
|
||||
for block in self.res_blocks:
|
||||
x = block(x)
|
||||
x = self.upsampler(x)
|
||||
for block in self.post_upsample_res_blocks:
|
||||
x = block(x)
|
||||
x = self.final_conv(x)
|
||||
x = rearrange(x, "(b f) c h w -> b c f h w", b=b, f=f)
|
||||
else:
|
||||
x = self.initial_conv(latent)
|
||||
x = self.initial_norm(x)
|
||||
x = self.initial_activation(x)
|
||||
for block in self.res_blocks:
|
||||
x = block(x)
|
||||
|
||||
if self.temporal_upsample:
|
||||
x = self.upsampler(x)
|
||||
x = x[:, :, 1:, :, :]
|
||||
elif isinstance(self.upsampler, SpatialRationalResampler):
|
||||
x = self.upsampler(x)
|
||||
else:
|
||||
x = rearrange(x, "b c f h w -> (b f) c h w")
|
||||
x = self.upsampler(x)
|
||||
x = rearrange(x, "(b f) c h w -> b c f h w", b=b, f=f)
|
||||
|
||||
for block in self.post_upsample_res_blocks:
|
||||
x = block(x)
|
||||
x = self.final_conv(x)
|
||||
|
||||
return x
|
||||
@@ -843,8 +843,8 @@ class AutoencoderKLLTX2Audio(ParallelTiledVAE):
|
||||
|
||||
# Per-channel statistics for normalizing and denormalizing the latent representation. This statistics is computed over
|
||||
# the entire dataset and stored in model's checkpoint under AudioVAE state_dict
|
||||
latents_std = torch.zeros((base_channels,))
|
||||
latents_mean = torch.ones((base_channels,))
|
||||
latents_std = torch.ones((base_channels,))
|
||||
latents_mean = torch.zeros((base_channels,))
|
||||
self.register_buffer("latents_mean", latents_mean, persistent=True)
|
||||
self.register_buffer("latents_std", latents_std, persistent=True)
|
||||
|
||||
|
||||
@@ -1135,6 +1135,28 @@ class AutoencoderKLLTX2Video(ParallelTiledVAE):
|
||||
config.arch_config.decoder_spatio_temporal_scaling
|
||||
)
|
||||
decoder_layers_per_block = config.arch_config.decoder_layers_per_block
|
||||
decoder_inject_noise = getattr(
|
||||
config.arch_config, "decoder_inject_noise", (False, False, False, False)
|
||||
)
|
||||
if isinstance(decoder_inject_noise, bool):
|
||||
decoder_inject_noise = (decoder_inject_noise,) * 4
|
||||
else:
|
||||
decoder_inject_noise = tuple(decoder_inject_noise)
|
||||
upsample_residual = getattr(
|
||||
config.arch_config, "upsample_residual", (True, True, True)
|
||||
)
|
||||
if isinstance(upsample_residual, bool):
|
||||
upsample_residual = (upsample_residual,) * 3
|
||||
else:
|
||||
upsample_residual = tuple(upsample_residual)
|
||||
upsample_factor = getattr(config.arch_config, "upsample_factor", (2, 2, 2))
|
||||
if isinstance(upsample_factor, int):
|
||||
upsample_factor = (upsample_factor,) * 3
|
||||
else:
|
||||
upsample_factor = tuple(upsample_factor)
|
||||
timestep_conditioning = getattr(
|
||||
config.arch_config, "timestep_conditioning", False
|
||||
)
|
||||
decoder_causal = config.arch_config.decoder_causal
|
||||
decoder_spatial_padding_mode = config.arch_config.decoder_spatial_padding_mode
|
||||
|
||||
@@ -1154,16 +1176,20 @@ class AutoencoderKLLTX2Video(ParallelTiledVAE):
|
||||
)
|
||||
|
||||
self.decoder = LTX2VideoDecoder3d(
|
||||
latent_channels,
|
||||
out_channels,
|
||||
decoder_block_out_channels,
|
||||
decoder_spatio_temporal_scaling,
|
||||
decoder_layers_per_block,
|
||||
patch_size,
|
||||
patch_size_t,
|
||||
resnet_norm_eps,
|
||||
decoder_causal,
|
||||
decoder_spatial_padding_mode,
|
||||
in_channels=latent_channels,
|
||||
out_channels=out_channels,
|
||||
block_out_channels=decoder_block_out_channels,
|
||||
spatio_temporal_scaling=decoder_spatio_temporal_scaling,
|
||||
layers_per_block=decoder_layers_per_block,
|
||||
patch_size=patch_size,
|
||||
patch_size_t=patch_size_t,
|
||||
resnet_norm_eps=resnet_norm_eps,
|
||||
is_causal=decoder_causal,
|
||||
inject_noise=decoder_inject_noise,
|
||||
timestep_conditioning=timestep_conditioning,
|
||||
upsample_residual=upsample_residual,
|
||||
upsample_factor=upsample_factor,
|
||||
spatial_padding_mode=decoder_spatial_padding_mode,
|
||||
)
|
||||
|
||||
latents_mean = torch.zeros((latent_channels,), requires_grad=False)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import inspect
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
|
||||
@@ -7,103 +5,183 @@ import numpy as np
|
||||
import torch
|
||||
from diffusers import FlowMatchEulerDiscreteScheduler
|
||||
|
||||
from sglang.multimodal_gen.runtime.loader.component_loaders.component_loader import (
|
||||
PipelineComponentLoader,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.composed_pipeline_base import (
|
||||
ComposedPipelineBase,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.lora_pipeline import LoRAPipeline
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages import (
|
||||
InputValidationStage,
|
||||
LTX2AVDecodingStage,
|
||||
LTX2AVDenoisingStage,
|
||||
LTX2AVLatentPreparationStage,
|
||||
LTX2HalveResolutionStage,
|
||||
LTX2LoRASwitchStage,
|
||||
LTX2RefinementStage,
|
||||
LTX2TextConnectorStage,
|
||||
LTX2UpsampleStage,
|
||||
TextEncodingStage,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.base import PipelineStage
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
def calculate_shift(
|
||||
image_seq_len,
|
||||
base_seq_len: int = 256,
|
||||
max_seq_len: int = 4096,
|
||||
base_shift: float = 0.5,
|
||||
max_shift: float = 1.15,
|
||||
):
|
||||
m = (max_shift - base_shift) / (max_seq_len - base_seq_len)
|
||||
b = base_shift - m * base_seq_len
|
||||
mu = image_seq_len * m + b
|
||||
return mu
|
||||
BASE_SHIFT_ANCHOR = 1024
|
||||
MAX_SHIFT_ANCHOR = 4096
|
||||
|
||||
|
||||
def prepare_mu(batch: Req, server_args: ServerArgs):
|
||||
height = batch.height
|
||||
width = batch.width
|
||||
num_frames = batch.num_frames
|
||||
def _resolve_ltx2_two_stage_component_paths(
|
||||
model_path: str, component_paths: dict[str, str]
|
||||
) -> dict[str, str]:
|
||||
resolved = dict(component_paths)
|
||||
auto_resolved = []
|
||||
|
||||
vae_arch = getattr(
|
||||
getattr(server_args.pipeline_config, "vae_config", None), "arch_config", None
|
||||
if "spatial_upsampler" not in resolved:
|
||||
spatial_candidates = [
|
||||
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:
|
||||
if os.path.exists(candidate):
|
||||
resolved["spatial_upsampler"] = candidate
|
||||
auto_resolved.append(f"spatial_upsampler={candidate}")
|
||||
break
|
||||
|
||||
if "distilled_lora" not in resolved:
|
||||
distilled_lora = os.path.join(
|
||||
model_path, "ltx-2-19b-distilled-lora-384.safetensors"
|
||||
)
|
||||
if os.path.exists(distilled_lora):
|
||||
resolved["distilled_lora"] = distilled_lora
|
||||
auto_resolved.append(f"distilled_lora={distilled_lora}")
|
||||
|
||||
if auto_resolved:
|
||||
logger.info(
|
||||
"Auto-resolved LTX2 two-stage components: %s", ", ".join(auto_resolved)
|
||||
)
|
||||
|
||||
return resolved
|
||||
|
||||
|
||||
def calculate_ltx2_shift(
|
||||
image_seq_len: int,
|
||||
base_seq_len: int = BASE_SHIFT_ANCHOR,
|
||||
max_seq_len: int = MAX_SHIFT_ANCHOR,
|
||||
base_shift: float = 0.95,
|
||||
max_shift: float = 2.05,
|
||||
) -> float:
|
||||
mm = (max_shift - base_shift) / (max_seq_len - base_seq_len)
|
||||
b = base_shift - mm * base_seq_len
|
||||
return image_seq_len * mm + b
|
||||
|
||||
|
||||
def prepare_ltx2_mu(batch: Req, server_args: ServerArgs):
|
||||
latent_num_frames = (int(batch.num_frames) - 1) // int(
|
||||
server_args.pipeline_config.vae_temporal_compression
|
||||
) + 1
|
||||
latent_height = int(batch.height) // int(
|
||||
server_args.pipeline_config.vae_scale_factor
|
||||
)
|
||||
vae_scale_factor = (
|
||||
getattr(vae_arch, "spatial_compression_ratio", None)
|
||||
or getattr(vae_arch, "vae_scale_factor", None)
|
||||
or getattr(server_args.pipeline_config, "vae_scale_factor", None)
|
||||
latent_width = int(batch.width) // int(server_args.pipeline_config.vae_scale_factor)
|
||||
video_sequence_length = latent_num_frames * latent_height * latent_width
|
||||
return "mu", calculate_ltx2_shift(video_sequence_length)
|
||||
|
||||
|
||||
class LTX2SigmaPreparationStage(PipelineStage):
|
||||
"""Prepare native LTX-2 sigma schedule before timestep setup."""
|
||||
|
||||
def forward(self, batch: Req, server_args: ServerArgs) -> Req:
|
||||
batch.extra["ltx2_phase"] = "stage1"
|
||||
batch.sigmas = np.linspace(
|
||||
1.0,
|
||||
1.0 / int(batch.num_inference_steps),
|
||||
int(batch.num_inference_steps),
|
||||
).tolist()
|
||||
return batch
|
||||
|
||||
|
||||
def _add_ltx2_front_stages(pipeline: ComposedPipelineBase):
|
||||
pipeline.add_stages(
|
||||
[
|
||||
InputValidationStage(),
|
||||
TextEncodingStage(
|
||||
text_encoders=[pipeline.get_module("text_encoder")],
|
||||
tokenizers=[pipeline.get_module("tokenizer")],
|
||||
),
|
||||
LTX2TextConnectorStage(connectors=pipeline.get_module("connectors")),
|
||||
]
|
||||
)
|
||||
vae_temporal_compression = getattr(
|
||||
vae_arch, "temporal_compression_ratio", None
|
||||
) or getattr(server_args.pipeline_config, "vae_temporal_compression", None)
|
||||
|
||||
# Values from LTX2Pipeline in diffusers
|
||||
mu = calculate_shift(
|
||||
4096,
|
||||
base_seq_len=1024,
|
||||
max_seq_len=4096,
|
||||
base_shift=0.95,
|
||||
max_shift=2.05,
|
||||
|
||||
def _add_ltx2_stage1_generation_stages(pipeline: ComposedPipelineBase):
|
||||
pipeline.add_stage(LTX2SigmaPreparationStage())
|
||||
pipeline.add_standard_timestep_preparation_stage(
|
||||
prepare_extra_kwargs=[prepare_ltx2_mu]
|
||||
)
|
||||
pipeline.add_stages(
|
||||
[
|
||||
LTX2AVLatentPreparationStage(
|
||||
scheduler=pipeline.get_module("scheduler"),
|
||||
transformer=pipeline.get_module("transformer"),
|
||||
audio_vae=pipeline.get_module("audio_vae"),
|
||||
),
|
||||
LTX2AVDenoisingStage(
|
||||
transformer=pipeline.get_module("transformer"),
|
||||
scheduler=pipeline.get_module("scheduler"),
|
||||
vae=pipeline.get_module("vae"),
|
||||
audio_vae=pipeline.get_module("audio_vae"),
|
||||
pipeline=pipeline,
|
||||
),
|
||||
]
|
||||
)
|
||||
return "mu", mu
|
||||
|
||||
|
||||
def _load_component_config(model_path: str, component_name: str):
|
||||
"""Helper to load component config from model_index.json or config.json"""
|
||||
try:
|
||||
# Try loading model_index.json first
|
||||
index_path = os.path.join(model_path, "model_index.json")
|
||||
if os.path.exists(index_path):
|
||||
with open(index_path, "r") as f:
|
||||
index = json.load(f)
|
||||
|
||||
if component_name in index:
|
||||
# It's a subfolder
|
||||
subfolder = index[component_name][1]
|
||||
config_path = os.path.join(model_path, subfolder, "config.json")
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, "r") as f:
|
||||
return json.load(f)
|
||||
|
||||
# Fallback to direct config.json in subfolder if standard structure
|
||||
config_path = os.path.join(model_path, component_name, "config.json")
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, "r") as f:
|
||||
return json.load(f)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load config for {component_name}: {e}")
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def _filter_kwargs_for_cls(cls, kwargs):
|
||||
"""Filter kwargs to only include those accepted by cls.__init__"""
|
||||
sig = inspect.signature(cls.__init__)
|
||||
return {k: v for k, v in kwargs.items() if k in sig.parameters}
|
||||
def _add_ltx2_decoding_stage(pipeline: ComposedPipelineBase):
|
||||
pipeline.add_stage(
|
||||
LTX2AVDecodingStage(
|
||||
vae=pipeline.get_module("vae"),
|
||||
audio_vae=pipeline.get_module("audio_vae"),
|
||||
vocoder=pipeline.get_module("vocoder"),
|
||||
pipeline=pipeline,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class LTX2FlowMatchScheduler(FlowMatchEulerDiscreteScheduler):
|
||||
"""Override ``_time_shift_exponential`` to use torch f32 instead of numpy f64."""
|
||||
|
||||
def set_timesteps(
|
||||
self,
|
||||
num_inference_steps=None,
|
||||
device=None,
|
||||
sigmas=None,
|
||||
mu=None,
|
||||
timesteps=None,
|
||||
):
|
||||
if sigmas is not None and timesteps is None and mu is None:
|
||||
sigmas = torch.tensor(sigmas, dtype=torch.float32, device=device)
|
||||
timesteps = sigmas * self.config.num_train_timesteps
|
||||
sigmas = torch.cat([sigmas, torch.zeros(1, device=sigmas.device)])
|
||||
self.num_inference_steps = len(timesteps)
|
||||
self.timesteps = timesteps
|
||||
self.sigmas = sigmas
|
||||
self._step_index = None
|
||||
self._begin_index = None
|
||||
return
|
||||
|
||||
return super().set_timesteps(
|
||||
num_inference_steps=num_inference_steps,
|
||||
device=device,
|
||||
sigmas=sigmas,
|
||||
mu=mu,
|
||||
timesteps=timesteps,
|
||||
)
|
||||
|
||||
def _time_shift_exponential(self, mu, sigma, t):
|
||||
if isinstance(t, np.ndarray):
|
||||
t_torch = torch.from_numpy(t).to(torch.float32)
|
||||
@@ -112,10 +190,7 @@ class LTX2FlowMatchScheduler(FlowMatchEulerDiscreteScheduler):
|
||||
return math.exp(mu) / (math.exp(mu) + (1 / t - 1) ** sigma)
|
||||
|
||||
|
||||
class LTX2Pipeline(ComposedPipelineBase):
|
||||
# NOTE: must match `model_index.json`'s `_class_name` for native dispatch.
|
||||
pipeline_name = "LTX2Pipeline"
|
||||
|
||||
class _BaseLTX2Pipeline(LoRAPipeline):
|
||||
_required_config_modules = [
|
||||
"transformer",
|
||||
"text_encoder",
|
||||
@@ -131,41 +206,127 @@ class LTX2Pipeline(ComposedPipelineBase):
|
||||
orig = self.get_module("scheduler")
|
||||
self.modules["scheduler"] = LTX2FlowMatchScheduler.from_config(orig.config)
|
||||
|
||||
|
||||
class LTX2Pipeline(_BaseLTX2Pipeline):
|
||||
# Must match model_index.json `_class_name`.
|
||||
pipeline_name = "LTX2Pipeline"
|
||||
|
||||
def create_pipeline_stages(self, server_args: ServerArgs):
|
||||
self.add_stages(
|
||||
[
|
||||
InputValidationStage(),
|
||||
TextEncodingStage(
|
||||
text_encoders=[self.get_module("text_encoder")],
|
||||
tokenizers=[self.get_module("tokenizer")],
|
||||
),
|
||||
LTX2TextConnectorStage(connectors=self.get_module("connectors")),
|
||||
]
|
||||
_add_ltx2_front_stages(self)
|
||||
_add_ltx2_stage1_generation_stages(self)
|
||||
_add_ltx2_decoding_stage(self)
|
||||
|
||||
|
||||
class LTX2TwoStagePipeline(_BaseLTX2Pipeline):
|
||||
pipeline_name = "LTX2TwoStagePipeline"
|
||||
STAGE_2_DISTILLED_SIGMA_VALUES = [0.909375, 0.725, 0.421875, 0.0]
|
||||
|
||||
def initialize_pipeline(self, server_args: ServerArgs):
|
||||
super().initialize_pipeline(server_args)
|
||||
server_args.component_paths = _resolve_ltx2_two_stage_component_paths(
|
||||
self.model_path, server_args.component_paths
|
||||
)
|
||||
|
||||
self.add_standard_timestep_preparation_stage(prepare_extra_kwargs=[prepare_mu])
|
||||
upsampler_path = server_args.component_paths.get("spatial_upsampler")
|
||||
if not upsampler_path:
|
||||
raise ValueError(
|
||||
"LTX2TwoStagePipeline requires --spatial-upsampler-path "
|
||||
"(component_paths['spatial_upsampler'])."
|
||||
)
|
||||
module, memory_usage = PipelineComponentLoader.load_component(
|
||||
component_name="spatial_upsampler",
|
||||
component_model_path=upsampler_path,
|
||||
transformers_or_diffusers="diffusers",
|
||||
server_args=server_args,
|
||||
)
|
||||
self.modules["spatial_upsampler"] = module
|
||||
self.memory_usages["spatial_upsampler"] = memory_usage
|
||||
|
||||
distilled_lora_path = server_args.component_paths.get("distilled_lora")
|
||||
if not distilled_lora_path:
|
||||
raise ValueError(
|
||||
"LTX2TwoStagePipeline requires --distilled-lora-path "
|
||||
"(component_paths['distilled_lora'])."
|
||||
)
|
||||
self._distilled_lora_path = distilled_lora_path
|
||||
self._stage1_lora_path = server_args.lora_path
|
||||
self._stage1_lora_scale = float(server_args.lora_scale)
|
||||
self._active_lora_phase = None
|
||||
|
||||
def switch_lora_phase(self, phase: str) -> None:
|
||||
if phase == self._active_lora_phase:
|
||||
return
|
||||
|
||||
if phase == "stage1":
|
||||
if self._stage1_lora_path:
|
||||
self.set_lora(
|
||||
lora_nickname="ltx2_stage1_base",
|
||||
lora_path=self._stage1_lora_path,
|
||||
target="transformer",
|
||||
strength=self._stage1_lora_scale,
|
||||
)
|
||||
else:
|
||||
# Stage 1 must run on the base transformer weights. If stage 2 left the
|
||||
# distilled adapter active, stage 1 quality drifts away from the official
|
||||
# two-stage pipeline immediately.
|
||||
self.deactivate_lora_weights(target="transformer")
|
||||
elif phase == "stage2":
|
||||
lora_nicknames = []
|
||||
lora_paths = []
|
||||
lora_strengths = []
|
||||
lora_targets = []
|
||||
if self._stage1_lora_path:
|
||||
lora_nicknames.append("ltx2_stage1_base")
|
||||
lora_paths.append(self._stage1_lora_path)
|
||||
lora_strengths.append(self._stage1_lora_scale)
|
||||
lora_targets.append("transformer")
|
||||
lora_nicknames.append("ltx2_stage2_distilled")
|
||||
lora_paths.append(self._distilled_lora_path)
|
||||
lora_strengths.append(1.0)
|
||||
lora_targets.append("transformer")
|
||||
self.set_lora(
|
||||
lora_nickname=lora_nicknames,
|
||||
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,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown LTX2 two-stage LoRA phase: {phase}")
|
||||
|
||||
self._active_lora_phase = phase
|
||||
|
||||
def create_pipeline_stages(self, server_args: ServerArgs):
|
||||
_add_ltx2_front_stages(self)
|
||||
self.add_stage(LTX2HalveResolutionStage())
|
||||
self.add_stage(
|
||||
LTX2LoRASwitchStage(pipeline=self, phase="stage1"),
|
||||
)
|
||||
_add_ltx2_stage1_generation_stages(self)
|
||||
self.add_stages(
|
||||
[
|
||||
LTX2AVLatentPreparationStage(
|
||||
scheduler=self.get_module("scheduler"),
|
||||
transformer=self.get_module("transformer"),
|
||||
audio_vae=self.get_module("audio_vae"),
|
||||
),
|
||||
LTX2AVDenoisingStage(
|
||||
transformer=self.get_module("transformer"),
|
||||
scheduler=self.get_module("scheduler"),
|
||||
LTX2UpsampleStage(
|
||||
spatial_upsampler=self.get_module("spatial_upsampler"),
|
||||
vae=self.get_module("vae"),
|
||||
audio_vae=self.get_module("audio_vae"),
|
||||
),
|
||||
LTX2AVDecodingStage(
|
||||
(
|
||||
LTX2LoRASwitchStage(pipeline=self, phase="stage2"),
|
||||
"ltx2_lora_switch_stage2",
|
||||
),
|
||||
LTX2RefinementStage(
|
||||
transformer=self.get_module("transformer"),
|
||||
scheduler=self.get_module("scheduler"),
|
||||
distilled_sigmas=self.STAGE_2_DISTILLED_SIGMA_VALUES,
|
||||
vae=self.get_module("vae"),
|
||||
audio_vae=self.get_module("audio_vae"),
|
||||
vocoder=self.get_module("vocoder"),
|
||||
pipeline=self,
|
||||
),
|
||||
]
|
||||
)
|
||||
_add_ltx2_decoding_stage(self)
|
||||
|
||||
|
||||
EntryClass = LTX2Pipeline
|
||||
EntryClass = [LTX2Pipeline, LTX2TwoStagePipeline]
|
||||
|
||||
@@ -8,7 +8,6 @@ This module defines the base class for pipelines that are composed of multiple s
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Callable, Literal, cast
|
||||
|
||||
@@ -333,12 +332,7 @@ class ComposedPipelineBase(ABC):
|
||||
|
||||
@staticmethod
|
||||
def _infer_stage_name(stage: PipelineStage) -> str:
|
||||
class_name = stage.__class__.__name__
|
||||
# snake_case
|
||||
name = re.sub(r"(?<!^)(?=[A-Z])", "_", class_name).lower()
|
||||
if not name.endswith("_stage"):
|
||||
name += "_stage"
|
||||
return name
|
||||
return stage.__class__.__name__
|
||||
|
||||
def add_stage(
|
||||
self, stage: PipelineStage, stage_name: str | None = None
|
||||
|
||||
@@ -397,6 +397,7 @@ class LoRAPipeline(ComposedPipelineBase):
|
||||
rank: int,
|
||||
strengths: list[float],
|
||||
clear_existing: bool = False,
|
||||
merge_weights: bool = True,
|
||||
) -> int:
|
||||
"""
|
||||
Apply LoRA weights to the given lora_layers. Supports multiple LoRA adapters.
|
||||
@@ -435,38 +436,29 @@ class LoRAPipeline(ComposedPipelineBase):
|
||||
lora_A_name in self.lora_adapters[nickname]
|
||||
and lora_B_name in self.lora_adapters[nickname]
|
||||
):
|
||||
# Some LoRA checkpoints (e.g. Lightning distill) store per-layer alpha as "<layer>.alpha".
|
||||
# If present, we must apply the standard LoRA scaling: scale = alpha / rank.
|
||||
try:
|
||||
inferred_rank = int(
|
||||
self.lora_adapters[nickname][lora_A_name].shape[0]
|
||||
)
|
||||
except Exception:
|
||||
inferred_rank = None
|
||||
# Default to None for some checkpoints without "<layer>.alpha"
|
||||
inferred_alpha: int | None = None
|
||||
inferred_rank = int(
|
||||
self.lora_adapters[nickname][lora_A_name].shape[0]
|
||||
)
|
||||
alpha_key = name + ".alpha"
|
||||
if alpha_key in self.lora_adapters[nickname]:
|
||||
try:
|
||||
inferred_alpha = int(
|
||||
self.lora_adapters[nickname][alpha_key].item()
|
||||
)
|
||||
except Exception:
|
||||
inferred_alpha = None
|
||||
|
||||
if inferred_rank is not None:
|
||||
layer.lora_rank = inferred_rank
|
||||
layer.lora_alpha = (
|
||||
inferred_alpha
|
||||
if inferred_alpha is not None
|
||||
else inferred_rank
|
||||
inferred_alpha = int(
|
||||
self.lora_adapters[nickname][alpha_key].item()
|
||||
)
|
||||
else:
|
||||
# Some distilled LoRAs omit per-layer alpha and rely on the
|
||||
# default LoRA scale of alpha == rank. Falling back to rank
|
||||
# keeps the effective delta consistent with the official path.
|
||||
inferred_alpha = inferred_rank
|
||||
|
||||
layer.lora_rank = inferred_rank
|
||||
layer.lora_alpha = inferred_alpha
|
||||
|
||||
layer.set_lora_weights(
|
||||
self.lora_adapters[nickname][lora_A_name],
|
||||
self.lora_adapters[nickname][lora_B_name],
|
||||
lora_path=path,
|
||||
strength=lora_strength,
|
||||
merge_weights=merge_weights,
|
||||
clear_existing=(
|
||||
clear_existing and idx == 0
|
||||
), # Only clear on first LoRA
|
||||
@@ -589,6 +581,7 @@ class LoRAPipeline(ComposedPipelineBase):
|
||||
lora_path: str | None | list[str | None] = None,
|
||||
target: str | list[str] = "all",
|
||||
strength: float | list[float] = 1.0,
|
||||
merge_weights: bool = True,
|
||||
): # type: ignore
|
||||
"""
|
||||
Load LoRA adapter(s) into the pipeline and apply them to the specified transformer(s).
|
||||
@@ -682,6 +675,7 @@ class LoRAPipeline(ComposedPipelineBase):
|
||||
rank,
|
||||
tgt_strengths,
|
||||
clear_existing=True,
|
||||
merge_weights=merge_weights,
|
||||
)
|
||||
adapted_count += count
|
||||
self.cur_adapter_name[module_name] = merged_name
|
||||
@@ -689,7 +683,7 @@ class LoRAPipeline(ComposedPipelineBase):
|
||||
str(p or self.loaded_adapter_paths.get(n, ""))
|
||||
for n, p in zip(tgt_nicknames, tgt_paths)
|
||||
)
|
||||
self.is_lora_merged[module_name] = True
|
||||
self.is_lora_merged[module_name] = merge_weights
|
||||
self.cur_adapter_strength[module_name] = tgt_strengths[0]
|
||||
# Store full configuration for multi-LoRA support (preserves order and all strengths)
|
||||
self.cur_adapter_config[module_name] = (
|
||||
@@ -698,7 +692,7 @@ class LoRAPipeline(ComposedPipelineBase):
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Rank %d: LoRA adapter(s) %s applied to %d layers (targets: %s, strengths: %s)",
|
||||
"Rank %d: LoRA adapter(s) %s applied to %d layers (targets: %s, strengths: %s, merge_weights=%s)",
|
||||
rank,
|
||||
", ".join(map(str, lora_paths)) if lora_paths else None,
|
||||
adapted_count,
|
||||
@@ -708,8 +702,42 @@ class LoRAPipeline(ComposedPipelineBase):
|
||||
if len(strengths) > 1
|
||||
else f"{strengths[0]:.2f}"
|
||||
),
|
||||
merge_weights,
|
||||
)
|
||||
|
||||
def deactivate_lora_weights(self, target: str = "all") -> None:
|
||||
"""
|
||||
Disable LoRA for the specified target, regardless of whether weights were
|
||||
merged into the base model or are still active in the wrapped LoRA path.
|
||||
"""
|
||||
target_modules, error = self._get_target_lora_layers(target)
|
||||
if error:
|
||||
logger.warning("deactivate_lora_weights: %s", error)
|
||||
if not target_modules:
|
||||
return
|
||||
|
||||
modules_requiring_unmerge = []
|
||||
for module_name, lora_layers_dict in target_modules:
|
||||
if self.is_lora_merged.get(module_name, False) or any(
|
||||
layer.merged for layer in lora_layers_dict.values()
|
||||
):
|
||||
modules_requiring_unmerge.append((module_name, lora_layers_dict))
|
||||
|
||||
offload_context = self._temporarily_disable_offload(
|
||||
target_modules=modules_requiring_unmerge
|
||||
)
|
||||
with offload_context:
|
||||
for module_name, lora_layers_dict in target_modules:
|
||||
for layer in lora_layers_dict.values():
|
||||
if layer.merged:
|
||||
layer.unmerge_lora_weights()
|
||||
if not layer.disable_lora:
|
||||
layer.disable_lora = True
|
||||
self.is_lora_merged[module_name] = False
|
||||
self.cur_adapter_strength.pop(module_name, None)
|
||||
self.cur_adapter_config.pop(module_name, None)
|
||||
logger.info("LoRA weights deactivated for %s", module_name)
|
||||
|
||||
def merge_lora_weights(self, target: str = "all", strength: float = 1.0) -> None:
|
||||
"""
|
||||
Merge LoRA weights into the base model for the specified target.
|
||||
|
||||
@@ -22,6 +22,7 @@ from sglang.multimodal_gen.runtime.pipelines_core.stages.decoding_av import (
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.denoising import DenoisingStage
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.denoising_av import (
|
||||
LTX2AVDenoisingStage,
|
||||
LTX2RefinementStage,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.denoising_dmd import (
|
||||
DmdDenoisingStage,
|
||||
@@ -64,6 +65,11 @@ from sglang.multimodal_gen.runtime.pipelines_core.stages.text_encoding import (
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.timestep_preparation import (
|
||||
TimestepPreparationStage,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.upsampling import (
|
||||
LTX2HalveResolutionStage,
|
||||
LTX2LoRASwitchStage,
|
||||
LTX2UpsampleStage,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"PipelineStage",
|
||||
@@ -92,4 +98,9 @@ __all__ = [
|
||||
"Hunyuan3DPaintPreprocessStage",
|
||||
"Hunyuan3DPaintTexGenStage",
|
||||
"Hunyuan3DPaintPostprocessStage",
|
||||
# LTX-2 two-stage
|
||||
"LTX2RefinementStage",
|
||||
"LTX2HalveResolutionStage",
|
||||
"LTX2LoRASwitchStage",
|
||||
"LTX2UpsampleStage",
|
||||
]
|
||||
|
||||
@@ -106,6 +106,23 @@ class LTX2AVDecodingStage(DecodingStage):
|
||||
logger.warning(
|
||||
"audio_vae.latents_std is all zeros; audio denorm may be incorrect."
|
||||
)
|
||||
try:
|
||||
latents_mean = self.audio_vae.latents_mean
|
||||
except AttributeError:
|
||||
latents_mean = None
|
||||
if isinstance(latents_mean, torch.Tensor) and isinstance(
|
||||
latents_std, torch.Tensor
|
||||
):
|
||||
latents_mean = latents_mean.to(device=device, dtype=dtype)
|
||||
latents_std = latents_std.to(device=device, dtype=dtype)
|
||||
if audio_latents.ndim == 4:
|
||||
latents_mean = latents_mean.view(
|
||||
1, audio_latents.shape[1], 1, audio_latents.shape[3]
|
||||
)
|
||||
latents_std = latents_std.view(
|
||||
1, audio_latents.shape[1], 1, audio_latents.shape[3]
|
||||
)
|
||||
audio_latents = audio_latents * latents_std + latents_mean
|
||||
|
||||
with torch.no_grad():
|
||||
# Decode latents to spectrogram
|
||||
|
||||
@@ -710,6 +710,8 @@ class DenoisingStage(PipelineStage):
|
||||
trajectory_timesteps: list,
|
||||
server_args: ServerArgs,
|
||||
is_warmup: bool = False,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
# Gather results if using sequence parallelism
|
||||
if trajectory_latents:
|
||||
|
||||
@@ -9,6 +9,7 @@ import PIL.Image
|
||||
import torch
|
||||
from diffusers.models.autoencoders.vae import DiagonalGaussianDistribution
|
||||
from diffusers.models.modeling_outputs import AutoencoderKLOutput
|
||||
from diffusers.utils.torch_utils import randn_tensor
|
||||
|
||||
from sglang.multimodal_gen.runtime.managers.forward_context import set_forward_context
|
||||
from sglang.multimodal_gen.runtime.models.vision_utils import (
|
||||
@@ -70,7 +71,11 @@ class LTX2AVDenoisingStage(DenoisingStage):
|
||||
return int(batch.sp_video_latent_num_frames)
|
||||
|
||||
pc = server_args.pipeline_config
|
||||
return int((batch.num_frames - 1) // int(pc.vae_temporal_compression) + 1)
|
||||
return int(
|
||||
(batch.num_frames - 1)
|
||||
// int(pc.vae_config.arch_config.temporal_compression_ratio)
|
||||
+ 1
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _truncate_sp_padded_token_latents(
|
||||
@@ -106,6 +111,57 @@ class LTX2AVDenoisingStage(DenoisingStage):
|
||||
return
|
||||
return super()._maybe_enable_cache_dit(num_inference_steps, batch)
|
||||
|
||||
def _get_ltx2_stage1_guider_params(
|
||||
self, batch: Req, server_args: ServerArgs, stage: str
|
||||
) -> dict[str, object] | None:
|
||||
if stage != "stage1":
|
||||
return None
|
||||
|
||||
pipeline_ref = getattr(self, "pipeline", None)
|
||||
pipeline = pipeline_ref() if callable(pipeline_ref) else pipeline_ref
|
||||
pipeline_name = getattr(pipeline, "pipeline_name", None)
|
||||
if pipeline_name != "LTX2TwoStagePipeline":
|
||||
return None
|
||||
|
||||
return batch.extra.get("ltx2_stage1_guider_params")
|
||||
|
||||
@staticmethod
|
||||
def _ltx2_should_skip_step(step_index: int, skip_step: int) -> bool:
|
||||
if skip_step == 0:
|
||||
return False
|
||||
return step_index % (skip_step + 1) != 0
|
||||
|
||||
@staticmethod
|
||||
def _ltx2_apply_rescale(
|
||||
cond: torch.Tensor, pred: torch.Tensor, rescale_scale: float
|
||||
) -> torch.Tensor:
|
||||
if rescale_scale == 0.0:
|
||||
return pred
|
||||
factor = cond.std() / pred.std()
|
||||
factor = rescale_scale * factor + (1.0 - rescale_scale)
|
||||
return pred * factor
|
||||
|
||||
@classmethod
|
||||
def _ltx2_calculate_guided_x0(
|
||||
cls,
|
||||
*,
|
||||
cond: torch.Tensor,
|
||||
uncond_text: torch.Tensor | float,
|
||||
uncond_perturbed: torch.Tensor | float,
|
||||
uncond_modality: torch.Tensor | float,
|
||||
cfg_scale: float,
|
||||
stg_scale: float,
|
||||
rescale_scale: float,
|
||||
modality_scale: float,
|
||||
) -> torch.Tensor:
|
||||
pred = (
|
||||
cond
|
||||
+ (cfg_scale - 1.0) * (cond - uncond_text)
|
||||
+ stg_scale * (cond - uncond_perturbed)
|
||||
+ (modality_scale - 1.0) * (cond - uncond_modality)
|
||||
)
|
||||
return cls._ltx2_apply_rescale(cond, pred, rescale_scale)
|
||||
|
||||
@staticmethod
|
||||
def _resize_center_crop(
|
||||
img: PIL.Image.Image, *, width: int, height: int
|
||||
@@ -328,22 +384,16 @@ class LTX2AVDenoisingStage(DenoisingStage):
|
||||
# Prepare variables for the denoising loop
|
||||
|
||||
prepared_vars = self._prepare_denoising_loop(batch, server_args)
|
||||
extra_step_kwargs = prepared_vars["extra_step_kwargs"]
|
||||
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"]
|
||||
image_kwargs = prepared_vars["image_kwargs"]
|
||||
pos_cond_kwargs = prepared_vars["pos_cond_kwargs"]
|
||||
neg_cond_kwargs = prepared_vars["neg_cond_kwargs"]
|
||||
latents = prepared_vars["latents"]
|
||||
boundary_timestep = prepared_vars["boundary_timestep"]
|
||||
z = prepared_vars["z"]
|
||||
reserved_frames_mask = prepared_vars["reserved_frames_mask"]
|
||||
seq_len = prepared_vars["seq_len"]
|
||||
guidance = prepared_vars["guidance"]
|
||||
|
||||
stage = batch.extra.get("ltx2_phase", "stage1")
|
||||
audio_latents = batch.audio_latents
|
||||
audio_scheduler = copy.deepcopy(self.scheduler)
|
||||
|
||||
@@ -356,8 +406,14 @@ class LTX2AVDenoisingStage(DenoisingStage):
|
||||
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_scale_factor
|
||||
latent_width = batch.width // server_args.pipeline_config.vae_scale_factor
|
||||
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] = []
|
||||
@@ -394,7 +450,6 @@ class LTX2AVDenoisingStage(DenoisingStage):
|
||||
clean_latent[:, :num_img_tokens, :] = batch.image_latent[
|
||||
:, :num_img_tokens, :
|
||||
].to(device=latents.device, dtype=latents.dtype)
|
||||
|
||||
with torch.autocast(
|
||||
device_type=current_platform.device_type,
|
||||
dtype=target_dtype,
|
||||
@@ -438,7 +493,9 @@ class LTX2AVDenoisingStage(DenoisingStage):
|
||||
|
||||
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
|
||||
@@ -468,56 +525,58 @@ class LTX2AVDenoisingStage(DenoisingStage):
|
||||
timestep_video = timestep
|
||||
timestep_audio = timestep
|
||||
|
||||
# Conditions
|
||||
encoder_hidden_states = batch.prompt_embeds[0]
|
||||
audio_encoder_hidden_states = batch.audio_prompt_embeds[0]
|
||||
encoder_attention_mask = batch.prompt_attention_mask
|
||||
|
||||
# Follow ltx-pipelines structure: separate pos/neg forward passes,
|
||||
# then apply CFG on denoised (x0) predictions.
|
||||
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,
|
||||
)
|
||||
|
||||
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 = batch.prompt_attention_mask
|
||||
if batch.do_classifier_free_guidance:
|
||||
neg_encoder_hidden_states = (
|
||||
batch.negative_prompt_embeds[0]
|
||||
latent_model_input = torch.cat(
|
||||
[latent_model_input] * 2, dim=0
|
||||
)
|
||||
neg_audio_encoder_hidden_states = (
|
||||
batch.negative_audio_prompt_embeds[0]
|
||||
audio_latent_model_input = torch.cat(
|
||||
[audio_latent_model_input] * 2, dim=0
|
||||
)
|
||||
neg_encoder_attention_mask = (
|
||||
batch.negative_attention_mask
|
||||
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,
|
||||
)
|
||||
encoder_attention_mask = torch.cat(
|
||||
[
|
||||
batch.negative_attention_mask,
|
||||
encoder_attention_mask,
|
||||
],
|
||||
dim=0,
|
||||
)
|
||||
timestep_video = timestep_video.expand(
|
||||
int(latent_model_input.shape[0])
|
||||
)
|
||||
timestep_audio = timestep_audio.expand(
|
||||
int(latent_model_input.shape[0])
|
||||
)
|
||||
|
||||
v_neg, a_v_neg = current_model(
|
||||
with set_forward_context(
|
||||
current_timestep=i, attn_metadata=attn_metadata
|
||||
):
|
||||
model_video, model_audio = 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,
|
||||
encoder_hidden_states=encoder_hidden_states,
|
||||
audio_encoder_hidden_states=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,
|
||||
encoder_attention_mask=encoder_attention_mask,
|
||||
audio_encoder_attention_mask=encoder_attention_mask,
|
||||
num_frames=latent_num_frames,
|
||||
height=latent_height,
|
||||
width=latent_width,
|
||||
@@ -528,16 +587,132 @@ class LTX2AVDenoisingStage(DenoisingStage):
|
||||
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()
|
||||
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]
|
||||
if do_ti2v:
|
||||
latents[:, :num_img_tokens, :] = batch.image_latent[
|
||||
:, :num_img_tokens, :
|
||||
].to(device=latents.device, dtype=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()
|
||||
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
|
||||
):
|
||||
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
|
||||
):
|
||||
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()
|
||||
|
||||
# Velocity -> denoised (x0): x0 = x - sigma * v
|
||||
sigma_val = float(sigma.item())
|
||||
@@ -547,9 +722,18 @@ class LTX2AVDenoisingStage(DenoisingStage):
|
||||
denoised_audio = (
|
||||
audio_latents.float() - sigma_val * a_v_pos
|
||||
).to(audio_latents.dtype)
|
||||
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 (
|
||||
batch.do_classifier_free_guidance
|
||||
(
|
||||
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
|
||||
):
|
||||
@@ -559,6 +743,159 @@ class LTX2AVDenoisingStage(DenoisingStage):
|
||||
denoised_audio_neg = (
|
||||
audio_latents.float() - sigma_val * a_v_neg
|
||||
).to(audio_latents.dtype)
|
||||
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(
|
||||
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,
|
||||
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 = (
|
||||
latents.float() - sigma_val * v_ptb.float()
|
||||
).to(latents.dtype)
|
||||
denoised_audio_perturbed = (
|
||||
audio_latents.float() - sigma_val * a_v_ptb.float()
|
||||
).to(audio_latents.dtype)
|
||||
|
||||
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(
|
||||
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,
|
||||
disable_a2v_cross_attn=True,
|
||||
disable_v2a_cross_attn=True,
|
||||
)
|
||||
denoised_video_modality = (
|
||||
latents.float() - sigma_val * v_mod.float()
|
||||
).to(latents.dtype)
|
||||
denoised_audio_modality = (
|
||||
audio_latents.float() - sigma_val * a_v_mod.float()
|
||||
).to(audio_latents.dtype)
|
||||
|
||||
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)
|
||||
@@ -576,7 +913,6 @@ class LTX2AVDenoisingStage(DenoisingStage):
|
||||
denoised_video * denoise_mask
|
||||
+ clean_latent.float() * (1.0 - denoise_mask)
|
||||
)
|
||||
|
||||
# Euler step in sigma space: x_next = x + (sigma_next - sigma) * v,
|
||||
# where v = (x - x0) / sigma.
|
||||
if sigma_val == 0.0:
|
||||
@@ -655,6 +991,8 @@ class LTX2AVDenoisingStage(DenoisingStage):
|
||||
trajectory_audio_latents: list,
|
||||
server_args: ServerArgs,
|
||||
is_warmup: bool = False,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
# 1. Handle Trajectory (Video) - Copy from base
|
||||
if trajectory_latents:
|
||||
@@ -705,7 +1043,8 @@ class LTX2AVDenoisingStage(DenoisingStage):
|
||||
|
||||
batch.latents = latents
|
||||
batch.audio_latents = audio_latents
|
||||
|
||||
# 4. Cleanup
|
||||
# TODO: make this a general denoising-stage hook
|
||||
if isinstance(self.transformer, OffloadableDiTMixin):
|
||||
for manager in self.transformer.layerwise_offload_managers:
|
||||
manager.release_all()
|
||||
@@ -753,9 +1092,6 @@ class LTX2AVDenoisingStage(DenoisingStage):
|
||||
)
|
||||
return result
|
||||
|
||||
def do_classifier_free_guidance(self, batch: Req) -> bool:
|
||||
return batch.guidance_scale > 1.0
|
||||
|
||||
|
||||
class LTX2RefinementStage(LTX2AVDenoisingStage):
|
||||
def __init__(
|
||||
@@ -764,34 +1100,109 @@ class LTX2RefinementStage(LTX2AVDenoisingStage):
|
||||
super().__init__(transformer, scheduler, vae, audio_vae)
|
||||
self.distilled_sigmas = torch.tensor(distilled_sigmas)
|
||||
|
||||
@staticmethod
|
||||
def _randn_like_with_batch_generators(
|
||||
reference_tensor: torch.Tensor, batch: Req
|
||||
) -> torch.Tensor:
|
||||
generator = getattr(batch, "generator", None)
|
||||
if isinstance(generator, list):
|
||||
bsz = int(reference_tensor.shape[0])
|
||||
valid_generators = [g for g in generator if isinstance(g, torch.Generator)]
|
||||
if len(valid_generators) == 1:
|
||||
generator = valid_generators[0]
|
||||
elif len(valid_generators) >= bsz:
|
||||
generator = valid_generators[:bsz]
|
||||
else:
|
||||
generator = None
|
||||
elif not isinstance(generator, torch.Generator):
|
||||
generator = None
|
||||
|
||||
return randn_tensor(
|
||||
reference_tensor.shape,
|
||||
generator=generator,
|
||||
device=reference_tensor.device,
|
||||
dtype=reference_tensor.dtype,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _reset_stage2_generators(batch: Req) -> None:
|
||||
generator = getattr(batch, "generator", None)
|
||||
if isinstance(generator, list) and generator:
|
||||
generator_device = str(generator[0].device)
|
||||
elif isinstance(generator, torch.Generator):
|
||||
generator_device = str(generator.device)
|
||||
else:
|
||||
generator_device = "cpu"
|
||||
|
||||
seeds = getattr(batch, "seeds", None)
|
||||
if not seeds:
|
||||
seed = getattr(batch, "seed", None)
|
||||
if seed is None:
|
||||
return
|
||||
seeds = [int(seed)]
|
||||
|
||||
batch.generator = [
|
||||
torch.Generator(device=generator_device).manual_seed(int(seed))
|
||||
for seed in seeds
|
||||
]
|
||||
|
||||
def forward(self, batch: Req, server_args: ServerArgs) -> Req:
|
||||
# 1. Add noise to latents
|
||||
batch.extra["ltx2_phase"] = "stage2"
|
||||
self._reset_stage2_generators(batch)
|
||||
noise_scale = self.distilled_sigmas[0].to(batch.latents.device)
|
||||
noise = torch.randn_like(batch.latents)
|
||||
batch.latents = batch.latents + noise * noise_scale
|
||||
video_noise = self._randn_like_with_batch_generators(batch.latents, batch)
|
||||
batch.latents = video_noise * noise_scale + batch.latents * (1 - noise_scale)
|
||||
|
||||
# 2. Run denoising loop with distilled_sigmas
|
||||
# Save original sigmas
|
||||
original_sigmas = self.scheduler.sigmas
|
||||
original_timesteps = self.scheduler.timesteps
|
||||
original_num_inference_steps = self.scheduler.num_inference_steps
|
||||
if isinstance(batch.audio_latents, torch.Tensor):
|
||||
audio_noise = self._randn_like_with_batch_generators(
|
||||
batch.audio_latents, batch
|
||||
)
|
||||
audio_noise_scale = noise_scale.to(
|
||||
batch.audio_latents.device, batch.audio_latents.dtype
|
||||
)
|
||||
batch.audio_latents = (
|
||||
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
|
||||
)
|
||||
|
||||
# Set distilled sigmas
|
||||
self.scheduler.sigmas = self.distilled_sigmas.to(self.scheduler.sigmas.device)
|
||||
# Approximation for timesteps
|
||||
self.scheduler.timesteps = self.scheduler.sigmas * 1000
|
||||
self.scheduler.num_inference_steps = len(self.distilled_sigmas) - 1
|
||||
# Stage 2 runs at full resolution, so Stage 1 TI2V conditioning is invalid.
|
||||
batch.image_latent = None
|
||||
batch.ltx2_num_image_tokens = 0
|
||||
|
||||
# Use a private scheduler copy to avoid mutating shared state.
|
||||
original_scheduler = self.scheduler
|
||||
original_batch_timesteps = batch.timesteps
|
||||
original_batch_num_inference_steps = batch.num_inference_steps
|
||||
|
||||
self.scheduler = copy.deepcopy(original_scheduler)
|
||||
distilled_device = self.scheduler.sigmas.device
|
||||
self.scheduler.sigmas = self.distilled_sigmas.to(distilled_device)
|
||||
num_steps = len(self.distilled_sigmas) - 1
|
||||
self.scheduler.num_inference_steps = num_steps
|
||||
self.scheduler.timesteps = (self.distilled_sigmas[:num_steps] * 1000).to(
|
||||
distilled_device
|
||||
)
|
||||
self.scheduler._step_index = None
|
||||
self.scheduler._begin_index = None
|
||||
|
||||
batch.timesteps = self.scheduler.timesteps
|
||||
batch.num_inference_steps = num_steps
|
||||
original_do_cfg = batch.do_classifier_free_guidance
|
||||
batch.do_classifier_free_guidance = False
|
||||
|
||||
# Call parent forward
|
||||
try:
|
||||
batch = super().forward(batch, server_args)
|
||||
finally:
|
||||
# Restore original sigmas
|
||||
self.scheduler.sigmas = original_sigmas
|
||||
self.scheduler.timesteps = original_timesteps
|
||||
self.scheduler.num_inference_steps = original_num_inference_steps
|
||||
self.scheduler = original_scheduler
|
||||
batch.timesteps = original_batch_timesteps
|
||||
batch.num_inference_steps = original_batch_num_inference_steps
|
||||
batch.do_classifier_free_guidance = original_do_cfg
|
||||
|
||||
return batch
|
||||
|
||||
def do_classifier_free_guidance(self, batch: Req) -> bool:
|
||||
return False # Stage 2 uses simple denoising (no CFG)
|
||||
|
||||
@@ -79,6 +79,11 @@ class InputValidationStage(PipelineStage):
|
||||
# Create generators based on generator_device parameter
|
||||
# Note: This will overwrite any existing batch.generator
|
||||
generator_device = batch.generator_device
|
||||
if generator_device is None:
|
||||
generator_device = (
|
||||
getattr(server_args.pipeline_config, "generator_device", None)
|
||||
or current_platform.device_type
|
||||
)
|
||||
|
||||
if generator_device == "cpu":
|
||||
device_str = "cpu"
|
||||
|
||||
@@ -37,6 +37,15 @@ class LatentPreparationStage(PipelineStage):
|
||||
self.scheduler = scheduler
|
||||
self.transformer = transformer
|
||||
|
||||
def _get_latent_dtype(
|
||||
self,
|
||||
batch: Req,
|
||||
server_args: ServerArgs,
|
||||
):
|
||||
return server_args.pipeline_config.get_latent_dtype(
|
||||
batch.prompt_embeds[0].dtype
|
||||
)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
batch: Req,
|
||||
@@ -57,9 +66,7 @@ class LatentPreparationStage(PipelineStage):
|
||||
batch_size = batch.batch_size
|
||||
|
||||
# Get required parameters
|
||||
dtype = server_args.pipeline_config.get_latent_dtype(
|
||||
batch.prompt_embeds[0].dtype
|
||||
)
|
||||
dtype = self._get_latent_dtype(batch, server_args)
|
||||
device = get_local_torch_device()
|
||||
generator = batch.generator
|
||||
latents = batch.latents
|
||||
|
||||
@@ -53,6 +53,13 @@ class LTX2AVLatentPreparationStage(LatentPreparationStage):
|
||||
result.add_check("latents", batch.latents, V.none_or_tensor)
|
||||
return result
|
||||
|
||||
def _get_latent_dtype(
|
||||
self,
|
||||
batch: Req,
|
||||
server_args: ServerArgs,
|
||||
):
|
||||
return torch.float32
|
||||
|
||||
def forward(self, batch: Req, server_args: ServerArgs) -> Req:
|
||||
# 1. Prepare Video Latents using base class logic
|
||||
# This sets batch.latents and batch.raw_latent_shape
|
||||
@@ -70,12 +77,7 @@ class LTX2AVLatentPreparationStage(LatentPreparationStage):
|
||||
return batch
|
||||
|
||||
device = get_local_torch_device()
|
||||
if isinstance(batch.prompt_embeds, list) and batch.prompt_embeds:
|
||||
dtype = batch.prompt_embeds[0].dtype
|
||||
elif isinstance(batch.prompt_embeds, torch.Tensor):
|
||||
dtype = batch.prompt_embeds.dtype
|
||||
else:
|
||||
dtype = torch.float16
|
||||
dtype = self._get_latent_dtype(batch, server_args)
|
||||
generator = batch.generator
|
||||
|
||||
audio_latents = batch.audio_latents
|
||||
|
||||
@@ -7,6 +7,8 @@ Prompt encoding stages for diffusion pipelines.
|
||||
This module contains implementations of prompt encoding stages for diffusion pipelines.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.configs.models.encoders import BaseEncoderOutput
|
||||
@@ -263,14 +265,22 @@ class TextEncodingStage(PipelineStage):
|
||||
attention_mask = torch.ones(input_ids.shape[:2], device=target_device)
|
||||
else:
|
||||
attention_mask = text_inputs["attention_mask"]
|
||||
encoder_forward_kwargs = {
|
||||
"input_ids": input_ids,
|
||||
"attention_mask": attention_mask,
|
||||
"output_hidden_states": True,
|
||||
}
|
||||
if "use_cache" in inspect.signature(text_encoder.forward).parameters:
|
||||
encoder_forward_kwargs["use_cache"] = False
|
||||
with set_forward_context(current_timestep=0, attn_metadata=None):
|
||||
outputs: BaseEncoderOutput = text_encoder(
|
||||
input_ids=input_ids,
|
||||
attention_mask=attention_mask,
|
||||
output_hidden_states=True,
|
||||
use_cache=False,
|
||||
)
|
||||
prompt_embeds = postprocess_func(outputs, text_inputs)
|
||||
outputs: BaseEncoderOutput = text_encoder(**encoder_forward_kwargs)
|
||||
postprocess_sig = inspect.signature(postprocess_func)
|
||||
|
||||
postprocess_kwargs = {}
|
||||
if "pipeline_config" in postprocess_sig.parameters:
|
||||
# required by models like LTX
|
||||
postprocess_kwargs["pipeline_config"] = server_args.pipeline_config
|
||||
prompt_embeds = postprocess_func(outputs, text_inputs, **postprocess_kwargs)
|
||||
if dtype is not None:
|
||||
prompt_embeds = prompt_embeds.to(dtype=dtype)
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.runtime.distributed import get_local_torch_device
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.base import PipelineStage
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
class LTX2HalveResolutionStage(PipelineStage):
|
||||
"""Halve batch height/width for two-stage Stage 1 (low-res generation)."""
|
||||
|
||||
def forward(self, batch: Req, server_args: ServerArgs) -> Req:
|
||||
original_h, original_w = batch.height, batch.width
|
||||
|
||||
vae_scale_factor = getattr(server_args.pipeline_config, "vae_scale_factor", 32)
|
||||
required_alignment = max(64, int(vae_scale_factor) * 2)
|
||||
if original_h % required_alignment != 0 or original_w % required_alignment != 0:
|
||||
raise ValueError(
|
||||
"LTX-2 two-stage requires resolution divisible by "
|
||||
f"{required_alignment}, got ({original_h}x{original_w})."
|
||||
)
|
||||
|
||||
batch.height = batch.height // 2
|
||||
batch.width = batch.width // 2
|
||||
logger.info(
|
||||
"Halved resolution: %dx%d -> %dx%d",
|
||||
original_h,
|
||||
original_w,
|
||||
batch.height,
|
||||
batch.width,
|
||||
)
|
||||
return batch
|
||||
|
||||
|
||||
class LTX2LoRASwitchStage(PipelineStage):
|
||||
"""Switch LoRA configuration for the requested two-stage phase."""
|
||||
|
||||
def __init__(self, pipeline, phase: str):
|
||||
super().__init__()
|
||||
self.pipeline = pipeline
|
||||
self.phase = phase
|
||||
|
||||
def forward(self, batch: Req, server_args: ServerArgs) -> Req:
|
||||
switch_fn = getattr(self.pipeline, "switch_lora_phase", None)
|
||||
if not callable(switch_fn):
|
||||
raise ValueError(
|
||||
"LTX2LoRASwitchStage requires pipeline.switch_lora_phase()"
|
||||
)
|
||||
switch_fn(self.phase)
|
||||
batch.extra["ltx2_phase"] = self.phase
|
||||
return batch
|
||||
|
||||
|
||||
class LTX2UpsampleStage(PipelineStage):
|
||||
"""Upsample Stage-1 video latents and prepare Stage-2 inputs."""
|
||||
|
||||
def __init__(self, spatial_upsampler, vae, audio_vae=None):
|
||||
super().__init__()
|
||||
self.spatial_upsampler = spatial_upsampler
|
||||
self.vae = vae
|
||||
self.audio_vae = audio_vae
|
||||
|
||||
def _upsample_video_latents(
|
||||
self, latents: torch.Tensor, server_args: ServerArgs, device: torch.device
|
||||
) -> torch.Tensor:
|
||||
vae_mean = self.vae.latents_mean.view(1, -1, 1, 1, 1).to(
|
||||
device=device, dtype=latents.dtype
|
||||
)
|
||||
vae_std = self.vae.latents_std.view(1, -1, 1, 1, 1).to(
|
||||
device=device, dtype=latents.dtype
|
||||
)
|
||||
latents = latents * vae_std + vae_mean
|
||||
self.spatial_upsampler = self.spatial_upsampler.to(
|
||||
device=device, dtype=latents.dtype
|
||||
)
|
||||
latents = self.spatial_upsampler(latents)
|
||||
if server_args.vae_cpu_offload:
|
||||
self.spatial_upsampler = self.spatial_upsampler.to("cpu")
|
||||
latents = (latents - vae_mean) / vae_std
|
||||
return latents
|
||||
|
||||
@staticmethod
|
||||
def _restore_full_resolution(batch: Req) -> None:
|
||||
batch.height *= 2
|
||||
batch.width *= 2
|
||||
|
||||
@staticmethod
|
||||
def _pack_video_latents(
|
||||
batch: Req, latents: torch.Tensor, server_args: ServerArgs
|
||||
) -> None:
|
||||
batch_size = latents.shape[0]
|
||||
latents = server_args.pipeline_config.maybe_pack_latents(
|
||||
latents, batch_size, batch
|
||||
)
|
||||
batch.latents = latents
|
||||
batch.raw_latent_shape = latents.shape
|
||||
|
||||
def _repack_audio_latents(self, batch: Req, server_args: ServerArgs) -> None:
|
||||
if batch.audio_latents is None or self.audio_vae is None:
|
||||
return
|
||||
audio_latents = server_args.pipeline_config.maybe_pack_audio_latents(
|
||||
batch.audio_latents, batch.audio_latents.shape[0], batch
|
||||
)
|
||||
batch.audio_latents = audio_latents
|
||||
batch.raw_audio_latent_shape = audio_latents.shape
|
||||
logger.info(
|
||||
"Re-packed audio latents for Stage 2: %s", list(audio_latents.shape)
|
||||
)
|
||||
|
||||
def forward(self, batch: Req, server_args: ServerArgs) -> Req:
|
||||
device = get_local_torch_device()
|
||||
latents = self._upsample_video_latents(batch.latents, server_args, device)
|
||||
logger.info("Upsampled video latents: %s", list(latents.shape))
|
||||
self._restore_full_resolution(batch)
|
||||
self._pack_video_latents(batch, latents, server_args)
|
||||
logger.info(
|
||||
"Packed video latents for Stage 2: %s (resolution %dx%d)",
|
||||
list(batch.latents.shape),
|
||||
batch.height,
|
||||
batch.width,
|
||||
)
|
||||
self._repack_audio_latents(batch, server_args)
|
||||
return batch
|
||||
@@ -48,7 +48,6 @@ from sglang.multimodal_gen.utils import (
|
||||
FlexibleArgumentParser,
|
||||
StoreBoolean,
|
||||
expand_path_fields,
|
||||
expand_path_kwargs,
|
||||
)
|
||||
|
||||
logger = init_logger(__name__)
|
||||
@@ -573,6 +572,15 @@ class ServerArgs:
|
||||
"(e.g. 'Qwen-Image' for 'Qwen/Qwen-Image')."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pipeline-class-name",
|
||||
type=str,
|
||||
default=ServerArgs.pipeline_class_name,
|
||||
help=(
|
||||
"Override pipeline class selection from model_index.json. "
|
||||
"Must match a registered pipeline_name."
|
||||
),
|
||||
)
|
||||
# attention
|
||||
parser.add_argument(
|
||||
"--attention-backend",
|
||||
@@ -936,7 +944,11 @@ class ServerArgs:
|
||||
unknown_args: list[str],
|
||||
) -> tuple[dict[str, str], list[str]]:
|
||||
"""
|
||||
Extract dynamic ``--<component>-path`` args from unrecognised CLI args.
|
||||
Extract dynamic component path args from unrecognised CLI args.
|
||||
|
||||
Supported forms:
|
||||
- ``--<component>-path /path/to/component``
|
||||
- ``--component-paths.<component> /path/to/component`` (expanded from config)
|
||||
"""
|
||||
component_paths: dict[str, str] = {}
|
||||
remaining: list[str] = []
|
||||
@@ -944,8 +956,15 @@ class ServerArgs:
|
||||
while i < len(unknown_args):
|
||||
arg = unknown_args[i]
|
||||
key_part = arg.split("=", 1)[0] if "=" in arg else arg
|
||||
if key_part.startswith("--") and key_part.endswith("-path"):
|
||||
component = None
|
||||
if key_part.startswith("--component-paths."):
|
||||
component = key_part[len("--component-paths.") :].replace("-", "_")
|
||||
elif key_part.startswith("--component_paths."):
|
||||
component = key_part[len("--component_paths.") :].replace("-", "_")
|
||||
elif key_part.startswith("--") and key_part.endswith("-path"):
|
||||
component = key_part[2:-5].replace("-", "_")
|
||||
|
||||
if component is not None:
|
||||
if "=" in arg:
|
||||
component_paths[component] = arg.split("=", 1)[1]
|
||||
elif i + 1 < len(unknown_args) and not unknown_args[i + 1].startswith(
|
||||
@@ -997,7 +1016,6 @@ class ServerArgs:
|
||||
@classmethod
|
||||
def from_dict(cls, kwargs: dict[str, Any]) -> "ServerArgs":
|
||||
"""Create a ServerArgs object from a dictionary."""
|
||||
kwargs = expand_path_kwargs(dict(kwargs))
|
||||
attrs = [attr.name for attr in dataclasses.fields(cls)]
|
||||
server_args_kwargs: dict[str, Any] = {}
|
||||
|
||||
|
||||
@@ -545,7 +545,7 @@ def maybe_download_model_index(model_name_or_path: str) -> dict[str, Any]:
|
||||
)
|
||||
return config
|
||||
except EntryNotFoundError:
|
||||
logger.warning(
|
||||
logger.debug(
|
||||
"model_index.json not found for %s. Assuming it is a single model and downloading it.",
|
||||
model_name_or_path,
|
||||
)
|
||||
|
||||
@@ -1089,6 +1089,70 @@
|
||||
"expected_avg_denoise_ms": 319.61,
|
||||
"expected_median_denoise_ms": 127.39
|
||||
},
|
||||
"ltx_2_two_stage_t2v": {
|
||||
"stages_ms": {
|
||||
"InputValidationStage": 0.05,
|
||||
"TextEncodingStage": 1827.03,
|
||||
"LTX2TextConnectorStage": 11.54,
|
||||
"LTX2HalveResolutionStage": 0.1,
|
||||
"LTX2LoRASwitchStage": 13014.75,
|
||||
"LTX2SigmaPreparationStage": 0.25,
|
||||
"TimestepPreparationStage": 19.26,
|
||||
"LTX2AVLatentPreparationStage": 0.37,
|
||||
"LTX2AVDenoisingStage": 53324.98,
|
||||
"LTX2UpsampleStage": 1894.17,
|
||||
"LTX2RefinementStage": 4330.57,
|
||||
"LTX2AVDecodingStage": 337.68
|
||||
},
|
||||
"denoise_step_ms": {
|
||||
"0": 1206.17,
|
||||
"1": 1335.08,
|
||||
"2": 1336.0,
|
||||
"3": 1337.77,
|
||||
"4": 1335.76,
|
||||
"5": 1334.02,
|
||||
"6": 1332.78,
|
||||
"7": 1333.1,
|
||||
"8": 1334.86,
|
||||
"9": 1335.26,
|
||||
"10": 1335.62,
|
||||
"11": 1334.21,
|
||||
"12": 1336.52,
|
||||
"13": 1335.84,
|
||||
"14": 1338.81,
|
||||
"15": 1330.74,
|
||||
"16": 1334.75,
|
||||
"17": 1337.27,
|
||||
"18": 1334.05,
|
||||
"19": 1335.62,
|
||||
"20": 1349.22,
|
||||
"21": 1338.2,
|
||||
"22": 1341.3,
|
||||
"23": 1358.03,
|
||||
"24": 1341.14,
|
||||
"25": 1339.98,
|
||||
"26": 1332.75,
|
||||
"27": 1333.31,
|
||||
"28": 1333.9,
|
||||
"29": 1333.36,
|
||||
"30": 1333.96,
|
||||
"31": 1336.93,
|
||||
"32": 1335.04,
|
||||
"33": 1334.48,
|
||||
"34": 1334.99,
|
||||
"35": 1333.58,
|
||||
"36": 1334.49,
|
||||
"37": 1333.83,
|
||||
"38": 1332.2,
|
||||
"39": 1333.38,
|
||||
"40": 1652.67,
|
||||
"41": 1330.67,
|
||||
"42": 1340.97
|
||||
},
|
||||
"expected_e2e_ms": 75229.22,
|
||||
"expected_avg_denoise_ms": 1340.53,
|
||||
"expected_median_denoise_ms": 1334.99
|
||||
},
|
||||
"wan2_2_ti2v_5b": {
|
||||
"stages_ms": {
|
||||
"InputValidationStage": 96.27,
|
||||
|
||||
@@ -384,6 +384,10 @@ MULTI_FRAME_I2I_sampling_params = DiffusionSamplingParams(
|
||||
|
||||
T2V_PROMPT = "A curious raccoon"
|
||||
|
||||
T2V_sampling_params = DiffusionSamplingParams(
|
||||
prompt=T2V_PROMPT,
|
||||
)
|
||||
|
||||
TI2V_sampling_params = DiffusionSamplingParams(
|
||||
prompt="The man in the picture slowly turns his head, his expression enigmatic and otherworldly. The camera performs a slow, cinematic dolly out, focusing on his face. Moody lighting, neon signs glowing in the background, shallow depth of field.",
|
||||
image_path="https://is1-ssl.mzstatic.com/image/thumb/Music114/v4/5f/fa/56/5ffa56c2-ea1f-7a17-6bad-192ff9b6476d/825646124206.jpg/600x600bb.jpg",
|
||||
@@ -399,7 +403,6 @@ TURBOWAN_I2V_sampling_params = DiffusionSamplingParams(
|
||||
fps=4,
|
||||
)
|
||||
|
||||
|
||||
# All test cases with clean default values
|
||||
# To test different models, simply add more DiffusionCase entries
|
||||
ONE_GPU_CASES_A: list[DiffusionTestCase] = [
|
||||
@@ -577,9 +580,7 @@ ONE_GPU_CASES_B: list[DiffusionTestCase] = [
|
||||
modality="video",
|
||||
custom_validator="video",
|
||||
),
|
||||
DiffusionSamplingParams(
|
||||
prompt=T2V_PROMPT,
|
||||
),
|
||||
T2V_sampling_params,
|
||||
),
|
||||
DiffusionTestCase(
|
||||
"wan2_1_t2v_1.3b_text_encoder_cpu_offload",
|
||||
@@ -589,9 +590,7 @@ ONE_GPU_CASES_B: list[DiffusionTestCase] = [
|
||||
custom_validator="video",
|
||||
text_encoder_cpu_offload=True,
|
||||
),
|
||||
DiffusionSamplingParams(
|
||||
prompt=T2V_PROMPT,
|
||||
),
|
||||
T2V_sampling_params,
|
||||
),
|
||||
# TeaCache acceleration test for Wan video model
|
||||
DiffusionTestCase(
|
||||
@@ -706,9 +705,7 @@ ONE_GPU_CASES_B: list[DiffusionTestCase] = [
|
||||
modality="video",
|
||||
custom_validator="video",
|
||||
),
|
||||
DiffusionSamplingParams(
|
||||
prompt=T2V_PROMPT,
|
||||
),
|
||||
T2V_sampling_params,
|
||||
),
|
||||
# === Text and Image to Video (TI2V) ===
|
||||
DiffusionTestCase(
|
||||
@@ -792,9 +789,7 @@ if not current_platform.is_hip():
|
||||
modality="video",
|
||||
custom_validator="video",
|
||||
),
|
||||
DiffusionSamplingParams(
|
||||
prompt=T2V_PROMPT,
|
||||
),
|
||||
T2V_sampling_params,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -828,9 +823,7 @@ TWO_GPU_CASES_A = [
|
||||
custom_validator="video",
|
||||
num_gpus=2,
|
||||
),
|
||||
DiffusionSamplingParams(
|
||||
prompt=T2V_PROMPT,
|
||||
),
|
||||
T2V_sampling_params,
|
||||
),
|
||||
# TeaCache smoke test for Wan2.2 T2V A14B — verifies enable_teacache=True
|
||||
# doesn't crash. Perf check disabled because Wan2.2-specific TeaCache
|
||||
@@ -886,9 +879,7 @@ TWO_GPU_CASES_A = [
|
||||
num_gpus=2,
|
||||
cfg_parallel=True,
|
||||
),
|
||||
DiffusionSamplingParams(
|
||||
prompt=T2V_PROMPT,
|
||||
),
|
||||
T2V_sampling_params,
|
||||
),
|
||||
DiffusionTestCase(
|
||||
"fsdp-inference",
|
||||
@@ -938,6 +929,17 @@ TWO_GPU_CASES_A = [
|
||||
TI2V_sampling_params,
|
||||
run_perf_check=False,
|
||||
),
|
||||
DiffusionTestCase(
|
||||
"ltx_2_two_stage_t2v",
|
||||
DiffusionServerArgs(
|
||||
model_path="Lightricks/LTX-2",
|
||||
modality="video",
|
||||
num_gpus=2,
|
||||
dit_layerwise_offload=True,
|
||||
extras=["--pipeline-class-name LTX2TwoStagePipeline"],
|
||||
),
|
||||
T2V_sampling_params,
|
||||
),
|
||||
]
|
||||
|
||||
TWO_GPU_CASES_B = [
|
||||
@@ -1054,7 +1056,6 @@ if not current_platform.is_hip():
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Load global configuration
|
||||
BASELINE_CONFIG = BaselineConfig.load(
|
||||
Path(__file__).with_name("perf_baselines.json")
|
||||
|
||||
@@ -102,5 +102,25 @@ class TestPipelineResolutionCliOverride(unittest.TestCase):
|
||||
self.assertEqual(server_args.pipeline_config.resolution, 768)
|
||||
|
||||
|
||||
class TestComponentPathParsing(unittest.TestCase):
|
||||
def test_extract_component_paths_accepts_config_expanded_keys(self):
|
||||
component_paths, remaining = ServerArgs._extract_component_paths(
|
||||
[
|
||||
"--component-paths.spatial-upsampler",
|
||||
"/tmp/latent_upsampler",
|
||||
"--component_paths.distilled-lora=/tmp/distilled.safetensors",
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
component_paths,
|
||||
{
|
||||
"spatial_upsampler": "/tmp/latent_upsampler",
|
||||
"distilled_lora": "/tmp/distilled.safetensors",
|
||||
},
|
||||
)
|
||||
self.assertEqual(remaining, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -35,25 +35,21 @@ logger = init_logger(__name__)
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def _expand_path_value(field_name: str, value: Any) -> Any:
|
||||
eu = os.path.expanduser
|
||||
if field_name.endswith("_path") and isinstance(value, str):
|
||||
return eu(value)
|
||||
if field_name.endswith("_path") and isinstance(value, list):
|
||||
return [eu(x) if isinstance(x, str) else x for x in value]
|
||||
if field_name.endswith("_paths") and isinstance(value, dict):
|
||||
return {k: eu(p) if isinstance(p, str) else p for k, p in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def expand_path_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
return {key: _expand_path_value(key, value) for key, value in kwargs.items()}
|
||||
|
||||
|
||||
def expand_path_fields(obj) -> None:
|
||||
"""In-place expanduser on all dataclass fields whose name ends with '_path' or '_paths'."""
|
||||
eu = os.path.expanduser
|
||||
for f in fields(obj):
|
||||
setattr(obj, f.name, _expand_path_value(f.name, getattr(obj, f.name)))
|
||||
v = getattr(obj, f.name)
|
||||
if f.name.endswith("_path") and isinstance(v, str):
|
||||
setattr(obj, f.name, eu(v))
|
||||
elif f.name.endswith("_path") and isinstance(v, list):
|
||||
setattr(obj, f.name, [eu(x) if isinstance(x, str) else x for x in v])
|
||||
elif f.name.endswith("_paths") and isinstance(v, dict):
|
||||
setattr(
|
||||
obj,
|
||||
f.name,
|
||||
{k: eu(p) if isinstance(p, str) else p for k, p in v.items()},
|
||||
)
|
||||
|
||||
|
||||
# TODO(will): used to convert server_args.precision to torch.dtype. Find a
|
||||
|
||||
Reference in New Issue
Block a user