Merge branch 'main' into lance-model-support

This commit is contained in:
SYLAR authored and GitHub committed 2026-05-22 04:13:58 +08:00
commit f0cec3ecd4
224 files changed
+19202 -7026

No files matched your search

@@ -701,6 +701,10 @@ class Flux2KleinPipeline(nn.Module, CFGParallelMixin, SupportImageInput, Diffusi
raise ValueError(
"Provide either `prompt` or `prompt_embeds`. Cannot leave both `prompt` and `prompt_embeds` undefined."
)
elif isinstance(prompt, str) and not prompt.strip():
raise ValueError("`prompt` cannot be empty or whitespace-only.")
elif isinstance(prompt, list) and any(isinstance(p, str) and not p.strip() for p in prompt):
raise ValueError("`prompt` cannot contain empty or whitespace-only strings.")
elif prompt is not None and (not isinstance(prompt, str) and not isinstance(prompt, list)):
raise ValueError(f"`prompt` has to be of type `str` or `list` but is {type(prompt)}")
@@ -861,7 +865,11 @@ class Flux2KleinPipeline(nn.Module, CFGParallelMixin, SupportImageInput, Diffusi
height = req.sampling_params.height or height
width = req.sampling_params.width or width
num_inference_steps = req.sampling_params.num_inference_steps or num_inference_steps
num_inference_steps = (
req.sampling_params.num_inference_steps
if req.sampling_params.num_inference_steps is not None
else num_inference_steps
)
sigmas = req.sampling_params.sigmas or sigmas
guidance_scale = (
req.sampling_params.guidance_scale if req.sampling_params.guidance_scale is not None else guidance_scale
@@ -72,6 +72,7 @@ from vllm_omni.diffusion.distributed.sp_plan import (
SequenceParallelOutput,
)
from vllm_omni.diffusion.distributed.utils import get_local_device
from vllm_omni.diffusion.forward_context import set_forward_context_denoise_step_idx
from vllm_omni.diffusion.layers.rope import RotaryEmbedding
from vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe import HunyuanFusedMoE
from vllm_omni.model_executor.layers.timestep_embedding import timestep_embedding
@@ -868,7 +869,15 @@ class ImageKVCacheManager:
Manages specialized caching and updating of KV-Cache for image tokens in multimodal models.
"""
def __init__(self, num_heads: int, num_kv_heads: int, head_dim: int, scaling: float, image_token_len: int = 4097):
def __init__(
self,
num_heads: int,
num_kv_heads: int,
head_dim: int,
scaling: float,
image_token_len: int = 4097,
prefix: str = "",
):
"""
Args:
image_token_len: Number of tokens per image (including special placeholders),
@@ -892,6 +901,7 @@ class ImageKVCacheManager:
causal=False,
softmax_scale=self.scaling,
num_kv_heads=self.num_kv_heads,
prefix=f"{prefix}.attn" if prefix else "",
)
def _cache_prompt_kv(
@@ -1650,6 +1660,7 @@ class HunYuanAttention(nn.Module):
num_kv_heads=self.num_kv_heads,
scaling=self.scaling,
image_token_len=4097,
prefix=f"{prefix}.image_attn",
)
self.image_rope2d_emb = HunYuanRotary2DEmbedder(
num_heads=self.num_heads,
@@ -3021,6 +3032,7 @@ class HunyuanImage3Text2ImagePipeline(DiffusionPipeline):
with self.progress_bar(total=num_inference_steps) as progress_bar:
for i, t in enumerate(timesteps):
set_forward_context_denoise_step_idx(i)
if cfg_parallel_ready:
# CFG parallel: each rank forwards its own branch (no batch doubling)
latent_model_input = latents
@@ -3109,6 +3121,8 @@ class HunyuanImage3Text2ImagePipeline(DiffusionPipeline):
if i == len(timesteps) - 1 or ((i + 1) > num_warmup_steps and (i + 1) % self.scheduler.order == 0):
progress_bar.update()
set_forward_context_denoise_step_idx(None)
if hasattr(self.vae.config, "scaling_factor") and self.vae.config.scaling_factor:
latents = latents / self.vae.config.scaling_factor
if hasattr(self.vae.config, "shift_factor") and self.vae.config.shift_factor:
@@ -30,7 +30,6 @@ from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.inputs.data import OmniTextPrompt
from vllm_omni.model_executor.models.hunyuan_image3.siglip2 import Siglip2VisionTransformer
from .autoencoder import AutoencoderKLConv3D
from .hunyuan_image3_tokenizer import TokenizerWrapper
from .hunyuan_image3_transformer import (
CausalMMOutputWithPast,
@@ -343,7 +342,13 @@ class HunyuanImage3Pipeline(
quant_config = od_config.quantization_config
self.model = HunyuanImage3Model(self.hf_config, quant_config=quant_config)
self.transformer = self.model
self.vae = AutoencoderKLConv3D.from_config(self.hf_config.vae)
# Lazy import to break circular dependency:
# autoencoder_kl_hunyuan -> hunyuan_image3/__init__ -> pipeline_hunyuan_image3 -> autoencoder_kl_hunyuan
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_hunyuan import ( # noqa: PLC0415
DistributedAutoencoderKLHunyuan,
)
self.vae = DistributedAutoencoderKLHunyuan.from_config(self.hf_config.vae)
self.vae.use_spatial_tiling = self.od_config.vae_use_tiling
self._pipeline = None
self._tkwrapper = TokenizerWrapper(od_config.model)
@@ -17,7 +17,7 @@ import inspect
from collections.abc import Iterable
from dataclasses import dataclass
from types import SimpleNamespace
from typing import Any
from typing import TYPE_CHECKING, Any
import torch
import torch.distributed
@@ -39,6 +39,9 @@ from vllm.model_executor.layers.linear import (
)
from vllm.model_executor.model_loader.weight_utils import default_weight_loader
if TYPE_CHECKING:
from vllm.model_executor.layers.quantization.base_config import QuantizationConfig
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata
from vllm_omni.diffusion.attention.layer import Attention
from vllm_omni.diffusion.distributed.hsdp_utils import is_transformer_block_module
@@ -173,7 +176,16 @@ class LTX2AdaLayerNormSingle(nn.Module):
class ColumnParallelApproxGELU(nn.Module):
def __init__(self, dim_in: int, dim_out: int, *, approximate: str, bias: bool = True):
def __init__(
self,
dim_in: int,
dim_out: int,
*,
approximate: str,
bias: bool = True,
quant_config: "QuantizationConfig | None" = None,
prefix: str = "",
):
super().__init__()
self.proj = ColumnParallelLinear(
dim_in,
@@ -181,6 +193,8 @@ class ColumnParallelApproxGELU(nn.Module):
bias=bias,
gather_output=False,
return_bias=False,
quant_config=quant_config,
prefix=f"{prefix}.proj" if prefix else "proj",
)
self.approximate = approximate
@@ -200,6 +214,8 @@ class LTX2FeedForward(nn.Module):
bias: bool = True,
dropout: float = 0.0,
final_dropout: bool = False,
quant_config: "QuantizationConfig | None" = None,
prefix: str = "",
) -> None:
super().__init__()
@@ -211,13 +227,22 @@ class LTX2FeedForward(nn.Module):
dropout_layer: nn.Module = nn.Dropout(dropout) if dropout > 0 else nn.Identity()
layers: list[nn.Module] = [
ColumnParallelApproxGELU(dim, inner_dim, approximate="tanh", bias=bias),
ColumnParallelApproxGELU(
dim,
inner_dim,
approximate="tanh",
bias=bias,
quant_config=quant_config,
prefix=f"{prefix}.net.0" if prefix else "net.0",
),
dropout_layer,
RowParallelLinear(
inner_dim,
dim_out,
input_is_parallel=True,
return_bias=False,
quant_config=quant_config,
prefix=f"{prefix}.net.2" if prefix else "net.2",
),
]
if final_dropout:
@@ -515,6 +540,9 @@ class LTX2Attention(torch.nn.Module):
rope_type: str = "interleaved",
apply_gated_attention: bool = False,
processor=None,
quant_config: "QuantizationConfig | None" = None,
prefix: str = "",
disable_kv_quant: bool = False,
):
super().__init__()
# LTX-2 uses "rms_norm_across_heads", LTX-2.3 uses "rms_norm" -- both
@@ -548,6 +576,8 @@ class LTX2Attention(torch.nn.Module):
head_size=self.head_dim,
total_num_heads=heads,
bias=bias,
quant_config=quant_config,
prefix=f"{prefix}.to_qkv" if prefix else "to_qkv",
)
self.query_num_heads = self.to_qkv.num_heads
self.kv_num_heads = self.to_qkv.num_kv_heads
@@ -562,6 +592,8 @@ class LTX2Attention(torch.nn.Module):
bias=bias,
gather_output=False,
return_bias=False,
quant_config=quant_config,
prefix=f"{prefix}.to_q" if prefix else "to_q",
)
self.to_k = ColumnParallelLinear(
self.cross_attention_dim,
@@ -569,6 +601,8 @@ class LTX2Attention(torch.nn.Module):
bias=bias,
gather_output=False,
return_bias=False,
quant_config=quant_config,
prefix=f"{prefix}.to_k" if prefix else "to_k",
)
self.to_v = ColumnParallelLinear(
self.cross_attention_dim,
@@ -576,6 +610,8 @@ class LTX2Attention(torch.nn.Module):
bias=bias,
gather_output=False,
return_bias=False,
quant_config=quant_config,
prefix=f"{prefix}.to_v" if prefix else "to_v",
)
self.heads = self.query_num_heads
@@ -617,6 +653,8 @@ class LTX2Attention(torch.nn.Module):
bias=out_bias,
input_is_parallel=True,
return_bias=False,
quant_config=quant_config,
prefix=f"{prefix}.to_out.0" if prefix else "to_out.0",
),
torch.nn.Dropout(dropout) if dropout > 0 else torch.nn.Identity(),
]
@@ -627,9 +665,12 @@ class LTX2Attention(torch.nn.Module):
num_kv_heads=self.kv_num_heads,
softmax_scale=1.0 / (dim_head**0.5),
causal=False,
prefix=prefix,
disable_kv_quant=disable_kv_quant,
)
# LTX-2.3: per-head gated attention
# leave unquantized for this linear
if apply_gated_attention:
self.to_gate_logits = nn.Linear(query_dim, heads, bias=True)
else:
@@ -748,6 +789,8 @@ class LTX2VideoTransformerBlock(nn.Module):
elementwise_affine: bool = False,
rope_type: str = "interleaved",
perturbed_attn: bool = False,
quant_config: "QuantizationConfig | None" = None,
prefix: str = "",
):
super().__init__()
self.video_cross_attn_adaln = video_cross_attn_adaln
@@ -767,6 +810,8 @@ class LTX2VideoTransformerBlock(nn.Module):
qk_norm=qk_norm,
rope_type=rope_type,
apply_gated_attention=video_gated_attn,
quant_config=quant_config,
prefix=f"{prefix}.attn1" if prefix else "attn1",
)
self.audio_norm1 = _make_rms_norm(audio_dim, eps=eps, elementwise_affine=elementwise_affine)
@@ -781,6 +826,8 @@ class LTX2VideoTransformerBlock(nn.Module):
qk_norm=qk_norm,
rope_type=rope_type,
apply_gated_attention=audio_gated_attn,
quant_config=quant_config,
prefix=f"{prefix}.audio_attn1" if prefix else "audio_attn1",
)
# 2. Prompt Cross-Attention
@@ -796,6 +843,9 @@ class LTX2VideoTransformerBlock(nn.Module):
qk_norm=qk_norm,
rope_type=rope_type,
apply_gated_attention=video_gated_attn,
quant_config=quant_config,
prefix=f"{prefix}.attn2" if prefix else "attn2",
disable_kv_quant=True,
)
self.audio_norm2 = _make_rms_norm(audio_dim, eps=eps, elementwise_affine=elementwise_affine)
@@ -810,9 +860,14 @@ class LTX2VideoTransformerBlock(nn.Module):
qk_norm=qk_norm,
rope_type=rope_type,
apply_gated_attention=audio_gated_attn,
quant_config=quant_config,
prefix=f"{prefix}.audio_attn2" if prefix else "audio_attn2",
disable_kv_quant=True,
)
# 3. Audio-to-Video (a2v) and Video-to-Audio (v2a) Cross-Attention
# K/V here come from the other modality's latents (long sequences),
# so KV-cache quant remains enabled.
self.audio_to_video_norm = _make_rms_norm(dim, eps=eps, elementwise_affine=elementwise_affine)
self.audio_to_video_attn = LTX2Attention(
query_dim=dim,
@@ -825,6 +880,8 @@ class LTX2VideoTransformerBlock(nn.Module):
qk_norm=qk_norm,
rope_type=rope_type,
apply_gated_attention=video_gated_attn,
quant_config=quant_config,
prefix=f"{prefix}.audio_to_video_attn" if prefix else "audio_to_video_attn",
)
self.video_to_audio_norm = _make_rms_norm(audio_dim, eps=eps, elementwise_affine=elementwise_affine)
@@ -839,14 +896,26 @@ class LTX2VideoTransformerBlock(nn.Module):
qk_norm=qk_norm,
rope_type=rope_type,
apply_gated_attention=audio_gated_attn,
quant_config=quant_config,
prefix=f"{prefix}.video_to_audio_attn" if prefix else "video_to_audio_attn",
)
# 4. Feedforward layers
self.norm3 = _make_rms_norm(dim, eps=eps, elementwise_affine=elementwise_affine)
self.ff = LTX2FeedForward(dim, activation_fn=activation_fn)
self.ff = LTX2FeedForward(
dim,
activation_fn=activation_fn,
quant_config=quant_config,
prefix=f"{prefix}.ff" if prefix else "ff",
)
self.audio_norm3 = _make_rms_norm(audio_dim, eps=eps, elementwise_affine=elementwise_affine)
self.audio_ff = LTX2FeedForward(audio_dim, activation_fn=activation_fn)
self.audio_ff = LTX2FeedForward(
audio_dim,
activation_fn=activation_fn,
quant_config=quant_config,
prefix=f"{prefix}.audio_ff" if prefix else "audio_ff",
)
# 5. Per-Layer Modulation Parameters
# LTX-2.3 with cross_attn_adaln uses 9 params (extra 3 for cross-attn modulation);
@@ -1365,6 +1434,9 @@ class LTX2VideoTransformer3DModel(nn.Module):
_layerwise_offload_blocks_attrs = ["transformer_blocks"]
_hsdp_shard_conditions = [is_transformer_block_module]
_sp_plan: dict[str, Any] | None = None
packed_modules_mapping = {
"to_qkv": ["to_q", "to_k", "to_v"],
}
@staticmethod
def _build_sp_plan(rope_type: str) -> dict[str, Any]:
@@ -1453,6 +1525,7 @@ class LTX2VideoTransformer3DModel(nn.Module):
cross_attn_mod: bool = False,
audio_gated_attn: bool = False,
audio_cross_attn_mod: bool = False,
quant_config: "QuantizationConfig | None" = None,
) -> None:
super().__init__()
self.perturbed_attn = perturbed_attn
@@ -1647,8 +1720,10 @@ class LTX2VideoTransformer3DModel(nn.Module):
elementwise_affine=norm_elementwise_affine,
rope_type=rope_type,
perturbed_attn=perturbed_attn,
quant_config=quant_config,
prefix=f"transformer_blocks.{layer_idx}",
)
for _ in range(num_layers)
for layer_idx in range(num_layers)
]
)
@@ -9,10 +9,13 @@ import json
import os
from collections.abc import Iterable
from contextlib import nullcontext
from typing import Any, ClassVar
from typing import TYPE_CHECKING, Any, ClassVar
import numpy as np
import torch
if TYPE_CHECKING:
from vllm.model_executor.layers.quantization.base_config import QuantizationConfig
from diffusers import AutoencoderKLLTX2Audio, AutoencoderKLLTX2Video, FlowMatchEulerDiscreteScheduler
from diffusers.pipelines.ltx2 import LTX2TextConnectors
from diffusers.pipelines.ltx2.utils import DISTILLED_SIGMA_VALUES, STAGE_2_DISTILLED_SIGMA_VALUES
@@ -67,14 +70,20 @@ def load_transformer_config(model_path: str, subfolder: str = "transformer", loc
return {}
def create_transformer_from_config(config: dict) -> LTX2VideoTransformer3DModel:
def create_transformer_from_config(
config: dict,
quant_config: QuantizationConfig | None = None,
) -> LTX2VideoTransformer3DModel:
"""Create LTX2VideoTransformer3DModel from config dict."""
if not config:
if not config and quant_config is None:
return LTX2VideoTransformer3DModel()
signature = inspect.signature(LTX2VideoTransformer3DModel.__init__)
allowed_keys = set(signature.parameters.keys())
kwargs = {k: v for k, v in config.items() if k in allowed_keys}
if quant_config is not None:
kwargs["quant_config"] = quant_config
return LTX2VideoTransformer3DModel(**kwargs)
@@ -214,7 +223,8 @@ class LTX2Pipeline(nn.Module, CFGParallelMixin, ProgressBarMixin):
).to(self.device)
transformer_config = load_transformer_config(model, "transformer", local_files_only)
self.transformer = create_transformer_from_config(transformer_config)
quant_config = getattr(self.od_config, "quantization_config", None)
self.transformer = create_transformer_from_config(transformer_config, quant_config=quant_config)
self.scheduler = FlowMatchEulerDiscreteScheduler.from_pretrained(
model, subfolder="scheduler", local_files_only=local_files_only
@@ -174,7 +174,8 @@ class LTX23Pipeline(nn.Module, ProgressBarMixin):
# --- Transformer: created empty, weights loaded via AutoWeightsLoader ---
transformer_config = load_transformer_config(model, "transformer", local_files_only)
self.transformer = create_transformer_from_config(transformer_config)
quant_config = getattr(self.od_config, "quantization_config", None)
self.transformer = create_transformer_from_config(transformer_config, quant_config=quant_config)
# --- Scheduler ---
self.scheduler = FlowMatchEulerDiscreteScheduler.from_pretrained(
@@ -290,7 +290,22 @@ class QwenImagePipeline(nn.Module, QwenImageCFGParallelMixin, DiffusionPipelineP
)
self.text_encoder = Qwen2_5_VLForConditionalGeneration.from_pretrained(
model, subfolder="text_encoder", local_files_only=local_files_only
).to(self.device)
)
# Qwen2.5-VL ships a vision tower that text-to-image does not use.
# Drop it while the model is still on CPU, before moving to GPU, so
# the vision tower never consumes GPU memory. Handle both transformers
# layouts: newer puts visual under .model, older puts it directly on
# the model.
visual_owner = None
if hasattr(self.text_encoder, "model") and hasattr(self.text_encoder.model, "visual"):
visual_owner = self.text_encoder.model
elif hasattr(self.text_encoder, "visual"):
visual_owner = self.text_encoder
if visual_owner is not None:
del visual_owner.visual
else:
logger.warning("Qwen-Image: vision tower not found on text encoder; skipping drop")
self.text_encoder = self.text_encoder.to(self.device)
self.vae = DistributedAutoencoderKLQwenImage.from_pretrained(
model, subfolder="vae", local_files_only=local_files_only
).to(self.device)
@@ -22,11 +22,6 @@ from .pipeline_wan2_2_s2v import (
get_wan22_s2v_post_process_func,
get_wan22_s2v_pre_process_func,
)
from .pipeline_wan2_2_ti2v import (
Wan22TI2VPipeline,
get_wan22_ti2v_post_process_func,
get_wan22_ti2v_pre_process_func,
)
from .pipeline_wan2_2_vace import (
Wan22VACEPipeline,
get_wan22_vace_post_process_func,
@@ -50,9 +45,6 @@ __all__ = [
"Wan22S2VPipeline",
"get_wan22_s2v_post_process_func",
"get_wan22_s2v_pre_process_func",
"Wan22TI2VPipeline",
"get_wan22_ti2v_post_process_func",
"get_wan22_ti2v_pre_process_func",
"Wan22VACEPipeline",
"get_wan22_vace_post_process_func",
"get_wan22_vace_pre_process_func",
@@ -17,10 +17,12 @@ from torch import nn
from transformers import AutoTokenizer, UMT5EncoderModel
from vllm.model_executor.layers.quantization.base_config import QuantizationConfig
from vllm.model_executor.models.utils import AutoWeightsLoader
from vllm.sequence import IntermediateTensors
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_wan import DistributedAutoencoderKLWan
from vllm_omni.diffusion.distributed.cfg_parallel import CFGParallelMixin
from vllm_omni.diffusion.distributed.pipeline_parallel import AsyncLatents, PipelineParallelMixin
from vllm_omni.diffusion.distributed.utils import get_local_device
from vllm_omni.diffusion.forward_context import set_forward_context_denoise_step_idx
from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader
@@ -120,8 +122,7 @@ def load_transformer_config(model_path: str, subfolder: str = "transformer", loc
def create_transformer_from_config(
config: dict,
quant_config: QuantizationConfig | None = None,
config: dict, quant_config: QuantizationConfig | None = None, prefix: str = ""
) -> WanTransformer3DModel:
"""Create WanTransformer3DModel from config dict."""
kwargs: dict = {}
@@ -157,57 +158,15 @@ def create_transformer_from_config(
if "pos_embed_seq_len" in config:
kwargs["pos_embed_seq_len"] = config["pos_embed_seq_len"]
# Auto-detect quantization from transformer's config.json when not explicitly provided.
# merge_mxfp8_checkpoint.py injects quantization_config into config.json so that
# offline quantized checkpoints are recognized here without a CLI flag.
if "quantization_config" in config:
from vllm_omni.quantization.factory import build_quant_config
from vllm_omni.quantization.factory import resolve_quant_config_from_disk
disk_qc = config["quantization_config"]
if isinstance(disk_qc, dict) and "quant_method" in disk_qc:
qc_method = disk_qc["quant_method"]
qc_kwargs = {k: v for k, v in disk_qc.items() if k != "quant_method"}
if quant_config is None:
# No CLI flag: full auto-detection.
quant_config = build_quant_config(qc_method, **qc_kwargs)
logger.info(
"Auto-detected quantization from transformer config.json: method=%s kwargs=%s",
qc_method,
qc_kwargs,
)
elif quant_config.get_name() != qc_method:
# The caller supplied a quant_config of a different method than
# what the checkpoint was built with. Loading serialized tensors
# (e.g. MXFP8 weight scales) with the wrong linear method would
# produce corrupt output or a shape mismatch crash. Reject early
# so the user gets a clear message instead of a silent failure.
raise ValueError(
f"Checkpoint config.json declares quant_method={qc_method!r} but the "
f"active quantization config is {quant_config.get_name()!r}. "
"Pass a matching --quantization flag or omit it for auto-detection."
)
elif (
qc_kwargs.get("is_checkpoint_mxfp8_serialized", False)
and hasattr(quant_config, "is_checkpoint_mxfp8_serialized")
and not quant_config.is_checkpoint_mxfp8_serialized
):
# Same method: CLI provided online mode but config.json marks this
# as a pre-quantized offline checkpoint. Switch to offline mode so
# users can pass --quantization mxfp8 without knowing the
# online/offline distinction.
quant_config = build_quant_config(qc_method, **qc_kwargs)
logger.info(
"config.json marks checkpoint as serialized; switching from online to offline MXFP8 mode.",
)
elif isinstance(disk_qc, str) and quant_config is None:
quant_config = build_quant_config(disk_qc)
logger.info(
"Auto-detected quantization from transformer config.json: method=%s",
disk_qc,
)
quant_config = resolve_quant_config_from_disk(quant_config, config["quantization_config"])
if quant_config is not None:
kwargs["quant_config"] = quant_config
if prefix:
kwargs["prefix"] = prefix
return WanTransformer3DModel(**kwargs)
@@ -297,7 +256,9 @@ def get_wan22_pre_process_func(
return pre_process_func
class Wan22Pipeline(nn.Module, CFGParallelMixin, ProgressBarMixin, DiffusionPipelineProfilerMixin):
class Wan22Pipeline(
nn.Module, PipelineParallelMixin, CFGParallelMixin, ProgressBarMixin, DiffusionPipelineProfilerMixin
):
def __init__(
self,
*,
@@ -460,7 +421,7 @@ class Wan22Pipeline(nn.Module, CFGParallelMixin, ProgressBarMixin, DiffusionPipe
attention_kwargs: dict[str, Any],
latent_condition: torch.Tensor | None = None,
first_frame_mask: torch.Tensor | None = None,
) -> torch.Tensor:
) -> torch.Tensor | AsyncLatents:
if attention_kwargs is None:
attention_kwargs = {}
with self.progress_bar(total=len(timesteps)) as pbar:
@@ -858,7 +819,11 @@ class Wan22Pipeline(nn.Module, CFGParallelMixin, ProgressBarMixin, DiffusionPipe
output=output, stage_durations=self.stage_durations if hasattr(self, "stage_durations") else None
)
def predict_noise(self, current_model: nn.Module | None = None, **kwargs: Any) -> torch.Tensor:
def predict_noise(
self,
current_model: nn.Module | None = None,
**kwargs: Any,
) -> torch.Tensor | IntermediateTensors:
"""
Forward pass through transformer to predict noise.
@@ -867,11 +832,12 @@ class Wan22Pipeline(nn.Module, CFGParallelMixin, ProgressBarMixin, DiffusionPipe
**kwargs: Arguments to pass to the transformer
Returns:
Predicted noise tensor
Predicted noise tensor or IntermediateTensors on non-last PP stages.
"""
if current_model is None:
current_model = self.transformer
return current_model(**kwargs)[0]
result = current_model(**kwargs)
return result if isinstance(result, IntermediateTensors) else result[0]
def encode_prompt(
self,
@@ -17,10 +17,12 @@ from diffusers.utils.torch_utils import randn_tensor
from torch import nn
from transformers import AutoTokenizer, CLIPImageProcessor, CLIPVisionModel, UMT5EncoderModel
from vllm.model_executor.models.utils import AutoWeightsLoader
from vllm.sequence import IntermediateTensors
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_wan import DistributedAutoencoderKLWan
from vllm_omni.diffusion.distributed.cfg_parallel import CFGParallelMixin
from vllm_omni.diffusion.distributed.pipeline_parallel import AsyncLatents, PipelineParallelMixin
from vllm_omni.diffusion.distributed.utils import get_local_device
from vllm_omni.diffusion.forward_context import set_forward_context_denoise_step_idx
from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader
@@ -135,7 +137,12 @@ def get_wan22_i2v_pre_process_func(
class Wan22I2VPipeline(
nn.Module, SupportImageInput, CFGParallelMixin, ProgressBarMixin, DiffusionPipelineProfilerMixin
nn.Module,
SupportImageInput,
PipelineParallelMixin,
CFGParallelMixin,
ProgressBarMixin,
DiffusionPipelineProfilerMixin,
):
"""
Wan2.2 Image-to-Video Pipeline.
@@ -224,10 +231,25 @@ class Wan22I2VPipeline(
# Transformers (weights loaded via load_weights)
# Load config from model directory or HF Hub to get correct in_channels for I2V models
transformer_config = load_transformer_config(model, "transformer", local_files_only)
self.transformer = self._create_transformer(transformer_config)
self.transformer = create_transformer_from_config(
transformer_config,
quant_config=od_config.quantization_config,
)
if self.has_transformer_2:
transformer_2_config = load_transformer_config(model, "transformer_2", local_files_only)
self.transformer_2 = self._create_transformer(transformer_2_config)
t2_quant = transformer_2_config.get("quantization_config")
if isinstance(t2_quant, dict) and "quant_method" in t2_quant:
from vllm_omni.quantization.factory import build_quant_config
method = t2_quant["quant_method"]
kwargs = {k: v for k, v in t2_quant.items() if k != "quant_method"}
t2_quant = build_quant_config(method, **kwargs)
else:
t2_quant = None
self.transformer_2 = create_transformer_from_config(
transformer_2_config,
quant_config=t2_quant,
)
else:
self.transformer_2 = None
@@ -283,7 +305,7 @@ class Wan22I2VPipeline(
attention_kwargs: dict[str, Any],
condition: torch.Tensor,
first_frame_mask: torch.Tensor,
) -> torch.Tensor:
) -> torch.Tensor | AsyncLatents:
if attention_kwargs is None:
attention_kwargs = {}
with self.progress_bar(total=len(timesteps)) as pbar:
@@ -348,7 +370,6 @@ class Wan22I2VPipeline(
# Compute the previous noisy sample x_t -> x_t-1 with automatic CFG sync
latents = self.scheduler_step_maybe_with_cfg(noise_pred, t, latents, do_true_cfg)
pbar.update()
return latents
@@ -667,7 +688,11 @@ class Wan22I2VPipeline(
output=output, stage_durations=self.stage_durations if hasattr(self, "stage_durations") else None
)
def predict_noise(self, current_model: nn.Module | None = None, **kwargs: Any) -> torch.Tensor:
def predict_noise(
self,
current_model: nn.Module | None = None,
**kwargs: Any,
) -> torch.Tensor | IntermediateTensors:
"""
Forward pass through transformer to predict noise.
@@ -676,11 +701,12 @@ class Wan22I2VPipeline(
**kwargs: Arguments to pass to the transformer
Returns:
Predicted noise tensor
Predicted noise tensor or IntermediateTensors on non-last PP stages.
"""
if current_model is None:
current_model = self.transformer
return current_model(**kwargs)[0]
result = current_model(**kwargs)
return result if isinstance(result, IntermediateTensors) else result[0]
def encode_prompt(
self,
@@ -1,731 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
Wan2.2 TI2V (Text-Image-to-Video) Pipeline.
This pipeline supports the unified TI2V-5B model that can generate videos from:
- Text only (T2V mode)
- Text + Image (I2V mode)
The key difference from the MoE-based I2V pipeline is:
- Single transformer (not MoE with two transformers)
- Uses expand_timesteps mode for image conditioning
- No CLIP image encoder - only VAE encoding for image condition
"""
from __future__ import annotations
import logging
import os
from collections.abc import Iterable
from typing import Any, cast
import numpy as np
import PIL.Image
import torch
from diffusers.utils.torch_utils import randn_tensor
from torch import nn
from transformers import AutoTokenizer, UMT5EncoderModel
from vllm.model_executor.models.utils import AutoWeightsLoader
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_wan import OmniAutoencoderKLWan
from vllm_omni.diffusion.distributed.cfg_parallel import CFGParallelMixin
from vllm_omni.diffusion.distributed.utils import get_local_device
from vllm_omni.diffusion.forward_context import set_forward_context_denoise_step_idx
from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader
from vllm_omni.diffusion.model_loader.hub_prefetch import prefetch_subfolders
from vllm_omni.diffusion.models.interface import SupportImageInput
from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import (
build_wan_scheduler,
create_transformer_from_config,
load_transformer_config,
resolve_wan_flow_shift,
resolve_wan_sample_solver,
retrieve_latents,
)
from vllm_omni.diffusion.models.wan2_2.wan2_2_transformer import WanTransformer3DModel
from vllm_omni.diffusion.postprocess import interpolate_video_tensor
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.inputs.data import OmniTextPrompt
from vllm_omni.platforms import current_omni_platform
logger = logging.getLogger(__name__)
def get_wan22_ti2v_post_process_func(
od_config: OmniDiffusionConfig,
):
from diffusers.video_processor import VideoProcessor
video_processor = VideoProcessor(vae_scale_factor=8)
def post_process_func(
video: torch.Tensor,
output_type: str = "np",
sampling_params=None,
):
if output_type == "latent":
return video
custom_output = {}
if sampling_params is not None and getattr(sampling_params, "enable_frame_interpolation", False):
video, multiplier = interpolate_video_tensor(
video,
exp=sampling_params.frame_interpolation_exp,
scale=sampling_params.frame_interpolation_scale,
model_path=sampling_params.frame_interpolation_model_path,
)
custom_output["video_fps_multiplier"] = multiplier
return {
"video": video_processor.postprocess_video(video, output_type=output_type),
"custom_output": custom_output,
}
return post_process_func
def get_wan22_ti2v_pre_process_func(
od_config: OmniDiffusionConfig,
):
"""Pre-process function for TI2V: optionally load and resize input image."""
def pre_process_func(request: OmniDiffusionRequest) -> OmniDiffusionRequest:
for i, prompt in enumerate(request.prompts):
multi_modal_data = prompt.get("multi_modal_data", {}) if not isinstance(prompt, str) else None
raw_image = multi_modal_data.get("image", None) if multi_modal_data is not None else None
if isinstance(prompt, str):
prompt = OmniTextPrompt(prompt=prompt)
if "additional_information" not in prompt:
prompt["additional_information"] = {}
if raw_image is None:
raise ValueError(
"""No image is provided. This model requires an image to run.""",
"""Please correctly set `"multi_modal_data": {"image": <an image object or file path>, …}`""",
)
if not isinstance(raw_image, (str, PIL.Image.Image)):
raise TypeError(
f"""Unsupported image format {raw_image.__class__}.""",
"""Please correctly set `"multi_modal_data": {"image": <an image object or file path>, …}`""",
)
image = PIL.Image.open(raw_image).convert("RGB") if isinstance(raw_image, str) else raw_image
# Calculate dimensions based on aspect ratio if not provided
if request.sampling_params.height is None or request.sampling_params.width is None:
# Default max area for 720P (TI2V-5B default)
max_area = 720 * 1280
aspect_ratio = image.height / image.width
# Calculate dimensions maintaining aspect ratio
mod_value = 16 # Must be divisible by 16
height = round(np.sqrt(max_area * aspect_ratio)) // mod_value * mod_value
width = round(np.sqrt(max_area / aspect_ratio)) // mod_value * mod_value
if request.sampling_params.height is None:
request.sampling_params.height = height
if request.sampling_params.width is None:
request.sampling_params.width = width
# Resize image to target dimensions
image = image.resize(
(request.sampling_params.width, request.sampling_params.height), # type: ignore # Above has ensured that width & height are not None
PIL.Image.Resampling.LANCZOS,
)
prompt["multi_modal_data"]["image"] = image # type: ignore # key existence already checked above
request.prompts[i] = prompt
return request
return pre_process_func
class Wan22TI2VPipeline(nn.Module, SupportImageInput, CFGParallelMixin, ProgressBarMixin):
"""
Wan2.2 Text-Image-to-Video (TI2V) Pipeline.
This is a unified pipeline that supports both:
- Text-to-Video (T2V): when no image is provided
- Image-to-Video (I2V): when an image is provided
Uses expand_timesteps mode for I2V conditioning where the first frame
is conditioned on the input image latent.
"""
def __init__(
self,
*,
od_config: OmniDiffusionConfig,
prefix: str = "",
):
super().__init__()
self.od_config = od_config
self.device = get_local_device()
dtype = getattr(od_config, "dtype", torch.bfloat16)
model = od_config.model
local_files_only = os.path.exists(model)
# Set up weights sources for single transformer
self.weights_sources = [
DiffusersPipelineLoader.ComponentSource(
model_or_path=od_config.model,
subfolder="transformer",
revision=None,
prefix="transformer.",
fall_back_to_pt=True,
),
]
# See ``hub_prefetch.py`` for the transformers v5 subfolder race.
prefetch_subfolders(
model,
["tokenizer", "text_encoder", "vae"],
local_files_only=local_files_only,
)
# Text encoder
self.tokenizer = AutoTokenizer.from_pretrained(model, subfolder="tokenizer", local_files_only=local_files_only)
self.text_encoder = UMT5EncoderModel.from_pretrained(
model, subfolder="text_encoder", torch_dtype=dtype, local_files_only=local_files_only
).to(self.device)
# VAE
self.vae = OmniAutoencoderKLWan.from_pretrained(
model, subfolder="vae", torch_dtype=dtype, local_files_only=local_files_only
).to(self.device)
# Single transformer (TI2V uses dense 5B model, not MoE)
# Load config from model to get correct dimensions
transformer_config = load_transformer_config(model, "transformer", local_files_only)
self.transformer = self._create_transformer(transformer_config)
self._sample_solver = "unipc"
self._flow_shift = od_config.flow_shift if od_config.flow_shift is not None else 5.0
self.scheduler = build_wan_scheduler(self._sample_solver, self._flow_shift)
# VAE scale factors
self.vae_scale_factor_temporal = self.vae.config.scale_factor_temporal if hasattr(self.vae, "config") else 4
self.vae_scale_factor_spatial = self.vae.config.scale_factor_spatial if hasattr(self.vae, "config") else 8
# TI2V always uses expand_timesteps mode
self.expand_timesteps = True
self._guidance_scale = None
self._num_timesteps = None
self._current_timestep = None
def _create_transformer(self, config: dict) -> WanTransformer3DModel:
"""Create a transformer from a config dict. Respects od_config.quantization_config."""
quant_config = getattr(self.od_config, "quantization_config", None)
return create_transformer_from_config(config, quant_config=quant_config)
@property
def guidance_scale(self):
return self._guidance_scale
@property
def do_classifier_free_guidance(self):
return self._guidance_scale is not None and self._guidance_scale > 1.0
@property
def num_timesteps(self):
return self._num_timesteps
@property
def current_timestep(self):
return self._current_timestep
def diffuse(
self,
latents: torch.Tensor,
timesteps: torch.Tensor,
prompt_embeds: torch.Tensor,
negative_prompt_embeds: torch.Tensor | None,
guidance_scale: float,
dtype: torch.dtype,
attention_kwargs: dict[str, Any],
num_latent_frames: int,
latent_height: int,
latent_width: int,
latent_condition: torch.Tensor | None = None,
first_frame_mask: torch.Tensor | None = None,
) -> torch.Tensor:
if attention_kwargs is None:
attention_kwargs = {}
with self.progress_bar(total=len(timesteps)) as pbar:
for step_idx, t in enumerate(timesteps):
self._current_timestep = t
set_forward_context_denoise_step_idx(step_idx)
# Prepare latent input
if latent_condition is not None:
# I2V mode: blend condition with latents using mask
latent_model_input = (1 - first_frame_mask) * latent_condition + first_frame_mask * latents
latent_model_input = latent_model_input.to(dtype)
# Expand timesteps for each patch (TI2V style)
temp_ts = (first_frame_mask[0][0][:, ::2, ::2] * t).flatten()
timestep = temp_ts.unsqueeze(0).expand(latents.shape[0], -1)
else:
# T2V mode: use latents directly
latent_model_input = latents.to(dtype)
# Expand timesteps for TI2V model architecture
mask = torch.ones(1, 1, num_latent_frames, latent_height, latent_width, device=latents.device)
temp_ts = (mask[0][0][:, ::2, ::2] * t).flatten()
timestep = temp_ts.unsqueeze(0).expand(latents.shape[0], -1)
do_true_cfg = guidance_scale > 1.0 and negative_prompt_embeds is not None
positive_kwargs = {
"hidden_states": latent_model_input,
"timestep": timestep,
"encoder_hidden_states": prompt_embeds,
"attention_kwargs": attention_kwargs,
"return_dict": False,
"current_model": self.transformer,
}
if do_true_cfg:
negative_kwargs = {
"hidden_states": latent_model_input,
"timestep": timestep,
"encoder_hidden_states": negative_prompt_embeds,
"attention_kwargs": attention_kwargs,
"return_dict": False,
"current_model": self.transformer,
}
else:
negative_kwargs = None
noise_pred = self.predict_noise_maybe_with_cfg(
do_true_cfg=do_true_cfg,
true_cfg_scale=guidance_scale,
positive_kwargs=positive_kwargs,
negative_kwargs=negative_kwargs,
cfg_normalize=False,
)
latents = self.scheduler_step_maybe_with_cfg(noise_pred, t, latents, do_true_cfg)
pbar.update()
return latents
def forward(
self,
req: OmniDiffusionRequest,
prompt: str | None = None,
negative_prompt: str | None = None,
image: PIL.Image.Image | torch.Tensor | None = None,
height: int = 704,
width: int = 1280,
num_inference_steps: int = 40,
guidance_scale: float = 5.0,
frame_num: int = 81,
output_type: str | None = "np",
generator: torch.Generator | list[torch.Generator] | None = None,
prompt_embeds: torch.Tensor | None = None,
negative_prompt_embeds: torch.Tensor | None = None,
attention_kwargs: dict | None = None,
**kwargs,
) -> DiffusionOutput:
# Get parameters from request or arguments
if len(req.prompts) > 1:
raise ValueError(
"""This model only supports a single prompt, not a batched request.""",
"""Please pass in a single prompt object or string, or a single-item list.""",
)
if len(req.prompts) == 1: # If req.prompt is empty, default to prompt & neg_prompt in param list
prompt = req.prompts[0] if isinstance(req.prompts[0], str) else req.prompts[0].get("prompt")
negative_prompt = None if isinstance(req.prompts[0], str) else req.prompts[0].get("negative_prompt")
if prompt is None and prompt_embeds is None:
raise ValueError("Prompt or prompt_embeds is required for Wan2.2 generation.")
# Get image from request (optional for TI2V)
if image is None:
multi_modal_data = (
req.prompts[0].get("multi_modal_data", {}) if not isinstance(req.prompts[0], str) else None
)
raw_image = multi_modal_data.get("image", None) if multi_modal_data is not None else None
if isinstance(raw_image, list):
if len(raw_image) > 1:
logger.warning(
"""Received a list of image. Only a single image is supported by this model."""
"""Taking only the first image for now."""
)
raw_image = raw_image[0]
if raw_image is None:
image = None
elif isinstance(raw_image, str):
image = PIL.Image.open(raw_image)
else:
image = cast(PIL.Image.Image | torch.Tensor, raw_image)
# Default dimensions for TI2V-5B (720P)
height = req.sampling_params.height or height
width = req.sampling_params.width or width
num_frames = req.sampling_params.num_frames if req.sampling_params.num_frames else frame_num
num_steps = req.sampling_params.num_inference_steps or num_inference_steps
# Respect per-request guidance_scale when explicitly provided.
if req.sampling_params.guidance_scale_provided:
guidance_scale = req.sampling_params.guidance_scale
self._guidance_scale = guidance_scale
# Validate inputs
self.check_inputs(
prompt=prompt,
negative_prompt=negative_prompt,
image=image,
height=height,
width=width,
prompt_embeds=prompt_embeds,
negative_prompt_embeds=negative_prompt_embeds,
)
# Adjust num_frames to be compatible with VAE temporal scaling
if num_frames % self.vae_scale_factor_temporal != 1:
num_frames = num_frames // self.vae_scale_factor_temporal * self.vae_scale_factor_temporal + 1
num_frames = max(num_frames, 1)
device = self.device
dtype = self.transformer.dtype
# Generator setup
if generator is None:
generator = req.sampling_params.generator
if generator is None and req.sampling_params.seed is not None:
generator = torch.Generator(device=device).manual_seed(req.sampling_params.seed)
# Encode prompts
if prompt_embeds is None:
prompt_embeds, negative_prompt_embeds = self.encode_prompt(
prompt=prompt,
negative_prompt=negative_prompt,
do_classifier_free_guidance=guidance_scale > 1.0,
num_videos_per_prompt=req.sampling_params.num_outputs_per_prompt or 1,
max_sequence_length=req.sampling_params.max_sequence_length or 512,
device=device,
dtype=dtype,
)
else:
prompt_embeds = prompt_embeds.to(device=device, dtype=dtype)
if negative_prompt_embeds is not None:
negative_prompt_embeds = negative_prompt_embeds.to(device=device, dtype=dtype)
batch_size = prompt_embeds.shape[0]
sample_solver = resolve_wan_sample_solver(req, default=self._sample_solver)
flow_shift = resolve_wan_flow_shift(req, self.od_config)
if sample_solver != self._sample_solver or abs(flow_shift - self._flow_shift) > 1e-6:
self.scheduler = build_wan_scheduler(sample_solver, flow_shift)
self._sample_solver = sample_solver
self._flow_shift = flow_shift
# Timesteps
self.scheduler.set_timesteps(num_steps, device=device)
timesteps = self.scheduler.timesteps
self._num_timesteps = len(timesteps)
# Prepare latents
num_channels_latents = self.transformer.config.in_channels
num_latent_frames = (num_frames - 1) // self.vae_scale_factor_temporal + 1
latent_height = height // self.vae_scale_factor_spatial
latent_width = width // self.vae_scale_factor_spatial
# Check if we have an image (I2V mode) or not (T2V mode)
if image is not None:
# I2V mode: prepare latents with image condition
from diffusers.video_processor import VideoProcessor
video_processor = VideoProcessor(vae_scale_factor=self.vae_scale_factor_spatial)
if isinstance(image, PIL.Image.Image):
image_tensor = video_processor.preprocess(image, height=height, width=width)
else:
image_tensor = image
image_tensor = image_tensor.to(device=device, dtype=torch.float32)
latents, latent_condition, first_frame_mask = self.prepare_i2v_latents(
image=image_tensor,
batch_size=batch_size,
num_channels_latents=num_channels_latents,
height=height,
width=width,
num_frames=num_frames,
dtype=torch.float32,
device=device,
generator=generator,
latents=req.sampling_params.latents,
)
else:
# T2V mode: prepare random latents
latents = self.prepare_latents(
batch_size=batch_size,
num_channels_latents=num_channels_latents,
height=height,
width=width,
num_frames=num_frames,
dtype=torch.float32,
device=device,
generator=generator,
latents=req.sampling_params.latents,
)
latent_condition = None
first_frame_mask = torch.ones(
1, 1, num_latent_frames, latent_height, latent_width, dtype=torch.float32, device=device
)
if attention_kwargs is None:
attention_kwargs = {}
latents = self.diffuse(
latents=latents,
timesteps=timesteps,
prompt_embeds=prompt_embeds,
negative_prompt_embeds=negative_prompt_embeds,
guidance_scale=guidance_scale,
dtype=dtype,
attention_kwargs=attention_kwargs,
num_latent_frames=num_latent_frames,
latent_height=latent_height,
latent_width=latent_width,
latent_condition=latent_condition,
first_frame_mask=first_frame_mask,
)
# Wan2.2 is prone to out of memory errors when predicting large videos
# so we empty the cache here to avoid OOM before vae decoding.
if current_omni_platform.is_available():
current_omni_platform.empty_cache()
self._current_timestep = None
# For I2V mode, blend final latents with condition
if latent_condition is not None:
latents = (1 - first_frame_mask) * latent_condition + first_frame_mask * latents
# Decode
if output_type == "latent":
output = latents
else:
latents = latents.to(self.vae.dtype)
latents_mean = (
torch.tensor(self.vae.config.latents_mean)
.view(1, self.vae.config.z_dim, 1, 1, 1)
.to(latents.device, latents.dtype)
)
latents_std = 1.0 / torch.tensor(self.vae.config.latents_std).view(1, self.vae.config.z_dim, 1, 1, 1).to(
latents.device, latents.dtype
)
latents = latents / latents_std + latents_mean
output = self.vae.decode(latents, return_dict=False)[0]
return DiffusionOutput(output=output)
def predict_noise(self, current_model: nn.Module | None = None, **kwargs: Any) -> torch.Tensor:
"""
Forward pass through transformer to predict noise.
Args:
current_model: The transformer model to use
**kwargs: Arguments to pass to the transformer
Returns:
Predicted noise tensor
"""
if current_model is None:
current_model = self.transformer
return current_model(**kwargs)[0]
def encode_prompt(
self,
prompt: str | list[str],
negative_prompt: str | list[str] | None = None,
do_classifier_free_guidance: bool = True,
num_videos_per_prompt: int = 1,
max_sequence_length: int = 512,
device: torch.device | None = None,
dtype: torch.dtype | None = None,
):
"""Encode text prompts using T5 text encoder."""
device = device or self.device
dtype = dtype or self.text_encoder.dtype
prompt = [prompt] if isinstance(prompt, str) else prompt
prompt_clean = [self._prompt_clean(p) for p in prompt]
batch_size = len(prompt_clean)
text_inputs = self.tokenizer(
prompt_clean,
padding="max_length",
max_length=max_sequence_length,
truncation=True,
add_special_tokens=True,
return_attention_mask=True,
return_tensors="pt",
)
ids, mask = text_inputs.input_ids, text_inputs.attention_mask
seq_lens = mask.gt(0).sum(dim=1).long()
prompt_embeds = self.text_encoder(ids.to(device), mask.to(device)).last_hidden_state
prompt_embeds = prompt_embeds.to(dtype=dtype, device=device)
prompt_embeds = [u[:v] for u, v in zip(prompt_embeds, seq_lens)]
prompt_embeds = torch.stack(
[torch.cat([u, u.new_zeros(max_sequence_length - u.size(0), u.size(1))]) for u in prompt_embeds], dim=0
)
_, seq_len, _ = prompt_embeds.shape
prompt_embeds = prompt_embeds.repeat(1, num_videos_per_prompt, 1)
prompt_embeds = prompt_embeds.view(batch_size * num_videos_per_prompt, seq_len, -1)
negative_prompt_embeds = None
if do_classifier_free_guidance:
negative_prompt = negative_prompt or ""
negative_prompt = batch_size * [negative_prompt] if isinstance(negative_prompt, str) else negative_prompt
neg_text_inputs = self.tokenizer(
[self._prompt_clean(p) for p in negative_prompt],
padding="max_length",
max_length=max_sequence_length,
truncation=True,
add_special_tokens=True,
return_attention_mask=True,
return_tensors="pt",
)
ids_neg, mask_neg = neg_text_inputs.input_ids, neg_text_inputs.attention_mask
seq_lens_neg = mask_neg.gt(0).sum(dim=1).long()
negative_prompt_embeds = self.text_encoder(ids_neg.to(device), mask_neg.to(device)).last_hidden_state
negative_prompt_embeds = negative_prompt_embeds.to(dtype=dtype, device=device)
negative_prompt_embeds = [u[:v] for u, v in zip(negative_prompt_embeds, seq_lens_neg)]
negative_prompt_embeds = torch.stack(
[
torch.cat([u, u.new_zeros(max_sequence_length - u.size(0), u.size(1))])
for u in negative_prompt_embeds
],
dim=0,
)
negative_prompt_embeds = negative_prompt_embeds.repeat(1, num_videos_per_prompt, 1)
negative_prompt_embeds = negative_prompt_embeds.view(batch_size * num_videos_per_prompt, seq_len, -1)
return prompt_embeds, negative_prompt_embeds
@staticmethod
def _prompt_clean(text: str) -> str:
return " ".join(text.strip().split())
def prepare_latents(
self,
batch_size: int,
num_channels_latents: int,
height: int,
width: int,
num_frames: int,
dtype: torch.dtype | None,
device: torch.device | None,
generator: torch.Generator | list[torch.Generator] | None,
latents: torch.Tensor | None = None,
) -> torch.Tensor:
"""Prepare random latents for T2V mode."""
if latents is not None:
return latents.to(device=device, dtype=dtype)
num_latent_frames = (num_frames - 1) // self.vae_scale_factor_temporal + 1
shape = (
batch_size,
num_channels_latents,
num_latent_frames,
int(height) // self.vae_scale_factor_spatial,
int(width) // self.vae_scale_factor_spatial,
)
if isinstance(generator, list) and len(generator) != batch_size:
raise ValueError(f"Generator list length {len(generator)} does not match batch size {batch_size}.")
latents = randn_tensor(shape, generator=generator, device=device, dtype=dtype)
return latents
def prepare_i2v_latents(
self,
image: torch.Tensor,
batch_size: int,
num_channels_latents: int,
height: int,
width: int,
num_frames: int,
dtype: torch.dtype | None,
device: torch.device | None,
generator: torch.Generator | list[torch.Generator] | None,
latents: torch.Tensor | None = None,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""
Prepare latents for I2V mode with image conditioning.
Returns:
latents: Initial noise latents
latent_condition: Encoded first frame condition
first_frame_mask: Mask (0 for first frame, 1 for rest)
"""
num_latent_frames = (num_frames - 1) // self.vae_scale_factor_temporal + 1
latent_height = height // self.vae_scale_factor_spatial
latent_width = width // self.vae_scale_factor_spatial
shape = (batch_size, num_channels_latents, num_latent_frames, latent_height, latent_width)
if latents is None:
latents = randn_tensor(shape, generator=generator, device=device, dtype=dtype)
else:
latents = latents.to(device=device, dtype=dtype)
# Prepare first frame condition
image = image.unsqueeze(2) # [batch, channels, 1, height, width]
image = image.to(device=device, dtype=self.vae.dtype)
# Encode through VAE
latent_condition = retrieve_latents(self.vae.encode(image), sample_mode="argmax")
latent_condition = latent_condition.repeat(batch_size, 1, 1, 1, 1)
# Normalize latents
latents_mean = (
torch.tensor(self.vae.config.latents_mean)
.view(1, self.vae.config.z_dim, 1, 1, 1)
.to(latent_condition.device, latent_condition.dtype)
)
latents_std = 1.0 / torch.tensor(self.vae.config.latents_std).view(1, self.vae.config.z_dim, 1, 1, 1).to(
latent_condition.device, latent_condition.dtype
)
latent_condition = (latent_condition - latents_mean) * latents_std
latent_condition = latent_condition.to(dtype)
# Create mask: 0 for first frame (condition), 1 for rest (to denoise)
first_frame_mask = torch.ones(1, 1, num_latent_frames, latent_height, latent_width, dtype=dtype, device=device)
first_frame_mask[:, :, 0] = 0
return latents, latent_condition, first_frame_mask
def check_inputs(
self,
prompt,
negative_prompt,
image,
height,
width,
prompt_embeds=None,
negative_prompt_embeds=None,
):
if height % 16 != 0 or width % 16 != 0:
raise ValueError(f"`height` and `width` have to be divisible by 16 but are {height} and {width}.")
if prompt is not None and prompt_embeds is not None:
raise ValueError("Cannot forward both `prompt` and `prompt_embeds`. Please provide only one.")
if negative_prompt is not None and negative_prompt_embeds is not None:
raise ValueError(
"Cannot forward both `negative_prompt` and `negative_prompt_embeds`. Please provide only one."
)
if prompt is None and prompt_embeds is None:
raise ValueError("Provide either `prompt` or `prompt_embeds`.")
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
"""Load weights using AutoWeightsLoader for vLLM integration."""
loader = AutoWeightsLoader(self)
return loader.load_weights(weights)
@@ -45,6 +45,7 @@ logger = init_logger(__name__)
def create_vace_transformer_from_config(
config: dict,
quant_config: QuantizationConfig | None = None,
prefix: str = "",
) -> WanVACETransformer3DModel:
"""Create WanVACETransformer3DModel from config dict."""
kwargs = {}
@@ -84,6 +85,8 @@ def create_vace_transformer_from_config(
kwargs["vace_in_channels"] = config["vace_in_channels"]
if quant_config is not None:
kwargs["quant_config"] = quant_config
if prefix:
kwargs["prefix"] = prefix
return WanVACETransformer3DModel(**kwargs)
@@ -21,9 +21,21 @@ from vllm.model_executor.layers.conv import Conv3dLayer
from vllm.model_executor.layers.linear import ColumnParallelLinear, QKVParallelLinear, RowParallelLinear
from vllm.model_executor.layers.quantization.base_config import QuantizationConfig
from vllm.model_executor.model_loader.weight_utils import default_weight_loader
from vllm.model_executor.models.utils import (
PPMissingLayer,
is_pp_missing_parameter,
make_empty_intermediate_tensors_factory,
make_layers,
)
from vllm.sequence import IntermediateTensors
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata
from vllm_omni.diffusion.attention.layer import Attention
from vllm_omni.diffusion.distributed.parallel_state import (
get_pipeline_parallel_world_size,
is_pipeline_first_stage,
is_pipeline_last_stage,
)
from vllm_omni.diffusion.distributed.sp_plan import (
SequenceParallelInput,
SequenceParallelOutput,
@@ -871,14 +883,23 @@ class WanTransformer3DModel(nn.Module):
inner_dim = num_attention_heads * attention_head_dim
out_channels = out_channels or in_channels
if get_pipeline_parallel_world_size() == 0 and not is_pipeline_first_stage():
raise RuntimeError(
"`initialize_model_parallel()` must be called before constructing `WanTransformer3DModel`"
)
# 1. Patch & position embedding
self.rope = WanRotaryPosEmbed(attention_head_dim, patch_size, rope_max_seq_len)
self.patch_embedding = Conv3dLayer(
in_channels=in_channels,
out_channels=inner_dim,
kernel_size=patch_size,
stride=patch_size,
)
# Patch embedding only on first PP stage; other stages receive hidden_states via P2P
if is_pipeline_first_stage():
self.patch_embedding = Conv3dLayer(
in_channels=in_channels,
out_channels=inner_dim,
kernel_size=patch_size,
stride=patch_size,
)
else:
self.patch_embedding = PPMissingLayer()
# 2. Condition embeddings
self.condition_embedder = WanTimeTextImageEmbedding(
@@ -890,30 +911,39 @@ class WanTransformer3DModel(nn.Module):
pos_embed_seq_len=pos_embed_seq_len,
)
# 3. Transformer blocks
self.blocks = nn.ModuleList(
[
WanTransformerBlock(
inner_dim,
ffn_dim,
num_attention_heads,
eps,
added_kv_proj_dim,
cross_attn_norm,
quant_config=quant_config,
prefix=f"blocks.{layer_idx}",
)
for layer_idx in range(num_layers)
]
# 3. Transformer blocks — partitioned across PP stages via vLLM's `make_layers`.
# It computes the [start_layer, end_layer) slice for this rank and fills the remaining slots
# with PPMissingLayer so that weight names stay globally consistent.
self.start_layer, self.end_layer, self.blocks = make_layers(
num_layers,
lambda prefix: WanTransformerBlock(
inner_dim,
ffn_dim,
num_attention_heads,
eps,
added_kv_proj_dim,
cross_attn_norm,
quant_config=quant_config,
prefix=prefix,
),
prefix="blocks",
)
# 4. Output norm & projection
self.norm_out = AdaLayerNorm(inner_dim, elementwise_affine=False, eps=eps)
self.proj_out = nn.Linear(inner_dim, out_channels * math.prod(patch_size))
# 4. Output norm & projection — only on the last PP stage
if is_pipeline_last_stage():
self.norm_out = AdaLayerNorm(inner_dim, elementwise_affine=False, eps=eps)
self.proj_out = nn.Linear(inner_dim, out_channels * math.prod(patch_size))
else:
self.norm_out, self.proj_out = PPMissingLayer(), PPMissingLayer()
# SP helper modules
self.timestep_proj_prepare = TimestepProjPrepare()
self.output_scale_shift_prepare = OutputScaleShiftPrepare(inner_dim)
if is_pipeline_last_stage():
self.output_scale_shift_prepare = OutputScaleShiftPrepare(inner_dim)
else:
self.output_scale_shift_prepare = PPMissingLayer()
self.make_empty_intermediate_tensors = make_empty_intermediate_tensors_factory(["hidden_states"], inner_dim)
# ROPE helper
self._cached_rope_emb = None
@@ -930,9 +960,10 @@ class WanTransformer3DModel(nn.Module):
timestep: torch.LongTensor,
encoder_hidden_states: torch.Tensor,
encoder_hidden_states_image: torch.Tensor | None = None,
intermediate_tensors: IntermediateTensors | None = None,
return_dict: bool = True,
attention_kwargs: dict[str, Any] | None = None,
) -> torch.Tensor | Transformer2DModelOutput:
) -> torch.Tensor | Transformer2DModelOutput | IntermediateTensors:
batch_size, num_channels, num_frames, height, width = hidden_states.shape
p_t, p_h, p_w = self.config.patch_size
post_patch_num_frames = num_frames // p_t
@@ -949,18 +980,25 @@ class WanTransformer3DModel(nn.Module):
self._hidden_states_shape = hidden_states.shape
self._cached_rope_emb = rotary_emb
# Patch embedding and flatten to sequence
# (hidden_states is sharded at blocks.0 input by _sp_plan)
hidden_states = self.patch_embedding(hidden_states)
hidden_states = hidden_states.flatten(2).transpose(1, 2)
if is_pipeline_first_stage():
# Patch embedding and flatten to sequence
# (hidden_states is sharded at blocks.0 input by _sp_plan)
hidden_states = self.patch_embedding(hidden_states)
hidden_states = hidden_states.flatten(2).transpose(1, 2)
else:
if intermediate_tensors is None:
raise RuntimeError("intermediate_tensors must be provided for non-first PP stages")
hidden_states = intermediate_tensors["hidden_states"]
# Handle timestep shape
# Handle timestep shape (2-D for TI2V, 1-D for T2V)
if timestep.ndim == 2:
ts_seq_len = timestep.shape[1]
timestep = timestep.flatten()
else:
ts_seq_len = None
# Compute conditioning on all PP stages.
# Each stage needs temb/timestep_proj for scale-shift modulation in its local blocks.
temb, timestep_proj, encoder_hidden_states, encoder_hidden_states_image = self.condition_embedder(
timestep, encoder_hidden_states, encoder_hidden_states_image, timestep_seq_len=ts_seq_len
)
@@ -996,14 +1034,13 @@ class WanTransformer3DModel(nn.Module):
hidden_states_mask = None
# Transformer blocks
for block in self.blocks:
hidden_states = block(
hidden_states,
encoder_hidden_states,
timestep_proj,
rotary_emb,
hidden_states_mask,
)
for block in self.blocks[self.start_layer : self.end_layer]:
hidden_states = block(hidden_states, encoder_hidden_states, timestep_proj, rotary_emb, hidden_states_mask)
if not is_pipeline_last_stage():
# Non-last PP stage: hand the token sequence to the caller via IntermediateTensors.
# predict_noise will broadcast it to the next stage before calling that stage's forward.
return IntermediateTensors({"hidden_states": hidden_states})
# Output norm, projection & unpatchify
shift, scale = self.output_scale_shift_prepare(temb)
@@ -1074,7 +1111,8 @@ class WanTransformer3DModel(nn.Module):
if weight_name not in original_name:
continue
lookup_name = original_name.replace(weight_name, param_name)
if lookup_name not in params_dict:
# Skip weights that belong to PP stages other than this one
if is_pp_missing_parameter(lookup_name, self) or lookup_name not in params_dict:
break
param = params_dict[lookup_name]
weight_loader = param.weight_loader
@@ -1098,6 +1136,10 @@ class WanTransformer3DModel(nn.Module):
if modulation_alias in params_dict:
lookup_name = modulation_alias
# Skip weights that belong to PP stages other than this one
if is_pp_missing_parameter(lookup_name, self):
continue
if lookup_name not in params_dict:
logger.warning(f"Skipping weight {original_name} -> {lookup_name}")
continue