Skip to content

Commit 61a4c44

Browse files
wan: add dual-GPU model-parallel path for Wan 2.x LoRA training
Off by default -- no behavior change unless WAN_DUAL_GPU=true is set. Wan 2.2 14B variants (I2V-A14B, T2V-A14B, S2V-14B, etc.) are ~28 GB in bf16 -- the weights fit on one 32 GB consumer card with fp8 quant, but video training activations at 480x832x49 frames + gradient checkpointing routinely push the actual step over 32 GB even on a 14B model. Splitting the transformer blocks across two GPUs gives training-step headroom that single-GPU users can't otherwise reach without dropping resolution or frame count. What changed: - examples/wanvideo/model_training/wan_dual_gpu_diffsynth.py (new): ~150 LOC helper. Splits WanModel.blocks at the midpoint across cuda:0/cuda:1. Registers forward_pre_hook on every cuda:1 block (not just the boundary -- Wan's forward passes loop-level constants context / t_mod / freqs positionally to each iteration, so a boundary-only hook would leave subsequent blocks receiving cuda:0 tensors). Bridges activations back to cuda:0 at the head module. Also explicitly moves WanModel.freqs (a tuple of plain CPU tensors, not registered buffers) so .to(device) doesn't miss them. - examples/wanvideo/model_training/train.py: forces CPU model load when WAN_DUAL_GPU=true (so the bf16 transformer doesn't pre-allocate on cuda:0 before split), runs torchao Float8WeightOnlyConfig quantize_ with the same LoRA-skip filter used by the FLUX.2 port (skips lora_A/lora_B Linear submodules -- otherwise their requires_grad is stripped and backward fails), then calls enable_wan_dual_gpu(model.pipe.dit) after PEFT has injected LoRA so block.to(device) carries LoRA params with their base layers. Also sets FLUX2_DUAL_GPU=true after distribute so the existing runner.py branch from PR #1434 catches the device_placement=[False, ...] case in accelerator.prepare without needing a parallel WAN_DUAL_GPU branch there. Depends on #1435 (patchify fix). The current main has a broken WanModel.patchify that returns the wrong shape and arity; Wan training fails immediately at the first forward call regardless of dual-GPU. Once #1435 lands, both single-GPU and dual-GPU Wan training paths work. Validated locally on 2x RTX 5090 with a synthetic 8-layer WanModel (same architecture shape as real Wan 2.2, miniaturized to fit a quick smoke test): forward + backward complete across the cross- device split, output round-trips to the original (B, C, T, H, W) shape, LoRA gradients land on both cuda:0 and cuda:1 (proving cross- device autograd). Same patch shape as the validated FLUX.2 port in PR #1434 from this account. Both share the runner.py model-parallel branch.
1 parent bf5faf6 commit 61a4c44

2 files changed

Lines changed: 214 additions & 1 deletion

File tree

examples/wanvideo/model_training/train.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from diffsynth.core.data.operators import LoadVideo, LoadAudio, ImageCropAndResize, ToAbsolutePath
44
from diffsynth.pipelines.wan_video import WanVideoPipeline, ModelConfig
55
from diffsynth.diffusion import *
6+
from wan_dual_gpu_diffsynth import enable_wan_dual_gpu, is_dual_gpu_enabled
67
os.environ["TOKENIZERS_PARALLELISM"] = "false"
78

89

