Make Nora+NAdamW the default optimizer

This commit is contained in:
QueenAmish
2026-07-31 23:16:39 +08:00
parent 7aa5ed09d9
commit 04899a2b15
11 changed files with 1010 additions and 105 deletions
+4
View File
@@ -30,6 +30,8 @@ class TrainConfig(BaseConfig):
strategy (str): Training strategy (seq, sft, dpo, grpo, online_*).
dataset (Dataset): Dataset for training.
optimizer_fn (Callable[[nn.Module], Optimizer]): Optimizer factory for training.
optimizer_name (Optional[str]): Serializable built-in optimizer identifier. Defaults to None.
optimizer_hyperparameters (Dict[str, Any]): Serializable optimizer settings. Defaults to {}.
scheduler_fn (Callable[[Optimizer], LRScheduler]): Scheduler factory for training.
n_epoch (int): Number of epochs for training. Defaults to 1.
batch_per_device (int): Batch size per device. Defaults to 4.
@@ -74,6 +76,8 @@ class TrainConfig(BaseConfig):
dataset: Dataset
optimizer_fn: Callable[[nn.Module], Optimizer]
scheduler_fn: Callable[[Optimizer], LRScheduler]
optimizer_name: Optional[str] = None
optimizer_hyperparameters: Dict[str, Any] = field(default_factory=dict)
n_epoch: int = 1
batch_per_device: int = 4
grad_accum_steps: int = 1
+36
View File
@@ -0,0 +1,36 @@
"""Optimizer implementations and factory registration."""
from torch.optim import Optimizer
from astrai.factory import BaseFactory
class OptimizerFactory(BaseFactory[Optimizer]):
"""Factory for built-in training optimizers."""
from astrai.optim.muon_mix import MuonMix
from astrai.optim.nora_nadamw import (
NAdamW,
Nora,
NoraNAdamW,
OptimizerParameterGroups,
nora_direction,
nora_lr_scale,
partition_optimizer_parameters,
)
OptimizerFactory.register("nora_nadamw")(NoraNAdamW)
OptimizerFactory.register("muon_adamw")(MuonMix)
__all__ = [
"MuonMix",
"NAdamW",
"Nora",
"NoraNAdamW",
"OptimizerFactory",
"OptimizerParameterGroups",
"nora_direction",
"nora_lr_scale",
"partition_optimizer_parameters",
]
+91
View File
@@ -0,0 +1,91 @@
"""Legacy Muon + AdamW combined optimizer."""
from typing import Any
import torch
from torch import Tensor, nn, optim
class MuonMix(optim.Optimizer):
"""Combined Muon (matrix) + AdamW (non-matrix) optimizer."""
optimizer_name = "muon_adamw"
def __init__(
self,
model: nn.Module,
lr: float = 3e-4,
weight_decay: float = 0.1,
momentum: float = 0.95,
nesterov: bool = True,
ns_steps: int = 5,
adjust_lr_fn: str = "match_rms_adamw",
):
defaults = {
"lr": lr,
"weight_decay": weight_decay,
"momentum": momentum,
"nesterov": nesterov,
"ns_steps": ns_steps,
"adjust_lr_fn": adjust_lr_fn,
}
params = [param for param in model.parameters() if param.requires_grad]
super().__init__(params, defaults)
matrix_params: list[Tensor] = []
other_params: list[Tensor] = []
for name, param in model.named_parameters():
if not param.requires_grad:
continue
if (
param.dim() >= 2
and "norm" not in name
and "bias" not in name
and "embed" not in name
and "lm_head" not in name
):
matrix_params.append(param)
else:
other_params.append(param)
self.muon = optim.Muon(
matrix_params,
lr=lr,
weight_decay=weight_decay,
momentum=momentum,
nesterov=nesterov,
ns_steps=ns_steps,
adjust_lr_fn=adjust_lr_fn,
)
self.adamw = optim.AdamW(
[{"params": other_params, "weight_decay": 0.0}],
lr=lr,
betas=(0.9, 0.95),
fused=True,
)
self.param_groups = [*self.muon.param_groups, *self.adamw.param_groups]
@torch.no_grad()
def step(self, closure=None):
self.muon.step(closure)
self.adamw.step(closure)
def zero_grad(self, set_to_none: bool = True):
self.muon.zero_grad(set_to_none=set_to_none)
self.adamw.zero_grad(set_to_none=set_to_none)
def state_dict(self) -> dict[str, Any]:
return {
"muon": self.muon.state_dict(),
"adamw": self.adamw.state_dict(),
}
def load_state_dict(self, state_dict: dict[str, Any]):
if "muon" not in state_dict or "adamw" not in state_dict:
raise ValueError(
"Checkpoint optimizer state is not compatible with muon_adamw"
)
self.muon.load_state_dict(state_dict["muon"])
self.adamw.load_state_dict(state_dict["adamw"])
self.param_groups = [*self.muon.param_groups, *self.adamw.param_groups]
+379
View File
@@ -0,0 +1,379 @@
"""Nora matrix optimizer combined with Nesterov AdamW."""
import math
from dataclasses import dataclass
from typing import Any
import torch
from torch import Tensor, nn
from torch.distributed.tensor import DTensor, Shard
from torch.optim import Optimizer
from astrai.model.components.embedding import Embedding
from astrai.model.components.linear import Linear
from astrai.model.components.lora import LoRALinear
from astrai.model.components.norm import RMSNorm
NORA_EPS = 1e-10
def _row_normalize(tensor: Tensor, eps: float) -> Tensor:
return tensor / tensor.norm(dim=-1, keepdim=True).clamp(min=eps)
def nora_direction(update: Tensor, param: Tensor, eps: float = NORA_EPS) -> Tensor:
"""Project an update onto each parameter row's tangent space and normalize."""
theta_hat = _row_normalize(param.to(torch.float32), eps)
update_fp32 = update.to(torch.float32)
radial = (update_fp32 * theta_hat).sum(dim=-1, keepdim=True) * theta_hat
direction = _row_normalize(update_fp32 - radial, eps)
return direction.to(update.dtype)
def nora_lr_scale(lr: float, shape: torch.Size) -> float:
"""Scale Nora's LR for tall ``[d_out, d_in]`` linear weights."""
return lr * math.sqrt(max(1.0, shape[-2] / shape[-1]))
def _validate_complete_rows(param: Tensor) -> None:
if not isinstance(param, DTensor):
return
last_dim = param.ndim - 1
for placement in param.placements:
if isinstance(placement, Shard) and placement.dim % param.ndim == last_dim:
raise ValueError(
"Nora requires complete parameter rows, but this DTensor is sharded "
"along its last dimension"
)
class Nora(Optimizer):
"""Normalized Orthogonal Row Alignment for two-dimensional matrices."""
def __init__(
self,
params,
lr: float = 5e-3,
weight_decay: float = 0.0,
momentum: float = 0.95,
beta: float = 0.95,
nesterov: bool = True,
eps: float = NORA_EPS,
):
if lr < 0:
raise ValueError(f"Invalid learning rate: {lr}")
if weight_decay < 0:
raise ValueError(f"Invalid weight decay: {weight_decay}")
if not 0 <= momentum <= 1:
raise ValueError(f"Invalid momentum: {momentum}")
if not 0 <= beta < 1:
raise ValueError(f"Invalid beta: {beta}")
if eps <= 0:
raise ValueError(f"Invalid epsilon: {eps}")
defaults = {
"lr": lr,
"weight_decay": weight_decay,
"momentum": momentum,
"beta": beta,
"nesterov": nesterov,
"eps": eps,
}
super().__init__(params, defaults)
for group in self.param_groups:
for param in group["params"]:
if param.ndim != 2:
raise ValueError(
f"Nora only supports 2D matrices, got shape {tuple(param.shape)}"
)
_validate_complete_rows(param)
@torch.no_grad()
def step(self, closure=None):
loss = None
if closure is not None:
with torch.enable_grad():
loss = closure()
for group in self.param_groups:
lr = group["lr"]
weight_decay = group["weight_decay"]
momentum = group["momentum"]
beta = group["beta"]
nesterov = group["nesterov"]
eps = group["eps"]
for param in group["params"]:
if param.grad is None:
continue
if param.grad.is_sparse:
raise RuntimeError("Nora does not support sparse gradients")
grad = param.grad
state = self.state[param]
momentum_buffer = state.get("momentum_buffer")
if momentum_buffer is None:
momentum_buffer = torch.zeros_like(grad)
momentum_buffer.lerp_(grad, 1 - beta)
update = (
grad.lerp(momentum_buffer, momentum)
if nesterov
else momentum_buffer
)
direction = nora_direction(update, param, eps)
if weight_decay != 0:
param.mul_(1 - lr * weight_decay)
param.add_(direction, alpha=-nora_lr_scale(lr, param.shape))
state["momentum_buffer"] = momentum_buffer
return loss
class NAdamW(Optimizer):
"""AdamW using the reference Nesterov first-moment update."""
def __init__(
self,
params,
lr: float = 3e-4,
betas: tuple[float, float] = (0.9, 0.999),
eps: float = 1e-8,
weight_decay: float = 0.1,
):
beta1, beta2 = betas
if lr < 0:
raise ValueError(f"Invalid learning rate: {lr}")
if not 0 <= beta1 < 1 or not 0 <= beta2 < 1:
raise ValueError(f"Invalid betas: {betas}")
if eps <= 0:
raise ValueError(f"Invalid epsilon: {eps}")
if weight_decay < 0:
raise ValueError(f"Invalid weight decay: {weight_decay}")
defaults = {
"lr": lr,
"betas": betas,
"eps": eps,
"weight_decay": weight_decay,
}
super().__init__(params, defaults)
@torch.no_grad()
def step(self, closure=None):
loss = None
if closure is not None:
with torch.enable_grad():
loss = closure()
for group in self.param_groups:
beta1, beta2 = group["betas"]
eps = group["eps"]
lr = group["lr"]
weight_decay = group["weight_decay"]
for param in group["params"]:
if param.grad is None:
continue
if param.grad.is_sparse:
raise RuntimeError("NAdamW does not support sparse gradients")
grad = param.grad
state = self.state[param]
if not state:
state["step"] = 0
state["m"] = torch.zeros_like(param)
state["v"] = torch.zeros_like(param)
state["step"] += 1
first_moment = state["m"]
second_moment = state["v"]
first_moment.mul_(beta1).add_(grad, alpha=1 - beta1)
second_moment.mul_(beta2).addcmul_(grad, grad, value=1 - beta2)
bias_correction1 = 1 - beta1 ** state["step"]
bias_correction2 = 1 - beta2 ** state["step"]
nesterov_moment = (
beta1 * first_moment + (1 - beta1) * grad
) / bias_correction1
corrected_second_moment = second_moment / bias_correction2
if weight_decay != 0:
param.mul_(1 - lr * weight_decay)
param.addcdiv_(
nesterov_moment,
corrected_second_moment.sqrt().add_(eps),
value=-lr,
)
return loss
@dataclass
class OptimizerParameterGroups:
nora: list[Tensor]
nadamw_decay: list[Tensor]
nadamw_no_decay: list[Tensor]
def partition_optimizer_parameters(model: nn.Module) -> OptimizerParameterGroups:
"""Partition trainable parameters by module role and parameter identity."""
nora_ids: set[int] = set()
no_decay_ids: set[int] = set()
for module_name, module in model.named_modules():
if isinstance(module, LoRALinear):
for param in module.parameters(recurse=False):
if param.requires_grad:
no_decay_ids.add(id(param))
continue
if isinstance(module, (Embedding, RMSNorm)):
for param in module.parameters(recurse=False):
if param.requires_grad:
no_decay_ids.add(id(param))
continue
if not isinstance(module, Linear):
continue
if module.bias is not None and module.bias.requires_grad:
no_decay_ids.add(id(module.bias))
if not module.weight.requires_grad:
continue
if module_name.rsplit(".", 1)[-1] == "lm_head":
no_decay_ids.add(id(module.weight))
elif module.weight.ndim == 2:
nora_ids.add(id(module.weight))
nora: list[Tensor] = []
nadamw_decay: list[Tensor] = []
nadamw_no_decay: list[Tensor] = []
seen: set[int] = set()
for param in model.parameters():
param_id = id(param)
if not param.requires_grad or param_id in seen:
continue
seen.add(param_id)
if param_id in no_decay_ids or param.ndim <= 1:
nadamw_no_decay.append(param)
elif param_id in nora_ids:
nora.append(param)
else:
nadamw_decay.append(param)
trainable_ids = {id(param) for param in model.parameters() if param.requires_grad}
grouped_ids = {id(param) for param in [*nora, *nadamw_decay, *nadamw_no_decay]}
if grouped_ids != trainable_ids:
missing = len(trainable_ids - grouped_ids)
extra = len(grouped_ids - trainable_ids)
raise RuntimeError(
f"Optimizer parameter partition is incomplete: missing={missing}, extra={extra}"
)
return OptimizerParameterGroups(nora, nadamw_decay, nadamw_no_decay)
class NoraNAdamW(Optimizer):
"""Nora for internal linear weights and NAdamW for remaining parameters."""
optimizer_name = "nora_nadamw"
def __init__(
self,
model: nn.Module,
lr: float = 3e-4,
weight_decay: float = 0.1,
nora_lr: float = 5e-3,
nora_weight_decay: float = 0.0,
nora_beta: float = 0.95,
nora_momentum: float = 0.95,
):
groups = partition_optimizer_parameters(model)
all_params = [
*groups.nora,
*groups.nadamw_decay,
*groups.nadamw_no_decay,
]
if not all_params:
raise ValueError(
"Cannot build an optimizer for a model with no trainable parameters"
)
super().__init__(all_params, {})
self.nora = (
Nora(
groups.nora,
lr=nora_lr,
weight_decay=nora_weight_decay,
momentum=nora_momentum,
beta=nora_beta,
)
if groups.nora
else None
)
nadamw_groups = []
if groups.nadamw_decay:
nadamw_groups.append(
{"params": groups.nadamw_decay, "weight_decay": weight_decay}
)
if groups.nadamw_no_decay:
nadamw_groups.append(
{"params": groups.nadamw_no_decay, "weight_decay": 0.0}
)
self.nadamw = NAdamW(nadamw_groups, lr=lr) if nadamw_groups else None
self._refresh_param_groups()
def _refresh_param_groups(self) -> None:
self.param_groups = []
if self.nora is not None:
self.param_groups.extend(self.nora.param_groups)
if self.nadamw is not None:
self.param_groups.extend(self.nadamw.param_groups)
@torch.no_grad()
def step(self, closure=None):
loss = None
if closure is not None:
with torch.enable_grad():
loss = closure()
if self.nora is not None:
self.nora.step()
if self.nadamw is not None:
self.nadamw.step()
return loss
def zero_grad(self, set_to_none: bool = True):
if self.nora is not None:
self.nora.zero_grad(set_to_none=set_to_none)
if self.nadamw is not None:
self.nadamw.zero_grad(set_to_none=set_to_none)
def state_dict(self) -> dict[str, Any]:
return {
"nora": self.nora.state_dict() if self.nora is not None else None,
"nadamw": self.nadamw.state_dict() if self.nadamw is not None else None,
}
def load_state_dict(self, state_dict: dict[str, Any]):
if "muon" in state_dict or "adamw" in state_dict:
raise ValueError(
"Checkpoint uses muon_adamw state; select optimizer='muon_adamw' "
"to resume it"
)
if "nora" not in state_dict or "nadamw" not in state_dict:
raise ValueError(
"Checkpoint optimizer state is not compatible with nora_nadamw"
)
saved_nora = state_dict["nora"]
saved_nadamw = state_dict["nadamw"]
if (self.nora is None) != (saved_nora is None):
raise ValueError("Checkpoint Nora parameter groups do not match the model")
if (self.nadamw is None) != (saved_nadamw is None):
raise ValueError(
"Checkpoint NAdamW parameter groups do not match the model"
)
if self.nora is not None:
self.nora.load_state_dict(saved_nora)
if self.nadamw is not None:
self.nadamw.load_state_dict(saved_nadamw)
self._refresh_param_groups()