|
| 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