diff options
| -rw-r--r-- | ep_run/casc_eq_train.py | 4 | ||||
| -rw-r--r-- | ep_run/muon.py | 28 | ||||
| -rw-r--r-- | ep_run/psgd_vendor.py | 1568 |
3 files changed, 1597 insertions, 3 deletions
diff --git a/ep_run/casc_eq_train.py b/ep_run/casc_eq_train.py index d19504d..db6d71a 100644 --- a/ep_run/casc_eq_train.py +++ b/ep_run/casc_eq_train.py @@ -22,7 +22,7 @@ ap.add_argument('--wandb_run', default='') ap.add_argument('--kmax', type=int, default=8) # adaptive fb rounds cap ap.add_argument('--noguard', action='store_true') # diagnosis: skip only non-finite grads ap.add_argument('--untie', action='store_true') # separate readout matrix (untied from tok) -ap.add_argument('--opt', choices=['adamw', 'muon', 'sgdm', 'lion', 'olion', 'adafactor', 'signline', 'conslion', 'ditherlion', 'cautlion', 'olionns', 'olionk1', 'olionk2', 'olionk3'], default='adamw') +ap.add_argument('--opt', choices=['adamw', 'muon', 'sgdm', 'lion', 'olion', 'adafactor', 'signline', 'conslion', 'ditherlion', 'cautlion', 'olionns', 'olionk1', 'olionk2', 'olionk3', 'psgdquad'], default='adamw') ap.add_argument('--muon_lr', type=float, default=0.02) ap.add_argument('--tok_init', type=float, default=0.0) # >0: init tok/pos with this std (GPT-standard 0.02) ap.add_argument('--compile', action='store_true') # torch.compile each block (free speed where supported) @@ -345,7 +345,7 @@ if args.bf16: if args.untie: with torch.no_grad(): W_out.data = W_out.data.to(torch.bfloat16) print('[bf16] model cast to bfloat16 (E-accum + sigma stay fp32)', flush=True) -if args.opt in ('sgdm', 'lion', 'olion', 'adafactor', 'signline', 'conslion', 'ditherlion', 'cautlion', 'olionns', 'olionk1', 'olionk2', 'olionk3'): +if args.opt in ('sgdm', 'lion', 'olion', 'adafactor', 'signline', 'conslion', 'ditherlion', 'cautlion', 'olionns', 'olionk1', 'olionk2', 'olionk3', 'psgdquad'): from muon import build_alt opt, sched = build_alt(args.opt, blocks, all_params, args.lr, args.warmup, total_steps=(args.steps if args.cosine else 0), lr_min_ratio=args.lr_min_ratio, diff --git a/ep_run/muon.py b/ep_run/muon.py index 7bad679..86c81d3 100644 --- a/ep_run/muon.py +++ b/ep_run/muon.py @@ -157,6 +157,29 @@ class Adafactor2D(torch.optim.Optimizer): p.add_(u, alpha=-g_['lr']) +class PSGDQuadWrap(torch.optim.Optimizer): + """Xi-Lin Li's KronWhiten (dQ='QUAD') as a proper torch Optimizer so LambdaLR accepts it. + Our EP flow pre-populates p.grad; the closure hands PSGD a synthetic scalar whose autograd + gradient equals the stored p.grad (loss = sum <p, p.grad_detached>). Screening-tier: the + inner preconditioner state is NOT checkpointed (base state_dict covers param_groups only).""" + def __init__(self, params, lr=1e-3, momentum=0.95): + params = list(params) + super().__init__(params, dict(lr=lr)) + from psgd_vendor import KronWhiten + self._flat = [p for g_ in self.param_groups for p in g_['params']] + self.inner = KronWhiten(self._flat, preconditioner_init_scale=1.0, + lr_params=lr, lr_preconditioner=0.1, momentum=momentum, + whiten_grad=True, dQ="QUAD") + + @torch.no_grad() + def step(self, closure=None): + self.inner.lr_params = self.param_groups[0]['lr'] + flat = self._flat + def _closure(): + return sum((p * p.grad.detach()).sum() for p in flat if p.grad is not None) + self.inner.step(_closure) + + def build_alt(opt_name, blocks, other_params, lr, warmup, total_steps=0, lr_min_ratio=0.1, lr_matrix=None, wd=0.0): """Screening-tier builder for the optimizer price list: OPT on block matrices + AdamW on the @@ -181,7 +204,8 @@ def build_alt(opt_name, blocks, other_params, lr, warmup, total_steps=0, lr_min_ 'olionns': lambda: OLion(mats, lr=lm, wd=wd, ns_steps=0), 'olionk1': lambda: OLion(mats, lr=lm, wd=wd, ns_steps=1), 'olionk2': lambda: OLion(mats, lr=lm, wd=wd, ns_steps=2), - 'olionk3': lambda: OLion(mats, lr=lm, wd=wd, ns_steps=3)}[opt_name]() + 'olionk3': lambda: OLion(mats, lr=lm, wd=wd, ns_steps=3), + 'psgdquad': lambda: PSGDQuadWrap(mats, lr=lm)}[opt_name]() oa = torch.optim.AdamW(rest, lr=lr, weight_decay=1e-4) opts = [om, oa] if total_steps > 0: @@ -296,3 +320,5 @@ class DitherLion(torch.optim.Optimizer): if g_['wd'] > 0: p.mul_(1 - g_['lr'] * g_['wd']) p.add_((u + d).sign_(), alpha=-g_['lr']) m.mul_(b2).add_(p.grad, alpha=1 - b2) + + diff --git a/ep_run/psgd_vendor.py b/ep_run/psgd_vendor.py new file mode 100644 index 0000000..1155b87 --- /dev/null +++ b/ep_run/psgd_vendor.py @@ -0,0 +1,1568 @@ +""" +The new PSGD-Kron Newton/Whitening preconditioners support five kinds of local coordinates for updating Q: + + QUAD): It's a specific form for updating Q to ensure that Q > 0 (thus Q is symmetric/Hermitian). + It still is numerically stable even if round-off errors break the SPD property of Q. + + QEQ): dQ = Q * mathcal{E} * Q + This leads to another simple way for updating Q (Q is in the general linear group). + + Q0.5EQ1.5/Q0p5EQ1p5): dQ = Q^0.5 * mathcal{E} * Q^1.5 + The default and recommended choice for fitting Q. + An online orthogonal Procrustes problem solver is used to keep Q approximately SPD (no need to be exactly SPD). + + EQ): dQ = mathcal{E} * Q + This choice recovers the old PSGD way for updating Q in Lie groups (Q is triangular). + Its main drawback is that triangular solvers are required for updating Q. + + QEP): dQ = Q * mathcal{E} * P + This last choice works very well if it does. Q is in the general linear group. + But, one drawback is that Q might get stuck around ill-conditioned matrices (not strongly convex). + +Both the QUAD and Q0.5EQ1.5 methods can be used to update P directly with little changes. +We call them QUAD4P and PRO4P (PRO is from Procrustes), respectively. +PRO4P still is a competitive and recommended choice for optimization with single precisions. + +The PSGD-LRA Newton/Whitening preconditioners still adopt local coordinate dQ = mathcal{E} * Q, +and needs a small linear solver to update the preconditioner. + +We also keep the PSGD dense matrix Newton-type preconditioner here to illustrate the math. +It supports all the five methods for updating Q, +and can be a good alternative to the BFGS like quasi-Newton optimizers as no line search is required. + +Please refer to + https://github.com/lixilinx/psgd_torch/blob/master/wrapped_as_torch_optimizer_for_ddp.py + https://github.com/lixilinx/psgd_torch/blob/master/wrapped_as_torch_optimizer_for_dtensor.py +for torch.optim optimizer wrappings for DDP, FSDP, FP, etc. trainings and typical settings. + +Xi-Lin Li, lixilinx@gmail.com; last updated in Oct., 2025. +Main refs: https://arxiv.org/abs/1512.04202; https://arxiv.org/abs/2402.11858. +""" + +import opt_einsum +import torch + + +def norm_lower_bound_spd(A, k=32, half_iters=2): + """ + Returns a cheap lower bound for the spectral norm of a symmetric positive definite matrix A, where, + k: the dim of subspace, suggesting 128 for bfloat16 and 32 for float32 (tested on my laptop 4070 GPU); + half_iters: half of the number of subspace iterations, suggesting 2. + A rough norm estimation with bfloat16 is good enough, and we don't orthonormalize the subspace vectors. + + The initial noise space V is rotated such that its centroid aligns with the largest row of A. + Hence, each row of V and the largest row of A has an angle about acos(1/sqrt(k)) when k << dim(A). + This feature makes the subspace iteration more robust for large matrices with very low rank. + A simplified branchless approximate implementation is provided here. + """ + smallest_normal = torch.finfo(A.dtype).smallest_normal + normalizing_factor = A.diagonal().real.amax() + smallest_normal + A = A / normalizing_factor # (complex tensor) / (subnormal number) could produce inf or nan unexpectedly + j = torch.argmax(torch.linalg.vector_norm(A, dim=1)) + V = torch.randn(k, A.shape[1], dtype=A.dtype, device=A.device) + V = A[j] + torch.sgn(torch.sum(A[j] * V.conj(), dim=1, keepdim=True)) * V # torch.sign for real + for _ in range(half_iters): + V = V @ A + V /= torch.linalg.vector_norm(V, dim=1, keepdim=True) + smallest_normal + V = V @ A + return normalizing_factor * torch.amax(torch.linalg.vector_norm(V, dim=1)) + + +def norm_lower_bound_skh(A, k=32, half_iters=2): + """ + Returns a cheap lower bound for the spectral norm of a skew-Hermitian matrix A, + k: the dim of subspace, suggesting 128 for bfloat16 and 32 for float32 (tested on my laptop 4070 GPU); + half_iters: half of the number of subspace iterations, suggesting 2. + A rough norm estimation with bfloat16 is good enough, and we don't orthonormalize the subspace vectors. + + The initial noise space V is rotated such that its centroid aligns with the largest row of A. + Hence, each row of V and the largest row of A has an angle about acos(1/sqrt(k)) when k << dim(A). + This feature makes the subspace iteration more robust for large matrices with very low rank. + A simplified branchless approximate implementation is provided here. + """ + smallest_normal = torch.finfo(A.dtype).smallest_normal + normalizing_factor = A.abs().amax() + smallest_normal + A = A / normalizing_factor # (complex tensor) / (subnormal number) could produce inf or nan unexpectedly + j = torch.argmax(torch.linalg.vector_norm(A, dim=1)) + V = torch.randn(k, A.shape[1], dtype=A.dtype, device=A.device) + V = A[j] + torch.sgn(torch.sum(A[j] * V.conj(), dim=1, keepdim=True)) * V # torch.sign for real + for _ in range(half_iters): + V = V @ A + V /= torch.linalg.vector_norm(V, dim=1, keepdim=True) + smallest_normal + V = V @ A + return normalizing_factor * torch.amax(torch.linalg.vector_norm(V, dim=1)) + + +def lift2single(x): + # lift half or lower precision to single precision; leave single precision unchanged + return x.to(torch.float32) if torch.finfo(x.dtype).eps > 1e-6 else x + + +def procrustes_step2(Q, max_step_size=1/8): + """ + A in-place (update Q directly) online solver for the orthogonal Procrustes problem, + min_U || U Q - I ||_F, s.t. U^H U = I + by rotating Q as exp(a R) Q, where R = Q^H - Q is the generator and ||a R|| < 1. + + We expand U = exp(a R) to its 2nd term as + U ~ I + aR + (aR)^2/2 + and the truncation error ||U^H U - I|| is upper bounded as ||a R||^4/4. + Set max_step_size <= 1/4 such that the truncation error <= (1/4)^4/4 < 1e-3. + + Note that U(n) is connected and such rotations can make almost any complex Q SPD except for convergence to saddle points. + However, O(n) is not connected. Hence, such SO(n) rotations can only make real Q with det(Q) > 0 SPD. + + We have simplified the original implementation. The one branch here is necessary for line search. + """ + R = Q.H - Q + R /= norm_lower_bound_skh(R) + torch.finfo(R.dtype).smallest_normal # normalize R as typically it's too small + RQ = R @ Q + RRQ = R @ RQ + tr_RQ = RQ.diagonal().real.sum() # tr_RQ >=0 by theory; torch.trace not implemented for CPU bfloat16, so using sum(diag(.)) here + tr_RRQ = RRQ.diagonal().real.sum() # line search is needed if tr_RRQ < 0 + a = torch.where(tr_RRQ < 0, torch.clamp(-tr_RQ / tr_RRQ, max=max_step_size), max_step_size) + Q.add_(a * (RQ + 0.5 * a * RRQ)) + + +def procrustes_step3(Q, max_step_size=1/3): + """ + A in-place (update Q directly) online solver for the orthogonal Procrustes problem, + min_U || U Q - I ||_F, s.t. U^H U = I + by rotating Q as exp(a R) Q, where R = Q^H - Q is the generator and ||a R|| < 1. + + We expand U = exp(a R) to its 3rd term as (not the same as the Taylor series of U) + U ~ I + aR + (aR)^2/2 + (aR)^3/8 + and the truncation error ||U^H U - I|| is upper bounded as ||a R||^6/64. + Set max_step_size <= 5/8 such that the truncation error <= (5/8)^6/64 < 1e-3. + + Note that U(n) is connected and such rotations can make almost any complex Q SPD except for convergence to saddle points. + However, O(n) is not connected. Hence, such SO(n) rotations can only make real Q with det(Q) > 0 SPD. + """ + R = Q.H - Q + R /= norm_lower_bound_skh(R) + torch.finfo(R.dtype).smallest_normal # normalize R as typically it's too small + RQ = R @ Q + RRQ = R @ RQ + RRRQ = R @ RRQ + tr_RQ = RQ.diagonal().real.sum() # tr_RQ >=0 by theory; torch.trace not implemented for CPU bfloat16, so using sum(diag(.)) here + tr_RRQ = RRQ.diagonal().real.sum() + tr_RRRQ = RRRQ.diagonal().real.sum() # tr_RRRQ <=0 + if tr_RQ > 0 and tr_RRRQ < 0: # otherwise, Q^T = Q up to machine precision + # optimal a is the larger root of tr_RQ + 2 * a * tr_RRQ / 2 + 3 * a^2 * tr_RRRQ / 8 = 0 + if torch.finfo(tr_RQ.dtype).eps > 1e-6: # half precision is not accurate enough when tr_RRQ < 0 + tr_RQ, tr_RRQ, tr_RRRQ = tr_RQ.to(torch.float32), tr_RRQ.to(torch.float32), tr_RRRQ.to(torch.float32) + a = (-tr_RRQ - torch.sqrt(tr_RRQ*tr_RRQ - 1.5*tr_RQ*tr_RRRQ)) / (0.75*tr_RRRQ) + a = torch.clamp(a, max=max_step_size) + Q.add_(a * (RQ + 0.5 * a * (RRQ + 0.25 * a * RRRQ))) + + +############# Begin of PSGD Kronecker product preconditioners ############# + + +def init_kron(t, Scale=1.0, max_size=float("inf"), max_skew=1.0, dQ="Q0.5EQ1.5"): + """ + For a scalar or tensor t, we initialize its states (preconditioner Q and Lipschitz smoothness constant L), + and reusable contraction expressions for updating Q and preconditioning gradient. + + 1, The preconditioner Q is initialized to + Q = Scale * I = Scale * kron(eye(t.shape[0]), eye(t.shape[1]), ...) + where the eye(.) may be replaced with diag(ones(.)) if that dim is too large, determined by max_size and max_skew. + + The Lipschitz smoothness constant L for Q is initialized to zero. + + 2, A series of einsum contract expressions. The following subscript examples are for a 5th order tensor. + 2.1, exprP is the expression for applying the Preconditioner on the gradient, e.g., + 'aA,bB,cC,dD,eE,aα,bβ,cγ,dδ,eε,αβγδε->ABCDE' + 2.2, the i-th expression of exprGs is for the contraction of two tensors that only keeps the i-th dim, e.g., + 'abCde,abγde->Cγ' + for i=2. It's useful for Gradient calculation. + 2.3, exprA is the expression for applying All the factors of Q on a tensor, e.g., + 'aA,bB,cC,dD,eE,ABCDE->abcde' + 2.4, the i-th expression of exprQs is the expression for applying the i-th factor of Q on a tensor, e.g., + 'Cγ,abγde->abCde' + for i=2. + + Please check https://drive.google.com/file/d/1CEEq7A3_l8EcPEDa_sYtqr5aMLVeZWL7/view?usp=drive_link for notations and derivations. + """ + if dQ in {"QUAD4P", "PRO4P"}: # the only two cases that we fit P directly; so square Scale + Scale = Scale ** 2 + shape = t.shape + if len(shape)==0: # scalar + Q = [Scale * torch.ones_like(t),] + L = [lift2single(torch.zeros_like(t.real)),] + exprA = opt_einsum.contract_expression(",->", Q[0].shape, t.shape) + exprP = opt_einsum.contract_expression(",,->", Q[0].shape, Q[0].shape, t.shape) + exprGs = [opt_einsum.contract_expression(",->", t.shape, t.shape),] + exprQs = [opt_einsum.contract_expression(",->", Q[0].shape, t.shape),] + else: # tensor + if len(shape) > 26: + raise ValueError(f"Got tensor with dim {len(t.shape)}; einsum runs out of letters; replace 26 with larger numbers.") + + scale = Scale ** (1/len(shape)) + + Q, L = [], [] + exprGs, exprQs = [], [] + piece1A, piece2A, piece3A = [], "", "" # used for getting the subscripts for exprA + piece1P, piece2P, piece3P, piece4P = [], [], "", "" # used for getting the subscripts for exprP + for i, size in enumerate(shape): + L.append(lift2single(torch.zeros([], dtype=t.real.dtype, device=t.device))) + if size <= 1 or size > max_size or size**2 > max_skew * t.numel(): + # use diagonal matrix as preconditioner for this dim + Q.append(scale * torch.ones(size, dtype=t.dtype, device=t.device)) + + piece1A.append(opt_einsum.get_symbol(i)) + piece2A = piece2A + opt_einsum.get_symbol(i) + piece3A = piece3A + opt_einsum.get_symbol(i) + + piece1P.append(opt_einsum.get_symbol(i + 26)) + piece2P.append(opt_einsum.get_symbol(i + 26)) + piece3P = piece3P + opt_einsum.get_symbol(i + 26) + piece4P = piece4P + opt_einsum.get_symbol(i + 26) + + piece1 = "".join([opt_einsum.get_symbol(i+26) if j==i else opt_einsum.get_symbol(j) for j in range(len(shape))]) + subscripts = piece1 + "," + piece1 + "->" + opt_einsum.get_symbol(i+26) + exprGs.append(opt_einsum.contract_expression(subscripts, t.shape, t.shape)) + + subscripts = opt_einsum.get_symbol(i+26) + "," + piece1 + "->" + piece1 + exprQs.append(opt_einsum.contract_expression(subscripts, Q[-1].shape, t.shape)) + else: # use matrix preconditioner for this dim + Q.append(scale * torch.eye(size, dtype=t.dtype, device=t.device)) + + piece1A.append(opt_einsum.get_symbol(i) + opt_einsum.get_symbol(i + 26)) + piece2A = piece2A + opt_einsum.get_symbol(i + 26) + piece3A = piece3A + opt_einsum.get_symbol(i) + + a, b, c = opt_einsum.get_symbol(i), opt_einsum.get_symbol(i + 26), opt_einsum.get_symbol(i + 805) + piece1P.append(a + b) + piece2P.append(a + c) + piece3P = piece3P + c + piece4P = piece4P + b + + piece1 = "".join([opt_einsum.get_symbol(i+26) if j==i else opt_einsum.get_symbol(j) for j in range(len(shape))]) + piece2 = "".join([opt_einsum.get_symbol(i+805) if j==i else opt_einsum.get_symbol(j) for j in range(len(shape))]) + subscripts = piece1 + "," + piece2 + "->" + opt_einsum.get_symbol(i+26) + opt_einsum.get_symbol(i+805) + exprGs.append(opt_einsum.contract_expression(subscripts, t.shape, t.shape)) + + subscripts = opt_einsum.get_symbol(i+26) + opt_einsum.get_symbol(i+805) + "," + piece2 + "->" + piece1 + exprQs.append(opt_einsum.contract_expression(subscripts, Q[-1].shape, t.shape)) + + subscripts = ",".join(piece1A) + "," + piece2A + "->" + piece3A + exprA = opt_einsum.contract_expression(subscripts, *[q.shape for q in Q], t.shape) + + subscripts = ",".join(piece1P) + "," + ",".join(piece2P) + "," + piece3P + "->" + piece4P + exprP = opt_einsum.contract_expression(subscripts, *[q.shape for q in Q], *[q.shape for q in Q], t.shape) + + exprGs, exprQs = tuple(exprGs), tuple(exprQs) + if dQ == "QEP": + return [[Q, L], (exprP, exprGs, exprQs)] + elif dQ == "EQ": + return [[Q, L], (exprP, exprGs, exprA)] + elif dQ in {"QEQ", "QUAD", "Q0p5EQ1p5", "Q0.5EQ1.5"}: + return [[Q, L], (exprP, exprGs)] + else: # the only two cases that we fit P directly; dQ actually is dP + assert dQ in {"QUAD4P", "PRO4P"}, "Invalid choice for dQ" + return [[Q, L], (exprA, exprGs)] + + +def balance_kron_precond(Q): + """ + In place balancing the dynamic ranges of the factors of Q to avoid over/under-flow. + """ + order = len(Q) # order of tensor or the number of factors in Q + if order>1: + norms = [torch.max(torch.abs(q)) for q in Q] + gmean = torch.prod(torch.stack(norms))**(1/order) # geometric mean + for i, q in enumerate(Q): + q.mul_(gmean/norms[i]) + + +def update_precond_kron_eq(QL, exprs, V, Hvp, lr=0.1, betaL=0.9): + """ + The raw function for updating the Kron preconditioner Q and Lipschitz smoothness constant L with pair (V, Hvp), + where Q is update as dQ = E*Q, + the pair (V, Hvp) can be (vector, hess-vector-prod) or (randn, gradient/momentum). + The damping logic is not included here. + """ + Q, L = QL + _, exprGs, exprA = exprs + + def solve_triangular_right(B, A): + # return B @ inv(A) + if B.dim()>1: + return torch.linalg.solve_triangular(lift2single(A), lift2single(B), upper=True, left=False).to(B.dtype) + else: # torch.linalg.solve_triangular complains if B.dim() < 2. So insert None. + return (torch.linalg.solve_triangular(lift2single(A), lift2single(B[None,:]), upper=True, left=False)[0]).to(B.dtype) + + A = exprA(*Q, Hvp) + + order = V.dim() + p = list(range(order)) + conjB = torch.permute(V.conj(), p[1:] + p[:1]) # permute dims like [0,1,2,3,4] -> [1,2,3,4,0] + for i, q in enumerate(Q): + conjB = conjB/q if q.dim()<2 else solve_triangular_right(conjB, q) + if i < order - 1: # transpose dims like [1,2,3,4,0]->[0,2,3,4,1]->[0,1,3,4,2]->[0,1,2,4,3]->[0,1,2,3,4] + conjB = torch.transpose(conjB, i, order - 1) + + for i, q in enumerate(Q): + term1 = exprGs[i](A, A.conj()) + term2 = exprGs[i](conjB.conj(), conjB) + + if q.dim() < 2: # q is a diagonal matrix or scalar preconditioner + ell = torch.max(torch.real(term1 + term2)) + L[i].copy_(torch.max(betaL*L[i] + (1 - betaL)*ell, ell)) + q.sub_(lr/L[i] * (term1 - term2) * q) # q.mul_(1 - lr/L[i] * (term1 - term2)): larger roundoff errors + else: # q is a matrix preconditioner + ell = norm_lower_bound_spd(term1 + term2) + L[i].copy_(torch.max(betaL*L[i] + (1 - betaL)*ell, ell)) + q.sub_(lr/L[i] * torch.triu(term1 - term2) @ q) + + if torch.rand([]) < 0.01: # balance factors of Q + balance_kron_precond(Q) + + +def precond_grad_kron(QL, exprs, G): + """ + Precondition gradient G with Kron preconditioner Q. + """ + Q, exprP = QL[0], exprs[0] + return exprP(*[q.conj() for q in Q], *Q, G) + + +def update_precond_kron_whiten_eq(QL, exprs, G, lr=0.1, betaL=0.9, damping=1e-9): + """ + Update the Kron preconditioner Q as dQ = E*Q. + """ + V = torch.randn_like(G) + damping = damping + torch.finfo(G.dtype).eps * G.abs() + update_precond_kron_eq(QL, exprs, V, G + damping*V, lr=lr, betaL=betaL) + + +def update_precond_kron_whiten_qep(QL, exprs, G, lr=0.1, betaL=0.9, damping=1e-9): + """ + Update the Kron preconditioner Q as dQ = Q*E*P. + """ + Q, L = QL + exprP, exprGs, exprQs = exprs + + # balancing is not optional as L for each factor is not scaling invariant + balance_kron_precond(Q) + + total_numel = G.numel() + damping = damping + torch.finfo(G.dtype).eps * G.abs() + Pg = exprP(*[q.conj() for q in Q], *Q, G + damping*torch.randn_like(G)) + for i, q in enumerate(Q): + QPg = exprQs[i](q, Pg) + term1 = exprGs[i](QPg, QPg.conj()) + if q.dim() < 2: # diagonal or scalar Q + term2 = total_numel/q.numel() * q * q.conj() + ell = torch.max(torch.real(term1 + term2)) + L[i].copy_(torch.max(betaL*L[i] + (1 - betaL)*ell, ell)) + q.mul_(1 - lr/L[i] * (term1 - term2)) + else: # matrix Q + term2 = total_numel/q.shape[0] * q @ q.H + ell = norm_lower_bound_spd(term1 + term2) + L[i].copy_(torch.max(betaL*L[i] + (1 - betaL)*ell, ell)) + q.sub_(lr/L[i] * (term1 - term2) @ q) + + +def update_precond_kron_whiten_qeq(QL, exprs, G, lr=0.1, betaL=0.9, damping=1e-9): + """ + Update the Kron preconditioner Q as dQ = Q*E*Q. + """ + Q, L = QL + exprP, exprGs = exprs + + total_numel = G.numel() + damping = damping + torch.finfo(G.dtype).eps * G.abs() + Pg = exprP(*[q.conj() for q in Q], *Q, G + damping*torch.randn_like(G)) + for i, q in enumerate(Q): + term1 = exprGs[i](Pg, Pg.conj()) + if q.dim() < 2: # diagonal or scalar Q + term2 = total_numel/q.numel() # times I + ell = torch.max(torch.real(term1)) + term2 + L[i].copy_(torch.max(betaL*L[i] + (1 - betaL)*ell, ell)) + q.mul_(1 - lr/L[i] * (term1 - term2)) + else: # matrix Q + term2 = total_numel/q.shape[0] # times I + ell = norm_lower_bound_spd(term1) + term2 + L[i].copy_(torch.max(betaL*L[i] + (1 - betaL)*ell, ell)) + q.sub_(lr/L[i] * (q @ term1 - q * term2)) + + if torch.rand([]) < 0.01: # balance factors of Q + balance_kron_precond(Q) + + +def update_precond_kron_whiten_q0p5eq1p5(QL, exprs, G, lr=0.1, betaL=0.9, damping=1e-9): + """ + Update the Kron preconditioner Q as dQ = Q^0.5 * E * Q^1.5. + """ + Q, L = QL + exprP, exprGs = exprs + + total_numel = G.numel() + damping = damping + torch.finfo(G.dtype).eps * G.abs() + Pg = exprP(*[q.conj() for q in Q], *Q, G + damping*torch.randn_like(G)) + for i, q in enumerate(Q): + term1 = exprGs[i](Pg, Pg.conj()) + if q.dim() < 2: # diagonal or scalar Q + term2 = total_numel/q.numel() # times I + ell = torch.max(torch.real(term1)) + term2 + L[i].copy_(torch.max(betaL*L[i] + (1 - betaL)*ell, ell)) + q.mul_(1 - lr/L[i] * (term1 - term2)) + else: # matrix Q + term2 = total_numel/q.shape[0] # times I + ell = norm_lower_bound_spd(term1) + term2 + L[i].copy_(torch.max(betaL*L[i] + (1 - betaL)*ell, ell)) + q.sub_(lr/L[i] * (term1 @ q - term2 * q)) + procrustes_step2(q) + + if torch.rand([]) < 0.01: # balance factors of Q + balance_kron_precond(Q) + + +def update_precond_kron_whiten_pro4p(QL, exprs, G, lr=0.1, betaL=0.9, damping=1e-9): + """ + Update the Kron preconditioner P as dP = P^0.5 * E * P. + Almost the same as update_precond_kron_whiten_q0p5eq1p5. But the Q here actually is P. + Unlike fitting Q, fitting P directly is more sensitive to numerical round-off errors. + """ + Q, L = QL + exprA, exprGs = exprs + + total_numel = G.numel() + damping = damping + torch.finfo(G.dtype).eps * G.abs() + Pg = exprA(*Q, G + damping*torch.randn_like(G)) # Q actually is P; so just applying all its factors once. + for i, q in enumerate(Q): + term1 = exprGs[i](Pg, Pg.conj()) + if q.dim() < 2: # diagonal or scalar Q + term2 = total_numel/q.numel() # times I + ell = torch.max(torch.real(term1)) + term2 + L[i].copy_(torch.max(betaL*L[i] + (1 - betaL)*ell, ell)) + q.mul_(1 - lr/L[i] * (term1 - term2)) + else: # matrix Q + term2 = total_numel/q.shape[0] # times I + ell = norm_lower_bound_spd(term1) + term2 + L[i].copy_(torch.max(betaL*L[i] + (1 - betaL)*ell, ell)) + q.sub_(lr/L[i] * (term1 @ q - term2 * q)) + for _ in range(10): + procrustes_step3(q) + if (q.H - q).abs().amax() < 0.001 * q.abs().amax(): + break # q is almost Hermitian + + if torch.rand([]) < 0.01: # balance factors of P + balance_kron_precond(Q) + + +def update_precond_kron_whiten_quad(QL, exprs, G, lr=0.1, betaL=0.9, damping=1e-9): + """ + Update the Kron preconditioner Q with a quadratic form. + """ + Q, L = QL + exprP, exprGs = exprs + + total_numel = G.numel() + damping = damping + torch.finfo(G.dtype).eps * G.abs() + Pg = exprP(*[q.conj() for q in Q], *Q, G + damping*torch.randn_like(G)) + for i, q in enumerate(Q): + term1 = exprGs[i](Pg, Pg.conj()) + if q.dim() < 2: # diagonal or scalar Q + term2 = total_numel/q.numel() # times I + ell = torch.max(torch.real(term1)) + term2 + L[i].copy_(torch.max(betaL*L[i] + (1 - betaL)*ell, ell)) + gain = 1 - lr/2/L[i] * (term1 - term2) + q.mul_(gain * gain) + else: # matrix Q + term2 = total_numel/q.shape[0] # times I + ell = norm_lower_bound_spd(term1) + term2 + L[i].copy_(torch.max(betaL*L[i] + (1 - betaL)*ell, ell)) + p = q - lr/2/L[i] * (term1 @ q - term2 * q) + p = p - lr/2/L[i] * (p @ term1 - p * term2) + q.copy_((p + p.H)/2) # p must be symmetric/hermitian + + if torch.rand([]) < 0.01: # balance factors of Q + balance_kron_precond(Q) + + +def update_precond_kron_whiten_quad4p(QL, exprs, G, lr=0.1, betaL=0.9, damping=1e-9): + """ + Almost the same as function update_precond_kron_whiten_quad except that fitting P directly (Q here actually is P). + Vulnerable to numerical errors as the round-off errors could break the SPD property of P. + """ + Q, L = QL + exprA, exprGs = exprs + + total_numel = G.numel() + damping = damping + torch.finfo(G.dtype).eps * G.abs() + Pg = exprA(*Q, G + damping*torch.randn_like(G)) # Q actually is P; so just applying all its factors once. + for i, q in enumerate(Q): + term1 = exprGs[i](Pg, Pg.conj()) + if q.dim() < 2: # diagonal or scalar Q + term2 = total_numel/q.numel() # times I + ell = torch.max(torch.real(term1)) + term2 + L[i].copy_(torch.max(betaL*L[i] + (1 - betaL)*ell, ell)) + gain = 1 - lr/L[i] * (term1 - term2) + q.mul_(gain * gain) + else: # matrix Q + term2 = total_numel/q.shape[0] # times I + ell = norm_lower_bound_spd(term1) + term2 + L[i].copy_(torch.max(betaL*L[i] + (1 - betaL)*ell, ell)) + p = q - lr/L[i] * (term1 @ q - term2 * q) + p = p - lr/L[i] * (p @ term1 - p * term2) + q.copy_((p + p.H)/2) # p must be symmetric/hermitian + + if torch.rand([]) < 0.01: # balance factors of Q + balance_kron_precond(Q) + + +class KronWhiten: + """ + Implements the PSGD optimizer with the Kronecker product gradient/momentum whitening preconditioner. + Most of the time, the hyperparameter name says it all. Here are some comments on a few key hyperparameters. + + 1, preconditioner_max_size and preconditioner_max_skew. These two together control the complexity of the preconditioners. + For example, we are to precondition a 2D gradient with shape 10 x 50. + With preconditioner_max_size 20, we use a dense preconditioner for the first dim since 10 <= 20 and diagonal preconditioner for the second dim since 50 > 20. + With preconditioner_max_skew 1.5, we use a dense preconditioner for the first dim since 10/50 <= 1.5 and diagonal preconditioner for the second dim since 50/10 > 1.5. + + 2, grad_clip_max_amps, betaL and damping. These three together help to stabilize the training. + PSGD here tries to normalize the gradients to unit amplitude. This can be problematic when gradients approach zeros. + The most effective way is to clip the preconditioned gradients if their average/element-wise amplitudes exceed grad_clip_max_amps[0]/[1], respectively. + Another way is to damp and upper bound the fitted preconditioner such that P < eye/damping. + For extremely sparse gradients, increasing betaL (say to 0.999) helps a lot, where betaL is the EMA factor for the L-smoothness constant (wrt Q) estimation. + + 3, Lastly, dQ is for the selection of geometry for preconditioner update. + The two recommended choices are dQ = Q0.5EQ1.5 and dP = P0.5EP (online Newton-Schulz iterations). + Q is initialized to preconditioner_init_scale * eye. Boolean setting whiten_grad decides to whiten whether the gradient or momentum. + Always good to check https://arxiv.org/abs/2402.11858 for math details. + """ + def __init__(self, params_with_grad, + preconditioner_max_size=float("inf"), preconditioner_max_skew=1.0, preconditioner_init_scale:float|None=None, + lr_params=0.001, lr_preconditioner=0.1, betaL=0.9, damping=1e-9, momentum=0.0, grad_clip_max_amps=(2.0, 10.0), + preconditioner_update_probability=1.0, update_preconditioner_first=True, whiten_grad=True, dQ="Q0.5EQ1.5"): + # mutable members + self.lr_params = lr_params + self.lr_preconditioner = lr_preconditioner + self.betaL = betaL # beta for the Lipschitz smoothness constant estimation; set to a large value for sparse gradients + self.damping = damping # to damp and upper bound the preconditioner such that P < eye/damping + self.momentum = momentum if (0<momentum<1) else 0.0 + self.grad_clip_max_amps = grad_clip_max_amps # clip grad with thresholds (max average amplitude, max element-wise amplitude) + self.preconditioner_update_probability = preconditioner_update_probability + self.update_preconditioner_first = update_preconditioner_first # True for biased update; False for unbiased update. + # protected members + self._preconditioner_max_size = preconditioner_max_size + self._preconditioner_max_skew = preconditioner_max_skew + params_with_grad = [params_with_grad,] if isinstance(params_with_grad, torch.Tensor) else params_with_grad + self._params_with_grad = [param for param in params_with_grad if param.requires_grad] # double check requires_grad flag + if preconditioner_init_scale is None: + self._QLs_exprs = None # initialize on the fly + print("FYI: Will set the preconditioner initial scale on the fly. Recommend to set it manually.") + else: + self._QLs_exprs = [init_kron(p.squeeze(), preconditioner_init_scale, preconditioner_max_size, preconditioner_max_skew, dQ) for p in self._params_with_grad] + self._ms, self._counter_m = None, 0 # momentum buffers and counter + self._whiten_grad = whiten_grad # set to False to whiten momentum. + if not whiten_grad: + assert self.momentum > 0, "Cannot whiten momentum if the momentum setting is invalid." + print(f"Recommend reducing the lr_params for gradient whitening by a factor of {((1 + self.momentum)/(1 - self.momentum))**0.5} for this momentum whitening setting.") + self._dQ = dQ + if dQ in {"QUAD4P", "PRO4P"}: # the only two cases that we fit P directly + if max([torch.finfo(p.dtype).eps for p in self._params_with_grad]) > 1e-6: + print("Fitting P directly with half precision is risky.") + if dQ == "QUAD4P": + self._update_precond = update_precond_kron_whiten_quad4p + else: # dP = P^0.5 * E * P + self._update_precond = update_precond_kron_whiten_pro4p + self._precond_grad = lambda QL, exprs, G: exprs[0](*QL[0], G) # it's exprA(*Q, G) + else: + self._precond_grad = precond_grad_kron + if dQ == "QEP": + self._update_precond = update_precond_kron_whiten_qep + elif dQ == "EQ": + self._update_precond = update_precond_kron_whiten_eq + elif dQ == "QEQ": + self._update_precond = update_precond_kron_whiten_qeq + elif dQ == "QUAD": + self._update_precond = update_precond_kron_whiten_quad + else: + assert dQ in {"Q0.5EQ1.5", "Q0p5EQ1p5"}, "Invalid choice for dQ" + self._update_precond = update_precond_kron_whiten_q0p5eq1p5 + + + @torch.no_grad() + def step(self, closure): + """ + Performs one step of PSGD with the Kronecker product gradient/momentum whitening preconditioner. + """ + with torch.enable_grad(): + closure_returns = closure() + loss = closure_returns if isinstance(closure_returns, torch.Tensor) else closure_returns[0] + grads = [g.squeeze() for g in torch.autograd.grad(loss, self._params_with_grad)] + + if self._QLs_exprs is None: + scale = max([torch.mean((torch.abs(g))**4) for g in grads]) + scale = (scale + self.damping**4)**(-1/8) + self._QLs_exprs = [init_kron(g, scale, self._preconditioner_max_size, self._preconditioner_max_skew, self._dQ) for g in grads] + + if self.momentum > 0: + beta = min(self._counter_m/(1 + self._counter_m), self.momentum) + self._counter_m += 1 + if self._ms is None: + self._ms = [torch.zeros_like(g) for g in grads] + + for (m, g) in zip(self._ms, grads): + m.mul_(beta).add_(g, alpha=1 - beta) + else: + self._ms, self._counter_m = None, 0 + + if torch.rand([]) < self.preconditioner_update_probability: + update_preconditioner_first, update_preconditioner_last = self.update_preconditioner_first, not self.update_preconditioner_first + else: + update_preconditioner_first, update_preconditioner_last = False, False + + if update_preconditioner_first: # update Q + if self._whiten_grad: # Q whitens gradient + for (QL_exprs, g) in zip(self._QLs_exprs, grads): + self._update_precond(*QL_exprs, g, lr=self.lr_preconditioner, betaL=self.betaL, damping=self.damping) + else: # Q whitens momentum + for (QL_exprs, m) in zip(self._QLs_exprs, self._ms): + self._update_precond(*QL_exprs, m, lr=self.lr_preconditioner, betaL=self.betaL, damping=self.damping) + + if self.momentum > 0: # precondition momentum + pre_grads = [self._precond_grad(*QL_exprs, m) for (QL_exprs, m) in zip(self._QLs_exprs, self._ms)] + else: # precondition gradient + pre_grads = [self._precond_grad(*QL_exprs, g) for (QL_exprs, g) in zip(self._QLs_exprs, grads)] + + if update_preconditioner_last: # update Q + if self._whiten_grad: # Q whitens gradient + for (QL_exprs, g) in zip(self._QLs_exprs, grads): + self._update_precond(*QL_exprs, g, lr=self.lr_preconditioner, betaL=self.betaL, damping=self.damping) + else: # Q whitens momentum + for (QL_exprs, m) in zip(self._QLs_exprs, self._ms): + self._update_precond(*QL_exprs, m, lr=self.lr_preconditioner, betaL=self.betaL, damping=self.damping) + + # Update the parameters after clipping the preconditioned gradient per tensor + max_avg_amp, max_element_amp = self.grad_clip_max_amps + for param, g in zip(self._params_with_grad, pre_grads): + avg_amp = torch.sqrt(torch.real(torch.mean(g*g.conj()))) + if avg_amp > max_avg_amp: + g *= max_avg_amp/avg_amp + if torch.is_complex(g): + g /= torch.clamp(torch.abs(g)/max_element_amp, min=1.0) + else: + g.clamp_(min=-max_element_amp, max=max_element_amp) + param.subtract_(g.view_as(param), alpha=self.lr_params) + + # return whatever closure returns + return closure_returns + + +def update_precond_kron_newton_eq(QL, exprs, V, Hvp, lr=0.1, betaL=0.9, damping=1e-9): + """ + Update the Kron Newton-type preconditioner Q as dQ = E*Q with a pair of vector and hvp, (V, Hvp). + """ + damping = damping + torch.finfo(Hvp.dtype).eps * Hvp.abs() + update_precond_kron_eq(QL, exprs, V, Hvp + damping*torch.randn_like(Hvp), lr=lr, betaL=betaL) + + +def update_precond_kron_newton_qep(QL, exprs, V, Hvp, lr=0.1, betaL=0.9, damping=1e-9): + """ + Update the Kron Newton-type preconditioner Q as dQ = Q*E*P with a pair of vector and hvp, (V, Hvp). + """ + Q, L = QL + exprP, exprGs, exprQs = exprs + + # balancing is not optional as L for each factor is not scaling invariant + balance_kron_precond(Q) + damping = damping + torch.finfo(Hvp.dtype).eps * Hvp.abs() + Ph = exprP(*[q.conj() for q in Q], *Q, Hvp + damping*torch.randn_like(Hvp)) + + for i, q in enumerate(Q): + QPh = exprQs[i](q, Ph) + Qv = exprQs[i](q, V) + term1 = exprGs[i](QPh, QPh.conj()) + term2 = exprGs[i](Qv, Qv.conj()) + if q.dim() < 2: # diagonal or scalar Q + ell = torch.max(torch.real(term1 + term2)) + L[i].copy_(torch.max(betaL*L[i] + (1 - betaL)*ell, ell)) + q.mul_(1 - lr/L[i] * (term1 - term2)) + else: # matrix Q + ell = norm_lower_bound_spd(term1 + term2) + L[i].copy_(torch.max(betaL*L[i] + (1 - betaL)*ell, ell)) + q.sub_(lr/L[i] * (term1 - term2) @ q) + + +def update_precond_kron_newton_qeq(QL, exprs, V, Hvp, lr=0.1, betaL=0.9, damping=1e-9): + """ + Update the Kron Newton-type preconditioner Q as dQ = Q*E*Q with a pair of vector and hvp, (V, Hvp). + """ + Q, L = QL + exprP, exprGs = exprs + damping = damping + torch.finfo(Hvp.dtype).eps * Hvp.abs() + Ph = exprP(*[q.conj() for q in Q], *Q, Hvp + damping*torch.randn_like(Hvp)) + + for i, q in enumerate(Q): + term1 = exprGs[i](Ph, Ph.conj()) + term2 = exprGs[i](V, V.conj()) + if q.dim() < 2: # diagonal or scalar Q + ell = torch.max(torch.real(term1 + term2)) + L[i].copy_(torch.max(betaL*L[i] + (1 - betaL)*ell, ell)) + q.mul_(1 - lr/L[i] * (term1 - term2)) + else: # matrix Q + ell = norm_lower_bound_spd(term1 + term2) + L[i].copy_(torch.max(betaL*L[i] + (1 - betaL)*ell, ell)) + q.sub_(lr/L[i] * q @ (term1 - term2)) + + if torch.rand([]) < 0.01: # balance factors of Q + balance_kron_precond(Q) + + +def update_precond_kron_newton_q0p5eq1p5(QL, exprs, V, Hvp, lr=0.1, betaL=0.9, damping=1e-9): + """ + Update the Kron Newton-type preconditioner Q as dQ = Q^0.5 * E * Q^1.5 with a pair of vector and hvp, (V, Hvp). + """ + Q, L = QL + exprP, exprGs = exprs + damping = damping + torch.finfo(Hvp.dtype).eps * Hvp.abs() + Ph = exprP(*[q.conj() for q in Q], *Q, Hvp + damping*torch.randn_like(Hvp)) + + for i, q in enumerate(Q): + term1 = exprGs[i](Ph, Ph.conj()) + term2 = exprGs[i](V, V.conj()) + if q.dim() < 2: # diagonal or scalar Q + ell = torch.max(torch.real(term1 + term2)) + L[i].copy_(torch.max(betaL*L[i] + (1 - betaL)*ell, ell)) + q.mul_(1 - lr/L[i] * (term1 - term2)) + else: # matrix Q + ell = norm_lower_bound_spd(term1 + term2) + L[i].copy_(torch.max(betaL*L[i] + (1 - betaL)*ell, ell)) + q.sub_(lr/L[i] * (term1 - term2) @ q) + procrustes_step2(q) + + if torch.rand([]) < 0.01: # balance factors of Q + balance_kron_precond(Q) + + +def update_precond_kron_newton_pro4p(QL, exprs, V, Hvp, lr=0.1, betaL=0.9, damping=1e-9): + """ + Update the Kron Newton-type preconditioner P as dP = P^0.5 * E * P with a pair of vector and hvp, (V, Hvp). + It is almost the same as update_precond_kron_newton_q0p5eq1p5. But we fit P directly (Q actually is P here). + """ + Q, L = QL + exprA, exprGs = exprs + damping = damping + torch.finfo(Hvp.dtype).eps * Hvp.abs() + Ph = exprA(*Q, Hvp + damping*torch.randn_like(Hvp)) # Q actually is P; so only need to apply its factors once. + + for i, q in enumerate(Q): + term1 = exprGs[i](Ph, Ph.conj()) + term2 = exprGs[i](V, V.conj()) + if q.dim() < 2: # diagonal or scalar Q + ell = torch.max(torch.real(term1 + term2)) + L[i].copy_(torch.max(betaL*L[i] + (1 - betaL)*ell, ell)) + q.mul_(1 - lr/L[i] * (term1 - term2)) + else: # matrix Q + ell = norm_lower_bound_spd(term1 + term2) + L[i].copy_(torch.max(betaL*L[i] + (1 - betaL)*ell, ell)) + q.sub_(lr/L[i] * (term1 - term2) @ q) + for _ in range(10): + procrustes_step3(q) + if (q.H - q).abs().amax() < 0.001 * q.abs().amax(): + break + + if torch.rand([]) < 0.01: # balance factors of Q + balance_kron_precond(Q) + + +def update_precond_kron_newton_quad(QL, exprs, V, Hvp, lr=0.1, betaL=0.9, damping=1e-9): + """ + Update the Kron Newton-type preconditioner Q with a quadratic form for dQ and pair of vector and hvp, (V, Hvp). + """ + Q, L = QL + exprP, exprGs = exprs + damping = damping + torch.finfo(Hvp.dtype).eps * Hvp.abs() + Ph = exprP(*[q.conj() for q in Q], *Q, Hvp + damping*torch.randn_like(Hvp)) + + for i, q in enumerate(Q): + term1 = exprGs[i](Ph, Ph.conj()) + term2 = exprGs[i](V, V.conj()) + if q.dim() < 2: # diagonal or scalar Q + ell = torch.max(torch.real(term1 + term2)) + L[i].copy_(torch.max(betaL*L[i] + (1 - betaL)*ell, ell)) + gain = 1 - lr/2/L[i] * (term1 - term2) + q.mul_(gain * gain) + else: # matrix Q + ell = norm_lower_bound_spd(term1 + term2) + L[i].copy_(torch.max(betaL*L[i] + (1 - betaL)*ell, ell)) + err = lr/2/L[i] * (term1 - term2) + p = q - err @ q # p = q - lr/L[i]/2 * (term1 - term2) @ q + p = p - p @ err # p = p - lr/L[i]/2 * p @ (term1 - term2) + q.copy_((p + p.H)/2) # p must be symmetric or hermitian + + if torch.rand([]) < 0.01: # balance factors of Q + balance_kron_precond(Q) + + +def update_precond_kron_newton_quad4p(QL, exprs, V, Hvp, lr=0.1, betaL=0.9, damping=1e-9): + """ + Almost the same as function update_precond_kron_newton_quad except that we fit P directly (Q here actually is P). + It's vulnerable to numerical errors as the round-off errors could break the SPD property of P. + """ + Q, L = QL + exprA, exprGs = exprs + damping = damping + torch.finfo(Hvp.dtype).eps * Hvp.abs() + Ph = exprA(*Q, Hvp + damping*torch.randn_like(Hvp)) # Q actually is P; so only need to apply its factors once. + + for i, q in enumerate(Q): + term1 = exprGs[i](Ph, Ph.conj()) + term2 = exprGs[i](V, V.conj()) + if q.dim() < 2: # diagonal or scalar Q + ell = torch.max(torch.real(term1 + term2)) + L[i].copy_(torch.max(betaL*L[i] + (1 - betaL)*ell, ell)) + gain = 1 - lr/L[i] * (term1 - term2) + q.mul_(gain * gain) + else: # matrix Q + ell = norm_lower_bound_spd(term1 + term2) + L[i].copy_(torch.max(betaL*L[i] + (1 - betaL)*ell, ell)) + err = lr/L[i] * (term1 - term2) + p = q - err @ q # p = q - lr/L[i] * (term1 - term2) @ q + p = p - p @ err # p = p - lr/L[i] * p @ (term1 - term2) + q.copy_((p + p.H)/2) # p must be symmetric or hermitian + + if torch.rand([]) < 0.01: # balance factors of Q + balance_kron_precond(Q) + + +class KronNewton: + """ + Implements the Kronecker product Newton-type preconditioner as a class. + Most of the time, the hyperparameter name says it all. Here are some comments on a few key parameters. + + 1, preconditioner_max_size and preconditioner_max_skew. These two together control the complexity of the preconditioners. + For example, we are to precondition a 2D gradient with shape 10 x 50. + With preconditioner_max_size 20, we use a dense preconditioner for the first dim since 10 <= 20 and diagonal preconditioner for the second dim since 50 > 20. + With preconditioner_max_skew 1.5, we use a dense preconditioner for the first dim since 10/50 <= 1.5 and diagonal preconditioner for the second dim since 50/10 > 1.5. + + 2, grad_clip_max_norm, betaL and damping. These three together help to stabilize the training. + The grad_clip_max_norm is used to clip the preconditioned gradient to stabilize the optimization as in the classic trust region method. + Setting damping is used to damp and upper bound the fitted preconditioner such that P < eye/damping. + For extremely sparse Hess-vector-prod, a large betaL (say 0.999) helps a lot, where betaL is the EMA factor for the L-smoothness constant (wrt Q) estimation. + + 3, exact_hessian_vector_product. + By setting this flag to False, the finite difference method will be used for Hvp approximation. + Be cautious with the finite difference method (possible numerical issues; the closure must behave like a stateless function). + + 4, Lastly, dQ is for the selection of geometry for preconditioner update. + The two recommended choices are dQ = Q0.5EQ1.5 and dP = P0.5EP (online Newton-Schulz iterations). + Both lr_params and lr_preconditioner are normalized learning rates. + Q is initialized to preconditioner_init_scale * eye. + Always good to check https://arxiv.org/abs/2402.11858 for math details. + """ + def __init__(self, params_with_grad, preconditioner_max_size=float("inf"), preconditioner_max_skew=1.0, preconditioner_init_scale:float|None=None, + lr_params=0.01, lr_preconditioner=0.1, betaL=0.9, damping=1e-9, momentum=0.0, + grad_clip_max_norm=float("inf"), preconditioner_update_probability=1.0, + exact_hessian_vector_product=True, dQ="Q0.5EQ1.5"): + # mutable members + self.lr_params = lr_params + self.lr_preconditioner = lr_preconditioner + self.betaL = betaL # beta for Lipschitz smoothness constant estimation; set to a large value for sparse Hvp + self.damping = damping # used to damp and upper bound P as P < eye/damping + self.momentum = momentum if (0<momentum<1) else 0.0 + self.grad_clip_max_norm = grad_clip_max_norm + self.preconditioner_update_probability = preconditioner_update_probability + # protected members + self._preconditioner_max_size = preconditioner_max_size + self._preconditioner_max_skew = preconditioner_max_skew + params_with_grad = [params_with_grad,] if isinstance(params_with_grad, torch.Tensor) else params_with_grad + self._params_with_grad = [param for param in params_with_grad if param.requires_grad] # double check requires_grad flag + eps = max([torch.finfo(p.dtype).eps for p in self._params_with_grad]) + self._delta_param_scale = eps ** 0.5 + if preconditioner_init_scale is None: + self._QLs_exprs = None # initialize on the fly + print("FYI: Will set the preconditioner initial scale on the fly. Recommend to set it manually.") + else: + self._QLs_exprs = [init_kron(p.squeeze(), preconditioner_init_scale, preconditioner_max_size, preconditioner_max_skew, dQ) for p in self._params_with_grad] + self._ms, self._counter_m = None, 0 # momentum buffers and counter + self._exact_hessian_vector_product = exact_hessian_vector_product + if not exact_hessian_vector_product: + print("FYI: Approximate Hvp with finite-difference method. Make sure that: 1) the closure behaves like a stateless function; 2) delta param scale is proper.") + self._dQ = dQ + if dQ in {"QUAD4P", "PRO4P"}: # the only two cases that fits P directly and dQ actually is dP + if eps > 1e-6: + print("Fitting P directly with half precision is risky.") + if dQ == "QUAD4P": + self._update_precond = update_precond_kron_newton_quad4p + else: # dP = P^0.5 * E * P + self._update_precond = update_precond_kron_newton_pro4p + self._precond_grad = lambda QL, exprs, G: exprs[0](*QL[0], G) # it's exprA(*Q, G) + else: + self._precond_grad = precond_grad_kron + if dQ == "QUAD": + self._update_precond = update_precond_kron_newton_quad + elif dQ == "QEP": + self._update_precond = update_precond_kron_newton_qep + elif dQ == "EQ": + self._update_precond = update_precond_kron_newton_eq + elif dQ == "QEQ": + self._update_precond = update_precond_kron_newton_qeq + else: + assert dQ in {"Q0.5EQ1.5", "Q0p5EQ1p5"}, "Invalid choice for dQ" + self._update_precond = update_precond_kron_newton_q0p5eq1p5 + + + @torch.no_grad() + def step(self, closure): + """ + Performs one step of PSGD with the Kronecker product Newton-type preconditioner. + """ + if (torch.rand([]) < self.preconditioner_update_probability) or (self._QLs_exprs is None): + # evaluates gradients, Hessian-vector product, and updates the preconditioner + if self._exact_hessian_vector_product: + with torch.enable_grad(): + closure_returns = closure() + loss = closure_returns if isinstance(closure_returns, torch.Tensor) else closure_returns[0] + grads = torch.autograd.grad(loss, self._params_with_grad, create_graph=True) + vs = [torch.randn_like(p) for p in self._params_with_grad] + Hvs = torch.autograd.grad(grads, self._params_with_grad, vs) # this line also works for complex matrices + else: # approximate the Hessian-vector product via finite-difference formulae. Use it with cautions. + with torch.enable_grad(): + closure_returns = closure() + loss = closure_returns if isinstance(closure_returns, torch.Tensor) else closure_returns[0] + grads = torch.autograd.grad(loss, self._params_with_grad) + + vs = [torch.randn_like(p) for p in self._params_with_grad] + for (p, v) in zip(self._params_with_grad, vs): # add perturbation + p.add_(v, alpha=self._delta_param_scale) + with torch.enable_grad(): + perturbed_returns = closure() + perturbed_loss = perturbed_returns if isinstance(perturbed_returns, torch.Tensor) else perturbed_returns[0] + perturbed_grads = torch.autograd.grad(perturbed_loss, self._params_with_grad) + Hvs = [(perturbed_g - g)/self._delta_param_scale for (perturbed_g, g) in zip(perturbed_grads, grads)] + for (p, v) in zip(self._params_with_grad, vs): # remove the perturbation + p.sub_(v, alpha=self._delta_param_scale) + + if self._QLs_exprs is None: # initialize QLs on the fly if it is None + scale = (sum([torch.sum(torch.abs(v)**2) for v in vs])/sum([v.numel() for v in vs])) ** (1/4) # (mean(|v|^2))^(1/4) + scale = scale * (max([torch.mean((torch.abs(h))**4) for h in Hvs]) + self.damping**4) ** (-1/8) # (mean(|v|^2))^(1/4) * (mean(|h|^4))^(-1/8) + self._QLs_exprs = [init_kron(h.squeeze(), scale, self._preconditioner_max_size, self._preconditioner_max_skew, self._dQ) for h in Hvs] + # update preconditioner + for (QL_exprs, v, h) in zip(self._QLs_exprs, vs, Hvs): + self._update_precond(*QL_exprs, v.squeeze(), h.squeeze(), lr=self.lr_preconditioner, betaL=self.betaL, damping=self.damping) + else: # only evaluate the gradients + with torch.enable_grad(): + closure_returns = closure() + loss = closure_returns if isinstance(closure_returns, torch.Tensor) else closure_returns[0] + grads = torch.autograd.grad(loss, self._params_with_grad) + + grads = [g.squeeze() for g in grads] + if self.momentum > 0: # precondition the momentum + beta = min(self._counter_m/(1 + self._counter_m), self.momentum) + self._counter_m += 1 + if self._ms is None: + self._ms = [torch.zeros_like(g) for g in grads] + + for (m, g) in zip(self._ms, grads): + m.mul_(beta).add_(g, alpha=1 - beta) + pre_grads = [self._precond_grad(*QL_exprs, m) for (QL_exprs, m) in zip(self._QLs_exprs, self._ms)] + else: # precondition the gradient + self._ms, self._counter_m = None, 0 # clear the buffer and counter when momentum is set to zero + pre_grads = [self._precond_grad(*QL_exprs, g) for (QL_exprs, g) in zip(self._QLs_exprs, grads)] + + lr = self.lr_params + if self.grad_clip_max_norm < float("inf"): + grad_norm = torch.sqrt(torch.real(sum([torch.sum(g*g.conj()) for g in pre_grads]))) + if grad_norm > self.grad_clip_max_norm: + lr = lr * self.grad_clip_max_norm / grad_norm + + # Update the parameters + for (param, g) in zip(self._params_with_grad, pre_grads): + param.subtract_(lr*g.view_as(param)) + + # return whatever closure returns + return closure_returns + + +############# End of PSGD Kronecker product preconditioners ############# + + +############# Begin of PSGD LRA (low rank approximation) preconditioners ############# + + +def IpUVtmatvec(U, V, x): + """ + Returns (I + U*V')*x. All variables are either matrices or column vectors. + """ + return x + U.mm(V.t().mm(x)) + + +def update_precond_lra(UVd, Luvd, v, h, lr=0.1, betaL=0.9): + """ + The raw function for updating the LRA preconditioner Q = (I + U*V')*diag(d) with pair (v, h), + where h can be a Hvp associated with v, or a gradient/momentum independent of v. + State variables (U, V, d) and their Lipschitz smoothness constant estimates (Lu, Lv, Ld) are updated inplace. + Damping logic is not implemented here. + Note that U, V, d, v, and h all are either matrices or column vectors. + """ + U, V, d = UVd + Lu, Lv, Ld = Luvd + + # Approximately balancing U and V such that U^T U = V^T V (exact balancing needs three EVDs) + UtU, VtV = U.t() @ U, V.t() @ V + trUtU, trVtV = torch.sum(UtU.diagonal()), torch.sum(VtV.diagonal()) + rho = (trUtU/trVtV) ** (1/4) # will scale U and V as U <-- U/rho and V <-- V*rho + rho2 = rho * rho + E = 0.1 * (UtU/rho2 - VtV*rho2)/(trUtU/rho2 + trVtV*rho2) # errors after scaling U and V + E2 = 0.5 * E @ E # using this E2 term to make (I - E + E^2/2)(I + E + E^2/2) = (I + E^2/2)^2 - E^2 = I + E^4/4 + U.div_(rho) # scale U and V to have ||U||_F = ||V||_F + V.mul_(rho) + U.sub_(U @ (E - E2)) # rotate (as tr(E)=0) U and V to approach U^TU = V^TV + V.add_(V @ (E + E2)) + + Qh = IpUVtmatvec(U, V, d * h) + Ph = d*IpUVtmatvec(V, U, Qh) + + IpVtU = V.t().mm(U) + IpVtU.diagonal().add_(1) # avoid forming matrix I explicitly + invQtv = v/d + LU, pivots = torch.linalg.lu_factor(lift2single(IpVtU)) + invQtv = invQtv - V.mm(torch.linalg.lu_solve(LU, pivots, lift2single(U.t().mm(invQtv)), adjoint=True).to(V.dtype)) + invPv = invQtv - U.mm(torch.linalg.lu_solve(LU, pivots, lift2single(V.t().mm(invQtv))).to(U.dtype)) + invPv = invPv/d + + # update d + Phh, vinvPv = Ph*h, v*invPv + ell = torch.max(torch.abs(Phh)) + torch.max(torch.abs(vinvPv)) + Ld.copy_(torch.max(betaL*Ld + (1 - betaL)*ell, ell)) + d.sub_(lr/Ld*(Phh - vinvPv)*d) # d.mul_(1 - lr/Ld*(Phh - vinvPv)): larger roundoff errors, unstable with bfloat16 and lr<<1 + + a, b = Qh, invQtv + if torch.rand([]) < 0.5: # only update U + atV = a.t().mm(V) + btV = b.t().mm(V) + atVVt = atV.mm(V.t()) + btVVt = btV.mm(V.t()) + ell = (torch.linalg.vector_norm(a)*torch.linalg.vector_norm(atVVt) + + torch.linalg.vector_norm(b)*torch.linalg.vector_norm(btVVt)) + Lu.copy_(torch.max(betaL*Lu + (1 - betaL)*ell, ell)) + U.sub_(lr/Lu * ( a.mm(atV.mm(IpVtU)) - b.mm(btV.mm(IpVtU)) )) + else: # only update V + atU = a.t().mm(U) + btU = b.t().mm(U) + UUta = U.mm(atU.t()) + UUtb = U.mm(btU.t()) + ell = (torch.linalg.vector_norm(a)*torch.linalg.vector_norm(UUta) + + torch.linalg.vector_norm(b)*torch.linalg.vector_norm(UUtb)) + Lv.copy_(torch.max(betaL*Lv + (1 - betaL)*ell, ell)) + V.sub_(lr/Lv * ( (a + V.mm(atU.t())).mm(atU) - (b + V.mm(btU.t())).mm(btU) )) + + +def precond_grad_lra(UVd, g): + """ + Precondition gradient g with Q = (I + U*V')*diag(d). + All variables here are either matrices or column vectors. + """ + U, V, d = UVd + g = IpUVtmatvec(U, V, d * g) + g = d * IpUVtmatvec(V, U, g) + return g + + +def update_precond_lra_whiten(UVd, Luvd, g, lr=0.1, betaL=0.9, damping=1e-9): + """ + Update the LRA whiten preconditioner. + """ + v = torch.randn_like(g) + damping = damping + torch.finfo(g.dtype).eps * g.abs() + update_precond_lra(UVd, Luvd, v, g + damping*v, lr=lr, betaL=betaL) + + +class LRAWhiten: + """ + Implements the PSGD LRA gradient/momentum whitening preconditioner as a class. + Most of the time, the hyperparameter name says it all. Here are some comments on a few key parameters. + + 1, rank_of_approximation. + Preconditioner Q has a diagonal part and a low rank part, whose rank is decided by this setting. + Rank 0 reduces Q to a diagonal preconditioner. + + 2, grad_clip_max_amps, betaL and damping. These three together help to stabilize the training. + PSGD here tries to normalize the gradients to unit amplitude. This can be problematic when gradients approach zeros. + The most effective way is to clip the preconditioned gradients when their average/element-wise amplitudes exceed grad_clip_max_amps[0]/[1], respectively. + Another way is to damp and upper bound the fitted preconditioner as P < eye/damping. + For extremely sparse gradient, increasing betaL (say to 0.999) also helps a lot, where betaL is the EMA factor for the L-smoothness constant (wrt Q) estimation. + + 3, Lastly, Q is initialized to preconditioner_init_scale * eye. + Boolean setting whiten_grad decides to whiten whether the gradient or momentum. + Always good to check https://arxiv.org/abs/2402.11858 for math details. + """ + def __init__(self, params_with_grad, rank_of_approximation:int=10, preconditioner_init_scale:float|None=None, + lr_params=0.001, lr_preconditioner=0.1, betaL=0.9, damping=1e-9, momentum=0.0, grad_clip_max_amps=(2.0, 10.0), + preconditioner_update_probability=1.0, update_preconditioner_first=True, whiten_grad=True): + # mutable members + self.lr_params = lr_params + self.lr_preconditioner = lr_preconditioner + self.betaL = betaL # set to a large betaL for sparse gradients + self.damping = damping # to damp and upper bound P as P < eye/damping + self.momentum = momentum if (0<momentum<1) else 0.0 + self.grad_clip_max_amps = grad_clip_max_amps + self.preconditioner_update_probability = preconditioner_update_probability + self.update_preconditioner_first = update_preconditioner_first # True for biased update; False for unbiased update. + # protected members + params_with_grad = [params_with_grad,] if isinstance(params_with_grad, torch.Tensor) else params_with_grad + self._params_with_grad = [param for param in params_with_grad if param.requires_grad] # double check requires_grad flag + dtype, device = self._params_with_grad[0].dtype, self._params_with_grad[0].device + self._param_sizes = [torch.numel(param) for param in self._params_with_grad] + self._param_cumsizes = torch.cumsum(torch.tensor(self._param_sizes), 0) + num_params = self._param_cumsizes[-1] + assert 0 <= rank_of_approximation < num_params, "Rank r should be in range [0, number of total parameters)" + self._UVd = [] # saves U, V and d + self._UVd.append(torch.randn(num_params, rank_of_approximation, dtype=dtype, device=device)) # U + self._UVd[0] *= 0.1**0.5 / torch.linalg.vector_norm(self._UVd[0]) + self._UVd.append(torch.randn(num_params, rank_of_approximation, dtype=dtype, device=device)) # V + self._UVd[1] *= 0.1**0.5 / torch.linalg.vector_norm(self._UVd[1]) + if preconditioner_init_scale is None: + print("FYI: Will set the preconditioner initial scale on the fly. Recommend to set it manually.") + else: + self._UVd.append(torch.ones(num_params, 1, dtype=dtype, device=device) * preconditioner_init_scale) + self._Luvd = [lift2single(torch.zeros([], dtype=dtype, device=device)) for _ in range(3)] + self._m, self._counter_m = None, 0 # momentum buffer and counter + self._whiten_grad = whiten_grad + if (not whiten_grad): + assert self.momentum > 0, "Cannot whiten momentum if the momentum setting is invalid." + print(f"Recommend reducing the lr_params for gradient whitening by a factor of {((1 + self.momentum)/(1 - self.momentum))**0.5} for this momentum whitening setting.") + + + @torch.no_grad() + def step(self, closure): + """ + Performs one step of the PSGD LRA gradient/momentum whitening optimizer. + """ + with torch.enable_grad(): + closure_returns = closure() + loss = closure_returns if isinstance(closure_returns, torch.Tensor) else closure_returns[0] + grads = torch.autograd.grad(loss, self._params_with_grad) + + # cat grads into a single vector + grad = torch.cat([torch.reshape(g, [-1, 1]) for g in grads]) # column vector + + if len(self._UVd) < 3: # initialize d on the fly + self._UVd.append((torch.mean(grad**4) + self.damping**4)**(-1/8) * torch.ones_like(grad)) + + if self.momentum > 0: + beta = min(self._counter_m/(1 + self._counter_m), self.momentum) + self._counter_m += 1 + if self._m is None: + self._m = torch.zeros_like(grad) + + self._m.mul_(beta).add_(grad, alpha=1 - beta) + else: # clear the momentum buffer and counter when momentum is set to zero + self._m, self._counter_m = None, 0 + + if torch.rand([]) < self.preconditioner_update_probability: + update_preconditioner_first, update_preconditioner_last = self.update_preconditioner_first, not self.update_preconditioner_first + else: + update_preconditioner_first, update_preconditioner_last = False, False + + if update_preconditioner_first: # update preconditioner first + if self._whiten_grad: # whitens gradient + update_precond_lra_whiten(self._UVd, self._Luvd, grad, lr=self.lr_preconditioner, betaL=self.betaL, damping=self.damping) + else: # whitens momentum + update_precond_lra_whiten(self._UVd, self._Luvd, self._m, lr=self.lr_preconditioner, betaL=self.betaL, damping=self.damping) + + if self.momentum > 0: # precondition momentum + pre_grad = precond_grad_lra(self._UVd, self._m) + else: # precondition gradient + pre_grad = precond_grad_lra(self._UVd, grad) + + if update_preconditioner_last: # update preconditioner later + if self._whiten_grad: # whitens gradient + update_precond_lra_whiten(self._UVd, self._Luvd, grad, lr=self.lr_preconditioner, betaL=self.betaL, damping=self.damping) + else: # whitens momentum + update_precond_lra_whiten(self._UVd, self._Luvd, self._m, lr=self.lr_preconditioner, betaL=self.betaL, damping=self.damping) + + max_avg_amp, max_element_amp = self.grad_clip_max_amps + avg_amp = torch.sqrt(torch.mean(pre_grad * pre_grad)) + if avg_amp > max_avg_amp: + pre_grad *= max_avg_amp/avg_amp + pre_grad.clamp_(min=-max_element_amp, max=max_element_amp) + + # update the parameters + for (param, i, j) in zip(self._params_with_grad, self._param_sizes, self._param_cumsizes): + param.subtract_(pre_grad[j - i:j].view_as(param), alpha=self.lr_params) + + # return whatever closure returns + return closure_returns + + +def update_precond_lra_newton(UVd, Luvd, v, h, lr=0.1, betaL=0.9, damping=1e-9): + """ + Update the LRA Newton preconditioner. + """ + damping = damping + torch.finfo(h.dtype).eps * h.abs() + update_precond_lra(UVd, Luvd, v, h + damping*torch.randn_like(h), lr=lr, betaL=betaL) + + +class LRANewton: + """ + Implements the PSGD LRA Newton-type preconditioner as a class. + Most of the time, the hyperparameter name says it all. Here are some comments on a few key parameters. + + 1, rank_of_approximation. + Preconditioner Q has a diagonal part and a low rank part, whose rank is decided by this setting. + Rank 0 reduces Q to a diagonal preconditioner. + + 2, grad_clip_max_norm, betaL and damping. These three together help to stabilize the training. + The grad_clip_max_norm is used to clip the preconditioned gradient to stabilize the optimization as in the classic trust region method. + Setting damping is used to damp and upper bound the preconditioner as P < eye/damping. + For extremely sparse hess-vector-prods, a large betaL (say 0.999) helps a lot, where betaL is the EMA factor for the L-smoothness constant (wrt Q) estimation. + + 3, exact_hessian_vector_product. + By setting this flag to False, the finite difference method will be used for Hvp approximation. + Be cautious with the finite difference method (possible numerical issues; the closure must behave like a stateless function). + + 4, Lastly, Q is initialized to preconditioner_init_scale * eye. + Both lr_params and lr_preconditioner are normalized learning rates. + Always good to check https://arxiv.org/abs/2402.11858 for math details. + """ + def __init__(self, params_with_grad, rank_of_approximation:int=10, preconditioner_init_scale:float|None=None, + lr_params=0.01, lr_preconditioner=0.1, betaL=0.9, damping=1e-9, momentum=0.0, + grad_clip_max_norm=float("inf"), preconditioner_update_probability=1.0, + exact_hessian_vector_product=True): + # mutable members + self.lr_params = lr_params + self.lr_preconditioner = lr_preconditioner + self.betaL = betaL # set to a large betaL for sparse Hvp + self.damping = damping # to damp and upper bound the preconditioner as P < eye/damping + self.momentum = momentum if (0<momentum<1) else 0.0 + self.grad_clip_max_norm = grad_clip_max_norm + self.preconditioner_update_probability = preconditioner_update_probability + # protected members + params_with_grad = [params_with_grad,] if isinstance(params_with_grad, torch.Tensor) else params_with_grad + self._params_with_grad = [param for param in params_with_grad if param.requires_grad] # double check requires_grad flag + dtype, device = self._params_with_grad[0].dtype, self._params_with_grad[0].device + self._delta_param_scale = torch.finfo(dtype).eps**0.5 + self._param_sizes = [torch.numel(param) for param in self._params_with_grad] + self._param_cumsizes = torch.cumsum(torch.tensor(self._param_sizes), 0) + num_params = self._param_cumsizes[-1] + assert 0 <= rank_of_approximation < num_params, "Rank r should be in range [0, number of total parameters)" + self._UVd = [] # saves U, V and d + self._UVd.append(torch.randn(num_params, rank_of_approximation, dtype=dtype, device=device)) # U + self._UVd[0] *= 0.1**0.5 / torch.linalg.vector_norm(self._UVd[0]) + self._UVd.append(torch.randn(num_params, rank_of_approximation, dtype=dtype, device=device)) # V + self._UVd[1] *= 0.1**0.5 / torch.linalg.vector_norm(self._UVd[1]) + if preconditioner_init_scale is None: + print("FYI: Will set the preconditioner initial scale on the fly. Recommend to set it manually.") + else: + self._UVd.append(torch.ones(num_params, 1, dtype=dtype, device=device) * preconditioner_init_scale) + self._Luvd = [lift2single(torch.zeros([], dtype=dtype, device=device)) for _ in range(3)] + self._m, self._counter_m = None, 0 # momentum buffer and counter + self._exact_hessian_vector_product = exact_hessian_vector_product + if not exact_hessian_vector_product: + print("FYI: Approximate Hvp with finite-difference method. Make sure that: 1) the closure behaves like a stateless function; 2) delta param scale is proper.") + + + @torch.no_grad() + def step(self, closure): + """ + Performs one step of the PSGD LRA Newton optimizer. + """ + if (torch.rand([]) < self.preconditioner_update_probability) or (len(self._UVd) < 3): + # evaluates gradients, Hessian-vector product, and updates the preconditioner + if self._exact_hessian_vector_product: + with torch.enable_grad(): + closure_returns = closure() + loss = closure_returns if isinstance(closure_returns, torch.Tensor) else closure_returns[0] + grads = torch.autograd.grad(loss, self._params_with_grad, create_graph=True) + vs = [torch.randn_like(param) for param in self._params_with_grad] + Hvs = torch.autograd.grad(grads, self._params_with_grad, vs) + else: # approximate Hessian-vector product via finite-difference formulae. Use it with cautions. + with torch.enable_grad(): + closure_returns = closure() + loss = closure_returns if isinstance(closure_returns, torch.Tensor) else closure_returns[0] + grads = torch.autograd.grad(loss, self._params_with_grad) + + vs = [torch.randn_like(param) for param in self._params_with_grad] + for (param, v) in zip(self._params_with_grad, vs): + param.add_(v, alpha=self._delta_param_scale) + with torch.enable_grad(): + perturbed_returns = closure() + perturbed_loss = perturbed_returns if isinstance(perturbed_returns, torch.Tensor) else perturbed_returns[0] + perturbed_grads = torch.autograd.grad(perturbed_loss, self._params_with_grad) + Hvs = [(perturbed_g - g)/self._delta_param_scale for (perturbed_g, g) in zip(perturbed_grads, grads)] + for (param, v) in zip(self._params_with_grad, vs): + param.sub_(v, alpha=self._delta_param_scale) + + v = torch.cat([torch.reshape(v, [-1, 1]) for v in vs]) # column vector + h = torch.cat([torch.reshape(h, [-1, 1]) for h in Hvs]) # column vector + if len(self._UVd) < 3: # init d if it's not in the UVd list + self._UVd.append((torch.mean(v*v))**(1/4) * (torch.mean(h**4) + self.damping**4)**(-1/8) * torch.ones_like(v)) + + # update preconditioner + update_precond_lra_newton(self._UVd, self._Luvd, v, h, lr=self.lr_preconditioner, betaL=self.betaL, damping=self.damping) + else: # only evaluates the gradients + with torch.enable_grad(): + closure_returns = closure() + loss = closure_returns if isinstance(closure_returns, torch.Tensor) else closure_returns[0] + grads = torch.autograd.grad(loss, self._params_with_grad) + + # cat grads + grad = torch.cat([torch.reshape(g, [-1, 1]) for g in grads]) # column vector + + if self.momentum > 0: # precondition momentum + beta = min(self._counter_m/(1 + self._counter_m), self.momentum) + self._counter_m += 1 + if self._m is None: + self._m = torch.zeros_like(grad) + + self._m.mul_(beta).add_(grad, alpha=1 - beta) + pre_grad = precond_grad_lra(self._UVd, self._m) + else: # precondition gradient + self._m, self._counter_m = None, 0 # clear the buffer and counter when momentum is set to zero + pre_grad = precond_grad_lra(self._UVd, grad) + + lr = self.lr_params + if self.grad_clip_max_norm < float("inf"): + grad_norm = torch.linalg.vector_norm(pre_grad) + if grad_norm > self.grad_clip_max_norm: + lr = lr * self.grad_clip_max_norm / grad_norm + + # update the parameters + for (param, i, j) in zip(self._params_with_grad, self._param_sizes, self._param_cumsizes): + param.subtract_(lr * pre_grad[j - i:j].view_as(param)) + + # return whatever closure returns + return closure_returns + + +############# End of PSGD LRA preconditioners ############# + + +############# Begin of PSGD dense matrix Newton-type preconditioner ############# + + +def update_precond_dense_eq(Q, L, v, h, lr=0.1, betaL=0.9, damping=1e-9): + """ + Update dense matrix Newton-type preconditioner Q with local coordinate dQ = mathcal{E} * Q. + """ + damping = damping + torch.finfo(h.dtype).eps * h.abs() + a = Q.mm(h + damping*torch.randn_like(h)) + b = torch.linalg.solve_triangular(lift2single(Q.t()), lift2single(v), upper=False).to(v.dtype) + ell = torch.sum(a*a + b*b) + L.copy_(torch.max(betaL*L + (1 - betaL)*ell, ell)) + Q.sub_(lr/L * torch.triu(a.mm(a.t()) - b.mm(b.t())) @ Q) + + +def update_precond_dense_qep(Q, L, v, h, lr=0.1, betaL=0.9, damping=1e-9): + """ + Update dense matrix Newton-type preconditioner Q with local coordinate dQ = Q * mathcal{E} * P. + """ + damping = damping + torch.finfo(h.dtype).eps * h.abs() + a = Q @ (Q.T @ (Q @ (h + damping*torch.randn_like(h)))) + b = Q @ v + ell = torch.sum(a*a + b*b) + L.copy_(torch.max(betaL*L + (1 - betaL)*ell, ell)) + Q.sub_(lr/L * (a @ (a.T @ Q) - b @ (b.T @ Q))) + + +def update_precond_dense_qeq(Q, L, v, h, lr=0.1, betaL=0.9, damping=1e-9): + """ + Update dense matrix Newton-type preconditioner Q with local coordinate dQ = Q * mathcal{E} * Q. + """ + damping = damping + torch.finfo(h.dtype).eps * h.abs() + a = Q.T @ (Q @ (h + damping*torch.randn_like(h))) + ell = torch.sum(a*a + v*v) + L.copy_(torch.max(betaL*L + (1 - betaL)*ell, ell)) + Q.sub_(lr/L * ((Q @ a) @ a.T - (Q @ v) @ v.T)) + + +def update_precond_dense_q0p5eq1p5(Q, L, v, h, lr=0.1, betaL=0.9, damping=1e-9): + """ + Update dense matrix Newton-type preconditioner Q with local coordinate dQ = Q^0.5 * mathcal{E} * Q^1.5. + """ + damping = damping + torch.finfo(h.dtype).eps * h.abs() + a = Q.T @ (Q @ (h + damping*torch.randn_like(h))) + ell = torch.sum(a*a + v*v) + L.copy_(torch.max(betaL*L + (1 - betaL)*ell, ell)) + Q.sub_(lr/L * (a @ (a.T @ Q) - v @ (v.T @ Q))) + procrustes_step2(Q) + + +def update_precond_dense_pro4p(Q, L, v, h, lr=0.1, betaL=0.9, damping=1e-9): + """ + Update dense matrix Newton-type preconditioner P with local coordinate dP = P^0.5 * mathcal{E} * P. + """ + damping = damping + torch.finfo(h.dtype).eps * h.abs() + a = Q @ (h + damping*torch.randn_like(h)) # Q actually is P; so just apply it once. + ell = torch.sum(a*a + v*v) + L.copy_(torch.max(betaL*L + (1 - betaL)*ell, ell)) + Q.sub_(lr/L * (a @ (a.T @ Q) - v @ (v.T @ Q))) + for _ in range(10): + procrustes_step3(Q) + if (Q.T - Q).abs().amax() < 0.001 * Q.abs().amax(): + break + + +def update_precond_dense_quad(Q, L, v, h, lr=0.1, betaL=0.9, damping=1e-9): + """ + Update dense matrix Newton-type preconditioner Q with a quadratic form for dQ. + """ + damping = damping + torch.finfo(h.dtype).eps * h.abs() + a = Q @ (Q @ (h + damping*torch.randn_like(h))) # Q is symmetric here + ell = torch.sum(a*a + v*v) + L.copy_(torch.max(betaL*L + (1 - betaL)*ell, ell)) + p = Q - lr/2/L * (a @ (a.T @ Q) - v @ (v.T @ Q)) + p = p - lr/2/L * ((p @ a) @ a.T - (p @ v) @ v.T) + Q.copy_((p + p.T)/2) + + +def update_precond_dense_quad4p(Q, L, v, h, lr=0.1, betaL=0.9, damping=1e-9): + """ + Almost the same as update_precond_dense_quad. But it fits P directly. + """ + damping = damping + torch.finfo(h.dtype).eps * h.abs() + a = Q @ (h + damping*torch.randn_like(h)) # Q actually is P; so just apply it once. + ell = torch.sum(a*a + v*v) + L.copy_(torch.max(betaL*L + (1 - betaL)*ell, ell)) + p = Q - lr/L * (a @ (a.T @ Q) - v @ (v.T @ Q)) + p = p - lr/L * ((p @ a) @ a.T - (p @ v) @ v.T) + Q.copy_((p + p.T)/2) + + +class DenseNewton: + """ + Implements the PSGD dense matrix Newton-type preconditioner as a class. + Be extra cautious when using the finite difference method for Hvp approximation (the closure must behave like a stateless function). + It's mainly for illustrating how PSGD works due to its simplicity. + It's also a good alternative to the BFGS like quasi-Newton methods as no line search is required. + """ + def __init__(self, params_with_grad, preconditioner_init_scale:float|None=None, + lr_params=0.01, lr_preconditioner=0.1, betaL=0.9, damping=1e-9, momentum=0.0, + grad_clip_max_norm=float("inf"), preconditioner_update_probability=1.0, + exact_hessian_vector_product=True, dQ="Q0.5EQ1.5"): + # mutable members + self.lr_params = lr_params + self.lr_preconditioner = lr_preconditioner + self.betaL = betaL # set to a large betaL for sparse Hvp + self.damping = damping # to damp and upper bound the preconditioner as P < eye/damping + self.momentum = momentum if (0<momentum<1) else 0.0 + self.grad_clip_max_norm = grad_clip_max_norm + self.preconditioner_update_probability = preconditioner_update_probability + # protected members + params_with_grad = [params_with_grad,] if isinstance(params_with_grad, torch.Tensor) else params_with_grad + self._params_with_grad = [param for param in params_with_grad if param.requires_grad] # double check requires_grad flag + dtype, device = self._params_with_grad[0].dtype, self._params_with_grad[0].device + self._delta_param_scale = torch.finfo(dtype).eps ** 0.5 + self._param_sizes = [torch.numel(param) for param in self._params_with_grad] + self._param_cumsizes = torch.cumsum(torch.tensor(self._param_sizes), 0) + num_params = self._param_cumsizes[-1] + if preconditioner_init_scale is None: # initialize Q on the fly + self._Q = None + else: + if dQ in {"QUAD4P", "PRO4P"}: # Q and dQ actually are P and dP, respectively + preconditioner_init_scale *= preconditioner_init_scale + self._Q = torch.eye(num_params, dtype=dtype, device=device) * preconditioner_init_scale + self._L = lift2single(torch.zeros([], dtype=dtype, device=device)) # Lipschitz smoothness constant estimation for the psgd criterion + self._m, self._counter_m = None, 0 # buffer and counter for momentum + self._exact_hessian_vector_product = exact_hessian_vector_product + if not exact_hessian_vector_product: + print("FYI: Approximate Hvp with finite-difference method. Make sure that: 1) the closure behaves like a stateless function; 2) delta param scale is proper.") + self._dQ = dQ + if dQ in {"QUAD4P", "PRO4P"}: # the only two cases that we fit P directly + if torch.finfo(dtype).eps > 1e-6: + print("Fitting P directly with half precision is risky.") + if dQ == "QUAD4P": + self._update_precond = update_precond_dense_quad4p + else: # dP = P^0.5 * E * P + self._update_precond = update_precond_dense_pro4p + self._precond_grad = lambda Q, g: Q @ g + elif dQ == "QUAD": + self._update_precond = update_precond_dense_quad + self._precond_grad = lambda Q, g: Q @ (Q @ g) # Q is symmetric here; so Q^T = Q + else: + self._precond_grad = lambda Q, g: Q.T @ (Q @ g) + if dQ == "QEP": + self._update_precond = update_precond_dense_qep + elif dQ == "EQ": + self._update_precond = update_precond_dense_eq + elif dQ == "QEQ": + self._update_precond = update_precond_dense_qeq + else: + assert dQ in {"Q0p5EQ1p5", "Q0.5EQ1.5"}, "Invalid choice for dQ" + self._update_precond = update_precond_dense_q0p5eq1p5 + + + @torch.no_grad() + def step(self, closure): + """ + Performs one step of PSGD with the dense matrix Newton-type preconditioner. + """ + if (torch.rand([]) < self.preconditioner_update_probability) or (self._Q is None): + # evaluates gradients, Hessian-vector product, and updates the preconditioner + if self._exact_hessian_vector_product: # exact Hessian-vector product + with torch.enable_grad(): + closure_returns = closure() + loss = closure_returns if isinstance(closure_returns, torch.Tensor) else closure_returns[0] + grads = torch.autograd.grad(loss, self._params_with_grad, create_graph=True) + vs = [torch.randn_like(param) for param in self._params_with_grad] + Hvs = torch.autograd.grad(grads, self._params_with_grad, vs) + else: # approximate Hessian-vector product via finite-difference formulae. Use it with cautions. + with torch.enable_grad(): + closure_returns = closure() + loss = closure_returns if isinstance(closure_returns, torch.Tensor) else closure_returns[0] + grads = torch.autograd.grad(loss, self._params_with_grad) + + vs = [torch.randn_like(param) for param in self._params_with_grad] + for (param, v) in zip(self._params_with_grad, vs): + param.add_(v, alpha=self._delta_param_scale) + with torch.enable_grad(): + perturbed_returns = closure() + perturbed_loss = perturbed_returns if isinstance(perturbed_returns, torch.Tensor) else perturbed_returns[0] + perturbed_grads = torch.autograd.grad(perturbed_loss, self._params_with_grad) + Hvs = [(perturbed_g - g)/self._delta_param_scale for (perturbed_g, g) in zip(perturbed_grads, grads)] + for (param, v) in zip(self._params_with_grad, vs): + param.sub_(v, alpha=self._delta_param_scale) + + v = torch.cat([torch.reshape(v, [-1, 1]) for v in vs]) + h = torch.cat([torch.reshape(h, [-1, 1]) for h in Hvs]) + if self._Q is None: # initialize Q on the fly if it is None + scale = (torch.mean(v*v))**(1/4) * (torch.mean(h**4) + self.damping**4)**(-1/8) + if self._dQ in {"QUAD4P", "PRO4P"}: # Q actually is P in this case + scale *= scale + self._Q = torch.eye(len(v), dtype=v.dtype, device=v.device) * scale + + # update preconditioner + self._update_precond(self._Q, self._L, v, h, lr=self.lr_preconditioner, betaL=self.betaL, damping=self.damping) + else: # only evaluates the gradients + with torch.enable_grad(): + closure_returns = closure() + loss = closure_returns if isinstance(closure_returns, torch.Tensor) else closure_returns[0] + grads = torch.autograd.grad(loss, self._params_with_grad) + + # cat grads + grad = torch.cat([torch.reshape(g, [-1, 1]) for g in grads]) + + if self.momentum > 0: # precondition momentum + beta = min(self._counter_m/(1 + self._counter_m), self.momentum) + self._counter_m += 1 + if self._m is None: + self._m = torch.zeros_like(grad) + + self._m.mul_(beta).add_(grad, alpha=1 - beta) + pre_grad = self._precond_grad(self._Q, self._m) + else: + self._m, self._counter_m = None, 0 # clear the buffer and counter when momentum is set to zero + pre_grad = self._precond_grad(self._Q, grad) + + lr = self.lr_params + if self.grad_clip_max_norm < float("inf"): + grad_norm = torch.linalg.vector_norm(pre_grad) + if grad_norm > self.grad_clip_max_norm: + lr = lr * self.grad_clip_max_norm / grad_norm + + # update the parameters + for (param, i, j) in zip(self._params_with_grad, self._param_sizes, self._param_cumsizes): + param.subtract_(lr * pre_grad[j - i:j].view_as(param)) + + # return whatever closure returns + return closure_returns + + +############# End of PSGD dense matrix Newton-type preconditioner ############# + +""" end of psgd """ |
