Files
wehub-resource-sync a203934033
Lint test / lint (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:34:58 +08:00

459 lines
15 KiB
Python

import math
import threading
import torch
import torch.nn.functional as F
from contextlib import suppress
from torch.optim import Optimizer
from typing import TYPE_CHECKING, Optional
from .base import OptimizerCallback
if TYPE_CHECKING:
from swift.trainers import TrainingArguments
class _MaxLogitsTracker:
"""
Collect a per-step scalar max logits value even when training loop can't pass it into optimizer.step().
- Eager attention: patch torch.softmax / F.softmax to capture exact softmax input max (attention scores).
- SDPA / FlashAttention: logits not exposed; record conservative upper bound via norms:
max(qk^T * scale) <= max||q|| * max||k|| * scale
Note: This is a GLOBAL scalar for the whole step (not per-layer, not per-head).
"""
_tls = threading.local()
_enabled = False
_patched_softmax = False
_patched_sdpa = False
_patched_flash = False
_orig_torch_softmax = None
_orig_F_softmax = None
_orig_sdpa = None
_orig_flash_attn_func = None
@classmethod
def _get_and_reset(cls) -> Optional[float]:
v = getattr(cls._tls, 'max_logits', None)
cls._tls.max_logits = None
return v
@classmethod
def _update(cls, v: float):
if v is None:
return
cur = getattr(cls._tls, 'max_logits', None)
if cur is None or v > cur:
cls._tls.max_logits = float(v)
@classmethod
def enable_softmax(cls):
if cls._patched_softmax:
return
cls._patched_softmax = True
cls._orig_torch_softmax = torch.softmax
cls._orig_F_softmax = F.softmax
def _maybe_capture(x: torch.Tensor, dim):
# attention scores softmax: usually [B,H,Lq,Lk], dim=-1
if not isinstance(x, torch.Tensor):
return
if x.dim() != 4:
return
if dim is None or not (dim == -1 or dim == x.dim() - 1):
return
with suppress(Exception):
cls._update(float(x.detach().float().amax().item()))
def _torch_softmax(x, dim=None, dtype=None):
with suppress(Exception):
_maybe_capture(x, dim)
return cls._orig_torch_softmax(x, dim=dim, dtype=dtype)
def _F_softmax(x, dim=None, _stacklevel=3, dtype=None):
with suppress(Exception):
_maybe_capture(x, dim)
return cls._orig_F_softmax(x, dim=dim, _stacklevel=_stacklevel, dtype=dtype)
torch.softmax = _torch_softmax
F.softmax = _F_softmax
@classmethod
def enable_sdpa(cls):
if cls._patched_sdpa:
return
cls._patched_sdpa = True
if not hasattr(F, 'scaled_dot_product_attention'):
return
cls._orig_sdpa = F.scaled_dot_product_attention
def _sdpa(query, key, value, attn_mask=None, dropout_p=0.0, is_causal=False, scale=None, enable_gqa=False):
with suppress(Exception):
if isinstance(query, torch.Tensor) and isinstance(key, torch.Tensor):
q = query.detach()
k = key.detach()
# upper bound using vector norms
qn = q.float().norm(p=2, dim=-1).max().item()
kn = k.float().norm(p=2, dim=-1).max().item()
d = q.size(-1)
s = float(scale) if scale is not None else (1.0 / math.sqrt(float(d)))
cls._update(qn * kn * s)
return cls._orig_sdpa(
query,
key,
value,
attn_mask=attn_mask,
dropout_p=dropout_p,
is_causal=is_causal,
scale=scale,
enable_gqa=enable_gqa,
)
F.scaled_dot_product_attention = _sdpa
@classmethod
def enable_flash_attn(cls):
if cls._patched_flash:
return
cls._patched_flash = True
try:
import flash_attn.flash_attn_interface as _fai
flash_attn_func = _fai.flash_attn_func
except Exception:
return
cls._orig_flash_attn_func = flash_attn_func
def _flash_attn(q,
k,
v,
dropout_p=0.0,
softmax_scale=None,
causal=False,
window_size=(-1, -1),
alibi_slopes=None,
deterministic=False,
return_attn_probs=False):
with suppress(Exception):
if isinstance(q, torch.Tensor) and isinstance(k, torch.Tensor):
qn = q.detach().float().norm(p=2, dim=-1).max().item()
kn = k.detach().float().norm(p=2, dim=-1).max().item()
d = q.size(-1)
s = float(softmax_scale) if softmax_scale is not None else (1.0 / math.sqrt(float(d)))
cls._update(qn * kn * s)
return cls._orig_flash_attn_func(
q,
k,
v,
dropout_p=dropout_p,
softmax_scale=softmax_scale,
causal=causal,
window_size=window_size,
alibi_slopes=alibi_slopes,
deterministic=deterministic,
return_attn_probs=return_attn_probs,
)
_fai.flash_attn_func = _flash_attn
@classmethod
def enable_all(cls):
if cls._enabled:
return
cls._enabled = True
cls.enable_softmax()
cls.enable_sdpa()
cls.enable_flash_attn()
@classmethod
def consume(cls) -> Optional[float]:
return cls._get_and_reset()
class MuonClip(Optimizer):
"""
MuonClip (stable version):
- Muon-style update for apply_muon=True (2D weights): momentum buffer + Moonlight polynomial NS orthogonalization.
- Other params (apply_muon=False): simple momentum SGD (kept minimal; you can switch to AdamW if needed).
- QK-Clip uses a scalar max_logits (exact in eager, upper bound in sdpa/flash) and applies gamma_sqrt scaling
to Q/K weights marked with is_qk=True.
"""
def __init__(
self,
params,
lr: float = 2e-4,
momentum: float = 0.95,
weight_decay: float = 0.1,
nesterov: bool = False,
newton_schulz_steps: int = 5,
qk_clip_tau: float = 10000.0,
qk_clip_enabled: bool = True,
rms_scale_factor: float = 0.2,
):
defaults = dict(
lr=lr,
momentum=momentum,
weight_decay=weight_decay,
nesterov=nesterov,
newton_schulz_steps=newton_schulz_steps,
qk_clip_tau=qk_clip_tau,
qk_clip_enabled=qk_clip_enabled,
rms_scale_factor=rms_scale_factor,
)
super().__init__(params, defaults)
_MaxLogitsTracker.enable_all()
@staticmethod
@torch.no_grad()
def newton_schulz(G: torch.Tensor, steps: int = 5, eps: float = 1e-7) -> torch.Tensor:
"""
Moonlight/Muon polynomial Newton-Schulz iteration (stable).
Works for rectangular matrices by transposing when needed.
"""
# constants from your previous stable implementation
a, b, c = (3.4445, -4.7750, 2.0315)
X = G.bfloat16() / (G.norm() + eps)
transposed = False
if G.size(0) > G.size(1):
X = X.T
transposed = True
for _ in range(steps):
A = X @ X.T
B = b * A + c * A @ A
X = a * X + B @ X
if transposed:
X = X.T
return X.to(G.dtype)
def _is_qk_weight(self, group) -> bool:
return bool(group.get('is_qk', False))
@torch.no_grad()
def step(self, closure=None, max_logits: Optional[float] = None):
loss = None
if closure is not None:
with torch.enable_grad():
loss = closure()
# fallback: collect scalar max_logits from tracker if not provided
if max_logits is None:
max_logits = _MaxLogitsTracker.consume()
for group in self.param_groups:
lr = float(group['lr'])
momentum = float(group['momentum'])
weight_decay = float(group['weight_decay'])
nesterov = bool(group.get('nesterov', False))
ns_steps = int(group.get('newton_schulz_steps', 5))
qk_clip_tau = float(group.get('qk_clip_tau', 10000.0))
qk_clip_enabled = bool(group.get('qk_clip_enabled', True))
apply_muon = bool(group.get('apply_muon', True))
is_qk_group = self._is_qk_weight(group)
for p in group['params']:
if p.grad is None:
continue
grad = p.grad
state = self.state[p]
if len(state) == 0:
state['momentum_buffer'] = torch.zeros_like(p)
state['step'] = 0
buf = state['momentum_buffer']
state['step'] += 1
buf.mul_(momentum).add_(grad)
# build update
if apply_muon and p.ndim >= 2:
orth = self.newton_schulz(buf, steps=ns_steps)
n, m = p.shape[0], p.shape[1]
rms_scale_factor = float(group.get('rms_scale_factor', 0.2))
rms_scale = math.sqrt(max(n, m)) * rms_scale_factor
update = orth * rms_scale
else:
update = buf
if nesterov:
update = grad.add(update, alpha=momentum)
# decoupled-ish weight decay
if weight_decay != 0:
p.mul_(1 - lr * weight_decay)
# QK-Clip (scalar)
if qk_clip_enabled and is_qk_group and (max_logits is not None):
if max_logits > qk_clip_tau:
gamma = qk_clip_tau / float(max_logits)
gamma_sqrt = math.sqrt(gamma)
# scale weight and update (matches your previous stable version)
p.mul_(gamma_sqrt)
update = update * gamma_sqrt
# apply update
p.add_(update, alpha=-lr)
return loss
class MuonClipOptimizerCallback(OptimizerCallback):
def create_optimizer(self, model=None):
args = self.args
if model is None:
model = self.trainer.model
# parse args.optim_args
optim_args = {}
raw = getattr(args, 'optim_args', None)
if raw:
for mapping in raw.replace(' ', '').split(','):
if not mapping:
continue
if '=' not in mapping:
continue
key, value = mapping.split('=', 1)
if not key:
continue
lv = value.lower()
if lv in ('true', 'false'):
value = (lv == 'true')
else:
try:
f = float(value)
value = int(f) if f.is_integer() else f
except ValueError:
pass
optim_args[key] = value
# resolve keys like create_muon_optimizer
model_arch = model.model_meta.model_arch
embed_key = getattr(model_arch, 'embedding', None) or 'embed_tokens'
lm_head_key = getattr(model_arch, 'lm_head', None) or 'lm_head'
# hyperparams (single-source of truth)
lr = args.learning_rate
weight_decay = optim_args.get('weight_decay', args.weight_decay)
momentum = optim_args.get('momentum', 0.95)
nesterov = optim_args.get('nesterov', False)
newton_schulz_steps = optim_args.get('newton_schulz_steps', 5)
qk_clip_tau = optim_args.get('qk_clip_tau', 100.0)
qk_clip_enabled = optim_args.get('qk_clip_enabled', True)
rms_scale_factor = optim_args.get('rms_scale_factor', 0.2)
# collect trainable params and group them
muon_named = []
rest_named = []
for name, p in model.named_parameters():
if not p.requires_grad:
continue
is_muon_candidate = (p.ndim >= 2 and embed_key not in name and lm_head_key not in name)
if is_muon_candidate:
muon_named.append((name, p))
else:
rest_named.append((name, p))
def _is_qk_name(name: str) -> bool:
ln = name.lower()
# qwen2.5/qwen3 common patterns
return ('q_proj' in ln) or ('k_proj' in ln) or ('.wq' in ln) or ('.wk' in ln) or ('/wq' in ln) or ('/wk'
in ln)
qk_muon_params = []
other_muon_params = []
for name, p in muon_named:
(qk_muon_params if _is_qk_name(name) else other_muon_params).append(p)
rest_params = [p for _, p in rest_named]
# build param groups
base_group_config = {
'lr': lr,
'momentum': momentum,
'weight_decay': weight_decay,
'nesterov': nesterov,
'newton_schulz_steps': newton_schulz_steps,
'qk_clip_tau': qk_clip_tau,
'qk_clip_enabled': qk_clip_enabled,
'rms_scale_factor': rms_scale_factor,
}
param_groups = []
if qk_muon_params:
group = base_group_config.copy()
group.update({
'params': qk_muon_params,
'apply_muon': True,
'is_qk': True,
})
param_groups.append(group)
if other_muon_params:
group = base_group_config.copy()
group.update({
'params': other_muon_params,
'apply_muon': True,
'is_qk': False,
})
param_groups.append(group)
if rest_params:
group = base_group_config.copy()
group.update({
'params': rest_params,
'apply_muon': False,
'is_qk': False,
})
param_groups.append(group)
# safety fallback
if not param_groups:
all_params = [p for _, p in model.named_parameters() if p.requires_grad]
param_groups = [{
'params': all_params,
'lr': lr,
'momentum': momentum,
'weight_decay': weight_decay,
'nesterov': nesterov,
'newton_schulz_steps': newton_schulz_steps,
'qk_clip_tau': qk_clip_tau,
'qk_clip_enabled': qk_clip_enabled,
'apply_muon': True,
'is_qk': False,
}]
# Only pass supported init kwargs; real behavior comes from param_groups
optimizer = MuonClip(
param_groups,
lr=lr,
momentum=momentum,
weight_decay=weight_decay,
nesterov=nesterov,
newton_schulz_steps=newton_schulz_steps,
qk_clip_tau=qk_clip_tau,
qk_clip_enabled=qk_clip_enabled,
rms_scale_factor=rms_scale_factor,
)
return optimizer