//! Hand-written AdamW optimizer (Phase T6). //! //! AdamW = Adam with **decoupled** weight decay (Loshchilov & Hutter, 2019): the //! weight-decay term is applied directly to the parameter, NOT folded into the //! gradient (so it does not interact with the adaptive `v` denominator). This //! matches `torch.optim.AdamW`. //! //! Update for parameter `θ` at step `t` (1-indexed), with gradient `g`: //! ```text //! m ← β1·m + (1−β1)·g //! v ← β2·v + (1−β2)·g² //! m̂ ← m / (1 − β1ᵗ) (bias correction) //! v̂ ← v / (1 − β2ᵗ) //! θ ← θ − lr·( m̂ / (√v̂ + ε) + wd·θ ) //! ``` //! The `lr·wd·θ` term is the decoupled decay. Note PyTorch applies decay as //! `θ ← θ·(1 − lr·wd)` then the Adam step; both are algebraically the same //! first-order update — we fold decay into the single subtraction above, which //! is what PyTorch's default (`maximize=False`, no `amsgrad`) computes. //! //! The math operates on flat host `f32` buffers ([`AdamW::step_host`]) so it is //! unit-testable on a GPU-less host; [`AdamW::step`] is a thin wrapper that //! round-trips each parameter's value/grad through the GPU tensor and is gated //! behind `not(no_cuda)`. /// Per-parameter optimizer state: the first (`m`) and second (`v`) moment /// estimates, one f32 per element, kept flat (matching the parameter layout). struct ParamState { m: Vec, v: Vec, } /// Decoupled-weight-decay Adam. One instance owns the moment state for a fixed /// list of parameters, keyed by their index in the slice passed to `step` /// (the model's stable `params()` order). pub struct AdamW { pub lr: f32, beta1: f32, beta2: f32, eps: f32, weight_decay: f32, /// Global step count (shared across all params for bias correction). t: u64, /// Lazily sized to the parameter list on the first `step`. state: Vec, } impl AdamW { /// PyTorch-default hyperparameters except `lr`/`weight_decay`, which you set /// (β1=0.9, β2=0.999, ε=1e-8). pub fn new(lr: f32, weight_decay: f32) -> Self { Self::with_betas(lr, weight_decay, 0.9, 0.999, 1e-8) } pub fn with_betas(lr: f32, weight_decay: f32, beta1: f32, beta2: f32, eps: f32) -> Self { Self { lr, beta1, beta2, eps, weight_decay, t: 0, state: Vec::new(), } } /// Current global step (number of `step` calls so far). pub fn step_count(&self) -> u64 { self.t } /// Pure-host AdamW step over flat parameter/gradient buffers. `params[i]` is /// updated in place using `grads[i]`; both are the i-th parameter's elements /// in the model's stable order. Lazily allocates moment state on first call. /// /// This is the testable core — no GPU, no autograd. `lr` is passed per call /// so a schedule can vary it each step. pub fn step_host(&mut self, lr: f32, params: &mut [Vec], grads: &[Vec]) { assert_eq!(params.len(), grads.len(), "param/grad count mismatch"); if self.state.is_empty() { self.state = params .iter() .map(|p| ParamState { m: vec![0.0; p.len()], v: vec![0.0; p.len()], }) .collect(); } assert_eq!(self.state.len(), params.len(), "param count changed"); self.t += 1; let bc1 = 1.0 - self.beta1.powi(self.t as i32); let bc2 = 1.0 - self.beta2.powi(self.t as i32); for (i, (p, g)) in params.iter_mut().zip(grads).enumerate() { assert_eq!(p.len(), g.len(), "param/grad len mismatch at {i}"); let st = &mut self.state[i]; for j in 0..p.len() { let gj = g[j]; st.m[j] = self.beta1 * st.m[j] + (1.0 - self.beta1) * gj; st.v[j] = self.beta2 * st.v[j] + (1.0 - self.beta2) * gj * gj; let mhat = st.m[j] / bc1; let vhat = st.v[j] / bc2; // Decoupled weight decay: decay term uses the *current* param, // matching PyTorch's `p ← p − lr·wd·p` applied alongside the step. p[j] -= lr * (mhat / (vhat.sqrt() + self.eps) + self.weight_decay * p[j]); } } } } #[cfg(not(no_cuda))] mod gpu { use super::AdamW; use xtrain_autodiff::tape::Var; use xtrain_tensor::{DType, Device, Tensor}; impl AdamW { /// Apply one AdamW step to every parameter `Var`, using `lr` for this step /// (so an LR schedule can vary it). Pulls each param's value and `.grad()` /// to the host, runs [`AdamW::step_host`], and writes the updated value /// back with `set_value`. A param with no grad is fed a zero grad, so the /// Adam term vanishes and only decoupled weight decay applies (the model's /// params all receive grads each step, so this is just a safety default). /// /// Does NOT zero grads — the caller does that (matching the GD-step /// template in the T5 overfit test). /// /// This is the host-roundtrip reference path; training uses /// [`GpuAdamW`] (kernel, m/v on device). Both are checked against the /// torch parity in tests. pub fn step(&mut self, lr: f32, params: &[Var]) { let device = params[0].value().device(); let shapes: Vec> = params.iter().map(|p| p.value().shape().to_vec()).collect(); let mut host_params: Vec> = params .iter() .map(|p| p.value().to_device(Device::Cpu).as_slice::().to_vec()) .collect(); let host_grads: Vec> = params .iter() .zip(&host_params) .map(|(p, hp)| match p.grad() { Some(g) => g.to_device(Device::Cpu).as_slice::().to_vec(), None => vec![0.0; hp.len()], // no grad → no update this step }) .collect(); self.step_host(lr, &mut host_params, &host_grads); for ((p, data), shape) in params.iter().zip(&host_params).zip(&shapes) { let t = Tensor::from_slice(data, shape).to_device(device); p.set_value(t); } } } /// GPU AdamW (Phase T7): the optimizer state (m/v moments) lives on the device /// as one tensor pair per parameter, and the update runs as a CUDA kernel that /// reads each param's `.grad()` and rewrites the param buffer in place — no /// per-step GPU↔host roundtrip of params/grads. Same math as /// [`AdamW::step_host`] (the parity reference). pub struct GpuAdamW { beta1: f32, beta2: f32, eps: f32, weight_decay: f32, t: u64, /// Per-parameter (m, v) device buffers, sized lazily on first step. state: Vec<(Tensor, Tensor)>, } impl GpuAdamW { /// PyTorch-default betas/eps; you set lr (per-step) + weight decay. pub fn new(weight_decay: f32) -> Self { Self { beta1: 0.9, beta2: 0.999, eps: 1e-8, weight_decay, t: 0, state: Vec::new(), } } pub fn step_count(&self) -> u64 { self.t } /// One in-place AdamW step over every parameter `Var` at learning rate /// `lr`. Updates the param value buffer and the device m/v state via the /// `adamw_step_f32` kernel. Params are mutated in place, so the leaf `Var` /// identities stay stable across steps (no `set_value`). Does NOT zero /// grads — the caller does. A param without a grad is skipped this step. pub fn step(&mut self, lr: f32, params: &[Var]) { let device = params[0].value().device(); if self.state.is_empty() { self.state = params .iter() .map(|p| { let shape = p.value().shape().to_vec(); ( Tensor::zeros(&shape, DType::F32, device), Tensor::zeros(&shape, DType::F32, device), ) }) .collect(); } assert_eq!(self.state.len(), params.len(), "param count changed"); self.t += 1; let bc1 = 1.0 - self.beta1.powi(self.t as i32); let bc2 = 1.0 - self.beta2.powi(self.t as i32); for (p, (m, v)) in params.iter().zip(&self.state) { let g = match p.grad() { Some(g) => g, None => continue, }; let pv = p.value(); let n = pv.numel() as i32; unsafe { xtrain_cuda::ffi::launch_adamw_step_f32( pv.data_ptr() as *mut f32, g.data_ptr() as *const f32, m.data_ptr() as *mut f32, v.data_ptr() as *mut f32, lr, self.beta1, self.beta2, self.eps, self.weight_decay, bc1, bc2, n, std::ptr::null_mut(), ); } } xtrain_cuda::device::synchronize().expect("adamw step sync failed"); } } } #[cfg(not(no_cuda))] pub use gpu::GpuAdamW;