[feat] train: add Muon optimizer (FSDP2/DTensor-aware) alongside AdamW#1519
[feat] train: add Muon optimizer (FSDP2/DTensor-aware) alongside AdamW#1519alexzms wants to merge 1 commit into
Conversation
`fastvideo/train` only offered AdamW. This adds Muon (Keller Jordan 2024) as an opt-in `training.optimizer.optimizer_type: muon`, applied to the transformer's 2-D hidden weight matrices while embeddings, the output head, and all 1-D params (norm/bias) fall back to an auxiliary AdamW group (the standard Muon recipe). FSDP2 support: under `fully_shard` the params are DTensors sharded on dim-0, but Newton-Schulz needs the full 2-D matrix. The momentum buffer stays sharded (memory-efficient); each matrix is gathered transiently (`full_tensor()`) only for the orthogonalization, and the update is re-sharded back to the param placement (gather-per-matrix). Peak extra memory is one full matrix at a time, not the whole replicated state. - `fastvideo/train/utils/muon.py`: `zeropower_via_newtonschulz5`, `split_params_for_muon`, and `MuonWithAuxAdam` (one optimizer, two param groups, so the single-optimizer/scheduler trainer plumbing is unchanged). - `OptimizerConfig`: `optimizer_type` (default "adamw") + `muon_lr` / `muon_momentum` / `muon_weight_decay` / `muon_ns_steps`; parsed in config.py. - `build_optimizer_and_scheduler`: branch on `optimizer_type`; the muon path takes the `module` so it can split params by name. All five method callers pass `module=`. Verified: CPU unit tests (NS orthogonality, param split, step reduces a convex loss, finite); and a 2-GPU FSDP2 run — 20 steps with the DTensor gather-per-matrix path, all params DTensors, loss 0.115 -> 0.033, finite.
Merge Protections🔴 1 of 1 protections blocking · waiting on 👀 reviews and 🤖 CI
🔴 PR merge requirementsWaiting for
This rule is failing.
|
There was a problem hiding this comment.
Code Review
This pull request introduces the Muon optimizer with an auxiliary AdamW group (MuonWithAuxAdam) to optimize 2-D hidden weight matrices while keeping other parameters on AdamW, including configuration parsing, integration into various training methods, and unit tests. Feedback on the changes highlights several important improvements: preventing potential overflow in zeropower_via_newtonschulz5 by computing the norm in float32, ensuring only the parameters in the provided params list are optimized to support parameter-efficient fine-tuning, making the transposed tensor contiguous to avoid issues in distributed environments, and correctly handling negative values when defaulting muon_lr to learning_rate.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| X = X.T | ||
| transposed = True | ||
| # Normalize so the spectral norm is <= 1 before the iteration. | ||
| X = X / (X.norm() + 1e-7) |
There was a problem hiding this comment.
In zeropower_via_newtonschulz5, calling X.norm() on a large bfloat16 tensor can easily overflow to inf because the intermediate sum of squares can exceed the maximum representable value in bfloat16 (65504). To prevent this, compute the norm in float32 before dividing.
| X = X / (X.norm() + 1e-7) | |
| X = X / (X.float().norm() + 1e-7) |
| muon_params, aux_params = split_params_for_muon([(n, p) for n, p in module.named_parameters() | ||
| if p.requires_grad]) |
There was a problem hiding this comment.
When optimizer_type == "muon", the optimizer parameters are extracted directly from module.named_parameters(). However, if the caller passed a specific subset of parameters in the params argument (e.g., for parameter-efficient fine-tuning like LoRA, or layer-wise learning rates), this will ignore that subset and optimize all trainable parameters in the module. Filter the parameters to ensure only those present in the params list are optimized.
param_set = set(params)\n muon_params, aux_params = split_params_for_muon([(n, p) for n, p in module.named_parameters()\n if p in param_set])| if transposed: | ||
| X = X.T |
There was a problem hiding this comment.
When transposed is True, X.T returns a non-contiguous tensor. Some distributed operations (such as distribute_tensor or other collective communication methods) require contiguous memory layouts and will raise a runtime error if passed a non-contiguous tensor. Ensure the returned tensor is contiguous.
if transposed:\n X = X.T.contiguous()| ) | ||
| muon_params, aux_params = split_params_for_muon([(n, p) for n, p in module.named_parameters() | ||
| if p.requires_grad]) | ||
| muon_lr = float(optimizer_config.muon_lr) or float(learning_rate) |
There was a problem hiding this comment.
The docstring states that muon_lr defaults to learning_rate when <= 0. However, using float(optimizer_config.muon_lr) or float(learning_rate) will only default to learning_rate if muon_lr is exactly 0.0 (since negative values are truthy in Python). Explicitly check if muon_lr <= 0.0 to handle negative values correctly.
muon_lr = float(optimizer_config.muon_lr)\n if muon_lr <= 0.0:\n muon_lr = float(learning_rate)
Purpose
fastvideo/trainonly offered AdamW. This adds Muon (MomentUm Orthogonalized by Newton-schulz, Keller Jordan 2024) as an opt-in optimizer for the modular trainer.How it works
Muon replaces a 2-D weight matrix's raw momentum update with its nearest semi-orthogonal matrix (a few Newton-Schulz iterations). Following the standard recipe, it's applied to the transformer's 2-D hidden weight matrices (attention q/k/v/out projections, MLP fc layers) while embeddings, the output head, and all 1-D params (norm/bias) fall back to an auxiliary AdamW group. Both live in one
MuonWithAuxAdamoptimizer (two param groups), so the existing single-optimizer / single-scheduler trainer plumbing is unchanged.FSDP2 / DTensor: under
fully_shardthe params are DTensors sharded on dim-0, but Newton-Schulz needs the full 2-D matrix. The momentum buffer stays sharded (memory-efficient); each matrix is gathered transiently (full_tensor()) only for the orthogonalization, then the update is re-sharded back to the param's placement (gather-per-matrix). Peak extra memory is one full matrix at a time, not the whole replicated optimizer state. (A fully-distributed Newton-Schulz that avoids the per-matrix gather is a natural follow-up.)Usage
Changes
fastvideo/train/utils/muon.py(new):zeropower_via_newtonschulz5,split_params_for_muon,MuonWithAuxAdam.OptimizerConfig:optimizer_type+muon_lr/muon_momentum/muon_weight_decay/muon_ns_steps; parsed inconfig.py.build_optimizer_and_scheduler: branch onoptimizer_type; the muon path takesmoduleto split params by name. All 5 method callers passmodule=.fastvideo/tests/train/utils/test_muon.py(new): unit tests.Test evidence
MuonWithAuxAdamreduces a convex loss with finite updates, empty-params guard.Test plan
pytest fastvideo/tests/train/utils/test_muon.py(CPU) -> 5 passed/test train-frameworkon CI