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%
ZIP
NameLast commitUpdated
configs chore: commit pending workspace changes yesterday
models Add wave 3: DeepSeek-V3-style MoE routing, all config-selected 5 days ago
paper Forge: LLM training from scratch in C++20 + Metal on Apple Silicon 10 days ago
src Add elapsed_s column to log.csv for structured metrics consumers 5 days ago
tests Add wave 3: DeepSeek-V3-style MoE routing, all config-selected 5 days ago
third_party Forge: LLM training from scratch in C++20 + Metal on Apple Silicon 10 days ago
tools Add HF streaming data pipeline and three research reports 5 days ago
.gitignore Forge: LLM training from scratch in C++20 + Metal on Apple Silicon 10 days ago
ARCHITECTURES.md Add wave 3: DeepSeek-V3-style MoE routing, all config-selected 5 days ago
CLAUDE.md Forge: LLM training from scratch in C++20 + Metal on Apple Silicon 10 days ago
CMakeLists.txt Add .forge — Apple-native, git-style weight format with zero-copy... 5 days ago
INFERENCE_RESEARCH.md Add HF streaming data pipeline and three research reports 5 days ago
LICENSE Forge: LLM training from scratch in C++20 + Metal on Apple Silicon 10 days ago
README.md Add HF streaming data pipeline and three research reports 5 days ago
RESEARCH.md Forge: LLM training from scratch in C++20 + Metal on Apple Silicon 10 days ago
SMALL_MODELS_RESEARCH.md Add HF streaming data pipeline and three research reports 5 days ago
README.md

# 🔥 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 Metal Apple Silicon License


ML deps Params Throughput GEMM MPP Parity Gradcheck Val PPL LOC


📄 Paper · 🔬 Research notes · 📐 Spec


# ✨ 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 Val PPL Tokens Steps

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×

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 allocas and 2 MMA intrinsics at -O0, -O2 and -O3and 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

# 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 GitHub


# 🙏 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.