Skip to content

Commit cb3ef91

Browse files
flux2: add dual-GPU model-parallel path for FLUX.2 LoRA training
Three changes, all gated on environment variables so single-GPU users see no behavior difference unless they opt in. 1) examples/flux2/model_training/flux2_dual_gpu_diffsynth.py (new): ~170 LOC helper. Splits Flux2DiT.single_transformer_blocks at the midpoint across cuda:0/cuda:1. Registers forward_pre_hook on every cuda:1 single block (not just the boundary) because Flux2DiT.forward passes loop-level constants (temb_mod_params, image_rotary_emb, joint_attention_kwargs) into every iteration -- a boundary-only hook would leave subsequent blocks receiving cuda:0 tensors. The norm_out hook bridges activations back to cuda:0 for the final output layers. 2) examples/flux2/model_training/train.py: When FLUX2_DUAL_GPU=true, force CPU model load (so the ~60 GB bf16 transformer doesn't pre-allocate on cuda:0 before split), run torchao Float8WeightOnlyConfig quantize_ with a filter that excludes lora_A/lora_B Linear submodules from the quant pass (otherwise their requires_grad is stripped and backward fails), then call enable_flux2_dual_gpu(model.pipe.dit) after PEFT has injected LoRA so layer .to(device) carries LoRA params with their base layers. 3) diffsynth/diffusion/runner.py: launch_training_task: skip model.to(accelerator.device) when FLUX2_DUAL_GPU=true (would undo the split), use device_placement=[False, True, True, True] so accelerate doesn't re-place the model. launch_data_process_task: separate DIFFSYNTH_DATA_PROCESS_ON_CPU env-var-gated branch for users without a 48 GB card -- Mistral-24B is too big for 32 GB; combined with --initialize_model_on_cpu this lets the TE/VAE feature caching step run on CPU. Bonus: diffsynth/models/flux2_text_encoder.py: transformers 5.8 trimmed Mistral3ForConditionalGeneration's positional args (dropped output_attentions, output_hidden_states, return_dict, cache_position from the signature; they're now TransformersKwargs-only). The existing code passed 15 positional args and crashed with "takes from 1 to 11 positional arguments but 15 were given". Rewrote to pass everything by keyword and re-inject output_hidden_states/output_attentions via **kwargs so the existing get_mistral_3_small_prompt_embeds caller still receives a populated output.hidden_states. Independent fix; single-GPU users benefit too. Validated end-to-end on 2x RTX 5090 with sumi v8 data + the unchanged recipe from examples/flux2/model_training/lora/FLUX.2-dev.sh: - sft:data_process (with --initialize_model_on_cpu + the env var): 3 sumi images cached on CPU-offloaded Mistral-24B in 43s. - sft:train (FLUX2_DUAL_GPU=true): 15/15 steps at 2.69 s/it sustained, 40s wall-clock. Distribution lands 20.7 GB cuda:0 / 12.6 GB cuda:1 after fp8 weight-only quant. LoRA checkpoint epoch-0.safetensors (270 MB at rank 32) saved. The same patch shape has been validated on three other FLUX.2 LoRA trainers (ai-toolkit, musubi-tuner, OneTrainer) -- documented at https://github.com/genno-whittlery/flux2-dual-gpu-lora -- with the same 20.7/12.6 GB distribution shape across all four.
1 parent bf5faf6 commit cb3ef91

4 files changed

Lines changed: 250 additions & 6 deletions

File tree

