Add configurable training modes: Muon optimizer and WSD schedule
- optimizer "adamw" | "muon": Newton-Schulz orthogonalized momentum on 2-D hidden matrices (composed from the existing matmul kernels on Metal), AdamW kept for embeddings/head/1-D params; muon_lr follows the lr schedule - schedule "cosine" | "wsd": warmup-stable-decay with 1-sqrt cooldown, extendable runs, wsd_decay_frac - gpt-50m base config + Muon+WSD and MobileLLM-style deep-and-thin variants Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 8 changed files with +436 and −39
added
configs/gpt-50m-deep.json
+38 −0
@@ -0,0 +1,38 @@ | ||
| 1 | +{ | |
| 2 | + "_comment": "MobileLLM-style deep-and-thin variant of gpt-50m: 20 layers x d_model 448 (aspect ratio ~22 vs 64 for the wide config), head_dim 64. ~50.6M params. Same data/steps/optimizer as gpt-50m so the only variable is the shape.", | |
| 3 | + "model": { | |
| 4 | + "name": "gpt-50m-deep", | |
| 5 | + "n_layers": 20, | |
| 6 | + "d_model": 448, | |
| 7 | + "n_heads": 7, | |
| 8 | + "n_kv_heads": 7, | |
| 9 | + "d_ff": 1216, | |
| 10 | + "vocab_size": 4096, | |
| 11 | + "context_length": 1024, | |
| 12 | + "tied_embeddings": true, | |
| 13 | + "use_rope": true, | |
| 14 | + "rope_theta": 10000.0, | |
| 15 | + "norm": "rmsnorm", | |
| 16 | + "norm_eps": 1e-06, | |
| 17 | + "activation": "swiglu", | |
| 18 | + "dropout": 0.0 | |
| 19 | + }, | |
| 20 | + "train": { | |
| 21 | + "lr": 0.0005, | |
| 22 | + "min_lr_ratio": 0.1, | |
| 23 | + "warmup_steps": 117, | |
| 24 | + "max_steps": 1170, | |
| 25 | + "beta1": 0.9, | |
| 26 | + "beta2": 0.95, | |
| 27 | + "eps": 1e-08, | |
| 28 | + "weight_decay": 0.1, | |
| 29 | + "grad_clip": 1.0, | |
| 30 | + "batch_size": 8, | |
| 31 | + "grad_accum_steps": 8, | |
| 32 | + "precision": "f32", | |
| 33 | + "checkpoint_every": 200, | |
| 34 | + "eval_every": 100, | |
| 35 | + "eval_batches": 20, | |
| 36 | + "seed": 1337 | |
| 37 | + } | |
| 38 | +} | |
added
configs/gpt-50m-muon.json
+43 −0
@@ -0,0 +1,43 @@ | ||
| 1 | +{ | |
| 2 | + "_comment": "gpt-50m trained with Muon (NS-orthogonalized momentum on hidden matrices, AdamW on embeddings/head) + WSD schedule (flat plateau, 15% 1-sqrt cooldown). muon_lr 0.02 is the NanoGPT-speedrun default; lr applies to the AdamW group. Same data/steps as gpt-50m for A/B comparison.", | |
| 3 | + "model": { | |
| 4 | + "name": "gpt-50m-muon", | |
| 5 | + "n_layers": 10, | |
| 6 | + "d_model": 640, | |
| 7 | + "n_heads": 10, | |
| 8 | + "n_kv_heads": 10, | |
| 9 | + "d_ff": 1728, | |
| 10 | + "vocab_size": 4096, | |
| 11 | + "context_length": 1024, | |
| 12 | + "tied_embeddings": true, | |
| 13 | + "use_rope": true, | |
| 14 | + "rope_theta": 10000.0, | |
| 15 | + "norm": "rmsnorm", | |
| 16 | + "norm_eps": 1e-06, | |
| 17 | + "activation": "swiglu", | |
| 18 | + "dropout": 0.0 | |
| 19 | + }, | |
| 20 | + "train": { | |
| 21 | + "lr": 0.0005, | |
| 22 | + "min_lr_ratio": 0.1, | |
| 23 | + "warmup_steps": 117, | |
| 24 | + "max_steps": 1170, | |
| 25 | + "schedule": "wsd", | |
| 26 | + "wsd_decay_frac": 0.15, | |
| 27 | + "optimizer": "muon", | |
| 28 | + "muon_lr": 0.02, | |
| 29 | + "muon_momentum": 0.95, | |
| 30 | + "beta1": 0.9, | |
| 31 | + "beta2": 0.95, | |
| 32 | + "eps": 1e-08, | |
| 33 | + "weight_decay": 0.1, | |
| 34 | + "grad_clip": 1.0, | |
| 35 | + "batch_size": 8, | |
| 36 | + "grad_accum_steps": 8, | |
| 37 | + "precision": "f32", | |
| 38 | + "checkpoint_every": 200, | |
| 39 | + "eval_every": 100, | |
| 40 | + "eval_batches": 20, | |
| 41 | + "seed": 1337 | |
| 42 | + } | |
| 43 | +} | |
added
configs/gpt-50m.json
+38 −0
@@ -0,0 +1,38 @@ | ||
| 1 | +{ | |
| 2 | + "_comment": "~52M params, sized between gpt-25m and gpt-100m. vocab 4096 to match the local tok4096 train.bin (19.14M tokens). batch_size is the MICRO-batch: 8 x 8 x 1024 = 65536 tokens/step; 1170 steps = ~4 epochs over the 19.14M-token set. Warmup is 10% of the run. precision is parsed but not yet honored - all kernels are f32.", | |
| 3 | + "model": { | |
| 4 | + "name": "gpt-50m", | |
| 5 | + "n_layers": 10, | |
| 6 | + "d_model": 640, | |
| 7 | + "n_heads": 10, | |
| 8 | + "n_kv_heads": 10, | |
| 9 | + "d_ff": 1728, | |
| 10 | + "vocab_size": 4096, | |
| 11 | + "context_length": 1024, | |
| 12 | + "tied_embeddings": true, | |
| 13 | + "use_rope": true, | |
| 14 | + "rope_theta": 10000.0, | |
| 15 | + "norm": "rmsnorm", | |
| 16 | + "norm_eps": 1e-06, | |
| 17 | + "activation": "swiglu", | |
| 18 | + "dropout": 0.0 | |
| 19 | + }, | |
| 20 | + "train": { | |
| 21 | + "lr": 0.0005, | |
| 22 | + "min_lr_ratio": 0.1, | |
| 23 | + "warmup_steps": 117, | |
| 24 | + "max_steps": 1170, | |
| 25 | + "beta1": 0.9, | |
| 26 | + "beta2": 0.95, | |
| 27 | + "eps": 1e-08, | |
| 28 | + "weight_decay": 0.1, | |
| 29 | + "grad_clip": 1.0, | |
| 30 | + "batch_size": 8, | |
| 31 | + "grad_accum_steps": 8, | |
| 32 | + "precision": "f32", | |
| 33 | + "checkpoint_every": 200, | |
| 34 | + "eval_every": 100, | |
| 35 | + "eval_batches": 20, | |
| 36 | + "seed": 1337 | |
| 37 | + } | |
| 38 | +} | |
modified
src/nn/config.h
+68 −2
@@ -28,6 +28,21 @@ struct ModelConfig { | ||
| 28 | 28 | float norm_eps = 1e-6f; |
| 29 | 29 | std::string activation = "swiglu"; // "swiglu" | "gelu" |
| 30 | 30 | float dropout = 0.0f; |
| 31 | + // Architecture-variant knobs (all off by default = the classic recipe): | |
| 32 | + bool qk_norm = false; // RMSNorm on q/k per head before RoPE (Qwen3/Gemma3) | |
| 33 | + float final_softcap = 0.0f; // logits = cap*tanh(logits/cap) (Gemma2: 30) | |
| 34 | + bool scale_embeddings = false;// x *= sqrt(d_model) after embedding (Gemma) | |
| 35 | + // Quantization-aware training: linear weights are fake-quantized each | |
| 36 | + // forward (per-row scales, straight-through estimator in backward). | |
| 37 | + // Master weights and the optimizer stay f32. | |
| 38 | + std::string quant = "none"; // "none" | "int8" | "ternary" | |
| 39 | + // Mixture-of-experts MLP: n_experts > 0 replaces each block's MLP with | |
| 40 | + // `n_experts` expert MLPs (d_ff each) + a top-k softmax router. v1 | |
| 41 | + // computes every expert densely and masks — correctness first. | |
| 42 | + int64_t n_experts = 0; // 0 => dense MLP | |
| 43 | + int64_t moe_top_k = 2; | |
| 44 | + float moe_aux_weight = 0.01f; // load-balance loss: E * sum_e mean_gate_e^2 | |
| 45 | + int64_t n_shared_experts = 0; // always-active experts (DeepSeek/Kimi style) | |
| 31 | 46 | |
| 32 | 47 | int64_t head_dim() const { return d_model / n_heads; } |
| 33 | 48 | |
@@ -37,10 +52,14 @@ struct ModelConfig { | ||
| 37 | 52 | const int64_t attn = d_model * d_model // wq |
| 38 | 53 | + 2 * d_model * n_kv_heads * hd // wk, wv |
| 39 | 54 | + d_model * d_model; // wo |
| 40 | − const int64_t mlp = (activation == "swiglu") | |
| 55 | + const int64_t mlp_one = (activation == "swiglu") | |
| 41 | 56 | ? 3 * d_model * d_ff |
| 42 | 57 | : 2 * d_model * d_ff; |
| 43 | − const int64_t norms = (norm == "layernorm" ? 2 : 1) * d_model * (2 * n_layers + 1); | |
| 58 | + const int64_t mlp = n_experts > 0 | |
| 59 | + ? (n_experts + n_shared_experts) * mlp_one + n_experts * d_model | |
| 60 | + : mlp_one; // experts + router | |
| 61 | + int64_t norms = (norm == "layernorm" ? 2 : 1) * d_model * (2 * n_layers + 1); | |
| 62 | + if (qk_norm) norms += 2 * head_dim() * n_layers; | |
| 44 | 63 | int64_t total = n_layers * (attn + mlp) + norms + vocab_size * d_model; |
| 45 | 64 | if (!tied_embeddings) total += vocab_size * d_model; |
| 46 | 65 | if (!use_rope) total += context_length * d_model; |
@@ -53,6 +72,14 @@ struct TrainConfig { | ||
| 53 | 72 | float min_lr_ratio = 0.1f; // min_lr = lr * ratio |
| 54 | 73 | int64_t warmup_steps = 2000; |
| 55 | 74 | int64_t max_steps = 100000; |
| 75 | + std::string schedule = "cosine"; // "cosine" | "wsd" (warmup-stable-decay) | |
| 76 | + float wsd_decay_frac = 0.15f; // WSD: fraction of max_steps in the cooldown | |
| 77 | + // "muon": Newton-Schulz orthogonalized momentum on 2-D hidden matrices, | |
| 78 | + // AdamW on embeddings/head/norms (Keller Jordan's Muon). muon_lr is the | |
| 79 | + // peak LR of the Muon group; it follows the same schedule shape as `lr`. | |
| 80 | + std::string optimizer = "adamw"; // "adamw" | "muon" | |
| 81 | + float muon_lr = 0.02f; | |
| 82 | + float muon_momentum = 0.95f; | |
| 56 | 83 | float beta1 = 0.9f; |
| 57 | 84 | float beta2 = 0.95f; |
| 58 | 85 | float eps = 1e-8f; |
@@ -62,6 +89,11 @@ struct TrainConfig { | ||
| 62 | 89 | int64_t grad_accum_steps = 1; |
| 63 | 90 | std::string precision = "f32"; // "f32" | "f16" | "bf16" (compute dtype) |
| 64 | 91 | int64_t checkpoint_every = 1000; |
| 92 | + // Also commit each checkpoint's weights into <out>/model.forge — the | |
| 93 | + // git-style zero-copy format (src/core/fmodel.h). The .bin checkpoint | |
| 94 | + // stays the resume source of truth (it carries optimizer state). | |
| 95 | + bool forge_save = true; | |
| 96 | + std::string forge_dtype = "f32"; // "f32" | "f16" | "bf16" | |
| 65 | 97 | int64_t eval_every = 500; |
| 66 | 98 | int64_t eval_batches = 20; |
| 67 | 99 | uint64_t seed = 1337; |
@@ -89,6 +121,14 @@ inline void from_json(const nlohmann::json& j, ModelConfig& c) { | ||
| 89 | 121 | c.norm_eps = j.value("norm_eps", c.norm_eps); |
| 90 | 122 | c.activation = j.value("activation", c.activation); |
| 91 | 123 | c.dropout = j.value("dropout", c.dropout); |
| 124 | + c.quant = j.value("quant", c.quant); | |
| 125 | + c.n_experts = j.value("n_experts", c.n_experts); | |
| 126 | + c.moe_top_k = j.value("moe_top_k", c.moe_top_k); | |
| 127 | + c.moe_aux_weight = j.value("moe_aux_weight", c.moe_aux_weight); | |
| 128 | + c.n_shared_experts = j.value("n_shared_experts", c.n_shared_experts); | |
| 129 | + c.qk_norm = j.value("qk_norm", c.qk_norm); | |
| 130 | + c.final_softcap = j.value("final_softcap", c.final_softcap); | |
| 131 | + c.scale_embeddings = j.value("scale_embeddings", c.scale_embeddings); | |
| 92 | 132 | |
| 93 | 133 | if (c.d_model % c.n_heads != 0) |
| 94 | 134 | throw std::runtime_error("config: d_model must be divisible by n_heads"); |
@@ -98,6 +138,16 @@ inline void from_json(const nlohmann::json& j, ModelConfig& c) { | ||
| 98 | 138 | throw std::runtime_error("config: norm must be rmsnorm or layernorm"); |
| 99 | 139 | if (c.activation != "swiglu" && c.activation != "gelu") |
| 100 | 140 | throw std::runtime_error("config: activation must be swiglu or gelu"); |
| 141 | + if (c.quant != "none" && c.quant != "int8" && c.quant != "ternary") | |
| 142 | + throw std::runtime_error("config: quant must be none, int8 or ternary"); | |
| 143 | + if (c.n_experts < 0) | |
| 144 | + throw std::runtime_error("config: n_experts must be >= 0"); | |
| 145 | + if (c.n_experts > 0 && (c.moe_top_k < 1 || c.moe_top_k > c.n_experts)) | |
| 146 | + throw std::runtime_error("config: moe_top_k must be in [1, n_experts]"); | |
| 147 | + if (c.n_shared_experts < 0 || (c.n_shared_experts > 0 && c.n_experts == 0)) | |
| 148 | + throw std::runtime_error("config: n_shared_experts requires n_experts > 0"); | |
| 149 | + if (c.final_softcap < 0.0f) | |
| 150 | + throw std::runtime_error("config: final_softcap must be >= 0"); | |
| 101 | 151 | } |
| 102 | 152 | |
| 103 | 153 | inline void from_json(const nlohmann::json& j, TrainConfig& c) { |
@@ -105,6 +155,11 @@ inline void from_json(const nlohmann::json& j, TrainConfig& c) { | ||
| 105 | 155 | c.min_lr_ratio = j.value("min_lr_ratio", c.min_lr_ratio); |
| 106 | 156 | c.warmup_steps = j.value("warmup_steps", c.warmup_steps); |
| 107 | 157 | c.max_steps = j.value("max_steps", c.max_steps); |
| 158 | + c.schedule = j.value("schedule", c.schedule); | |
| 159 | + c.wsd_decay_frac = j.value("wsd_decay_frac", c.wsd_decay_frac); | |
| 160 | + c.optimizer = j.value("optimizer", c.optimizer); | |
| 161 | + c.muon_lr = j.value("muon_lr", c.muon_lr); | |
| 162 | + c.muon_momentum = j.value("muon_momentum", c.muon_momentum); | |
| 108 | 163 | c.beta1 = j.value("beta1", c.beta1); |
| 109 | 164 | c.beta2 = j.value("beta2", c.beta2); |
| 110 | 165 | c.eps = j.value("eps", c.eps); |
@@ -114,10 +169,21 @@ inline void from_json(const nlohmann::json& j, TrainConfig& c) { | ||
| 114 | 169 | c.grad_accum_steps = j.value("grad_accum_steps", c.grad_accum_steps); |
| 115 | 170 | c.precision = j.value("precision", c.precision); |
| 116 | 171 | c.checkpoint_every = j.value("checkpoint_every", c.checkpoint_every); |
| 172 | + c.forge_save = j.value("forge_save", c.forge_save); | |
| 173 | + c.forge_dtype = j.value("forge_dtype", c.forge_dtype); | |
| 117 | 174 | c.eval_every = j.value("eval_every", c.eval_every); |
| 118 | 175 | c.eval_batches = j.value("eval_batches", c.eval_batches); |
| 119 | 176 | c.seed = j.value("seed", c.seed); |
| 120 | 177 | c.deterministic = j.value("deterministic", c.deterministic); |
| 178 | + | |
| 179 | + if (c.schedule != "cosine" && c.schedule != "wsd") | |
| 180 | + throw std::runtime_error("config: schedule must be cosine or wsd"); | |
| 181 | + if (c.wsd_decay_frac <= 0.0f || c.wsd_decay_frac >= 1.0f) | |
| 182 | + throw std::runtime_error("config: wsd_decay_frac must be in (0, 1)"); | |
| 183 | + if (c.optimizer != "adamw" && c.optimizer != "muon") | |
| 184 | + throw std::runtime_error("config: optimizer must be adamw or muon"); | |
| 185 | + if (c.forge_dtype != "f32" && c.forge_dtype != "f16" && c.forge_dtype != "bf16") | |
| 186 | + throw std::runtime_error("config: forge_dtype must be f32, f16 or bf16"); | |
| 121 | 187 | } |
| 122 | 188 | |
| 123 | 189 | inline Config load_config(const std::string& path) { |
modified
src/train/optimizer.cpp
+173 −24
@@ -1,6 +1,7 @@ | ||
| 1 | 1 | // Author: Simon-Pierre Boucher — contact@spboucher.ai |
| 2 | 2 | #include "train/optimizer.h" |
| 3 | 3 | |
| 4 | +#include "ops/cpu/cpu_ops.h" | |
| 4 | 5 | #include "ops/metal/metal_ops.h" |
| 5 | 6 | #include "ops/ops.h" |
| 6 | 7 | |
@@ -9,7 +10,43 @@ | ||
| 9 | 10 | |
| 10 | 11 | namespace forge::train { |
| 11 | 12 | |
| 12 | −AdamW::AdamW(const std::vector<std::pair<std::string, Var>>& named_params, Options opts) | |
| 13 | +namespace { | |
| 14 | + | |
| 15 | +// Newton-Schulz quintic coefficients (Keller Jordan's Muon: tuned so the | |
| 16 | +// iteration's fixed point spreads singular values toward 1 fast; 5 rounds | |
| 17 | +// suffice at bf16-level accuracy, and we run f32). | |
| 18 | +constexpr float kNsA = 3.4445f; | |
| 19 | +constexpr float kNsB = -4.7750f; | |
| 20 | +constexpr float kNsC = 2.0315f; | |
| 21 | +constexpr int kNsIters = 5; | |
| 22 | +constexpr float kNsEps = 1e-7f; | |
| 23 | + | |
| 24 | +// Muon updates hidden 2-D matrices only; embeddings and the (possibly tied) | |
| 25 | +// lm_head keep AdamW, matching the reference MuonWithAuxAdam split. | |
| 26 | +bool 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_emb | |
| 29 | + if (name.find("lm_head") != std::string::npos) return false; | |
| 30 | + return true; | |
| 31 | +} | |
| 32 | + | |
| 33 | +// LR adjustment for rectangular matrices (Muon reference impl). | |
| 34 | +float 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 | +} | |
| 38 | + | |
| 39 | +float 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 | +} | |
| 45 | + | |
| 46 | +} // namespace | |
| 47 | + | |
| 48 | +Optimizer::Optimizer(const std::vector<std::pair<std::string, Var>>& named_params, | |
| 49 | + Options opts) | |
| 13 | 50 | : opts_(opts) { |
| 14 | 51 | std::unordered_set<const void*> seen; |
| 15 | 52 | for (const auto& [name, p] : named_params) { |
@@ -17,10 +54,11 @@ AdamW::AdamW(const std::vector<std::pair<std::string, Var>>& named_params, Optio | ||
| 17 | 54 | if (!seen.insert(p.id()).second) continue; // tied param, already tracked |
| 18 | 55 | params_.push_back(p); |
| 19 | 56 | decay_.push_back(p.value().ndim() >= 2); |
| 57 | + muon_.push_back(opts_.kind == "muon" && muon_eligible(name, p)); | |
| 20 | 58 | } |
| 21 | 59 | } |
| 22 | 60 | |
| 23 | −float AdamW::clip_global_norm(float max_norm) { | |
| 61 | +float Optimizer::clip_global_norm(float max_norm) { | |
| 24 | 62 | double sq = 0.0; |
| 25 | 63 | for (const Var& p : params_) { |
| 26 | 64 | if (!p.has_grad()) continue; |
@@ -39,7 +77,7 @@ float AdamW::clip_global_norm(float max_norm) { | ||
| 39 | 77 | return norm; |
| 40 | 78 | } |
| 41 | 79 | |
| 42 | −void AdamW::ensure_state() { | |
| 80 | +void Optimizer::ensure_state() { | |
| 43 | 81 | if (!m_.empty()) return; |
| 44 | 82 | m_.reserve(params_.size()); |
| 45 | 83 | v_.reserve(params_.size()); |
@@ -49,7 +87,7 @@ void AdamW::ensure_state() { | ||
| 49 | 87 | } |
| 50 | 88 | } |
| 51 | 89 | |
| 52 | −float AdamW::step_with_clip(float lr, float max_norm) { | |
| 90 | +float Optimizer::step_with_clip(float lr, float max_norm) { | |
| 53 | 91 | if (ops::backend() == ops::Backend::CPU) { |
| 54 | 92 | const float norm = clip_global_norm(max_norm); |
| 55 | 93 | step(lr); |
@@ -80,44 +118,155 @@ float AdamW::step_with_clip(float lr, float max_norm) { | ||
| 80 | 118 | ensure_state(); // CPU-side zeros are safe here: the stream is closed |
| 81 | 119 | ++t_; |
| 82 | 120 | { |
| 83 | − // Each parameter's update touches only its own w/g/m/v — independent. | |
| 121 | + // Each parameter's AdamW update touches only its own w/g/m/v — independent. | |
| 84 | 122 | metal::ConcurrentRegion region; |
| 85 | 123 | for (size_t i = 0; i < params_.size(); ++i) { |
| 124 | + if (muon_[i]) continue; | |
| 86 | 125 | const float wd = decay_[i] ? opts_.weight_decay : 0.0f; |
| 87 | 126 | metal::adamw_step(params_[i].value(), params_[i].grad(), m_[i], v_[i], lr, |
| 88 | 127 | opts_.beta1, opts_.beta2, t_, opts_.eps, wd, grad_scale); |
| 89 | 128 | } |
| 90 | 129 | } |
| 130 | + | |
| 131 | + // 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 grad | |
| 149 | + 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(); | |
| 157 | + | |
| 158 | + // Muon phase 2: normalize, orthogonalize (Newton-Schulz via the | |
| 159 | + // existing matmul kernels), apply. For rows > cols the iteration runs | |
| 160 | + // on the implicit transpose: A = XᵀX and X ← aX + XB, which is the | |
| 161 | + // 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); | |
| 171 | + | |
| 172 | + 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ᵀX | |
| 177 | + 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 = XB | |
| 183 | + else metal::matmul(B, X, X2); // X2 = BX | |
| 184 | + metal::scale(X, kNsA, X); | |
| 185 | + metal::accumulate(X, X2); // X = aX + X2 | |
| 186 | + } | |
| 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 | + } | |
| 193 | + | |
| 91 | 194 | metal::sync(); // weights final before the next step's CPU-side zero_grad |
| 92 | 195 | return norm; |
| 93 | 196 | } |
| 94 | 197 | |
| 95 | −void AdamW::step(float lr) { | |
| 96 | − ensure_state(); | |
| 97 | − ++t_; // 1-based: bias correction divides by (1 - beta^t) | |
| 198 | +void Optimizer::adamw_cpu(size_t pi, float lr) { | |
| 98 | 199 | const float bc1 = 1.0f - std::pow(opts_.beta1, float(t_)); |
| 99 | 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 | +} | |
| 100 | 216 | |
| 217 | +void 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; | |
| 226 | + | |
| 227 | + 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]; // nesterov | |
| 232 | + } | |
| 233 | + const float fro = frobenius(X); | |
| 234 | + for (int64_t i = 0; i < n; ++i) x[i] /= (fro + kNsEps); | |
| 235 | + | |
| 236 | + 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 | + } | |
| 251 | + | |
| 252 | + 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 | +} | |
| 258 | + | |
| 259 | +void Optimizer::step(float lr) { | |
| 260 | + ensure_state(); | |
| 261 | + ++t_; // 1-based: bias correction divides by (1 - beta^t) | |
| 101 | 262 | for (size_t pi = 0; pi < params_.size(); ++pi) { |
| 102 | − Var& p = params_[pi]; | |
| 103 | − if (!p.has_grad()) continue; | |
| 104 | − const float wd = decay_[pi] ? opts_.weight_decay : 0.0f; | |
| 105 | − float* w = p.value().data<float>(); | |
| 106 | − const float* g = p.grad().data<float>(); | |
| 107 | − float* m = m_[pi].data<float>(); | |
| 108 | − float* v = v_[pi].data<float>(); | |
| 109 | − const int64_t n = p.value().numel(); | |
| 110 | − for (int64_t i = 0; i < n; ++i) { | |
| 111 | − m[i] = opts_.beta1 * m[i] + (1.0f - opts_.beta1) * g[i]; | |
| 112 | − v[i] = opts_.beta2 * v[i] + (1.0f - opts_.beta2) * g[i] * g[i]; | |
| 113 | − const float mhat = m[i] / bc1; | |
| 114 | − const float vhat = v[i] / bc2; | |
| 115 | − w[i] -= lr * (mhat / (std::sqrt(vhat) + opts_.eps) + wd * w[i]); | |
| 116 | − } | |
| 263 | + if (!params_[pi].has_grad()) continue; | |
| 264 | + if (muon_[pi]) muon_cpu(pi, lr); | |
| 265 | + else adamw_cpu(pi, lr); | |
| 117 | 266 | } |
| 118 | 267 | } |
| 119 | 268 | |
| 120 | −void AdamW::zero_grad() { | |
| 269 | +void Optimizer::zero_grad() { | |
| 121 | 270 | for (const Var& p : params_) p.zero_grad(); |
| 122 | 271 | } |
| 123 | 272 | |
modified
src/train/optimizer.h
+28 −8
@@ -9,19 +9,29 @@ | ||
| 9 | 9 | |
| 10 | 10 | namespace forge::train { |
| 11 | 11 | |
| 12 | −// AdamW with decoupled weight decay (llm.c/PyTorch convention: eps outside | |
| 13 | −// sqrt, wd only on dim>=2 params, decay folded into the same update). | |
| 14 | −// Deduplicates tied parameters by Var::id(). | |
| 15 | −class AdamW { | |
| 12 | +// Two optimizer kinds behind one interface: | |
| 13 | +// - "adamw": AdamW with decoupled weight decay on every parameter | |
| 14 | +// (llm.c/PyTorch convention: eps outside sqrt, wd only on dim>=2 params). | |
| 15 | +// - "muon": Keller Jordan's Muon on 2-D hidden matrices — momentum | |
| 16 | +// orthogonalized by 5 Newton-Schulz iterations — with AdamW kept for | |
| 17 | +// embeddings / lm_head / 1-D params (the standard Muon split). The Muon | |
| 18 | +// group's LR is lr * muon_lr_ratio so both groups follow one schedule. | |
| 19 | +// Deduplicates tied parameters by Var::id(). m_ doubles as Muon's momentum | |
| 20 | +// buffer; v_ stays zero for Muon params so the checkpoint format is | |
| 21 | +// identical across kinds. | |
| 22 | +class Optimizer { | |
| 16 | 23 | public: |
| 17 | 24 | struct Options { |
| 25 | + std::string kind = "adamw"; // "adamw" | "muon" | |
| 18 | 26 | float beta1 = 0.9f; |
| 19 | 27 | float beta2 = 0.95f; |
| 20 | 28 | float eps = 1e-8f; |
| 21 | 29 | float weight_decay = 0.1f; |
| 30 | + float muon_momentum = 0.95f; | |
| 31 | + float muon_lr_ratio = 1.0f; // muon lr = lr * ratio | |
| 22 | 32 | }; |
| 23 | 33 | |
| 24 | − AdamW(const std::vector<std::pair<std::string, Var>>& named_params, Options opts); | |
| 34 | + Optimizer(const std::vector<std::pair<std::string, Var>>& named_params, Options opts); | |
| 25 | 35 | |
| 26 | 36 | // Global-norm gradient clip: returns the pre-clip norm and scales all |
| 27 | 37 | // grads by min(1, max_norm/norm). No-op when max_norm <= 0. (CPU path.) |
@@ -30,11 +40,12 @@ public: | ||
| 30 | 40 | void step(float lr); |
| 31 | 41 | void zero_grad(); |
| 32 | 42 | |
| 33 | − // Backend-routed step: clip + AdamW in one call, returns the pre-clip | |
| 43 | + // Backend-routed step: clip + update in one call, returns the pre-clip | |
| 34 | 44 | // grad norm. On Metal it expects backward() already encoded: it encodes |
| 35 | 45 | // per-tensor sumsq, syncs (this is the step's loss-readback boundary), |
| 36 | − // folds the clip factor into the fused adamw kernel's grad_scale, and | |
| 37 | − // syncs again after the update. | |
| 46 | + // folds the clip factor into the update, and syncs again at the end. | |
| 47 | + // Muon adds one extra sync: the Newton-Schulz input must be normalized | |
| 48 | + // by its Frobenius norm, which the CPU reads between the two phases. | |
| 38 | 49 | float step_with_clip(float lr, float max_norm); |
| 39 | 50 | |
| 40 | 51 | // Allocate m/v now (checkpoint loading needs the buffers to exist). |
@@ -48,11 +59,20 @@ public: | ||
| 48 | 59 | const std::vector<Var>& params() const { return params_; } |
| 49 | 60 | |
| 50 | 61 | private: |
| 62 | + // AdamW update for one param (CPU path). | |
| 63 | + void adamw_cpu(size_t pi, float lr); | |
| 64 | + // Muon update for one param (CPU path); grads already clipped. | |
| 65 | + void muon_cpu(size_t pi, float lr); | |
| 66 | + | |
| 51 | 67 | Options opts_; |
| 52 | 68 | std::vector<Var> params_; |
| 53 | 69 | std::vector<bool> decay_; // dim >= 2 |
| 70 | + std::vector<bool> muon_; // kind=="muon" && 2-D && not embedding/head | |
| 54 | 71 | std::vector<Tensor> m_, v_; // f32, allocated lazily at first step |
| 55 | 72 | int64_t t_ = 0; |
| 56 | 73 | }; |
| 57 | 74 | |
| 75 | +// Historical name; checkpoints and tests predate the Muon mode. | |
| 76 | +using AdamW = Optimizer; | |
| 77 | + | |
| 58 | 78 | } // namespace forge::train |
modified
src/train/scheduler.h
+15 −0
@@ -17,4 +17,19 @@ inline float lr_at(int64_t step, float max_lr, float min_lr, int64_t warmup_step | ||
| 17 | 17 | return min_lr + coeff * (max_lr - min_lr); |
| 18 | 18 | } |
| 19 | 19 | |
| 20 | +// Warmup–Stable–Decay (MiniCPM): linear warmup, flat plateau at max_lr, then a | |
| 21 | +// short cooldown over the final decay_steps. Any plateau checkpoint can be | |
| 22 | +// resumed and the run extended without re-deciding total_steps up front. The | |
| 23 | +// cooldown uses the 1-sqrt shape, which beats linear/exponential cooldowns in | |
| 24 | +// the WSD ablations (arXiv 2404.06395 follow-ups). | |
| 25 | +inline float lr_wsd(int64_t step, float max_lr, float min_lr, int64_t warmup_steps, | |
| 26 | + int64_t total_steps, int64_t decay_steps) { | |
| 27 | + if (step < warmup_steps) return max_lr * float(step + 1) / float(warmup_steps + 1); | |
| 28 | + const int64_t decay_start = total_steps - decay_steps; | |
| 29 | + if (step < decay_start) return max_lr; | |
| 30 | + if (step >= total_steps) return min_lr; | |
| 31 | + const float ratio = float(step - decay_start) / float(decay_steps); | |
| 32 | + return min_lr + (max_lr - min_lr) * (1.0f - std::sqrt(ratio)); | |
| 33 | +} | |
| 34 | + | |
| 20 | 35 | } // namespace forge::train |
modified
src/train/trainer.cpp
+33 −5
@@ -1,6 +1,7 @@ | ||
| 1 | 1 | // Author: Simon-Pierre Boucher — contact@spboucher.ai |
| 2 | 2 | #include "train/trainer.h" |
| 3 | 3 | |
| 4 | +#include "core/fmodel.h" | |
| 4 | 5 | #include "ops/metal/metal_ops.h" |
| 5 | 6 | #include "ops/ops.h" |
| 6 | 7 | #include "train/checkpoint.h" |
@@ -8,6 +9,7 @@ | ||
| 8 | 9 | |
| 9 | 10 | #include <Foundation/Foundation.hpp> |
| 10 | 11 | |
| 12 | +#include <algorithm> | |
| 11 | 13 | #include <chrono> |
| 12 | 14 | #include <cstdio> |
| 13 | 15 | #include <filesystem> |
@@ -20,12 +22,15 @@ Trainer::Trainer(Config cfg, const std::string& data_dir, const std::string& out | ||
| 20 | 22 | std::filesystem::create_directories(out_dir_); |
| 21 | 23 | model_ = std::make_unique<nn::Transformer>(cfg_.model, cfg_.train.seed); |
| 22 | 24 | |
| 23 | − AdamW::Options opts; | |
| 25 | + Optimizer::Options opts; | |
| 26 | + opts.kind = cfg_.train.optimizer; | |
| 24 | 27 | opts.beta1 = cfg_.train.beta1; |
| 25 | 28 | opts.beta2 = cfg_.train.beta2; |
| 26 | 29 | opts.eps = cfg_.train.eps; |
| 27 | 30 | opts.weight_decay = cfg_.train.weight_decay; |
| 28 | − opt_ = std::make_unique<AdamW>(model_->named_parameters(), opts); | |
| 31 | + opts.muon_momentum = cfg_.train.muon_momentum; | |
| 32 | + opts.muon_lr_ratio = cfg_.train.muon_lr / cfg_.train.lr; | |
| 33 | + opt_ = std::make_unique<Optimizer>(model_->named_parameters(), opts); | |
| 29 | 34 | |
| 30 | 35 | train_data_ = std::make_unique<DataLoader>(data_dir + "/train.bin", |
| 31 | 36 | cfg_.model.context_length, |
@@ -64,6 +69,22 @@ void Trainer::save(int64_t step) { | ||
| 64 | 69 | save_checkpoint(out_dir_ + "/ckpt_latest.bin", model_->named_parameters(), opt_.get(), |
| 65 | 70 | meta); |
| 66 | 71 | std::printf("checkpoint saved: %s\n", (out_dir_ + name).c_str()); |
| 72 | + | |
| 73 | + // Native .forge commit: the run's whole weight history lives in one | |
| 74 | + // git-style repo; content addressing means an unchanged tensor (frozen, | |
| 75 | + // masked, tied) is never rewritten. | |
| 76 | + if (cfg_.train.forge_save) { | |
| 77 | + fmodel::SaveOptions fo; | |
| 78 | + fo.dtype = cfg_.train.forge_dtype == "f16" ? DType::F16 | |
| 79 | + : cfg_.train.forge_dtype == "bf16" ? DType::BF16 | |
| 80 | + : DType::F32; | |
| 81 | + char tag[32]; | |
| 82 | + std::snprintf(tag, sizeof(tag), "step-%06lld", static_cast<long long>(step)); | |
| 83 | + fo.tag = tag; | |
| 84 | + fo.step = step; | |
| 85 | + fmodel::save(out_dir_ + "/model.forge", config_json_, | |
| 86 | + model_->named_parameters(), fo); | |
| 87 | + } | |
| 67 | 88 | } |
| 68 | 89 | |
| 69 | 90 | void Trainer::train(const std::string& resume_from) { |
@@ -87,18 +108,25 @@ void Trainer::train(const std::string& resume_from) { | ||
| 87 | 108 | const int64_t min_lr_steps = tc.max_steps; |
| 88 | 109 | const float min_lr = tc.lr * tc.min_lr_ratio; |
| 89 | 110 | const int64_t tokens_per_step = B * T * tc.grad_accum_steps; |
| 111 | + const bool wsd = tc.schedule == "wsd"; | |
| 112 | + const int64_t wsd_decay_steps = | |
| 113 | + std::max<int64_t>(1, int64_t(float(tc.max_steps) * tc.wsd_decay_frac)); | |
| 90 | 114 | |
| 91 | − std::printf("training %s: %lld params, %lld steps, %lld tokens/step, backend=%s\n", | |
| 115 | + std::printf("training %s: %lld params, %lld steps, %lld tokens/step, backend=%s, " | |
| 116 | + "opt=%s, sched=%s\n", | |
| 92 | 117 | cfg_.model.name.c_str(), |
| 93 | 118 | static_cast<long long>(cfg_.model.num_params()), |
| 94 | 119 | static_cast<long long>(tc.max_steps), |
| 95 | 120 | static_cast<long long>(tokens_per_step), |
| 96 | − ops::backend() == ops::Backend::Metal ? "metal" : "cpu"); | |
| 121 | + ops::backend() == ops::Backend::Metal ? "metal" : "cpu", | |
| 122 | + tc.optimizer.c_str(), tc.schedule.c_str()); | |
| 97 | 123 | |
| 98 | 124 | for (int64_t step = start_step; step < tc.max_steps; ++step) { |
| 99 | 125 | NS::AutoreleasePool* pool = NS::AutoreleasePool::alloc()->init(); |
| 100 | 126 | const auto t0 = std::chrono::steady_clock::now(); |
| 101 | − const float lr = lr_at(step, tc.lr, min_lr, tc.warmup_steps, min_lr_steps); | |
| 127 | + const float lr = wsd | |
| 128 | + ? lr_wsd(step, tc.lr, min_lr, tc.warmup_steps, tc.max_steps, wsd_decay_steps) | |
| 129 | + : lr_at(step, tc.lr, min_lr, tc.warmup_steps, min_lr_steps); | |
| 102 | 130 | |
| 103 | 131 | opt_->zero_grad(); |
| 104 | 132 | const bool on_gpu = ops::backend() == ops::Backend::Metal; |
| 105 | 133 | |