Skip to content

Commit bbbcfa8

Browse files
jasainioFlux Split Trial
andauthored
feat(flux): FSDP2 fp32/bf16 optimizers + fp8 all-gather (#808)
Part of an 18-PR series splitting the Flux diffusion-training feature (training Flux, a DiT text-to-image diffusion model, on Primus/Megatron) out of one large branch for reviewability. Targets `feat/flux/core` — review after it. The diff here is only this layer. ## What this changes The FSDP2 optimization layer used by Flux training: fp32 and bf16-master-weight optimizer variants, incremental grad-norm, the FSDP2 fp8 all-gather path, and the related torch-FSDP2 / fp8-cache / optimizer-registration patches. ## Dependencies Sequenced after the CI-pins PR (`feat/flux/ci-env`); builds on `feat/flux/core`. It is the parent of the turbo layer, whose float8 extension lazily imports this layer's fp8 all-gather. ## Test plan `pytest tests/unit_tests/optimizer tests/unit_tests/backends/megatron/diffusion/distributed`. Validated locally on an AMD GPU container: 87 passed. ## Files 14 (FSDP2 optimizers, fp8 all-gather, optimizer/FSDP2 patches + tests). Co-authored-by: Flux Split Trial <flux-split-trial@local>
1 parent 6e8914f commit bbbcfa8

14 files changed

Lines changed: 4383 additions & 39 deletions

primus/backends/megatron/core/distributed/fsdp2_fp8_all_gather.py

Lines changed: 654 additions & 0 deletions
Large diffs are not rendered by default.

primus/backends/megatron/core/distributed/torch_fully_sharded_data_parallel.py

Lines changed: 404 additions & 15 deletions
Large diffs are not rendered by default.

primus/backends/megatron/core/optimizer/fsdp2_bf16_master_weight_optimizer.py

Lines changed: 686 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
###############################################################################
2+
# Copyright (c) 2026, Advanced Micro Devices, Inc. All rights reserved.
3+
#
4+
# See LICENSE for license information.
5+
###############################################################################
6+
7+
"""
8+
FP32 optimizer for FSDP2 mixed precision training.
9+
10+
Uses the TorchTitan approach: model parameters are initialized in FP32,
11+
FSDP2's MixedPrecisionPolicy casts to BF16 for forward/backward, and
12+
the optimizer operates on FP32 parameters with FP32 states.
13+
14+
This eliminates the "stale weights" problem of BF16 optimizer states
15+
while avoiding the master-copy duplication of Float16OptimizerWithFloat16Params.
16+
17+
Memory impact vs BF16 optimizer (Flux 12B, 8 GPUs):
18+
+3 GB/GPU for FP32 parameters (vs BF16)
19+
+6 GB/GPU for FP32 optimizer states (vs BF16)
20+
= +9 GB/GPU total
21+
22+
Gradient clipping uses PyTorch-native DTensor-aware APIs matching TorchTitan.
23+
"""
24+
25+
from typing import TYPE_CHECKING, Callable, List, Optional
26+
27+
import torch
28+
from megatron.core.dist_checkpointing.mapping import ShardedStateDict
29+
from megatron.core.dist_checkpointing.optimizer import (
30+
get_param_id_to_sharded_param_map,
31+
optim_state_to_sharding_state,
32+
)
33+
from megatron.core.optimizer.optimizer import MegatronOptimizer
34+
from megatron.core.optimizer.optimizer_config import OptimizerConfig
35+
36+
from primus.modules.module_utils import log_rank_0
37+
38+
if TYPE_CHECKING:
39+
from megatron.core.process_groups_config import ProcessGroupCollection
40+
41+
42+
def _safe_log_rank_0(msg: str):
43+
try:
44+
log_rank_0(msg)
45+
except (AttributeError, TypeError):
46+
import torch.distributed as dist
47+
48+
if not dist.is_initialized() or dist.get_rank() == 0:
49+
print(msg)
50+
51+
52+
class FSDP2FP32Optimizer(MegatronOptimizer):
53+
"""FP32 optimizer for FSDP2 mixed precision training.
54+
55+
Extends MegatronOptimizer directly (not MixedPrecisionOptimizer).
56+
Modeled on Megatron's own FP32Optimizer with two key differences:
57+
58+
1. prepare_grads is a no-op: FSDP2 writes gradients directly to param.grad
59+
(no main_grad -> grad copy needed).
60+
2. clip_grad_norm uses TorchTitan-style DTensor-native APIs:
61+
torch.nn.utils.get_total_norm + clip_grads_with_norm_ for correct
62+
norm computation across FSDP2's sharded DTensor parameters.
63+
64+
Args:
65+
optimizer: Base PyTorch optimizer (e.g., AdamW with fused=True).
66+
config: OptimizerConfig from Megatron.
67+
init_state_fn: Function to initialize optimizer state tensors.
68+
"""
69+
70+
def __init__(
71+
self,
72+
optimizer: torch.optim.Optimizer,
73+
config: OptimizerConfig,
74+
init_state_fn: Callable,
75+
):
76+
super().__init__(optimizer, config, init_state_fn)
77+
self._scale = torch.tensor([1.0], dtype=torch.float, device="cuda")
78+
self.is_stub_optimizer = optimizer is None
79+
80+
from megatron.training import get_args
81+
82+
args = get_args()
83+
self._grad_norm_accumulator = getattr(args, "_grad_norm_accumulator", None)
84+
85+
def zero_grad(self, set_to_none=True):
86+
if self.is_stub_optimizer:
87+
return
88+
if self._grad_norm_accumulator is not None:
89+
self._grad_norm_accumulator.reset()
90+
self.optimizer.zero_grad(set_to_none=set_to_none)
91+
92+
def get_loss_scale(self):
93+
return self._scale
94+
95+
@torch.no_grad()
96+
def prepare_grads(self) -> bool:
97+
"""No-op: FSDP2 writes gradients directly to param.grad."""
98+
return False
99+
100+
@torch.no_grad()
101+
def clip_grad_norm(self, clip_grad: float) -> float | torch.Tensor:
102+
"""DTensor-native gradient clipping matching TorchTitan.
103+
104+
Uses torch.nn.utils.get_total_norm which natively handles DTensor
105+
gradients (returns a DTensor with _NormPartial placement that is
106+
reduced via full_tensor()), then clips with foreach-optimized
107+
clip_grads_with_norm_.
108+
109+
When overlap_grad_norm is enabled, the squared norms have already been
110+
accumulated in the RS stream via post_accumulate_grad_hooks. Only a
111+
single all-reduce + sqrt + clip is needed.
112+
"""
113+
params = self.get_parameters()
114+
115+
if self._grad_norm_accumulator is not None:
116+
return self._grad_norm_accumulator.finalize(clip_grad, params)
117+
118+
from torch.distributed.tensor import DTensor
119+
120+
grads = [p.grad for p in params if p.grad is not None]
121+
122+
if not grads:
123+
return 0.0
124+
125+
total_norm = torch.nn.utils.get_total_norm(grads, norm_type=2.0, foreach=True)
126+
if isinstance(total_norm, DTensor):
127+
total_norm = total_norm.full_tensor()
128+
torch.nn.utils.clip_grads_with_norm_(params, clip_grad, total_norm, foreach=True)
129+
return total_norm
130+
131+
@torch.no_grad()
132+
def step_with_ready_grads(self) -> bool:
133+
if self.is_stub_optimizer:
134+
return True
135+
timers = self.config.timers
136+
137+
if timers is not None:
138+
timers("optimizer-inner-step", log_level=1).start(barrier=self.config.barrier_with_L1_time)
139+
self.optimizer.step()
140+
if timers is not None:
141+
timers("optimizer-inner-step").stop()
142+
143+
return True
144+
145+
@torch.no_grad()
146+
def step(self):
147+
"""Clip gradients and step. Always succeeds (no overflow for FP32)."""
148+
timers = self.config.timers
149+
150+
found_inf_flag = self.prepare_grads()
151+
if found_inf_flag:
152+
return False, None, None
153+
154+
if timers is not None:
155+
timers("optimizer-clip-main-grad", log_level=1).start(barrier=self.config.barrier_with_L1_time)
156+
grad_norm = None
157+
if self.config.clip_grad > 0.0:
158+
grad_norm = self.clip_grad_norm(self.config.clip_grad)
159+
if timers is not None:
160+
timers("optimizer-clip-main-grad").stop()
161+
162+
if timers is not None:
163+
timers("optimizer-count-zeros", log_level=1).start(barrier=self.config.barrier_with_L1_time)
164+
num_zeros_in_grad = self.count_zeros() if self.config.log_num_zeros_in_grad else None
165+
if timers is not None:
166+
timers("optimizer-count-zeros").stop()
167+
168+
success = self.step_with_ready_grads()
169+
170+
return success, grad_norm, num_zeros_in_grad
171+
172+
def reload_model_params(self, state_dict=None):
173+
pass
174+
175+
def state_dict(self):
176+
return self.optimizer.state_dict()
177+
178+
def load_state_dict(self, state_dict):
179+
if "common_step" in state_dict.get("state", {}):
180+
common_step = state_dict["state"].pop("common_step")
181+
self._restore_common_per_param_step(state_dict, common_step)
182+
183+
state_dict["param_groups"] = self._filter_and_reorder_param_groups(
184+
self.optimizer.param_groups, state_dict["param_groups"]
185+
)
186+
self.optimizer.load_state_dict(state_dict)
187+
188+
def sharded_state_dict(
189+
self,
190+
model_sharded_state_dict: ShardedStateDict,
191+
is_loading: bool = False,
192+
metadata: Optional[dict] = None,
193+
):
194+
if is_loading:
195+
self.init_state_fn(self.optimizer, self.config)
196+
197+
state_dict = self.state_dict()
198+
id_to_sharded_param_map = get_param_id_to_sharded_param_map(
199+
model_sharded_state_dict, self.get_parameters()
200+
)
201+
step = self._extract_common_per_param_step(state_dict)
202+
203+
optim_state_to_sharding_state(state_dict, id_to_sharded_param_map, exclude_keys="step")
204+
if step:
205+
state_dict["state"]["common_step"] = step
206+
return state_dict
207+
208+
def finalize_dist_ckpt_load(self, iteration):
209+
"""Restore optimizer step counter after dist_checkpointing in-place load.
210+
211+
When skip_load_to_model_and_opt=True (FSDP2), load_state_dict is not
212+
called, so common_step is never fanned out to per-parameter step
213+
entries. This fills step from the training iteration.
214+
"""
215+
step_val = float(iteration)
216+
for p in self.get_parameters():
217+
if p in self.optimizer.state and "step" in self.optimizer.state[p]:
218+
self.optimizer.state[p]["step"].fill_(step_val)
219+
220+
221+
def get_fsdp2_fp32_optimizer(
222+
config: OptimizerConfig,
223+
model_chunks: List[torch.nn.Module],
224+
no_weight_decay_cond: Optional[Callable] = None,
225+
scale_lr_cond: Optional[Callable] = None,
226+
lr_mult: float = 1.0,
227+
use_gloo_process_groups: bool = True,
228+
default_skip_embedding_weight_decay: bool = False,
229+
pg_collection: Optional["ProcessGroupCollection"] = None,
230+
base_optimizer_cls=torch.optim.AdamW,
231+
use_foreach: bool = False,
232+
**optimizer_kwargs,
233+
) -> FSDP2FP32Optimizer:
234+
"""Factory function to create FSDP2 FP32 param optimizer from model chunks.
235+
236+
Collects trainable FP32 parameters, builds param groups with weight decay
237+
and LR scaling, creates AdamW (fused or foreach), and wraps in
238+
FSDP2FP32Optimizer.
239+
240+
Args:
241+
config: OptimizerConfig from Megatron.
242+
model_chunks: List of model modules (FSDP2-wrapped).
243+
no_weight_decay_cond: Optional predicate for zero weight decay.
244+
scale_lr_cond: Optional predicate for scaled learning rate.
245+
lr_mult: Learning rate multiplier for scaled params.
246+
use_gloo_process_groups: Unused (kept for API compatibility).
247+
default_skip_embedding_weight_decay: Skip weight decay for embeddings
248+
if no_weight_decay_cond not provided.
249+
pg_collection: Unused (kept for API compatibility).
250+
base_optimizer_cls: PyTorch optimizer class (default: AdamW).
251+
use_foreach: If True, use foreach mode; if False (default), use fused.
252+
**optimizer_kwargs: Additional kwargs for base optimizer.
253+
254+
Returns:
255+
FSDP2FP32Optimizer instance.
256+
"""
257+
all_params = []
258+
for model_chunk in model_chunks:
259+
for param in model_chunk.parameters():
260+
if param.requires_grad:
261+
all_params.append(param)
262+
263+
if not all_params:
264+
raise ValueError("No trainable parameters found in model chunks!")
265+
266+
weight_decay = config.weight_decay
267+
lr = config.lr
268+
param_groups = []
269+
270+
if default_skip_embedding_weight_decay and no_weight_decay_cond is None:
271+
embedding_params = []
272+
non_embedding_params = []
273+
for param in all_params:
274+
is_embedding = False
275+
for model_chunk in model_chunks:
276+
for name, p in model_chunk.named_parameters():
277+
if p is param and "embed" in name.lower():
278+
is_embedding = True
279+
break
280+
if is_embedding:
281+
break
282+
if is_embedding:
283+
embedding_params.append(param)
284+
else:
285+
non_embedding_params.append(param)
286+
287+
if embedding_params:
288+
param_groups.append({"params": embedding_params, "weight_decay": 0.0, "lr": lr})
289+
if non_embedding_params:
290+
param_groups.append({"params": non_embedding_params, "weight_decay": weight_decay, "lr": lr})
291+
elif no_weight_decay_cond is not None:
292+
no_wd_params = []
293+
wd_params = []
294+
for param in all_params:
295+
if no_weight_decay_cond(param):
296+
no_wd_params.append(param)
297+
else:
298+
wd_params.append(param)
299+
if no_wd_params:
300+
param_groups.append({"params": no_wd_params, "weight_decay": 0.0, "lr": lr})
301+
if wd_params:
302+
param_groups.append({"params": wd_params, "weight_decay": weight_decay, "lr": lr})
303+
else:
304+
param_groups.append({"params": all_params, "weight_decay": weight_decay, "lr": lr})
305+
306+
if scale_lr_cond is not None and lr_mult != 1.0:
307+
new_groups = []
308+
for param_group in param_groups:
309+
scaled = [p for p in param_group["params"] if scale_lr_cond(p)]
310+
normal = [p for p in param_group["params"] if not scale_lr_cond(p)]
311+
if normal:
312+
new_groups.append({**param_group, "params": normal})
313+
if scaled:
314+
new_groups.append({**param_group, "params": scaled, "lr": param_group["lr"] * lr_mult})
315+
param_groups = new_groups
316+
317+
_safe_log_rank_0(
318+
f"Creating FSDP2 FP32 optimizer with {len(all_params):,} parameters "
319+
f"in {len(param_groups)} param groups"
320+
)
321+
322+
base_optimizer = base_optimizer_cls(
323+
param_groups,
324+
betas=(config.adam_beta1, config.adam_beta2),
325+
eps=config.adam_eps,
326+
fused=not use_foreach,
327+
foreach=use_foreach,
328+
**optimizer_kwargs,
329+
)
330+
331+
for param_group in base_optimizer.param_groups:
332+
param_group.setdefault("wd_mult", 1.0)
333+
param_group.setdefault("lr_mult", 1.0)
334+
param_group.setdefault("is_expert_parallel", False)
335+
param_group.setdefault("is_decoupled_lr", False)
336+
param_group.setdefault("default_config", True)
337+
338+
def init_state_fn(opt, config=None):
339+
for group in opt.param_groups:
340+
for p in group["params"]:
341+
if len(opt.state[p]) == 0:
342+
opt.state[p]["step"] = torch.zeros((), dtype=torch.float32, device=p.device)
343+
opt.state[p]["exp_avg"] = torch.zeros_like(p.data)
344+
opt.state[p]["exp_avg_sq"] = torch.zeros_like(p.data)
345+
346+
optimizer = FSDP2FP32Optimizer(
347+
optimizer=base_optimizer,
348+
config=config,
349+
init_state_fn=init_state_fn,
350+
)
351+
352+
fp32_count = sum(1 for p in all_params if p.dtype == torch.float32)
353+
bf16_count = sum(1 for p in all_params if p.dtype == torch.bfloat16)
354+
other_count = len(all_params) - fp32_count - bf16_count
355+
356+
_safe_log_rank_0("=" * 80)
357+
_safe_log_rank_0("[FSDP2FP32ParamOptimizer Initialized]")
358+
_safe_log_rank_0(" TorchTitan-style: FP32 params + FSDP2 MixedPrecisionPolicy")
359+
_safe_log_rank_0(" DTensor-native gradient clipping")
360+
_safe_log_rank_0(f" AdamW mode: {'foreach' if use_foreach else 'fused'}")
361+
_safe_log_rank_0(f" FP32 parameters: {fp32_count:,}")
362+
_safe_log_rank_0(f" BF16 parameters: {bf16_count:,}")
363+
if other_count > 0:
364+
_safe_log_rank_0(f" Other dtype parameters: {other_count:,}")
365+
_safe_log_rank_0(f" Total trainable parameters: {len(all_params):,}")
366+
_safe_log_rank_0("=" * 80)
367+
368+
return optimizer

0 commit comments

Comments
 (0)