-
Notifications
You must be signed in to change notification settings - Fork 70
Muon optimizer #2639
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ankitpatnala
wants to merge
18
commits into
ecmwf:develop
Choose a base branch
from
ankitpatnala:ankit_muon_optimizer
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Muon optimizer #2639
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
9dceccc
changes made by claude while implementing Muon optimizer
ankitpatnala f0c66ed
moved optimizer to separate folder
ankitpatnala e347bc6
moved optimizer states from trainer to trainer/optimizer.
ankitpatnala 3eca0d2
removed verbose comments generated by Claude while optimizing hyperpa…
ankitpatnala b00b9a8
removed plotting configs
ankitpatnala dba93e5
lint formatted
ankitpatnala 57dcb10
updated training config
ankitpatnala 3fb61b3
updated config
ankitpatnala 03e1978
increased lr_max to check stable run
ankitpatnala 356b864
Merge remote-tracking branch 'origin/develop' into ankit_muon_optimizer
ankitpatnala 916e015
removed separate logging and refactored optimizer as a separate class
ankitpatnala 34faf61
added training config
ankitpatnala 539b2c4
changed default optimizer from adamw to muon
ankitpatnala f572799
added updated configs
ankitpatnala 7137c80
checked out the forecating_finetuning config of develop
ankitpatnala abc3a42
removed comments
ankitpatnala 8c8ed61
Merge branch 'develop' into ankit_muon_optimizer
ankitpatnala 9a5c593
Merge branch 'develop' into ankit_muon_optimizer
ankitpatnala File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| # (C) Copyright 2025 WeatherGenerator contributors. | ||
| # | ||
| # This software is licensed under the terms of the Apache Licence Version 2.0 | ||
| # which can be obtained at http://www.apache.org/licenses/LICENSE-2.0. | ||
| # | ||
| # In applying this licence, ECMWF does not waive the privileges and immunities | ||
| # granted to it by virtue of its status as an intergovernmental organisation | ||
| # nor does it submit to any jurisdiction. | ||
|
|
||
| from .optimizer import AdamW, Muon, OptimizerBase | ||
| from .utils import build_optimizer | ||
|
|
||
| __all__ = [AdamW, Muon, OptimizerBase, build_optimizer] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,195 @@ | ||
| # (C) Copyright 2025 WeatherGenerator contributors. | ||
| # | ||
| # This software is licensed under the terms of the Apache Licence Version 2.0 | ||
| # which can be obtained at http://www.apache.org/licenses/LICENSE-2.0. | ||
| # | ||
| # In applying this licence, ECMWF does not waive the privileges and immunities | ||
| # granted to it by virtue of its status as an intergovernmental organisation | ||
| # nor does it submit to any jurisdiction. | ||
|
|
||
| import logging | ||
|
|
||
| import numpy as np | ||
| import torch | ||
|
|
||
| from weathergen.utils.distributed import is_root | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| def _adamw_betas_eps(optimizer_cfg, kappa: float) -> dict: | ||
| """ | ||
| DDP-scaled adamw betas/eps, shared by AdamW and Muon (which falls back to adamw for | ||
| non-2D parameters). | ||
|
|
||
| https://www.cs.princeton.edu/~smalladi/blog/2024/01/22/SDEs-ScalingRules/ | ||
| aiming for beta1=0.9 and beta2=0.95 following the MAE paper https://arxiv.org/pdf/2111.06377 | ||
| """ | ||
| # aiming for beta1 = 0.9 at one node, ie kappa=B=4 | ||
| beta1 = max(0.5, 1.0 - kappa * (1.0 - optimizer_cfg.adamw.beta1)) | ||
| # aiming for beta2 = 0.95 at one node, ie B=4 | ||
| beta2 = max(0.9, 1.0 - kappa * (1.0 - optimizer_cfg.adamw.beta2)) | ||
| eps = optimizer_cfg.adamw.get("eps", 2e-08) / np.sqrt(kappa) | ||
| return {"betas": (beta1, beta2), "eps": eps} | ||
|
|
||
|
|
||
| def _muon_adjust_lr_factor(shape, adjust_lr_fn: str) -> float: | ||
| """ | ||
| Mirrors torch.optim.Muon's internal per-parameter lr-adjustment factor | ||
| (torch/optim/_muon.py::_adjust_lr), so a representative effective lr can be logged. | ||
| """ | ||
| a, b = shape[0], shape[1] | ||
| if adjust_lr_fn == "match_rms_adamw": | ||
| return 0.2 * max(a, b) ** 0.5 | ||
| return max(1.0, a / b) ** 0.5 | ||
|
|
||
|
|
||
| class OptimizerBase(torch.optim.Optimizer): | ||
| """ | ||
| Base class of the optimizers used for training. Some optimizers (see Muon) need one | ||
| torch optimizer per class of parameters; this presents them to the trainer as a single | ||
| torch.optim.Optimizer, so that the trainer, the learning rate scheduler and the grad scaler | ||
| all keep working with one optimizer object and one learning rate. | ||
|
|
||
| The param groups of the wrapped optimizers are shared (not copied) into self.param_groups, | ||
| so that a learning rate scheduler stepping this object writes the lr straight through to | ||
| them. Subclasses build the wrapped optimizers and pass them to __init__. | ||
| """ | ||
|
|
||
| def __init__(self, optimizers: list[torch.optim.Optimizer], names: list[str], lr: float): | ||
| self.optimizers = optimizers | ||
| self.names = names | ||
|
|
||
| params = [p for opt in optimizers for group in opt.param_groups for p in group["params"]] | ||
| super().__init__(params, {"lr": lr}) | ||
|
|
||
| # replace the param group created by Optimizer.__init__ by the wrapped optimizers' own | ||
| # group dicts; they are shared by reference, so an lr written here is seen by them | ||
| self.param_groups = [group for opt in optimizers for group in opt.param_groups] | ||
|
|
||
| def zero_grad(self, set_to_none: bool = True) -> None: | ||
| for optimizer in self.optimizers: | ||
| optimizer.zero_grad(set_to_none=set_to_none) | ||
|
|
||
| def step(self, closure=None) -> None: | ||
| assert closure is None, "closures are not supported" | ||
| for optimizer in self.optimizers: | ||
| optimizer.step() | ||
|
|
||
| def state_dict(self) -> dict: | ||
| """ | ||
| State dict of all wrapped optimizers, merged into a single, standard optimizer state | ||
| dict: parameter indices of each wrapped optimizer are offset by the number of parameters | ||
| of the preceding ones, so that they stay unique. | ||
| """ | ||
| merged_state, merged_groups, offset = {}, [], 0 | ||
| for optimizer in self.optimizers: | ||
| state_dict = optimizer.state_dict() | ||
| merged_state.update({idx + offset: s for idx, s in state_dict["state"].items()}) | ||
| merged_groups += [ | ||
| {**group, "params": [idx + offset for idx in group["params"]]} | ||
| for group in state_dict["param_groups"] | ||
| ] | ||
| offset += sum(len(group["params"]) for group in state_dict["param_groups"]) | ||
| return {"state": merged_state, "param_groups": merged_groups} | ||
|
|
||
| def load_state_dict(self, state_dict: dict) -> None: | ||
| """ | ||
| Split a state dict merged by state_dict() back over the wrapped optimizers. | ||
| """ | ||
| param_offset, group_offset = 0, 0 | ||
| for optimizer in self.optimizers: | ||
| groups = state_dict["param_groups"][ | ||
| group_offset : group_offset + len(optimizer.param_groups) | ||
| ] | ||
| num_params = sum(len(group["params"]) for group in groups) | ||
| optimizer.load_state_dict( | ||
| { | ||
| "state": { | ||
| idx - param_offset: s | ||
| for idx, s in state_dict["state"].items() | ||
| if param_offset <= idx < param_offset + num_params | ||
| }, | ||
| "param_groups": [ | ||
| {**group, "params": [idx - param_offset for idx in group["params"]]} | ||
| for group in groups | ||
| ], | ||
| } | ||
| ) | ||
| param_offset += num_params | ||
| group_offset += len(optimizer.param_groups) | ||
|
|
||
|
|
||
| class AdamW(OptimizerBase): | ||
| """ | ||
| Single torch.optim.AdamW optimizer over all of the model's parameters. | ||
| """ | ||
|
|
||
| def __init__(self, model: torch.nn.Module, optimizer_cfg, lr_cfg, kappa: float): | ||
| optimizer = torch.optim.AdamW( | ||
| model.parameters(), | ||
| lr=lr_cfg.lr_start, | ||
| weight_decay=optimizer_cfg.weight_decay, | ||
| fused=True, | ||
| **_adamw_betas_eps(optimizer_cfg, kappa), | ||
| ) | ||
|
|
||
| super().__init__([optimizer], ["adamw"], lr_cfg.lr_start) | ||
|
|
||
|
|
||
| class Muon(OptimizerBase): | ||
| """ | ||
| Muon optimizer for the model's 2D weight matrices (hidden layers), paired with a separate | ||
| AdamW optimizer for all other (non-2D) parameters -- biases, norms, ... -- as recommended by | ||
| https://kellerjordan.github.io/posts/muon/ (torch.optim.Muon also hard-requires exactly 2D | ||
| tensors and raises ValueError otherwise, e.g. for a [1, 1, 2048] param). | ||
|
|
||
| step() therefore runs twice: once over the 2D parameters with muon, once over the remaining | ||
| parameters with adamw. | ||
|
|
||
| Both share a single learning rate and hence a single schedule: torch.optim.Muon rescales the | ||
| lr per parameter inside step(), and with adjust_lr_fn="match_rms_adamw" that rescaling is | ||
| exactly what makes the RMS of muon's update match adamw's at the same nominal lr | ||
| (`Muon is Scalable for LLM Training`, https://arxiv.org/abs/2502.16982). | ||
| """ | ||
|
|
||
| def __init__(self, model: torch.nn.Module, optimizer_cfg, lr_cfg, kappa: float): | ||
| muon_cfg = optimizer_cfg.muon | ||
| muon_params = [p for p in model.parameters() if p.requires_grad and p.ndim == 2] | ||
| adamw_params = [p for p in model.parameters() if p.requires_grad and p.ndim != 2] | ||
|
|
||
| adjust_lr_fn = muon_cfg.get("adjust_lr_fn", None) or "original" | ||
| # muon never writes the adjusted lr back to param_groups[...]["lr"], so this is the only | ||
| # place where the lr that is really applied to the 2D parameters is visible | ||
| self.muon_effective_lr_factor: float = float( | ||
| np.median([_muon_adjust_lr_factor(p.shape, adjust_lr_fn) for p in muon_params]) | ||
| ) | ||
|
|
||
| if is_root(): | ||
| logger.info( | ||
| f"Using muon optimizer: {len(muon_params)} params (ndim == 2) via muon, " | ||
| f"{len(adamw_params)} params (ndim != 2) via adamw, " | ||
| f"shared lr_max={lr_cfg.lr_max:.3g}, median {adjust_lr_fn} " | ||
| f"factor={self.muon_effective_lr_factor:.3g} " | ||
| f"(muon effective lr_max={lr_cfg.lr_max * self.muon_effective_lr_factor:.3g})" | ||
| ) | ||
|
|
||
| muon_optimizer = torch.optim.Muon( | ||
| muon_params, | ||
| lr=lr_cfg.lr_start, | ||
| weight_decay=optimizer_cfg.weight_decay, | ||
| momentum=muon_cfg.get("momentum", 0.95), | ||
| nesterov=muon_cfg.get("nesterov", True), | ||
| ns_steps=muon_cfg.get("ns_steps", 5), | ||
| eps=muon_cfg.get("eps", 1e-7), | ||
| adjust_lr_fn=muon_cfg.get("adjust_lr_fn", None), | ||
| ) | ||
| adamw_optimizer = torch.optim.AdamW( | ||
| adamw_params, | ||
| lr=lr_cfg.lr_start, | ||
| weight_decay=optimizer_cfg.weight_decay, | ||
| fused=True, | ||
| **_adamw_betas_eps(optimizer_cfg, kappa), | ||
| ) | ||
|
|
||
| super().__init__([muon_optimizer, adamw_optimizer], ["muon", "adamw"], lr_cfg.lr_start) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| # (C) Copyright 2025 WeatherGenerator contributors. | ||
| # | ||
| # This software is licensed under the terms of the Apache Licence Version 2.0 | ||
| # which can be obtained at http://www.apache.org/licenses/LICENSE-2.0. | ||
| # | ||
| # In applying this licence, ECMWF does not waive the privileges and immunities | ||
| # granted to it by virtue of its status as an intergovernmental organisation | ||
| # nor does it submit to any jurisdiction. | ||
|
|
||
| import torch | ||
|
|
||
| from weathergen.train.optimizer.optimizer import AdamW, Muon, OptimizerBase | ||
|
|
||
| _OPTIMIZER_CLASSES = {"adamw": AdamW, "muon": Muon} | ||
|
|
||
|
|
||
| def build_optimizer(model: torch.nn.Module, optimizer_cfg, lr_cfg, kappa: float) -> OptimizerBase: | ||
| """ | ||
| Builds the optimizer for a model, according to optimizer_cfg.name ("adamw" or "muon"). | ||
| Returns a torch.optim.Optimizer (an OptimizerBase subclass), which drives one optimizer per | ||
| class of parameters internally but behaves as a single optimizer with a single learning rate. | ||
| """ | ||
| optimizer_name = optimizer_cfg.get("name", "adamw").lower() | ||
| assert optimizer_name in _OPTIMIZER_CLASSES, ( | ||
| f"Unsupported optimizer '{optimizer_name}', expected one of {list(_OPTIMIZER_CLASSES)}" | ||
| ) | ||
| return _OPTIMIZER_CLASSES[optimizer_name](model, optimizer_cfg, lr_cfg, kappa) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Only tangentially related to this PR, but do we want to remove this scaling of beta parameters with batch size because I think it makes setting those variables so much more complicated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we then hardcode it for the 2-node, 4-GPU setup?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We would need at least some evidence that it's neutral and doesn't affect skill.