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%
9.4 KB · 190 lines c
Raw Blame History
1// Author: Simon-Pierre Boucher — contact@spboucher.ai2#pragma once34#include "core/tensor.h"56#include <cstdint>78namespace MTL {9class CommandBuffer;10class ComputeCommandEncoder;11}1213// Metal dispatch wrappers over the batched execution model (RESEARCH.md §4):14// ops encode into one long-lived serial compute encoder inside one command15// buffer; nothing runs until the caller reaches a readback boundary and16// calls sync(). Serial dispatch type means dispatch N+1 sees dispatch N's17// writes — no barriers, no per-op waits.18//19// Contract: tensors passed to these functions must stay alive until sync()20// returns (the training loop and tests naturally satisfy this; the21// allocator-level retire list lands with M4's trainer).22namespace forge::metal {2324class Stream {25public:26    static Stream& get();2728    // Current encoder (lazily opens a command buffer + serial encoder).29    MTL::ComputeCommandEncoder* encoder();3031    // Encoder for a run of MUTUALLY INDEPENDENT dispatches. Metal's default32    // serial encoder orders every dispatch against the previous one, which33    // wastes the GPU when the work is genuinely parallel — measured 15x on a34    // batch of small independent dispatches (RESEARCH.md 4b). Switching35    // dispatch type ends the current encoder and opens a new one in the same36    // command buffer; Metal orders tracked resources across that boundary, so37    // the switch itself acts as the barrier.38    //39    // Caller's contract: everything encoded between two switches must be40    // free of read-after-write dependencies on each other.41    MTL::ComputeCommandEncoder* concurrent_encoder();4243    // Makes encoder() hand back a concurrent encoder; see ConcurrentRegion.44    void set_concurrent(bool on);4546    // Readback boundary: end encoding, commit, wait. Returns immediately if47    // nothing is pending. GPU time of the completed buffer is accumulated48    // into gpu_seconds().49    void sync();5051    double gpu_seconds() const { return gpu_seconds_; }5253private:54    Stream() = default;55    MTL::ComputeCommandEncoder* encoder_of(int dispatch_type);5657    MTL::CommandBuffer* cmd_ = nullptr;58    MTL::ComputeCommandEncoder* enc_ = nullptr;59    int dispatch_type_ = -1;60    bool concurrent_ = false;61    double gpu_seconds_ = 0.0;62};6364inline void sync() { Stream::get().sync(); }6566// RAII: inside this scope, ops encode into a CONCURRENT compute encoder.67// Everything encoded in the region must be mutually independent (no68// read-after-write between them); dependencies across the region boundary are69// fine because switching encoder kind ends the encoder, and Metal orders70// tracked resources across encoders in a command buffer.71struct ConcurrentRegion {72    ConcurrentRegion() { Stream::get().set_concurrent(true); }73    ~ConcurrentRegion() { Stream::get().set_concurrent(false); }74    ConcurrentRegion(const ConcurrentRegion&) = delete;75    ConcurrentRegion& operator=(const ConcurrentRegion&) = delete;76};7778// ---- f32 forward ops (parity-tested vs forge::cpu) -------------------------79enum class MatmulKernel { Auto, Naive, Tiled, Simdgroup };8081void matmul(const Tensor& a, const Tensor& b, Tensor& c,82            bool transpose_a = false, bool transpose_b = false,83            bool accumulate = false, MatmulKernel kernel = MatmulKernel::Auto);8485void add(const Tensor& a, const Tensor& b, Tensor& out);86void mul(const Tensor& a, const Tensor& b, Tensor& out);87void scale(const Tensor& a, float s, Tensor& out);88void add_bias(const Tensor& x, const Tensor& bias, Tensor& out);89void silu(const Tensor& x, Tensor& out);90void gelu(const Tensor& x, Tensor& out);91void relu2(const Tensor& x, Tensor& out);                         // max(x,0)^292void relu2_backward(const Tensor& x, const Tensor& dout, Tensor& dx);93void softcap(const Tensor& x, float cap, Tensor& out);            // cap*tanh(x/cap)94void softcap_backward(const Tensor& y, const Tensor& dout, float cap, Tensor& dx);9596void softmax(const Tensor& x, Tensor& out);                      // rows = last dim97void rmsnorm(const Tensor& x, const Tensor& w, float eps, Tensor& out);98void layernorm(const Tensor& x, const Tensor& w, const Tensor& b, float eps, Tensor& out);99100// ---- f32 backward / training ops (ACCUMULATE into d* outputs) --------------101void accumulate(Tensor& dst, const Tensor& src);          // dst += src102void axpy(Tensor& dst, const Tensor& src, const Tensor& s); // dst += src * s[0]103void silu_backward(const Tensor& x, const Tensor& dout, Tensor& dx);104void gelu_backward(const Tensor& x, const Tensor& dout, Tensor& dx);105void add_bias_backward(const Tensor& dout, Tensor& dbias);106void rmsnorm_backward(const Tensor& x, const Tensor& w, float eps,107                      const Tensor& dout, Tensor& dx, Tensor& dw);108void layernorm_backward(const Tensor& x, const Tensor& w, float eps,109                        const Tensor& dout, Tensor& dx, Tensor& dw, Tensor& db);110111// freqs: [head_dim/2] per-pair inverse frequencies, host-precomputed112// (fixed theta, llama3 rope-scaling, per-layer theta — all just tables).113void rope(const Tensor& x, int64_t n_heads, const Tensor& freqs, int64_t pos_offset,114          Tensor& out);115void rope_backward(const Tensor& dout, int64_t n_heads, const Tensor& freqs,116                   int64_t pos_offset, Tensor& dx);117118void embedding(const Tensor& weight, const Tensor& ids, Tensor& out); // ids i32119void embedding_backward(const Tensor& ids, const Tensor& dout, Tensor& dweight);120121// window > 0: sliding-window attention; attn_softcap > 0: cap·tanh pre-softmax122// (unfused path only — the fused kernels don't support softcap).123void attention(const Tensor& q, const Tensor& k, const Tensor& v,124               int64_t n_heads, int64_t n_kv_heads, bool causal, float scale,125               Tensor& out, Tensor* probs_out, int64_t window = 0,126               float attn_softcap = 0.0f);127void attention_backward(const Tensor& q, const Tensor& k, const Tensor& v,128                        const Tensor& probs, const Tensor& out, const Tensor& dout,129                        int64_t n_heads, int64_t n_kv_heads, float scale,130                        Tensor& dq, Tensor& dk, Tensor& dv, float attn_softcap = 0.0f);131132// Fused (flash) attention: stores no T x T probabilities, only the per-row133// logsumexp `lse` [B, n_heads, T] that the backward re-expands. Supported for134// a fixed set of head_dims — flash_supported() reports which.135bool flash_supported(int64_t head_dim);136// Scalar: one thread per query row. MMA: simdgroup_matrix 8x8 tiles. Same137// outputs; Auto picks MMA where supported.138enum class FlashKernel { Auto, Scalar, MMA };139// window > 0 requires the Scalar kernel (Auto routes there automatically);140// out-of-window KV blocks are skipped, so cost scales with the window.141void flash_attention(const Tensor& q, const Tensor& k, const Tensor& v,142                     int64_t n_heads, int64_t n_kv_heads, bool causal, float scale,143                     Tensor& out, Tensor& lse, FlashKernel kernel = FlashKernel::Auto,144                     int64_t window = 0);145void flash_attention_backward(const Tensor& q, const Tensor& k, const Tensor& v,146                              const Tensor& out, const Tensor& lse, const Tensor& dout,147                              int64_t n_heads, int64_t n_kv_heads, bool causal,148                              float scale, Tensor& dq, Tensor& dk, Tensor& dv,149                              FlashKernel kernel = FlashKernel::Auto,150                              int64_t window = 0);151152// losses: [N] per-row buffer; loss_out: [1] mean over n_valid. dlogits153// optional (accumulated). ids must be i32.154void cross_entropy(const Tensor& logits, const Tensor& targets, int64_t n_valid,155                   Tensor& losses, Tensor& loss_out, Tensor* dlogits);156157// ---- QAT / MoE --------------------------------------------------------------158// Per-row fake quantization of a 2-D weight; mode 0 = int8 (absmax/127),159// mode 1 = ternary (absmean, BitNet-style). Backward is STE — no kernel.160void fake_quant(const Tensor& w, int mode, Tensor& out);161// dx += p ∘ (dout − dot(dout, p)) per row; thread-per-row, small last dims.162void softmax_backward(const Tensor& p, const Tensor& dout, Tensor& dx);163void sigmoid(const Tensor& x, Tensor& out);164void sigmoid_backward(const Tensor& y, const Tensor& dout, Tensor& dx);165// Keep top-k per row of p [N,E] — selected by p+bias, gated by p alone;166// norm renormalizes kept gates to sum 1.167void topk_renorm(const Tensor& p, const Tensor& bias, int64_t k, bool norm,168                 Tensor& out);169void topk_renorm_backward(const Tensor& p, const Tensor& bias, const Tensor& dout,170                          int64_t k, bool norm, Tensor& dp);171// counts[e] += #rows with nonzero gate for expert e172void expert_counts(const Tensor& gates, Tensor& counts);173// out[i,:] = x[i,:] * gates[i,e]; the _accumulate variant does dst += (dx path).174void row_scale(const Tensor& x, const Tensor& gates, int64_t e, Tensor& out);175void row_scale_accumulate(const Tensor& x, const Tensor& gates, int64_t e, Tensor& dst);176// dgates[i,e] += dot(dout[i,:], x[i,:])177void row_scale_gate_backward(const Tensor& dout, const Tensor& x, int64_t e,178                             Tensor& dgates);179180// Fused optimizer update for one tensor; all state f32.181void adamw_step(Tensor& w, const Tensor& g, Tensor& m, Tensor& v,182                float lr, float beta1, float beta2, int64_t t, float eps, float wd,183                float grad_scale);184// out[0] = sum(x^2) — single-threadgroup reduce (fine for M4).185void sumsq(const Tensor& x, Tensor& out);186// out[0] = sum(x) * mul187void sum(const Tensor& x, Tensor& out, float mul);188189} // namespace forge::metal190