Files
ai-toolkit/toolkit/optimizers/automagic4.py
T
2026-10-23 14:00:27 -06:00

260 lines
11 KiB
Python

"""
NOTE: This is experimental and under active development; expect breaking changes and bugs. Feedback welcome.
"""
import math
import torch
from toolkit.optimizers.automagic3 import Automagic3
class Automagic4(Automagic3):
"""
Automagic v4: Automagic3 with sensitivity-balanced updates.
v3 gives every element an Adam-normalized step of about lr, so how far one
step moves the OUTPUT scales with that weight's sensitivity. A single shared
lr cannot fit a model whose tensors differ in sensitivity by orders of
magnitude (a head reading a huge raw residual, a modulation shared by every
layer): at the lr the bulk needs, the sensitive ones overshoot.
v4 multiplies each element's (clipped) v3 update by
balance = min(1, reference / sensitivity)
sensitivity = sqrt(bias-corrected second moment). For a noise-dominated MSE
loss (diffusion) gradient power tracks the Gauss-Newton curvature, i.e. how
far the output moves per unit change of the weight. It comes from the same
row/col (or 1D) state v3 keeps, so it is free and per element: per column
for a head whose input dims differ in scale, per row for a modulation.
Above the reference the step becomes Newton-like (grad / v), which is
invariant to rescaling the weights -- including coupled Q/K-style
rescalings, so it cannot seed the fights the shared group lr prevents.
One-sided on purpose: low gradient power cannot be told apart from a weight
that is gated off (a LoRA down while its zero-initialized up is still ~0,
dead units), where Newton would take an unbounded step. Those keep v3's
normalized step, which is what gets zero-initialized LoRA pairs moving.
reference: the ``sensitivity_quantile`` (default 0.9) quantile of the
per-tensor geometric-mean sensitivity, weighted by element count, from the
previous step. High so a large near-dead mass (LoRA downs at init are about
half of a LoRA's elements) cannot drag it down and stall everything above
it; the outliers it targets are a few percent of elements.
The first step only measures (second moments and the reference): without a
reference the sensitive tensors would take a full v3 step.
lr controller: re-centered flip-rate vote. v3's neutral point is a 50% sign
flip rate, but a weight bouncing on gradient noise at its minimum has
lag-1 gradient correlation -effective_lr * curvature / 2 at any lr > 0, so
v3 only balances at lr = 0 and anneals once the signal runs out. v4 votes
(neutral - observed) flip rate per element, with
neutral = 0.5 + asin(step_target / 2) / pi
the flip rate of a stationary weight whose effective_lr * curvature equals
``step_target`` (default 0.1; 2 is the stability edge). The lr then has a
fixed point set by step_target, with or without signal: trends flip less
and raise it, overshoot flips more and lowers it. Votes are weighted by the
balanced |update| (output impact). No gain: near the fixed point the flip
rate moves ~0.16 * step_target per e-fold of lr, so the loop gain over the
H-step window lag stays well below 1.
"""
def __init__(
self,
params,
*args,
sensitivity_quantile: float = 0.9,
step_target: float = 0.1,
**kwargs,
):
if not 0.0 < sensitivity_quantile <= 1.0:
raise ValueError(
f"sensitivity_quantile ({sensitivity_quantile}) must be in (0, 1]"
)
if not 0.0 < step_target < 2.0:
raise ValueError(f"step_target ({step_target}) must be in (0, 2)")
self.sensitivity_quantile = float(sensitivity_quantile)
self.step_target = float(step_target)
self._neutral_flip = 0.5 + math.asin(self.step_target / 2.0) / math.pi
super().__init__(params, *args, **kwargs)
# ------------------------------------------------------------------ utils
@staticmethod
def _ema_(
state_t: torch.Tensor, sample: torch.Tensor, beta2: float
) -> torch.Tensor:
# fp32 beta2-EMA; low-precision state is written back
v = state_t if state_t.dtype == torch.float32 else state_t.to(torch.float32)
v.mul_(beta2).add_(sample, alpha=1.0 - beta2)
if v is not state_t:
state_t.copy_(v)
return v
def _rebuild_group_index(self) -> None:
super()._rebuild_group_index()
n = len(self.param_groups)
self._group_ref: list = [None] * n
self._group_sens_log: list = [[] for _ in range(n)]
self._group_sens_numel: list = [[] for _ in range(n)]
def _init_state(self, p: torch.Tensor, group: dict) -> None:
super()._init_state(p, group)
gi = self._param_group_index.get(p)
ref = self._group_ref[gi] if gi is not None else None
if ref is not None:
self.state[p]["sens_ref"] = ref.to(p.device, copy=True)
# -------------------------------------------------------------- per-param
@torch.no_grad()
def _update_param(self, p: torch.Tensor, group: dict) -> None:
if p.grad is None:
return
state = self.state[p]
if len(state) == 0:
self._init_state(p, group)
grad = p.grad
if grad.is_sparse:
raise RuntimeError("Automagic4 does not support sparse gradients.")
if grad.dtype != torch.float32:
grad = grad.to(torch.float32)
grad.nan_to_num_(nan=0.0, posinf=0.0, neginf=0.0)
beta2 = group["beta2"]
eps = group["eps"]
sq = grad * grad
if p.dim() >= 2:
row = self._ema_(state["exp_avg_sq_row"], sq.mean(dim=-1).add_(eps), beta2)
col = self._ema_(state["exp_avg_sq_col"], sq.mean(dim=-2).add_(eps), beta2)
del sq
# mean log of the factored v = row (x) col / mean(row)
log_v = (
row.log().mean(-1) - row.mean(-1).log() + col.log().mean(-1)
).mean()
inv_sqrt_v = self._approx_sq_grad(row, col)
else:
v = self._ema_(state["exp_avg_sq"], sq, beta2).add(eps)
del sq
log_v = v.log().mean()
inv_sqrt_v = v.rsqrt_()
state["step"] += 1
bias_corr = 1.0 - beta2 ** state["step"]
gi = self._param_group_index.get(p)
if gi is not None:
self._group_sens_log[gi].append(0.5 * (log_v - math.log(bias_corr)))
self._group_sens_numel[gi].append(p.numel())
ref = state.get("sens_ref")
if ref is None:
# measurement-only first step (no reference yet)
p.grad = None
return
update = grad.mul_(inv_sqrt_v)
update.div_((self._rms(update) / group["clip_threshold"]).clamp_(min=1.0))
update.clamp_(-group["clip_threshold"], group["clip_threshold"])
# balance = min(1, ref / sensitivity), sensitivity = sqrt(v / bias_corr)
balance = inv_sqrt_v.mul_(ref * math.sqrt(bias_corr)).clamp_(max=1.0)
update.mul_(balance)
del balance, inv_sqrt_v
# flip-rate vote (see class docstring), pooled per group, applied in .step()
cur_bits = update.gt(0.0)
hist = state["sign_history"]
idx = state["hist_idx"]
H = hist.shape[0]
lr_t = state["lr"]
hist[idx].copy_(self._pack_bits(cur_bits))
state["hist_idx"] = (idx + 1) % H
fill = min(H, state["hist_fill"] + 1)
state["hist_fill"] = fill
if fill == H and gi is not None:
_, shifts = self._pack_consts(hist.device)
chron = torch.roll(hist, -state["hist_idx"], dims=0)
bits = (
(chron.unsqueeze(-1) >> shifts)
.bitwise_and_(1)
.view(H, -1)[:, : update.numel()]
)
flips = (bits[1:] ^ bits[:-1]).sum(0, dtype=torch.int16)
w = update.abs().view(-1)
den = w.sum()
num = den * self._neutral_flip - (w * flips).sum() / (H - 1)
if self._group_num[gi] is None:
self._group_num[gi] = num
self._group_den[gi] = den
else:
acc = self._group_num[gi]
if num.device != acc.device:
num = num.to(acc.device)
den = den.to(acc.device)
acc.add_(num)
self._group_den[gi].add_(den)
wd = group["weight_decay"]
if p.dtype == torch.float32:
if wd != 0.0:
update.add_(p, alpha=wd)
p.addcmul_(update, lr_t, value=-1.0)
else:
new_p_fp32 = p.to(torch.float32)
if wd != 0.0:
update.add_(new_p_fp32, alpha=wd)
new_p_fp32.addcmul_(update, lr_t, value=-1.0)
self._stochastic_copy_(p, new_p_fp32)
p.grad = None
# ----------------------------------------------------------- optimizer API
def _apply_group_votes(self) -> None:
self._update_sensitivity_refs()
super()._apply_group_votes()
def _update_sensitivity_refs(self) -> None:
# numel-weighted quantile of this step's per-tensor log sensitivities; no GPU sync
for gi, group in enumerate(self.param_groups):
logs = self._group_sens_log[gi]
if not logs:
continue
dev = logs[0].device
vals = torch.stack([t.to(dev) for t in logs])
weights = torch.tensor(
self._group_sens_numel[gi], dtype=torch.float64, device=dev
)
order = vals.argsort()
cum = weights[order].cumsum(0)
pos = torch.searchsorted(cum, cum[-1:] * self.sensitivity_quantile)
ref = vals[order][pos.clamp_(max=len(logs) - 1)].squeeze(0).exp_()
self._group_ref[gi] = ref
for p in group["params"]:
st = self.state.get(p)
if not st:
continue
cur = st.get("sens_ref")
if cur is None:
st["sens_ref"] = ref.to(p.device, copy=True)
else:
cur.copy_(ref)
self._group_sens_log[gi] = []
self._group_sens_numel[gi] = []
def load_state_dict(self, state_dict):
super().load_state_dict(state_dict)
# parent cast the reference to param dtype; restore fp32 and the group mirror
for gi, group in enumerate(self.param_groups):
for p in group["params"]:
st = self.state.get(p)
ref = st.get("sens_ref") if st is not None else None
if isinstance(ref, torch.Tensor):
st["sens_ref"] = ref.to(torch.float32)
if self._group_ref[gi] is None:
self._group_ref[gi] = st["sens_ref"].clone()