diffsynth/diffusion/runner.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,17 @@ def launch_training_task(
2727
optimizer = torch.optim.AdamW(model.trainable_modules(), lr=learning_rate, weight_decay=weight_decay)
2828
scheduler = torch.optim.lr_scheduler.ConstantLR(optimizer)
2929
dataloader = torch.utils.data.DataLoader(dataset, shuffle=True, collate_fn=lambda x: x[0], num_workers=num_workers)
30-
model.to(device=accelerator.device)
31-
model, optimizer, dataloader, scheduler = accelerator.prepare(model, optimizer, dataloader, scheduler)
30+
# Dual-GPU model-parallel: skip the device move that would undo our
31+
# manual split, and tell accelerate not to touch the model's device.
32+
_flux2_dual_gpu = os.environ.get("FLUX2_DUAL_GPU", "false").lower() == "true"
33+
if not _flux2_dual_gpu:
34+
model.to(device=accelerator.device)
35+
model, optimizer, dataloader, scheduler = accelerator.prepare(model, optimizer, dataloader, scheduler)
36+
else:
37+
model, optimizer, dataloader, scheduler = accelerator.prepare(
38+
model, optimizer, dataloader, scheduler,
39+
device_placement=[False, True, True, True],
40+
)
3241
initialize_deepspeed_gradient_checkpointing(accelerator)
3342
for epoch_id in range(num_epochs):
3443
for data in tqdm(dataloader):
@@ -59,8 +68,16 @@ def launch_data_process_task(
5968
num_workers = args.dataset_num_workers
6069

6170
dataloader = torch.utils.data.DataLoader(dataset, shuffle=False, collate_fn=lambda x: x[0], num_workers=num_workers)
62-
model.to(device=accelerator.device)
63-
model, dataloader = accelerator.prepare(model, dataloader)
71+
# Keep TE/VAE on CPU when explicitly requested. FLUX.2's Mistral-24B TE
72+
# is ~48 GB bf16; data_process OOMs on cards <48 GB without CPU offload.
73+
_data_process_on_cpu = os.environ.get('DIFFSYNTH_DATA_PROCESS_ON_CPU', 'false').lower() == 'true'
74+
if not _data_process_on_cpu:
75+
model.to(device=accelerator.device)
76+
model, dataloader = accelerator.prepare(model, dataloader)
77+
else:
78+
model, dataloader = accelerator.prepare(
79+
model, dataloader, device_placement=[False, True],
80+
)
6481

6582
for data_id, data in enumerate(tqdm(dataloader)):
6683
with accelerator.accumulate(model):

diffsynth/models/flux2_text_encoder.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,5 +54,25 @@ def __init__(self):
5454
super().__init__(config)
5555

5656
def forward(self, input_ids = None, pixel_values = None, attention_mask = None, position_ids = None, past_key_values = None, inputs_embeds = None, labels = None, use_cache = None, output_attentions = None, output_hidden_states = None, return_dict = None, cache_position = None, logits_to_keep = 0, image_sizes = None, **kwargs):
57-
return super().forward(input_ids, pixel_values, attention_mask, position_ids, past_key_values, inputs_embeds, labels, use_cache, output_attentions, output_hidden_states, return_dict, cache_position, logits_to_keep, image_sizes, **kwargs)
57+
# transformers 5.8 trimmed Mistral3's positional args; pass everything
58+
# as kwargs so the call survives across transformers versions. The dropped
59+
# output_hidden_states / output_attentions are still honored via
60+
# TransformersKwargs -> forward them through **kwargs.
61+
if output_hidden_states is not None:
62+
kwargs.setdefault("output_hidden_states", output_hidden_states)
63+
if output_attentions is not None:
64+
kwargs.setdefault("output_attentions", output_attentions)
65+
return super().forward(
66+
input_ids=input_ids,
67+
pixel_values=pixel_values,
68+
attention_mask=attention_mask,
69+
position_ids=position_ids,
70+
past_key_values=past_key_values,
71+
inputs_embeds=inputs_embeds,
72+
labels=labels,
73+
use_cache=use_cache,
74+
logits_to_keep=logits_to_keep,
75+
image_sizes=image_sizes,
76+
**kwargs,
77+
)
5878

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"""Dual-GPU model-parallel for FLUX.2 in DiffSynth-Studio.
2+
3+
Drop-in helper for DiffSynth-Studio
4+
(https://github.com/modelscope/DiffSynth-Studio) ``examples/flux2/model_training/``
5+
that splits the ``Flux2DiT`` transformer across two CUDA devices at the
6+
``single_transformer_blocks`` midpoint. Enables FLUX.2-dev LoRA training
7+
on pairs of 24+ GB consumer GPUs (2× RTX 3090, 2× RTX 4090, 2× RTX 5090)
8+
— on a single 24 GB card the FLUX.2 transformer can't fit alongside
9+
activations even with WDDM unified-memory paging.
10+
11+
Companion to the validated ai-toolkit patch
12+
(https://github.com/genno-whittlery/flux2-dual-gpu-lora) and parallel
13+
ports for musubi-tuner, OneTrainer, and HuggingFace diffusers. The
14+
DiffSynth port is small because:
15+
16+
- ``Flux2DiT`` is structurally identical to the diffusers
17+
``Flux2Transformer2DModel`` (same field names: ``x_embedder``,
18+
``context_embedder``, ``transformer_blocks``,
19+
``single_transformer_blocks``, ``norm_out``, ``proj_out``,
20+
``time_guidance_embed``, ``*_modulation*``, ``pos_embed``). The
21+
identical helper structure transfers directly.
22+
- DiffSynth uses PEFT (``inject_adapter_in_model`` in
23+
``diffsynth.diffusion.training_module``) for LoRA injection, so
24+
per-layer LoRA routing follows the base layer's device automatically.
25+
- The forward isn't overridden — pre-hooks bridge devices at the split
26+
point and at ``norm_out`` (the boundary back to cuda:0).
27+
28+
Env vars:
29+
FLUX2_DUAL_GPU=true enable dual-GPU path
30+
FLUX2_DUAL_GPU_SPLIT_AT=24 override split index
31+
(default: num_single // 2)
32+
33+
Usage in DiffSynth-Studio's training script
34+
(``examples/flux2/model_training/train.py``)::
35+
36+
from flux2_dual_gpu_diffsynth import enable_flux2_dual_gpu
37+
38+
# ...build pipeline / training module normally...
39+
training_module = Flux2ImageTrainingModule(...)
40+
41+
# Activate the split (env-gated; no-op when FLUX2_DUAL_GPU is unset).
42+
# Call AFTER LoRA injection (switch_pipe_to_training_mode) so PEFT
43+
# has wrapped target modules. The split places the wrapped modules
44+
# and PEFT LoRA params follow the base layer's device automatically.
45+
enable_flux2_dual_gpu(training_module.pipe.dit)
46+
47+
# ...accelerator.prepare with device_placement=False for the
48+
# training_module, since the transformer is already distributed...
49+
50+
Launch with ``--num_processes=1`` — this is *model* parallelism (both
51+
GPUs cooperate on a single training step), not data parallelism (which
52+
would spawn one process per GPU).
53+
"""
54+
from __future__ import annotations
55+
56+
import os
57+
from typing import Any
58+
59+
import torch
60+
import torch.nn as nn
61+
62+
63+
# ─── Env-gated public surface ───────────────────────────────────────────────
64+
65+
def is_dual_gpu_enabled() -> bool:
66+
"""True iff ``FLUX2_DUAL_GPU=true`` in the environment."""
67+
return os.getenv("FLUX2_DUAL_GPU", "false").lower() == "true"
68+
69+
70+
def get_split_at(num_single_blocks: int) -> int:
71+
"""Single-blocks split index. Override via ``FLUX2_DUAL_GPU_SPLIT_AT``."""
72+
override = os.getenv("FLUX2_DUAL_GPU_SPLIT_AT")
73+
if override is not None:
74+
return int(override)
75+
return num_single_blocks // 2
76+
77+
78+
def enable_flux2_dual_gpu(dit: nn.Module) -> nn.Module:
79+
"""Distribute the DiffSynth Flux2DiT across cuda:0 and cuda:1.
80+
81+
When ``FLUX2_DUAL_GPU`` is unset this is a no-op pass-through.
82+
83+
Call after the Flux2DiT (typically ``training_module.pipe.dit``) is
84+
loaded and after any PEFT LoRA injection has run. PEFT places LoRA
85+
params on the base layer's device automatically, so calling this
86+
function after LoRA injection puts everything in the right place.
87+
88+
Returns the (in-place modified) dit.
89+
"""
90+
if not is_dual_gpu_enabled():
91+
return dit
92+
93+
if torch.cuda.device_count() < 2:
94+
raise RuntimeError(
95+
f"FLUX2_DUAL_GPU=true requires ≥2 CUDA devices, found "
96+
f"{torch.cuda.device_count()}."
97+
)
98+
99+
num_single = len(dit.single_transformer_blocks)
100+
split_at = get_split_at(num_single)
101+
if not 0 < split_at < num_single:
102+
raise RuntimeError(
103+
f"FLUX2_DUAL_GPU_SPLIT_AT={split_at} out of range "
104+
f"(dit has {num_single} single blocks)."
105+
)
106+
107+
cuda0 = torch.device("cuda:0")
108+
cuda1 = torch.device("cuda:1")
109+
110+
# Place pre-blocks scaffolding + all double_blocks + first half of
111+
# single_blocks on cuda:0. Output layers (norm_out + proj_out) stay
112+
# on cuda:0 — the second pre-hook below brings the activation back
113+
# from cuda:1 just in time for them.
114+
dit.x_embedder.to(cuda0)
115+
dit.context_embedder.to(cuda0)
116+
dit.time_guidance_embed.to(cuda0)
117+
dit.pos_embed.to(cuda0)
118+
dit.double_stream_modulation_img.to(cuda0)
119+
dit.double_stream_modulation_txt.to(cuda0)
120+
dit.single_stream_modulation.to(cuda0)
121+
for block in dit.transformer_blocks:
122+
block.to(cuda0)
123+
for block in dit.single_transformer_blocks[:split_at]:
124+
block.to(cuda0)
125+
for block in dit.single_transformer_blocks[split_at:]:
126+
block.to(cuda1)
127+
dit.norm_out.to(cuda0)
128+
dit.proj_out.to(cuda0)
129+
130+
# Per-block hook on every cuda:1 single_block — the forward loop passes
131+
# loop-level constants (temb_mod_params, image_rotary_emb,
132+
# joint_attention_kwargs) to each block; a boundary-only hook bridges
133+
# only the first block's inputs, so subsequent blocks receive the
134+
# originals from cuda:0 and crash with a device-mismatch error.
135+
for block in dit.single_transformer_blocks[split_at:]:
136+
block.register_forward_pre_hook(
137+
_make_device_bridge_hook(cuda1), with_kwargs=True
138+
)
139+
dit.norm_out.register_forward_pre_hook(
140+
_make_device_bridge_hook(cuda0), with_kwargs=True
141+
)
142+
143+
dit._flux2_dual_gpu_split_at = split_at
144+
return dit
145+
146+
147+
# ─── Internals ──────────────────────────────────────────────────────────────
148+
149+
def _move_to_device(obj: Any, device: torch.device) -> Any:
150+
"""Recursively move tensors in nested tuple/list/dict to ``device``.
151+
152+
No-op when a tensor is already on ``device`` (``Tensor.to`` is an
153+
identity operation in that case).
154+
"""
155+
if torch.is_tensor(obj):
156+
return obj.to(device) if obj.device != device else obj
157+
if isinstance(obj, tuple):
158+
return tuple(_move_to_device(x, device) for x in obj)
159+
if isinstance(obj, list):
160+
return [_move_to_device(x, device) for x in obj]
161+
if isinstance(obj, dict):
162+
return {k: _move_to_device(v, device) for k, v in obj.items()}
163+
return obj
164+
165+
166+
def _make_device_bridge_hook(target_device: torch.device):
167+
"""Forward pre-hook that moves all tensor inputs to ``target_device``."""
168+
def hook(module, args, kwargs):
169+
return (
170+
_move_to_device(args, target_device),
171+
_move_to_device(kwargs, target_device),
172+
)
173+
return hook

examples/flux2/model_training/train.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from diffsynth.core import UnifiedDataset
33
from diffsynth.pipelines.flux2_image import Flux2ImagePipeline, ModelConfig
44
from diffsynth.diffusion import *
5+
from flux2_dual_gpu_diffsynth import enable_flux2_dual_gpu, is_dual_gpu_enabled
56
os.environ["TOKENIZERS_PARALLELISM"] = "false"
67

78

@@ -133,8 +134,41 @@ def flux2_parser():
133134
template_model_id_or_path=args.template_model_id_or_path,
134135
enable_lora_hot_loading=args.enable_lora_hot_loading,
135136
task=args.task,
136-
device="cpu" if args.initialize_model_on_cpu else accelerator.device,
137+
# Dual-GPU: force CPU load so the 60 GB bf16 transformer doesn't
138+
# pre-allocate on cuda:0 before we get a chance to split it.
139+
device="cpu" if (args.initialize_model_on_cpu or is_dual_gpu_enabled()) else accelerator.device,
137140
)
141+
# Dual-GPU split: gated on FLUX2_DUAL_GPU env var. Distributes
142+
# pipe.dit across cuda:0 and cuda:1 at the single_transformer_blocks
143+
# midpoint. Called AFTER LoRA injection (which happened inside
144+
# Flux2ImageTrainingModule.__init__) so PEFT LoRA params follow
145+
# the base layer's device automatically when block.to() runs.
146+
if is_dual_gpu_enabled():
147+
for _i in range(torch.cuda.device_count()):
148+
_free, _total = torch.cuda.mem_get_info(_i)
149+
print(f"[dual-gpu] pre-distribute cuda:{_i} free={_free/1024**3:.1f}G / {_total/1024**3:.1f}G")
150+
# fp8 weight-only quant on CPU BEFORE distribute. Without this the
151+
# bf16 transformer (~60 GB) overflows a 32 GB card on either side.
152+
# Filter excludes LoRA's lora_A/lora_B Linear submodules -- quantizing
153+
# them strips requires_grad and breaks backward.
154+
import gc as _gc
155+
from torchao.quantization import quantize_, Float8WeightOnlyConfig
156+
def _quant_filter(module, name):
157+
if not isinstance(module, torch.nn.Linear):
158+
return False
159+
return "lora_A" not in name and "lora_B" not in name
160+
quantize_(model.pipe.dit, Float8WeightOnlyConfig(), filter_fn=_quant_filter)
161+
_gc.collect()
162+
print("[dual-gpu] fp8 weight-only quant done (LoRA params unmodified)")
163+
enable_flux2_dual_gpu(model.pipe.dit)
164+
# Pipeline tracks a logical device for input transfer (forward()
165+
# calls transfer_data_to_device(inputs, pipe.device, ...)). Pin it
166+
# to cuda:0 since x_embedder lives there.
167+
model.pipe.device = torch.device("cuda:0")
168+
for _i in range(torch.cuda.device_count()):
169+
_free, _total = torch.cuda.mem_get_info(_i)
170+
print(f"[dual-gpu] post-distribute cuda:{_i} free={_free/1024**3:.1f}G / {_total/1024**3:.1f}G")
171+
138172
model_logger = ModelLogger(
139173
args.output_path,
140174
remove_prefix_in_ckpt=args.remove_prefix_in_ckpt,

0 commit comments

Comments
 (0)