SPB Git

spb/forge Public MIT

Forge — LLM training from scratch in pure C++20 + Metal on Apple Silicon.

C++ 61.2% C 23% Python 7.6% TeX 7.2% CMake 1.1%
10.4 KB · 274 lines cpp
Raw Blame History
1// Author: Simon-Pierre Boucher — contact@spboucher.ai2#include "train/optimizer.h"34#include "ops/cpu/cpu_ops.h"5#include "ops/metal/metal_ops.h"6#include "ops/ops.h"78#include <cmath>9#include <unordered_set>1011namespace forge::train {1213namespace {1415// Newton-Schulz quintic coefficients (Keller Jordan's Muon: tuned so the16// iteration's fixed point spreads singular values toward 1 fast; 5 rounds17// suffice at bf16-level accuracy, and we run f32).18constexpr float kNsA = 3.4445f;19constexpr float kNsB = -4.7750f;20constexpr float kNsC = 2.0315f;21constexpr int kNsIters = 5;22constexpr float kNsEps = 1e-7f;2324// Muon updates hidden 2-D matrices only; embeddings and the (possibly tied)25// lm_head keep AdamW, matching the reference MuonWithAuxAdam split.26bool muon_eligible(const std::string& name, const Var& p) {27    if (p.value().ndim() != 2) return false;28    if (name.find("emb") != std::string::npos) return false;     // tok_emb/pos_emb29    if (name.find("lm_head") != std::string::npos) return false;30    return true;31}3233// LR adjustment for rectangular matrices (Muon reference impl).34float muon_adj(const Tensor& w) {35    const float r = float(w.size(0)), c = float(w.size(1));36    return std::sqrt(std::max(1.0f, r / c));37}3839float frobenius(const Tensor& x) {40    double sq = 0.0;41    const float* p = x.data<float>();42    for (int64_t i = 0; i < x.numel(); ++i) sq += double(p[i]) * double(p[i]);43    return float(std::sqrt(sq));44}4546} // namespace4748Optimizer::Optimizer(const std::vector<std::pair<std::string, Var>>& named_params,49                     Options opts)50    : opts_(opts) {51    std::unordered_set<const void*> seen;52    for (const auto& [name, p] : named_params) {53        if (!p.defined() || !p.requires_grad()) continue;54        if (!seen.insert(p.id()).second) continue; // tied param, already tracked55        params_.push_back(p);56        decay_.push_back(p.value().ndim() >= 2);57        muon_.push_back(opts_.kind == "muon" && muon_eligible(name, p));58    }59}6061float Optimizer::clip_global_norm(float max_norm) {62    double sq = 0.0;63    for (const Var& p : params_) {64        if (!p.has_grad()) continue;65        const float* g = p.grad().data<float>();66        for (int64_t i = 0; i < p.grad().numel(); ++i) sq += double(g[i]) * double(g[i]);67    }68    const float norm = float(std::sqrt(sq));69    if (max_norm > 0.0f && norm > max_norm) {70        const float s = max_norm / norm;71        for (const Var& p : params_) {72            if (!p.has_grad()) continue;73            float* g = p.grad().data<float>();74            for (int64_t i = 0; i < p.grad().numel(); ++i) g[i] *= s;75        }76    }77    return norm;78}7980void Optimizer::ensure_state() {81    if (!m_.empty()) return;82    m_.reserve(params_.size());83    v_.reserve(params_.size());84    for (const Var& p : params_) {85        m_.push_back(Tensor::zeros(p.value().shape()));86        v_.push_back(Tensor::zeros(p.value().shape()));87    }88}8990float Optimizer::step_with_clip(float lr, float max_norm) {91    if (ops::backend() == ops::Backend::CPU) {92        const float norm = clip_global_norm(max_norm);93        step(lr);94        return norm;95    }9697    // Metal path. Encode per-tensor sum-of-squares into one partials buffer,98    // then sync — this is also the boundary where the step's loss becomes99    // readable.100    // These are hundreds of independent single-threadgroup reductions (one per101    // parameter tensor), which a serial encoder pointlessly serializes.102    Tensor partials = Tensor::empty({int64_t(params_.size())});103    {104        metal::ConcurrentRegion region;105        for (size_t i = 0; i < params_.size(); ++i) {106            Tensor slot = partials.slice0(int64_t(i), 1);107            metal::sumsq(params_[i].grad(), slot);108        }109    }110    metal::sync();111112    double sq = 0.0;113    for (size_t i = 0; i < params_.size(); ++i) sq += double(partials.data<float>()[i]);114    const float norm = float(std::sqrt(sq));115    const float grad_scale =116        (max_norm > 0.0f && norm > max_norm) ? max_norm / norm : 1.0f;117118    ensure_state(); // CPU-side zeros are safe here: the stream is closed119    ++t_;120    {121        // Each parameter's AdamW update touches only its own w/g/m/v — independent.122        metal::ConcurrentRegion region;123        for (size_t i = 0; i < params_.size(); ++i) {124            if (muon_[i]) continue;125            const float wd = decay_[i] ? opts_.weight_decay : 0.0f;126            metal::adamw_step(params_[i].value(), params_[i].grad(), m_[i], v_[i], lr,127                              opts_.beta1, opts_.beta2, t_, opts_.eps, wd, grad_scale);128        }129    }130131    // Muon phase 1: momentum + nesterov direction + its Frobenius norm.132    // (Serial encoder: each param's chain has read-after-write dependencies;133    // cross-param over-ordering is harmless — these are tiny dispatches.)134    std::vector<Tensor> xs(params_.size());135    Tensor mu_partials;136    std::vector<int64_t> mu_slot(params_.size(), -1);137    int64_t n_muon = 0;138    for (size_t i = 0; i < params_.size(); ++i)139        if (muon_[i]) mu_slot[i] = n_muon++;140    if (n_muon > 0) {141        mu_partials = Tensor::empty({n_muon});142        const float beta = opts_.muon_momentum;143        for (size_t i = 0; i < params_.size(); ++i) {144            if (!muon_[i]) continue;145            const Tensor& g = params_[i].grad();146            Tensor gs = Tensor::empty(g.shape());147            xs[i] = Tensor::empty(g.shape());148            metal::scale(g, grad_scale, gs);      // g̃ = clip-scaled grad149            metal::scale(m_[i], beta, m_[i]);150            metal::accumulate(m_[i], gs);         // m = β·m + g̃151            metal::scale(m_[i], beta, xs[i]);152            metal::accumulate(xs[i], gs);         // X = g̃ + β·m (nesterov)153            Tensor slot = mu_partials.slice0(mu_slot[i], 1);154            metal::sumsq(xs[i], slot);155        }156        metal::sync();157158        // Muon phase 2: normalize, orthogonalize (Newton-Schulz via the159        // existing matmul kernels), apply. For rows > cols the iteration runs160        // on the implicit transpose: A = XᵀX and X ← aX + XB, which is the161        // transpose of the tall-side recurrence — no materialized transpose.162        const float lr_m = lr * opts_.muon_lr_ratio;163        for (size_t i = 0; i < params_.size(); ++i) {164            if (!muon_[i]) continue;165            Tensor& X = xs[i];166            const int64_t r = X.size(0), c = X.size(1);167            const bool tall = r > c;168            const int64_t k = tall ? c : r;169            const float fro = std::sqrt(mu_partials.data<float>()[mu_slot[i]]);170            metal::scale(X, 1.0f / (fro + kNsEps), X);171172            Tensor A = Tensor::empty({k, k});173            Tensor B = Tensor::empty({k, k});174            Tensor X2 = Tensor::empty({r, c});175            for (int it = 0; it < kNsIters; ++it) {176                if (tall) metal::matmul(X, X, A, true, false);   // A = XᵀX177                else      metal::matmul(X, X, A, false, true);   // A = XXᵀ178                metal::matmul(A, A, B);179                metal::scale(B, kNsC, B);180                metal::scale(A, kNsB, A);181                metal::accumulate(B, A);                          // B = bA + cA²182                if (tall) metal::matmul(X, B, X2);                // X2 = XB183                else      metal::matmul(B, X, X2);                // X2 = BX184                metal::scale(X, kNsA, X);185                metal::accumulate(X, X2);                         // X = aX + X2186            }187            metal::scale(params_[i].value(), 1.0f - lr_m * opts_.weight_decay,188                         params_[i].value());189            metal::scale(X, -lr_m * muon_adj(params_[i].value()), X);190            metal::accumulate(params_[i].value(), X);191        }192    }193194    metal::sync(); // weights final before the next step's CPU-side zero_grad195    return norm;196}197198void Optimizer::adamw_cpu(size_t pi, float lr) {199    const float bc1 = 1.0f - std::pow(opts_.beta1, float(t_));200    const float bc2 = 1.0f - std::pow(opts_.beta2, float(t_));201    Var& p = params_[pi];202    const float wd = decay_[pi] ? opts_.weight_decay : 0.0f;203    float* w = p.value().data<float>();204    const float* g = p.grad().data<float>();205    float* m = m_[pi].data<float>();206    float* v = v_[pi].data<float>();207    const int64_t n = p.value().numel();208    for (int64_t i = 0; i < n; ++i) {209        m[i] = opts_.beta1 * m[i] + (1.0f - opts_.beta1) * g[i];210        v[i] = opts_.beta2 * v[i] + (1.0f - opts_.beta2) * g[i] * g[i];211        const float mhat = m[i] / bc1;212        const float vhat = v[i] / bc2;213        w[i] -= lr * (mhat / (std::sqrt(vhat) + opts_.eps) + wd * w[i]);214    }215}216217void Optimizer::muon_cpu(size_t pi, float lr) {218    Var& p = params_[pi];219    const float beta = opts_.muon_momentum;220    const float* g = p.grad().data<float>();221    float* m = m_[pi].data<float>();222    const int64_t n = p.value().numel();223    const int64_t r = p.value().size(0), c = p.value().size(1);224    const bool tall = r > c;225    const int64_t k = tall ? c : r;226227    Tensor X = Tensor::empty({r, c});228    float* x = X.data<float>();229    for (int64_t i = 0; i < n; ++i) {230        m[i] = beta * m[i] + g[i];231        x[i] = g[i] + beta * m[i]; // nesterov232    }233    const float fro = frobenius(X);234    for (int64_t i = 0; i < n; ++i) x[i] /= (fro + kNsEps);235236    Tensor A = Tensor::empty({k, k});237    Tensor B = Tensor::empty({k, k});238    Tensor X2 = Tensor::empty({r, c});239    for (int it = 0; it < kNsIters; ++it) {240        if (tall) cpu::matmul(X, X, A, true, false);241        else      cpu::matmul(X, X, A, false, true);242        cpu::matmul(A, A, B);243        float* pa = A.data<float>();244        float* pb = B.data<float>();245        for (int64_t i = 0; i < k * k; ++i) pb[i] = kNsB * pa[i] + kNsC * pb[i];246        if (tall) cpu::matmul(X, B, X2);247        else      cpu::matmul(B, X, X2);248        const float* p2 = X2.data<float>();249        for (int64_t i = 0; i < n; ++i) x[i] = kNsA * x[i] + p2[i];250    }251252    const float lr_m = lr * opts_.muon_lr_ratio;253    const float adj = muon_adj(p.value());254    float* w = p.value().data<float>();255    for (int64_t i = 0; i < n; ++i)256        w[i] = w[i] * (1.0f - lr_m * opts_.weight_decay) - lr_m * adj * x[i];257}258259void Optimizer::step(float lr) {260    ensure_state();261    ++t_; // 1-based: bias correction divides by (1 - beta^t)262    for (size_t pi = 0; pi < params_.size(); ++pi) {263        if (!params_[pi].has_grad()) continue;264        if (muon_[pi]) muon_cpu(pi, lr);265        else adamw_cpu(pi, lr);266    }267}268269void Optimizer::zero_grad() {270    for (const Var& p : params_) p.zero_grad();271}272273} // namespace forge::train274