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%
14.3 KB · 279 lines c
Raw Blame History
1// Author: Simon-Pierre Boucher — contact@spboucher.ai2#pragma once34#include <algorithm>5#include <cstdint>6#include <fstream>7#include <stdexcept>8#include <string>910#include <nlohmann/json.hpp>1112namespace forge {1314// The entire architecture comes from here — no hardcoded model sizes15// anywhere else in the codebase.16struct ModelConfig {17    std::string name = "model";18    int64_t n_layers = 6;19    int64_t d_model = 384;20    int64_t n_heads = 6;21    int64_t n_kv_heads = 6;      // < n_heads => GQA22    int64_t d_ff = 1024;23    int64_t vocab_size = 4096;24    int64_t context_length = 512;25    bool tied_embeddings = true;26    bool use_rope = true;        // false => learned positional embeddings27    float rope_theta = 10000.0f;28    std::string norm = "rmsnorm";     // "rmsnorm" | "layernorm"29    float norm_eps = 1e-6f;30    std::string activation = "swiglu"; // "swiglu" | "gelu"31    float dropout = 0.0f;32    // Architecture-variant knobs (all off by default = the classic recipe):33    bool qk_norm = false;         // RMSNorm on q/k per head before RoPE (Qwen3/Gemma3)34    float final_softcap = 0.0f;   // logits = cap*tanh(logits/cap) (Gemma2: 30)35    bool scale_embeddings = false;// x *= sqrt(d_model) after embedding (Gemma)36    bool attention_bias = false;  // bias on q/k/v projections only (Qwen2.5)37    int64_t head_dim_override = 0;// decouple head_dim from d_model/n_heads (Qwen3)38    int64_t nope_every = 0;       // skip RoPE every Nth layer (SmolLM3: 4)39    std::string norm_placement = "pre"; // "pre" | "post" (OLMo2) | "sandwich" (Gemma)40    // RoPE scaling, HF "llama3" style (0 = off). Applied to the inv-freq table.41    float rope_scale_factor = 0.0f;      // llama3: 3242    float rope_scale_low = 1.0f;         // low_freq_factor43    float rope_scale_high = 4.0f;        // high_freq_factor44    int64_t rope_scale_orig_ctx = 8192;  // original_max_position_embeddings45    // Sliding-window attention (Mistral/Gemma3). 0 = full attention everywhere.46    int64_t sliding_window = 0;47    int64_t sliding_global_every = 0; // every Nth layer is global (Gemma3: 6); 0 = none48    float rope_theta_global = 0.0f;   // theta for global layers (Gemma3: 1e6); 0 = theta49    float attn_softcap = 0.0f;        // cap on attention scores (Gemma2: 50)50    // Quantization-aware training: linear weights are fake-quantized each51    // forward (per-row scales, straight-through estimator in backward).52    // Master weights and the optimizer stay f32.53    std::string quant = "none";       // "none" | "int8" | "ternary"54    // Mixture-of-experts MLP: n_experts > 0 replaces each block's MLP with55    // `n_experts` expert MLPs (d_ff each) + a top-k softmax router. v156    // computes every expert densely and masks — correctness first.57    int64_t n_experts = 0;            // 0 => dense MLP58    int64_t moe_top_k = 2;59    float moe_aux_weight = 0.01f;     // load-balance loss: E * sum_e mean_gate_e^260    int64_t n_shared_experts = 0;     // always-active experts (DeepSeek/Kimi style)61    // DeepSeek-V3-style routing refinements:62    std::string moe_scoring = "softmax"; // "softmax" | "sigmoid" (V3/K2)63    bool moe_norm_topk = true;        // renormalize kept gates to sum 164    float routed_scaling_factor = 1.0f; // y = shared + factor * sum(gated) (V3: 2.5)65    int64_t moe_d_ff = 0;             // per-expert d_ff; 0 = d_ff (V3: 2048 vs 18432)66    int64_t first_k_dense = 0;        // first k layers keep a dense MLP (V3: 3, K2: 1)67    // Aux-loss-FREE balancing (V3 "noaux"): select top-k on s+b but gate on s;68    // after each step b += gamma for underloaded experts, -= gamma for69    // overloaded. 0 disables (bias stays zero).70    float moe_bias_gamma = 0.0f;      // V3: 0.0017172    int64_t head_dim() const {73        return head_dim_override > 0 ? head_dim_override : d_model / n_heads;74    }7576    // True when layer i (0-based) attends globally; only meaningful with77    // sliding_window > 0.78    bool layer_is_global(int64_t i) const {79        if (sliding_window <= 0) return true;80        return sliding_global_every > 0 && (i + 1) % sliding_global_every == 0;81    }82    bool layer_uses_rope(int64_t i) const {83        return use_rope && !(nope_every > 0 && (i + 1) % nope_every == 0);84    }8586    // Total parameter count (SwiGLU MLP has three mats, GELU/ReLU² have two).87    int64_t num_params() const {88        const int64_t hd = head_dim();89        int64_t attn = d_model * n_heads * hd             // wq90                     + 2 * d_model * n_kv_heads * hd      // wk, wv91                     + n_heads * hd * d_model;            // wo92        if (attention_bias) attn += (n_heads + 2 * n_kv_heads) * hd;93        const int64_t act_mats = (activation == "swiglu") ? 3 : 2;94        const int64_t mlp_dense = act_mats * d_model * d_ff;95        const int64_t expert_dff = (n_experts > 0 && moe_d_ff > 0) ? moe_d_ff : d_ff;96        const int64_t mlp_moe = (n_experts + n_shared_experts) * act_mats * d_model *97                                    expert_dff +98                                n_experts * d_model;       // experts + router99        // first_k_dense layers keep the dense MLP (V3-style).100        const int64_t n_moe_layers =101            n_experts > 0 ? n_layers - std::min(first_k_dense, n_layers) : 0;102        const int64_t mlp_total =103            n_moe_layers * mlp_moe + (n_layers - n_moe_layers) * mlp_dense;104        const int64_t norms_per_layer = norm_placement == "sandwich" ? 4 : 2;105        int64_t norms = (norm == "layernorm" ? 2 : 1) * d_model *106                        (norms_per_layer * n_layers + 1);107        if (qk_norm) norms += 2 * head_dim() * n_layers;108        int64_t total = n_layers * attn + mlp_total + norms + vocab_size * d_model;109        if (!tied_embeddings) total += vocab_size * d_model;110        if (!use_rope) total += context_length * d_model;111        return total;112    }113};114115struct TrainConfig {116    float lr = 6e-4f;117    float min_lr_ratio = 0.1f;       // min_lr = lr * ratio118    int64_t warmup_steps = 2000;119    int64_t max_steps = 100000;120    std::string schedule = "cosine"; // "cosine" | "wsd" (warmup-stable-decay)121    float wsd_decay_frac = 0.15f;    // WSD: fraction of max_steps in the cooldown122    // "muon": Newton-Schulz orthogonalized momentum on 2-D hidden matrices,123    // AdamW on embeddings/head/norms (Keller Jordan's Muon). muon_lr is the124    // peak LR of the Muon group; it follows the same schedule shape as `lr`.125    std::string optimizer = "adamw"; // "adamw" | "muon"126    float muon_lr = 0.02f;127    float muon_momentum = 0.95f;128    float beta1 = 0.9f;129    float beta2 = 0.95f;130    float eps = 1e-8f;131    float weight_decay = 0.1f;       // dim>=2 params only132    float grad_clip = 1.0f;          // 0 disables133    int64_t batch_size = 32;         // sequences per micro-batch134    int64_t grad_accum_steps = 1;135    std::string precision = "f32";   // "f32" | "f16" | "bf16" (compute dtype)136    int64_t checkpoint_every = 1000;137    // Also commit each checkpoint's weights into <out>/model.forge — the138    // git-style zero-copy format (src/core/fmodel.h). The .bin checkpoint139    // stays the resume source of truth (it carries optimizer state).140    bool forge_save = true;141    std::string forge_dtype = "f32"; // "f32" | "f16" | "bf16"142    int64_t eval_every = 500;143    int64_t eval_batches = 20;144    uint64_t seed = 1337;145    bool deterministic = false;146};147148struct Config {149    ModelConfig model;150    TrainConfig train;151};152153inline void from_json(const nlohmann::json& j, ModelConfig& c) {154    c.name = j.value("name", c.name);155    c.n_layers = j.value("n_layers", c.n_layers);156    c.d_model = j.value("d_model", c.d_model);157    c.n_heads = j.value("n_heads", c.n_heads);158    c.n_kv_heads = j.value("n_kv_heads", c.n_heads);159    c.d_ff = j.value("d_ff", c.d_ff);160    c.vocab_size = j.value("vocab_size", c.vocab_size);161    c.context_length = j.value("context_length", c.context_length);162    c.tied_embeddings = j.value("tied_embeddings", c.tied_embeddings);163    c.use_rope = j.value("use_rope", c.use_rope);164    c.rope_theta = j.value("rope_theta", c.rope_theta);165    c.norm = j.value("norm", c.norm);166    c.norm_eps = j.value("norm_eps", c.norm_eps);167    c.activation = j.value("activation", c.activation);168    c.dropout = j.value("dropout", c.dropout);169    c.quant = j.value("quant", c.quant);170    c.n_experts = j.value("n_experts", c.n_experts);171    c.moe_top_k = j.value("moe_top_k", c.moe_top_k);172    c.moe_aux_weight = j.value("moe_aux_weight", c.moe_aux_weight);173    c.n_shared_experts = j.value("n_shared_experts", c.n_shared_experts);174    c.moe_scoring = j.value("moe_scoring", c.moe_scoring);175    c.moe_norm_topk = j.value("moe_norm_topk", c.moe_norm_topk);176    c.routed_scaling_factor = j.value("routed_scaling_factor", c.routed_scaling_factor);177    c.moe_d_ff = j.value("moe_d_ff", c.moe_d_ff);178    c.first_k_dense = j.value("first_k_dense", c.first_k_dense);179    c.moe_bias_gamma = j.value("moe_bias_gamma", c.moe_bias_gamma);180    c.qk_norm = j.value("qk_norm", c.qk_norm);181    c.final_softcap = j.value("final_softcap", c.final_softcap);182    c.scale_embeddings = j.value("scale_embeddings", c.scale_embeddings);183    c.attention_bias = j.value("attention_bias", c.attention_bias);184    c.head_dim_override = j.value("head_dim", c.head_dim_override);185    c.nope_every = j.value("nope_every", c.nope_every);186    c.norm_placement = j.value("norm_placement", c.norm_placement);187    c.rope_scale_factor = j.value("rope_scale_factor", c.rope_scale_factor);188    c.rope_scale_low = j.value("rope_scale_low", c.rope_scale_low);189    c.rope_scale_high = j.value("rope_scale_high", c.rope_scale_high);190    c.rope_scale_orig_ctx = j.value("rope_scale_orig_ctx", c.rope_scale_orig_ctx);191    c.sliding_window = j.value("sliding_window", c.sliding_window);192    c.sliding_global_every = j.value("sliding_global_every", c.sliding_global_every);193    c.rope_theta_global = j.value("rope_theta_global", c.rope_theta_global);194    c.attn_softcap = j.value("attn_softcap", c.attn_softcap);195196    if (c.head_dim_override == 0 && c.d_model % c.n_heads != 0)197        throw std::runtime_error("config: d_model must be divisible by n_heads "198                                 "(or set head_dim explicitly)");199    if (c.n_heads % c.n_kv_heads != 0)200        throw std::runtime_error("config: n_heads must be divisible by n_kv_heads");201    if (c.head_dim() % 2 != 0)202        throw std::runtime_error("config: head_dim must be even (RoPE pairs)");203    if (c.norm != "rmsnorm" && c.norm != "layernorm")204        throw std::runtime_error("config: norm must be rmsnorm or layernorm");205    if (c.activation != "swiglu" && c.activation != "gelu" && c.activation != "relu2")206        throw std::runtime_error("config: activation must be swiglu, gelu or relu2");207    if (c.norm_placement != "pre" && c.norm_placement != "post" &&208        c.norm_placement != "sandwich")209        throw std::runtime_error("config: norm_placement must be pre, post or sandwich");210    if (c.sliding_window < 0 || c.sliding_global_every < 0 || c.nope_every < 0)211        throw std::runtime_error("config: window/pattern values must be >= 0");212    if (c.attn_softcap < 0.0f)213        throw std::runtime_error("config: attn_softcap must be >= 0");214    if (c.quant != "none" && c.quant != "int8" && c.quant != "ternary")215        throw std::runtime_error("config: quant must be none, int8 or ternary");216    if (c.n_experts < 0)217        throw std::runtime_error("config: n_experts must be >= 0");218    if (c.n_experts > 0 && (c.moe_top_k < 1 || c.moe_top_k > c.n_experts))219        throw std::runtime_error("config: moe_top_k must be in [1, n_experts]");220    if (c.n_shared_experts < 0 || (c.n_shared_experts > 0 && c.n_experts == 0))221        throw std::runtime_error("config: n_shared_experts requires n_experts > 0");222    if (c.moe_scoring != "softmax" && c.moe_scoring != "sigmoid")223        throw std::runtime_error("config: moe_scoring must be softmax or sigmoid");224    if (c.first_k_dense < 0 || c.first_k_dense > c.n_layers)225        throw std::runtime_error("config: first_k_dense must be in [0, n_layers]");226    if (c.moe_d_ff < 0 || c.moe_bias_gamma < 0.0f)227        throw std::runtime_error("config: moe_d_ff and moe_bias_gamma must be >= 0");228    if (c.final_softcap < 0.0f)229        throw std::runtime_error("config: final_softcap must be >= 0");230}231232inline void from_json(const nlohmann::json& j, TrainConfig& c) {233    c.lr = j.value("lr", c.lr);234    c.min_lr_ratio = j.value("min_lr_ratio", c.min_lr_ratio);235    c.warmup_steps = j.value("warmup_steps", c.warmup_steps);236    c.max_steps = j.value("max_steps", c.max_steps);237    c.schedule = j.value("schedule", c.schedule);238    c.wsd_decay_frac = j.value("wsd_decay_frac", c.wsd_decay_frac);239    c.optimizer = j.value("optimizer", c.optimizer);240    c.muon_lr = j.value("muon_lr", c.muon_lr);241    c.muon_momentum = j.value("muon_momentum", c.muon_momentum);242    c.beta1 = j.value("beta1", c.beta1);243    c.beta2 = j.value("beta2", c.beta2);244    c.eps = j.value("eps", c.eps);245    c.weight_decay = j.value("weight_decay", c.weight_decay);246    c.grad_clip = j.value("grad_clip", c.grad_clip);247    c.batch_size = j.value("batch_size", c.batch_size);248    c.grad_accum_steps = j.value("grad_accum_steps", c.grad_accum_steps);249    c.precision = j.value("precision", c.precision);250    c.checkpoint_every = j.value("checkpoint_every", c.checkpoint_every);251    c.forge_save = j.value("forge_save", c.forge_save);252    c.forge_dtype = j.value("forge_dtype", c.forge_dtype);253    c.eval_every = j.value("eval_every", c.eval_every);254    c.eval_batches = j.value("eval_batches", c.eval_batches);255    c.seed = j.value("seed", c.seed);256    c.deterministic = j.value("deterministic", c.deterministic);257258    if (c.schedule != "cosine" && c.schedule != "wsd")259        throw std::runtime_error("config: schedule must be cosine or wsd");260    if (c.wsd_decay_frac <= 0.0f || c.wsd_decay_frac >= 1.0f)261        throw std::runtime_error("config: wsd_decay_frac must be in (0, 1)");262    if (c.optimizer != "adamw" && c.optimizer != "muon")263        throw std::runtime_error("config: optimizer must be adamw or muon");264    if (c.forge_dtype != "f32" && c.forge_dtype != "f16" && c.forge_dtype != "bf16")265        throw std::runtime_error("config: forge_dtype must be f32, f16 or bf16");266}267268inline Config load_config(const std::string& path) {269    std::ifstream in(path);270    if (!in) throw std::runtime_error("config: cannot open " + path);271    nlohmann::json j = nlohmann::json::parse(in);272    Config cfg;273    if (j.contains("model")) j.at("model").get_to(cfg.model);274    if (j.contains("train")) j.at("train").get_to(cfg.train);275    return cfg;276}277278} // namespace forge279