# 🔥 Forge ### LLM training from scratch — pure C++20 + Metal on Apple Silicon **No PyTorch. No MLX. No ML dependencies. Just C++, Metal kernels, and a JSON parser.**
![C++20](https://img.shields.io/badge/C%2B%2B-20-00599C?style=for-the-badge&logo=cplusplus&logoColor=white) ![Metal](https://img.shields.io/badge/Metal-4-000000?style=for-the-badge&logo=apple&logoColor=white) ![Apple Silicon](https://img.shields.io/badge/Apple_Silicon-M1→M5-555555?style=for-the-badge&logo=apple&logoColor=white) ![License](https://img.shields.io/badge/License-MIT-blue?style=for-the-badge)
![ML deps](https://img.shields.io/badge/ML_dependencies-ZERO-success?style=flat-square) ![Params](https://img.shields.io/badge/models-12M→205M-blueviolet?style=flat-square) ![Throughput](https://img.shields.io/badge/throughput-38.2k_tok%2Fs-orange?style=flat-square) ![GEMM](https://img.shields.io/badge/GEMM_f32-10.8_TFLOPS-red?style=flat-square) ![MPP](https://img.shields.io/badge/matmul2d_f16-51.5_TFLOPS-critical?style=flat-square) ![Parity](https://img.shields.io/badge/parity_checks-85_passing-brightgreen?style=flat-square) ![Gradcheck](https://img.shields.io/badge/gradcheck-green-brightgreen?style=flat-square) ![Val PPL](https://img.shields.io/badge/val_perplexity-20.27-yellow?style=flat-square) ![LOC](https://img.shields.io/badge/lines_of_code-~6.5k-lightgrey?style=flat-square)
**[📄 Paper](paper/forge.tex)** · **[🔬 Research notes](RESEARCH.md)** · **[📐 Spec](CLAUDE.md)**
--- ## ✨ What this is A complete, working transformer training stack built from nothing on Apple Silicon. Tensors, autograd, Metal compute kernels, flash attention (forward **and** backward), AdamW, BPE tokenizer, checkpointing, generation — all hand-written, all validated against a CPU reference. Architecture is **entirely config-driven**: the same binary trains a 12M or a 205M parameter model by changing a JSON file. > **Why it matters:** as of July 2026, *no* major open-source framework ships a fused > attention **backward** kernel for Metal. MLX throws `"NYI"`. llama.cpp doesn't > support the op. PyTorch MPS and Candle are forward-only. Forge has one, and it's > 15× faster than the naive version. --- ## 🏆 Headline results
| | Metric | Value | |:--|:--|--:| | 🚀 | Training throughput (12M model, ctx 512) | **38.2k tok/s** | | ⚡ | GEMM f32, `simdgroup_matrix` | **10.8 TFLOPS** | | 🔥 | GEMM f16 via `matmul2d` (M5 neural accelerators) | **51.5 TFLOPS** | | 🎯 | Flash attention backward speedup | **15.2×** | | 📉 | Validation perplexity (12M, 1 epoch TinyStories) | **20.27** | | ✅ | CPU↔Metal parity checks | **85 passing** |
### 🎓 Trained model — 12.2M params, one epoch, ~8 minutes
![Loss](https://img.shields.io/badge/train_loss-8.40_→_2.99-success?style=flat-square) ![Val](https://img.shields.io/badge/val_loss-3.009-success?style=flat-square) ![PPL](https://img.shields.io/badge/perplexity-20.27-yellow?style=flat-square) ![Tokens](https://img.shields.io/badge/tokens_seen-19.1M-blue?style=flat-square) ![Steps](https://img.shields.io/badge/steps-584-lightgrey?style=flat-square)
Validation loss falls monotonically with no sign of overfitting: | step | 99 | 199 | 299 | 399 | 499 | final | |:--|--:|--:|--:|--:|--:|--:| | **train** | 5.14 | 4.10 | 3.48 | 3.17 | 3.07 | **2.99** | | **val** | 5.16 | 4.10 | 3.48 | 3.20 | 3.07 | **3.01** | Sampled from the trained checkpoint (`temp 0.8`, `top-k 40`): > *Once upon a time, there was a little dog named Spot. Spot loved to hop and play in > the yard with his toys. One day, it was very cold. Spot wanted to play, but it was > too big for Spot to jump in the yard with the yarn. A big dog saw the yarn and > wanted them too…* Coherent English, consistent characters, story structure — from a 12M-parameter model trained for eight minutes on one machine. --- ## 🔬 Four findings worth your time These came out of measurement, and several contradicted what we expected. ### 1️⃣ `constant constexpr` cost us 12× ```cpp constant constexpr uint TM = 4; // ❌ an address-space VARIABLE, not a constant enum : uint { TM = 4 }; // ✅ a real compile-time constant ``` In MSL, `constant` is an **address-space qualifier**, and program-scope variables must live there. So that declaration isn't a compile-time constant — loop bounds built from it don't unroll, `acc[i][j]` becomes dynamic indexing into opaque `simdgroup_matrix` values, and the driver spills all 16 accumulators (256 B each) to the stack. **0.82 → 10.21 TFLOPS.** Before the fix, the "optimized" matrix kernel was *3× slower than a naive one*. ### 2️⃣ The IR can't diagnose it — benchmark instead `metal -S -emit-llvm` shows the same 3 `alloca`s and 2 MMA intrinsics at `-O0`, `-O2` and `-O3` — **and after the fix that made it 12× faster**. Unrolling and fragment promotion happen in the driver's AIR→ISA back end. Reading the IR looked like confirmation and sent us the wrong way. ### 3️⃣ Register pressure, not bandwidth, owns attention backward | attention backward, per layer | gpt-10m | gpt-25m | |:--|--:|--:| | `k,v,dk,dv` all in registers | 150 ms | — | | read-only `k,v` from device | 118 ms | 611 ms | | `dK`/`dV` split into 2 kernels | 105 ms | 549 ms | | **tiled with `simdgroup_matrix`** | **9.66 ms** | **45.5 ms** | | **+ `dK`/`dV` split again** | **7.05 ms** | **32.9 ms** | Once shader sources were embedded in the metallib, `gpudebug` showed it directly: | kernel | temp registers | **spilled bytes** | |:--|--:|--:| | flash forward, scalar | 126 | **368** | | flash forward, MMA | 85 | **0** ✅ | | flash backward dKV, fused | 111 | **4352** ❌ | That 4352-byte spill was a bug we hadn't suspected. Splitting the kernel recovered another 27%. ### 4️⃣ The M5 neural accelerators are worth 4.9× — and f16 is the key `mpp::tensor_ops::matmul2d` (Metal 4 cooperative tensors) vs. our hand-written kernel: | shape | `simdgroup` f32 | `simdgroup` f16 | **`matmul2d` f32** | **`matmul2d` f16** | |:--|--:|--:|--:|--:| | 2048³ | 9.3 T | 10.6 T | 14.9 T | **51.5 T** | | 4096³ | 9.9 T | 11.8 T | 14.6 T | **44.3 T** | Verified numerically, not just timed: f32 is **bit-exact** vs the CPU reference, f16 differs by 3.8e-06, all outputs non-zero, and the transposed variants training needs (`nt`, `tn`) are bit-exact too. **The negative result matters as much:** f16 buys only **+18–22%** on the `simdgroup_matrix` path. So mixed precision looked like a memory-only feature — until it turned out to be the *entry condition* for a 4.9× path. --- ## 🚀 Quick start ```bash # Build (macOS + Xcode CLT + CMake ≥ 3.24) cmake -B build && cmake --build build -j cd build && ctest --output-on-failure # parity + gradcheck + overfit + tokenizer # Data: download TinyStories, train a BPE vocab, tokenize to uint16 .bin python3 tools/prepare_data.py --out data/tinystories --vocab-size 4096 # Train · generate · eval ./build/forge train --config configs/gpt-10m-1epoch.json --data data/tinystories --out runs/exp1 ./build/forge generate --checkpoint runs/exp1/ckpt_latest.bin \ --tokenizer data/tinystories/tok4096.model \ --prompt "Once upon a time" --temp 0.8 --top-k 40 ./build/forge eval --checkpoint runs/exp1/ckpt_latest.bin --data data/tinystories/val.bin ./build/forge info --config configs/gpt-200m.json ``` Add `--backend cpu` to run the same model through the CPU reference path — every op is bit-comparable with the GPU path, which is what the parity suite checks. --- ## 📊 Benchmarks **GEMM** (`tests/bench_matmul.cpp`, TFLOPS, f32): | kernel | naive | 16×16 tiled | `simdgroup_matrix` | |:--|--:|--:|--:| | 4096³ | 1.29 | 2.53 | **9.49** | | forward MLP `X·W₁ᵀ` | 1.46 | 2.53 | **10.68** | | backward `dX = dY·W` | 1.51 | 2.54 | **10.58** | | backward `dW = dYᵀ·X` | 0.76 | 2.15 | 4.69 ⚠️ | ⚠️ `dW` lags — `K = B·T` is huge with few threadgroups. Split-K would fix it (not done). **Scale** — all configs train on one M5 Max: | config | params | context | tok/s | |:--|--:|--:|--:| | `gpt-10m` | 12.2M | 512 | **38.2k** | | `gpt-25m` | 29.9M | 1024 | 22.1k | | `gpt-100m` | 97.5M | 1024 | 9.7k | | `gpt-200m` | 205.5M | 1024 | 1.8k† | † measured before the MMA backward landed — pessimistic. --- ## 🏗️ Architecture | path | contents | |:--|:--| | `src/core/` | `Tensor` (shared-storage views), `Allocator` (bucketed MTLBuffer pool), `Device` (queue + pipeline cache), autograd tape | | `src/kernels/` | 14 `.metal` files: GEMM (naive→tiled→simdgroup→`matmul2d`), flash attention (scalar + MMA), softmax, norms, elementwise, embedding, cross-entropy, AdamW, fake-quant (QAT), MoE gating | | `src/ops/` | `cpu/` reference impls · `metal/` dispatch + batched `Stream` · `ops.cpp` autograd layer routing to either backend | | `src/nn/` | `Module`, `Linear` (pruning mask + int8/ternary QAT), attention (GQA + RoPE behind an interface), SwiGLU/GELU MLP or top-k MoE, `Transformer` | | `src/train/` | mmap dataloader, AdamW or Muon, warmup+cosine or WSD schedule, trainer, resumable checkpoints | | `src/tokenizer/` | byte-level BPE, verified identical to the Python encoder | | `src/core/fmodel.*` | **`.forge` weight format** — Apple-native, git-style (see below) | | `tools/` | `prepare_data.py` (TinyStories) · `prepare_hf_data.py` (any HF dataset/mixture, streamed) · `fmodel.py` (inspect / history / safetensors export) | | `tests/` | parity · gradcheck · overfit · tokenizer · 3 benchmarks | **Model:** decoder-only transformer · RMSNorm or LayerNorm · SwiGLU or GELU · RoPE (interleaved pairs) or learned positions · GQA · tied embeddings — all from config. **Training modes (config-selected):** optimizer `adamw` | `muon` (Newton–Schulz orthogonalized momentum on hidden matrices, AdamW on embeddings/head) · schedule `cosine` | `wsd` (flat plateau + 1−√ cooldown, extendable runs) · `quant` `none` | `int8` | `ternary` (BitNet-style fake-quant each forward, STE backward, f32 master weights) · `n_experts`/`moe_top_k` (softmax router, renormalized top-k gates, differentiable load-balance loss; v1 computes experts densely). See `configs/gpt-50m-{muon,deep,ternary,moe}.json`. **Verified:** 85 CPU↔Metal parity checks (≤1e-4, most bit-exact) · numerical gradient checks on every parameterized op and a full transformer · single-batch overfit to loss < 0.05 in 86 steps · exact checkpoint resume · BPE round-trip vs Python. **`.forge` weight format** (`forge export --checkpoint ckpt.bin --out model.forge`): a model *repository* rather than a file. Tensors are 16 KB-page-aligned inside content-addressed shards capped at 95 MB (GitHub-pushable), and loading is `mmap` + `newBuffer(bytesNoCopy)` — on unified memory the file-cache pages *are* the GPU memory, so a multi-GB model loads in milliseconds with zero copies. Saves are git-style commits: a tiny JSON manifest per save, and **only tensors whose content hash changed since the parent are written** — repeated exports cost only the delta. Store as f32 (zero-copy load) or f16/bf16 (half size). `generate`/`eval` accept a `.forge` repo directly; `tools/fmodel.py` gives `inspect`, `log` (history), and `to-safetensors` for PyTorch/HF interop. **HF data pipeline** (`tools/prepare_hf_data.py`): streams any of 13 registered Hugging Face datasets (FineWeb-Edu, DCLM, Cosmopedia, FineMath, OpenWebMath, Wikipedia, C4, SmolTalk, …) or a weighted mixture (`--mix fineweb-edu:0.6,dclm:0.4`, or presets like `smollm-web`) straight into `train.bin`/`val.bin` — no full-corpus downloads, pay only for the megabytes you keep. --- ## 🗺️ Roadmap - [ ] **Mixed precision** (f16/bf16 + f32 master weights + loss scaling) — now the top priority, since it gates the 4.9× `matmul2d` path - [ ] **Wire `matmul2d` into `ops::matmul`** behind runtime macOS-26 detection - [ ] **Activation checkpointing** — activation memory, not parameter count, bounds model size today - [ ] **Split-K** for the `dW = dYᵀ·X` GEMM (4.7 vs 10.6 TFLOPS) - [ ] **KV cache** for generation (currently recomputes full context per token) - [ ] Attention tile sweep — BQ/BK unswept; softmax leaves 96/128 threads idle --- ## 👤 Author
**Simon-Pierre Boucher** [![Email](https://img.shields.io/badge/contact@spboucher.ai-D14836?style=for-the-badge&logo=gmail&logoColor=white)](mailto:contact@spboucher.ai) [![GitHub](https://img.shields.io/badge/spboucher--ai-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/spboucher-ai)
--- ## 🙏 Acknowledgements GEMM structure follows **MLX**'s STEEL kernels. Attention follows **FlashAttention-2** as adapted to Apple GPUs by **metal-flash-attention**. Training-loop details — AdamW with ε outside the sqrt, weight decay on rank-≥2 params only, clip folded into the optimizer's gradient read, fused classifier writing logit gradients in place — follow **llm.c** and **nanoGPT**. Built on Apple's **metal-cpp**.

Built from scratch on Apple Silicon. Measured, not assumed.