[Feat] Support layerwise CPU offloading for more videogen models (#2018)

Signed-off-by: Yuanheng Zhao <jonathan.zhaoyh@gmail.com>
Signed-off-by: yuanheng <jonathan.zhaoyh@gmail.com>
Co-authored-by: Didan Deng <33117903+wtomin@users.noreply.github.com>
This commit is contained in:
Yuanheng ZhaoandDidan Deng authored and GitHub committed 2026-04-20 00:43:53 +08:00
1 parent 93beef17aa
commit 68f28f9631
5 files changed
+192 -110

No files matched your search

+2 -2
View File
@@ -140,10 +140,10 @@ The following tables show which models support each feature:
|-------|:----------:|:-----------:|:---------------------:|:--------------:|:-----------------:|:------:|:------------------------:|:--------------------:|:--------------:|:----------------:|
| **Wan2.2** | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (encode/decode) | ❌ | ❌ |
| **Wan2.1-VACE** | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
| **LTX-2** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **LTX-2** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
| **Helios** | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| **HunyuanVideo-1.5 T2V I2V** | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ (decode) | ✅ | ❌ |
| **DreamID-Omni** | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **DreamID-Omni** | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
**Frame Interpolation Support**
@@ -58,6 +58,11 @@ def parse_args() -> argparse.Namespace:
default=False,
help="Enable CPU offloading for diffusion models.",
)
parser.add_argument(
"--enable-layerwise-offload",
action="store_true",
help="Enable layerwise (blockwise) offloading on DiT modules.",
)
return parser.parse_args()
@@ -126,6 +131,7 @@ def main() -> None:
parallel_config=parallel_config,
model_type=args.model_type,
enable_cpu_offload=args.enable_cpu_offload,
enable_layerwise_offload=args.enable_layerwise_offload,
)
start = time.perf_counter()
outputs = omni.generate(prompt, sampling_params)
+160 -97
View File
@@ -1,3 +1,5 @@
import re
import torch
import torch.nn as nn
from vllm.logger import init_logger
@@ -15,78 +17,26 @@ from vllm_omni.diffusion.models.dreamid_omni.wan2_2 import WanModel, rope_apply
logger = init_logger(__name__)
class FusionModel(nn.Module):
def __init__(self, video_config=None, audio_config=None):
super().__init__()
has_video = True
has_audio = True
if video_config is not None:
self.video_model = WanModel(**video_config)
else:
has_video = False
self.video_model = None
logger.warning("No video model is provided!")
class FusedBlock(nn.Module):
"""Wrapper pairing a video block and audio block for layerwise offloading.
if audio_config is not None:
self.audio_model = WanModel(**audio_config)
else:
has_audio = False
self.audio_model = None
logger.warning("No audio model is provided!")
Registers both blocks as submodules so their parameters are visible to the offload hooks.
"""
if has_video and has_audio:
assert len(self.video_model.blocks) == len(self.audio_model.blocks)
self.num_blocks = len(self.video_model.blocks)
self.inject_cross_attention_kv_projections()
self.device = get_local_device()
self.num_heads = self.video_model.num_heads
self.head_dim = self.video_model.dim // self.video_model.num_heads
self.attn = Attention(
num_heads=self.num_heads,
head_size=self.head_dim,
num_kv_heads=self.num_heads,
softmax_scale=1.0 / (self.head_dim**0.5),
causal=False,
)
def inject_cross_attention_kv_projections(self):
for vid_block in self.video_model.blocks:
vid_block.cross_attn.k_fusion = nn.Linear(vid_block.dim, vid_block.dim)
vid_block.cross_attn.v_fusion = nn.Linear(vid_block.dim, vid_block.dim)
vid_block.cross_attn.pre_attn_norm_fusion = WanLayerNorm(vid_block.dim, elementwise_affine=True)
vid_block.cross_attn.norm_k_fusion = (
WanRMSNorm(vid_block.dim, eps=1e-6) if vid_block.qk_norm else nn.Identity()
)
for audio_block in self.audio_model.blocks:
audio_block.cross_attn.k_fusion = nn.Linear(audio_block.dim, audio_block.dim)
audio_block.cross_attn.v_fusion = nn.Linear(audio_block.dim, audio_block.dim)
audio_block.cross_attn.pre_attn_norm_fusion = WanLayerNorm(audio_block.dim, elementwise_affine=True)
audio_block.cross_attn.norm_k_fusion = (
WanRMSNorm(audio_block.dim, eps=1e-6) if audio_block.qk_norm else nn.Identity()
)
def merge_kwargs(self, vid_kwargs, audio_kwargs):
"""
keys in each kwarg:
e
seq_lens
grid_sizes
freqs
context
context_lens
"""
merged_kwargs = {}
for key in vid_kwargs:
merged_kwargs[f"vid_{key}"] = vid_kwargs[key]
for key in audio_kwargs:
merged_kwargs[f"audio_{key}"] = audio_kwargs[key]
return merged_kwargs
def single_fusion_cross_attention_forward(
def __init__(
self,
vid_block: nn.Module,
audio_block: nn.Module,
device: torch.device,
):
super().__init__()
self.vid_block = vid_block
self.audio_block = audio_block
self.device = device
def _cross_attention_forward(
self,
attn: Attention,
cross_attn_block,
src_seq,
src_grid_sizes,
@@ -104,21 +54,17 @@ class FusionModel(nn.Module):
):
b, n, d = src_seq.size(0), cross_attn_block.num_heads, cross_attn_block.head_dim
if hasattr(cross_attn_block, "k_img"):
## means is i2v block
q, k, v, k_img, v_img = cross_attn_block.qkv_fn(src_seq, context)
else:
## means is t2v block
q, k, v = cross_attn_block.qkv_fn(src_seq, context)
k_img = v_img = None
x = self.attn(q, k, v)
x = attn(q, k, v)
if k_img is not None:
img_x = self.attn(q, k_img, v_img)
img_x = attn(q, k_img, v_img)
x = x + img_x
# is_vid = src_grid_sizes.shape[1] > 1
# compute target attention
target_seq = cross_attn_block.pre_attn_norm_fusion(target_seq)
k_target = cross_attn_block.norm_k_fusion(cross_attn_block.k_fusion(target_seq)).view(b, -1, n, d)
v_target = cross_attn_block.v_fusion(target_seq).view(b, -1, n, d)
@@ -132,17 +78,16 @@ class FusionModel(nn.Module):
freqs_scaling=target_freqs_scaling,
)
target_x = self.attn(q, k_target, v_target)
target_x = attn(q, k_target, v_target)
x = x + target_x
x = x.flatten(2) # [B, L/P, C]
x = x.flatten(2)
x = cross_attn_block.o(x)
return x
def single_fusion_cross_attention_ffn_forward(
def _cross_attention_ffn_forward(
self,
attn: Attention,
attn_block,
src_seq,
src_grid_sizes,
@@ -159,7 +104,8 @@ class FusionModel(nn.Module):
target_ref_lengths=None,
target_freqs_scaling=None,
):
src_seq = src_seq + self.single_fusion_cross_attention_forward(
src_seq = src_seq + self._cross_attention_forward(
attn,
attn_block.cross_attn,
attn_block.norm3(src_seq),
src_grid_sizes=src_grid_sizes,
@@ -180,12 +126,11 @@ class FusionModel(nn.Module):
src_seq = src_seq + y * src_e[5].squeeze(2)
return src_seq
def single_fusion_block_forward(
def forward(
self,
vid_block,
audio_block,
vid,
audio,
attn: Attention,
vid_e,
vid_seq_lens,
vid_grid_sizes,
@@ -203,6 +148,9 @@ class FusionModel(nn.Module):
audio_ref_lengths,
audio_freqs_scaling,
):
vid_block = self.vid_block
audio_block = self.audio_block
## audio modulation
assert audio_e.dtype == torch.bfloat16
assert len(audio_e.shape) == 4 and audio_e.size(2) == 6 and audio_e.shape[1] == audio.shape[1], (
@@ -246,7 +194,8 @@ class FusionModel(nn.Module):
og_audio = audio
# audio cross-attention
audio = self.single_fusion_cross_attention_ffn_forward(
audio = self._cross_attention_ffn_forward(
attn,
audio_block,
audio,
audio_grid_sizes,
@@ -267,7 +216,8 @@ class FusionModel(nn.Module):
assert not torch.equal(og_audio, audio), "Audio should be changed after cross-attention!"
# video cross-attention
vid = self.single_fusion_cross_attention_ffn_forward(
vid = self._cross_attention_ffn_forward(
attn,
vid_block,
vid,
vid_grid_sizes,
@@ -287,6 +237,128 @@ class FusionModel(nn.Module):
return vid, audio
class FusionModel(nn.Module):
_layerwise_offload_blocks_attrs = ["fused_blocks"]
def __init__(self, video_config=None, audio_config=None):
super().__init__()
has_video = True
has_audio = True
self.device = get_local_device()
if video_config is not None:
self.video_model = WanModel(**video_config)
else:
has_video = False
self.video_model = None
logger.warning("No video model is provided!")
if audio_config is not None:
self.audio_model = WanModel(**audio_config)
else:
has_audio = False
self.audio_model = None
logger.warning("No audio model is provided!")
if has_video and has_audio:
assert len(self.video_model.blocks) == len(self.audio_model.blocks)
self.num_blocks = len(self.video_model.blocks)
self.inject_cross_attention_kv_projections()
self.num_heads = self.video_model.num_heads
self.head_dim = self.video_model.dim // self.video_model.num_heads
# Make a single shared instance to pass in at forward time
self.attn = Attention(
num_heads=self.num_heads,
head_size=self.head_dim,
num_kv_heads=self.num_heads,
softmax_scale=1.0 / (self.head_dim**0.5),
causal=False,
)
if has_video and has_audio:
self.fused_blocks = nn.ModuleList(
[
FusedBlock(
self.video_model.blocks[i],
self.audio_model.blocks[i],
self.device,
)
for i in range(self.num_blocks)
]
)
def load_state_dict(self, state_dict, strict=True, assign=False):
"""Remap checkpoints where blocks are stored under
`video_model.blocks.N.*` / `audio_model.blocks.N.*` to the current
`fused_blocks.N.vid_block.*` / `fused_blocks.N.audio_block.*`.
"""
needs_remap = any(re.match(r"^(video_model|audio_model)\.blocks\.\d+\.", k) for k in state_dict)
if needs_remap:
remapped = {}
for k, v in state_dict.items():
new_k = re.sub(r"^video_model\.blocks\.(\d+)\.", r"fused_blocks.\1.vid_block.", k)
new_k = re.sub(r"^audio_model\.blocks\.(\d+)\.", r"fused_blocks.\1.audio_block.", new_k)
remapped[new_k] = v
state_dict = remapped
self._detach_blocks_from_backbones()
return super().load_state_dict(state_dict, strict=strict, assign=assign)
def inject_cross_attention_kv_projections(self):
for vid_block in self.video_model.blocks:
vid_block.cross_attn.k_fusion = nn.Linear(vid_block.dim, vid_block.dim)
vid_block.cross_attn.v_fusion = nn.Linear(vid_block.dim, vid_block.dim)
vid_block.cross_attn.pre_attn_norm_fusion = WanLayerNorm(vid_block.dim, elementwise_affine=True)
vid_block.cross_attn.norm_k_fusion = (
WanRMSNorm(vid_block.dim, eps=1e-6) if vid_block.qk_norm else nn.Identity()
)
for audio_block in self.audio_model.blocks:
audio_block.cross_attn.k_fusion = nn.Linear(audio_block.dim, audio_block.dim)
audio_block.cross_attn.v_fusion = nn.Linear(audio_block.dim, audio_block.dim)
audio_block.cross_attn.pre_attn_norm_fusion = WanLayerNorm(audio_block.dim, elementwise_affine=True)
audio_block.cross_attn.norm_k_fusion = (
WanRMSNorm(audio_block.dim, eps=1e-6) if audio_block.qk_norm else nn.Identity()
)
def _detach_blocks_from_backbones(self) -> None:
"""Keep offloadable blocks owned only by a single place.
NOTE: This is a special workaround to support layerwise offloading.
The model registers the same Wan blocks under both the video/audio
backbones and `fused_blocks` which is a wrapper for unified blocks
walking through. However, layerwise offloading will only consider
`fused_blocks` as offloadable components and will materialize all
other modules onto device, including the same blocks owned by both
`fused_blocks` and `video_model` and `audio_model`.
"""
video_blocks = list(self.video_model.blocks)
audio_blocks = list(self.audio_model.blocks)
self.video_model._modules.pop("blocks", None)
self.audio_model._modules.pop("blocks", None)
self.video_model.blocks = tuple(video_blocks)
self.audio_model.blocks = tuple(audio_blocks)
def merge_kwargs(self, vid_kwargs, audio_kwargs):
"""
keys in each kwarg:
e
seq_lens
grid_sizes
freqs
context
context_lens
"""
merged_kwargs = {}
for key in vid_kwargs:
merged_kwargs[f"vid_{key}"] = vid_kwargs[key]
for key in audio_kwargs:
merged_kwargs[f"audio_{key}"] = audio_kwargs[key]
return merged_kwargs
def forward(
self,
vid,
@@ -316,17 +388,8 @@ class FusionModel(nn.Module):
kwargs = self.merge_kwargs(vid_kwargs, audio_kwargs)
for i in range(self.num_blocks):
"""
1 fusion block refers to 1 audio block with 1 video block.
"""
vid_block = self.video_model.blocks[i]
audio_block = self.audio_model.blocks[i]
vid, audio = self.single_fusion_block_forward(
vid_block=vid_block, audio_block=audio_block, vid=vid, audio=audio, **kwargs
)
for fused_block in self.fused_blocks:
vid, audio = fused_block(vid, audio, self.attn, **kwargs)
vid = self.video_model.post_transformer_block_out(vid, vid_kwargs["grid_sizes"], vid_e)
audio = self.audio_model.post_transformer_block_out(audio, audio_kwargs["grid_sizes"], audio_e)
@@ -4,6 +4,7 @@
import logging
import math
import os
from collections.abc import Iterable
import torch
import torch.distributed
@@ -16,6 +17,7 @@ from tqdm import tqdm
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.distributed.cfg_parallel import CFGParallelMixin
from vllm_omni.diffusion.distributed.utils import get_local_device
from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader
from vllm_omni.diffusion.models.interface import SupportAudioInput, SupportImageInput
from vllm_omni.diffusion.request import OmniDiffusionRequest
@@ -27,7 +29,6 @@ try:
init_mmaudio_vae,
init_text_model,
init_wan_vae_2_2,
load_fusion_checkpoint,
)
from dreamid_omni.utils.rearrange import Rearrange
from dreamid_omni.utils.resize import NaResize
@@ -122,16 +123,24 @@ class DreamIDOmniPipeline(nn.Module, CFGParallelMixin, SupportImageInput, Suppor
self.text_model = init_text_model(model, rank=self.device)
self.text_encoder = self.text_model.model
# Fusion model
## load audio/video model config
Fusion_model = FusionModel(VIDEO_CONFIG, AUDIO_CONFIG)
checkpoint_path = self.od_config.tf_model_config.get("fusion", None)
assert checkpoint_path is not None, "fusion checkpoint path is None"
load_fusion_checkpoint(Fusion_model, checkpoint_path=os.path.join(model, checkpoint_path))
self.model = Fusion_model
# Fusion model — weights are loaded later via load_weights()
self.model = FusionModel(VIDEO_CONFIG, AUDIO_CONFIG)
self.transformer = self.model
fusion_path = self.od_config.tf_model_config.get("fusion", None)
assert fusion_path is not None, "fusion checkpoint path is None in transformer config"
fusion_subfolder = os.path.dirname(fusion_path) or None
fusion_filename = os.path.basename(fusion_path)
self.weights_sources = [
DiffusersPipelineLoader.ComponentSource(
model_or_path=model,
subfolder=fusion_subfolder,
revision=None,
prefix="model.",
allow_patterns_overrides=[fusion_filename],
)
]
# Fixed attributes, non-configurable
self.audio_latent_channel = AUDIO_CONFIG.get("in_dim")
self.video_latent_channel = VIDEO_CONFIG.get("in_dim")
@@ -226,8 +235,11 @@ class DreamIDOmniPipeline(nn.Module, CFGParallelMixin, SupportImageInput, Suppor
return ref_vae_latents, ref_audio_lengths
def load_weights(self, weights):
pass
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
prefix = "model."
state_dict = {name[len(prefix) :]: tensor for name, tensor in weights if name.startswith(prefix)}
self.model.load_state_dict(state_dict, strict=True)
return {prefix + k for k in state_dict}
def get_scheduler_time_steps(self, sampling_steps, solver_name="unipc", device=0, shift=5.0):
torch.manual_seed(4)
@@ -1264,6 +1264,7 @@ class LTX2VideoTransformer3DModel(nn.Module):
_supports_gradient_checkpointing = True
_skip_layerwise_casting_patterns = ["norm"]
_repeated_blocks = ["LTX2VideoTransformerBlock"]
_layerwise_offload_blocks_attrs = ["transformer_blocks"]
_sp_plan: dict[str, Any] | None = None
@staticmethod