@@ -171,10 +172,50 @@ def wan_parser():
171172
fp8_models=args.fp8_models,
172173
offload_models=args.offload_models,
173174
task=args.task,
174-
device="cpu" if args.initialize_model_on_cpu else accelerator.device,
175+
# Dual-GPU: force CPU load so the 14B bf16 transformer doesn't
176+
# pre-allocate on cuda:0 before we get a chance to split it.
177+
device="cpu" if (args.initialize_model_on_cpu or is_dual_gpu_enabled()) else accelerator.device,
175178
max_timestep_boundary=args.max_timestep_boundary,
176179
min_timestep_boundary=args.min_timestep_boundary,
177180
)
181+
182+
# Dual-GPU split: gated on WAN_DUAL_GPU env var. Distributes pipe.dit
183+
# across cuda:0 and cuda:1 at the blocks midpoint. Called AFTER LoRA
184+
# injection (which happened inside WanTrainingModule.__init__) so PEFT
185+
# LoRA params follow the base layer's device automatically when
186+
# block.to() runs.
187+
if is_dual_gpu_enabled():
188+
for _i in range(torch.cuda.device_count()):
189+
_free, _total = torch.cuda.mem_get_info(_i)
190+
print(f"[wan-dual-gpu] pre-distribute cuda:{_i} free={_free/1024**3:.1f}G / {_total/1024**3:.1f}G")
191+
# fp8 weight-only quant on CPU BEFORE distribute. Wan 2.2 14B is
192+
# ~28 GB bf16 -- fits one 32 GB card, but with video activations
193+
# at 480x832x49 frames plus gradient checkpointing the dual split
194+
# gives breathing room. Filter excludes LoRA's lora_A/lora_B
195+
# submodules -- quantizing them strips requires_grad and breaks
196+
# backward (see also the FLUX.2 helper for the same pattern).
197+
import gc as _gc
198+
from torchao.quantization import quantize_, Float8WeightOnlyConfig
199+
def _quant_filter(module, name):
200+
if not isinstance(module, torch.nn.Linear):
201+
return False
202+
return "lora_A" not in name and "lora_B" not in name
203+
quantize_(model.pipe.dit, Float8WeightOnlyConfig(), filter_fn=_quant_filter)
204+
_gc.collect()
205+
print("[wan-dual-gpu] fp8 weight-only quant done (LoRA params unmodified)")
206+
enable_wan_dual_gpu(model.pipe.dit)
207+
# Pin pipe.device to cuda:0 for input transfer (forward() calls
208+
# transfer_data_to_device(inputs, pipe.device, ...)). Scaffold
209+
# (patch_embedding, text_embedding) lives on cuda:0.
210+
model.pipe.device = torch.device("cuda:0")
211+
# Signal launch_training_task to skip its own model.to() move
212+
# (runner.py keys off FLUX2_DUAL_GPU for that branch; reuse to
213+
# avoid duplicating the device-placement logic).
214+
os.environ["FLUX2_DUAL_GPU"] = "true"
215+
for _i in range(torch.cuda.device_count()):
216+
_free, _total = torch.cuda.mem_get_info(_i)
217+
print(f"[wan-dual-gpu] post-distribute cuda:{_i} free={_free/1024**3:.1f}G / {_total/1024**3:.1f}G")
218+
178219
model_logger = ModelLogger(
179220
args.output_path,
180221
remove_prefix_in_ckpt=args.remove_prefix_in_ckpt,
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"""Dual-GPU model-parallel for Wan video DiT in DiffSynth-Studio.
2+
3+
Drop-in helper for DiffSynth-Studio
4+
(https://github.com/modelscope/DiffSynth-Studio) ``examples/wanvideo/model_training/``
5+
that splits the ``WanModel`` transformer across two CUDA devices at the
6+
``blocks`` midpoint. Enables Wan 2.1 / 2.2 LoRA training on pairs of
7+
24+ GB consumer GPUs (2× RTX 3090, 2× RTX 4090, 2× RTX 5090) — useful
8+
for the 14B variants where the fp8-quantized weights fit on a single
9+
32 GB card but video activations push training OOM at 480×832×49
10+
frames + gradient checkpointing.
11+
12+
Companion to the FLUX.2 dual-GPU helper at
13+
``examples/flux2/model_training/flux2_dual_gpu_diffsynth.py``. The
14+
shape is similar but simpler — Wan has one block type
15+
(``DiTBlock``) instead of FLUX.2's double + single stream split, so
16+
the helper only registers per-block hooks across one boundary.
17+
18+
Env vars:
19+
WAN_DUAL_GPU=true enable dual-GPU path
20+
WAN_DUAL_GPU_SPLIT_AT=15 override split index
21+
(default: num_blocks // 2)
22+
23+
Usage in DiffSynth-Studio's training script
24+
(``examples/wanvideo/model_training/train.py``)::
25+
26+
from wan_dual_gpu_diffsynth import enable_wan_dual_gpu
27+
28+
# ...build training module normally...
29+
training_module = WanTrainingModule(...)
30+
31+
# Activate the split (env-gated; no-op when WAN_DUAL_GPU is unset).
32+
# Call AFTER LoRA injection (switch_pipe_to_training_mode) so PEFT
33+
# has wrapped target modules. The split places the wrapped modules
34+
# and PEFT LoRA params follow the base layer's device automatically.
35+
enable_wan_dual_gpu(training_module.pipe.dit)
36+
37+
Launch with ``--num_processes=1`` — this is *model* parallelism (both
38+
GPUs cooperate on a single training step), not data parallelism (which
39+
would spawn one process per GPU).
40+
"""
41+
from __future__ import annotations
42+
43+
import os
44+
from typing import Any
45+
46+
import torch
47+
import torch.nn as nn
48+
49+
50+
# ─── Env-gated public surface ───────────────────────────────────────────────
51+
52+
def is_dual_gpu_enabled() -> bool:
53+
"""True iff ``WAN_DUAL_GPU=true`` in the environment."""
54+
return os.getenv("WAN_DUAL_GPU", "false").lower() == "true"
55+
56+
57+
def get_split_at(num_blocks: int) -> int:
58+
"""Block split index. Override via ``WAN_DUAL_GPU_SPLIT_AT``."""
59+
override = os.getenv("WAN_DUAL_GPU_SPLIT_AT")
60+
if override is not None:
61+
return int(override)
62+
return num_blocks // 2
63+
64+
65+
def enable_wan_dual_gpu(dit: nn.Module) -> nn.Module:
66+
"""Distribute the WanModel DiT across cuda:0 and cuda:1.
67+
68+
When ``WAN_DUAL_GPU`` is unset this is a no-op pass-through.
69+
70+
Call after the WanModel is loaded and after PEFT LoRA injection has
71+
run. PEFT places LoRA params on the base layer's device automatically,
72+
so calling this function after LoRA injection puts everything in the
73+
right place.
74+
75+
Returns the (in-place modified) dit.
76+
"""
77+
if not is_dual_gpu_enabled():
78+
return dit
79+
80+
if torch.cuda.device_count() < 2:
81+
raise RuntimeError(
82+
f"WAN_DUAL_GPU=true requires ≥2 CUDA devices, found "
83+
f"{torch.cuda.device_count()}."
84+
)
85+
86+
num_blocks = len(dit.blocks)
87+
split_at = get_split_at(num_blocks)
88+
if not 0 < split_at < num_blocks:
89+
raise RuntimeError(
90+
f"WAN_DUAL_GPU_SPLIT_AT={split_at} out of range "
91+
f"(dit has {num_blocks} blocks)."
92+
)
93+
94+
cuda0 = torch.device("cuda:0")
95+
cuda1 = torch.device("cuda:1")
96+
97+
# Place pre-block scaffolding + first half of blocks + output head on
98+
# cuda:0. WanModel uses self.freqs (RoPE precomputed table) as a
99+
# plain tensor attribute, not a registered buffer/parameter -- move
100+
# it explicitly so the .to(device) calls below don't miss it. Same
101+
# for any optional .img_emb / .ref_conv / .control_adapter modules
102+
# that some Wan variants add.
103+
dit.patch_embedding.to(cuda0)
104+
dit.text_embedding.to(cuda0)
105+
dit.time_embedding.to(cuda0)
106+
dit.time_projection.to(cuda0)
107+
for block in dit.blocks[:split_at]:
108+
block.to(cuda0)
109+
for block in dit.blocks[split_at:]:
110+
block.to(cuda1)
111+
dit.head.to(cuda0)
112+
if hasattr(dit, "img_emb") and dit.img_emb is not None:
113+
dit.img_emb.to(cuda0)
114+
if hasattr(dit, "ref_conv") and dit.ref_conv is not None:
115+
dit.ref_conv.to(cuda0)
116+
if hasattr(dit, "control_adapter") and dit.control_adapter is not None:
117+
dit.control_adapter.to(cuda0)
118+
119+
# WanModel.freqs is a plain tuple of CPU tensors (not a registered
120+
# buffer); .to() on the module doesn't move it. Push it to cuda:0
121+
# since the patchify/freq-concat step happens there.
122+
if hasattr(dit, "freqs"):
123+
if isinstance(dit.freqs, (tuple, list)):
124+
dit.freqs = tuple(f.to(cuda0) if torch.is_tensor(f) else f for f in dit.freqs)
125+
elif torch.is_tensor(dit.freqs):
126+
dit.freqs = dit.freqs.to(cuda0)
127+
128+
# Per-block hook on every cuda:1 block. WanModel.forward passes
129+
# loop-level constants (context, t_mod, freqs) positionally to each
130+
# block; a boundary-only hook bridges only the first block's inputs,
131+
# subsequent blocks receive the cuda:0 originals and crash with a
132+
# device-mismatch error.
133+
for block in dit.blocks[split_at:]:
134+
block.register_forward_pre_hook(
135+
_make_device_bridge_hook(cuda1), with_kwargs=True
136+
)
137+
# Bridge activations back to cuda:0 for the head + unpatchify.
138+
dit.head.register_forward_pre_hook(
139+
_make_device_bridge_hook(cuda0), with_kwargs=True
140+
)
141+
142+
dit._wan_dual_gpu_split_at = split_at
143+
return dit
144+
145+
146+
# ─── Internals ──────────────────────────────────────────────────────────────
147+
148+
def _move_to_device(obj: Any, device: torch.device) -> Any:
149+
"""Recursively move tensors in nested tuple/list/dict to ``device``.
150+
151+
No-op when a tensor is already on ``device`` (``Tensor.to`` is an
152+
identity operation in that case).
153+
"""
154+
if torch.is_tensor(obj):
155+
return obj.to(device) if obj.device != device else obj
156+
if isinstance(obj, tuple):
157+
return tuple(_move_to_device(x, device) for x in obj)
158+
if isinstance(obj, list):
159+
return [_move_to_device(x, device) for x in obj]
160+
if isinstance(obj, dict):
161+
return {k: _move_to_device(v, device) for k, v in obj.items()}
162+
return obj
163+
164+
165+
def _make_device_bridge_hook(target_device: torch.device):
166+
"""Forward pre-hook that moves all tensor inputs to ``target_device``."""
167+
def hook(module, args, kwargs):
168+
return (
169+
_move_to_device(args, target_device),
170+
_move_to_device(kwargs, target_device),
171+
)
172+
return hook

0 commit comments

Comments
 (0)