Add QAT, MoE, and architecture-variant knobs — all config-selected
- quant "int8"|"ternary": per-row fake-quant each forward (BitNet-style absmean for ternary), straight-through estimator backward, f32 masters; wired through the Linear quantization seam - n_experts/moe_top_k/n_shared_experts: softmax router, renormalized top-k gates (topk_renorm + row_scale ops, CPU+Metal), differentiable load-balance loss, DeepSeek-style always-active shared experts; v1 computes experts densely (correctness first) - qk_norm (Qwen3/Gemma3), final_softcap (Gemma2), scale_embeddings (Gemma) - new kernels: quant.metal, moe.metal, softcap in elementwise.metal - CPU references + parity tests for every new op and full-model variants (QAT int8/ternary, MoE 4+1shared, qk-norm+softcap+embed-scale) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 17 changed files with +849 and −10
added
configs/gpt-50m-moe.json
+41 −0
@@ -0,0 +1,41 @@ | ||
| 1 | +{ | |
| 2 | + "_comment": "MoE variant: 4 experts (d_ff 1728 each) + top-2 softmax router per block, ~152M total params but only ~2 experts' worth active per token. v1 computes all experts densely (correctness first), so steps cost ~4x the dense MLP; the aux load-balance loss (E * sum mean_gate^2, weight 0.01) keeps the router from collapsing. Same data/steps as gpt-50m.", | |
| 3 | + "model": { | |
| 4 | + "name": "gpt-50m-moe", | |
| 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 | + "n_experts": 4, | |
| 20 | + "moe_top_k": 2, | |
| 21 | + "moe_aux_weight": 0.01 | |
| 22 | + }, | |
| 23 | + "train": { | |
| 24 | + "lr": 0.0005, | |
| 25 | + "min_lr_ratio": 0.1, | |
| 26 | + "warmup_steps": 117, | |
| 27 | + "max_steps": 1170, | |
| 28 | + "beta1": 0.9, | |
| 29 | + "beta2": 0.95, | |
| 30 | + "eps": 1e-08, | |
| 31 | + "weight_decay": 0.1, | |
| 32 | + "grad_clip": 1.0, | |
| 33 | + "batch_size": 8, | |
| 34 | + "grad_accum_steps": 8, | |
| 35 | + "precision": "f32", | |
| 36 | + "checkpoint_every": 200, | |
| 37 | + "eval_every": 100, | |
| 38 | + "eval_batches": 20, | |
| 39 | + "seed": 1337 | |
| 40 | + } | |
| 41 | +} | |
added
configs/gpt-50m-ternary.json
+39 −0
@@ -0,0 +1,39 @@ | ||
| 1 | +{ | |
| 2 | + "_comment": "gpt-50m with BitNet-style ternary QAT: every linear weight is fake-quantized to {-s, 0, +s} (per-row absmean scale) each forward, gradients flow via straight-through estimator into f32 master weights. Embeddings/lm_head stay f32. Same data/steps as gpt-50m for A/B comparison.", | |
| 3 | + "model": { | |
| 4 | + "name": "gpt-50m-ternary", | |
| 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 | + "quant": "ternary" | |
| 20 | + }, | |
| 21 | + "train": { | |
| 22 | + "lr": 0.0005, | |
| 23 | + "min_lr_ratio": 0.1, | |
| 24 | + "warmup_steps": 117, | |
| 25 | + "max_steps": 1170, | |
| 26 | + "beta1": 0.9, | |
| 27 | + "beta2": 0.95, | |
| 28 | + "eps": 1e-08, | |
| 29 | + "weight_decay": 0.1, | |
| 30 | + "grad_clip": 1.0, | |
| 31 | + "batch_size": 8, | |
| 32 | + "grad_accum_steps": 8, | |
| 33 | + "precision": "f32", | |
| 34 | + "checkpoint_every": 200, | |
| 35 | + "eval_every": 100, | |
| 36 | + "eval_batches": 20, | |
| 37 | + "seed": 1337 | |
| 38 | + } | |
| 39 | +} | |
modified
src/kernels/elementwise.metal
+18 −0
@@ -52,6 +52,24 @@ kernel void gelu_f32(device const float* x [[buffer(0)]], | ||
| 52 | 52 | out[gid] = 0.5f * v * (1.0f + precise::tanh(k * (v + 0.044715f * v * v * v))); |
| 53 | 53 | } |
| 54 | 54 | |
| 55 | +// Gemma-style logit softcap: out = cap * tanh(x / cap). | |
| 56 | +kernel void softcap_f32(device const float* x [[buffer(0)]], | |
| 57 | + device float* out [[buffer(1)]], | |
| 58 | + constant float& cap [[buffer(2)]], | |
| 59 | + uint gid [[thread_position_in_grid]]) { | |
| 60 | + out[gid] = cap * precise::tanh(x[gid] / cap); | |
| 61 | +} | |
| 62 | + | |
| 63 | +// dx += dout * (1 - (y/cap)^2), using the forward output y. | |
| 64 | +kernel void softcap_bwd_f32(device const float* y [[buffer(0)]], | |
| 65 | + device const float* dout [[buffer(1)]], | |
| 66 | + device float* dx [[buffer(2)]], | |
| 67 | + constant float& cap [[buffer(3)]], | |
| 68 | + uint gid [[thread_position_in_grid]]) { | |
| 69 | + const float t = y[gid] / cap; | |
| 70 | + dx[gid] = fma(dout[gid], 1.0f - t * t, dx[gid]); | |
| 71 | +} | |
| 72 | + | |
| 55 | 73 | // ---- backward / accumulation kernels (all ACCUMULATE into their outputs) ---- |
| 56 | 74 | |
| 57 | 75 | kernel void accum_f32(device float* dst [[buffer(0)]], |
added
src/kernels/moe.metal
+120 −0
@@ -0,0 +1,120 @@ | ||
| 1 | +// Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// | |
| 3 | +// Mixture-of-experts kernels. All operate on router-sized rows (E experts, | |
| 4 | +// E <= 64 in practice), so every kernel is one thread per row with a plain | |
| 5 | +// loop — no threadgroup reductions needed. row_scale kernels are flat | |
| 6 | +// elementwise over [N, C] activations. | |
| 7 | +#include <metal_stdlib> | |
| 8 | +using namespace metal; | |
| 9 | + | |
| 10 | +// Generic row softmax backward: dx += p ∘ (dout − dot(dout, p)), row = last | |
| 11 | +// dim. Thread-per-row; meant for small C (the router's E columns). | |
| 12 | +kernel void softmax_bwd_f32(device const float* P [[buffer(0)]], | |
| 13 | + device const float* DOUT [[buffer(1)]], | |
| 14 | + device float* DX [[buffer(2)]], | |
| 15 | + constant uint& C [[buffer(3)]], | |
| 16 | + uint row [[thread_position_in_grid]]) { | |
| 17 | + device const float* prob = P + ulong(row) * C; | |
| 18 | + device const float* dout = DOUT + ulong(row) * C; | |
| 19 | + device float* dx = DX + ulong(row) * C; | |
| 20 | + float dot = 0.0f; | |
| 21 | + for (uint j = 0; j < C; ++j) dot += dout[j] * prob[j]; | |
| 22 | + for (uint j = 0; j < C; ++j) dx[j] += prob[j] * (dout[j] - dot); | |
| 23 | +} | |
| 24 | + | |
| 25 | +// Top-k gating: keep the k largest of each row of P [N, E], renormalize the | |
| 26 | +// kept entries to sum to 1, zero the rest. Ties broken by lower index, which | |
| 27 | +// keeps forward and backward selections identical. | |
| 28 | +// p = (E, k) | |
| 29 | +kernel void topk_renorm_f32(device const float* P [[buffer(0)]], | |
| 30 | + device float* OUT [[buffer(1)]], | |
| 31 | + constant uint2& p [[buffer(2)]], | |
| 32 | + uint row [[thread_position_in_grid]]) { | |
| 33 | + const uint E = p.x, K = p.y; | |
| 34 | + device const float* in = P + ulong(row) * E; | |
| 35 | + device float* out = OUT + ulong(row) * E; | |
| 36 | + | |
| 37 | + bool kept[64]; | |
| 38 | + for (uint j = 0; j < E; ++j) kept[j] = false; | |
| 39 | + float S = 0.0f; | |
| 40 | + for (uint sel = 0; sel < K; ++sel) { | |
| 41 | + float best = -FLT_MAX; | |
| 42 | + uint arg = 0; | |
| 43 | + for (uint j = 0; j < E; ++j) | |
| 44 | + if (!kept[j] && in[j] > best) { best = in[j]; arg = j; } | |
| 45 | + kept[arg] = true; | |
| 46 | + S += best; | |
| 47 | + } | |
| 48 | + const float inv = 1.0f / max(S, 1e-12f); | |
| 49 | + for (uint j = 0; j < E; ++j) out[j] = kept[j] ? in[j] * inv : 0.0f; | |
| 50 | +} | |
| 51 | + | |
| 52 | +// Backward of topk_renorm: for kept entries, g_i = p_i / S with S the kept | |
| 53 | +// sum, so dp_i += (dg_i − Σ_j dg_j g_j) / S; dropped entries get zero. The | |
| 54 | +// kept set is recomputed from P with the same tie-breaking as forward. | |
| 55 | +// p = (E, k) | |
| 56 | +kernel void topk_renorm_bwd_f32(device const float* P [[buffer(0)]], | |
| 57 | + device const float* DOUT [[buffer(1)]], | |
| 58 | + device float* DP [[buffer(2)]], | |
| 59 | + constant uint2& p [[buffer(3)]], | |
| 60 | + uint row [[thread_position_in_grid]]) { | |
| 61 | + const uint E = p.x, K = p.y; | |
| 62 | + device const float* in = P + ulong(row) * E; | |
| 63 | + device const float* dout = DOUT + ulong(row) * E; | |
| 64 | + device float* dp = DP + ulong(row) * E; | |
| 65 | + | |
| 66 | + bool kept[64]; | |
| 67 | + for (uint j = 0; j < E; ++j) kept[j] = false; | |
| 68 | + float S = 0.0f; | |
| 69 | + for (uint sel = 0; sel < K; ++sel) { | |
| 70 | + float best = -FLT_MAX; | |
| 71 | + uint arg = 0; | |
| 72 | + for (uint j = 0; j < E; ++j) | |
| 73 | + if (!kept[j] && in[j] > best) { best = in[j]; arg = j; } | |
| 74 | + kept[arg] = true; | |
| 75 | + S += best; | |
| 76 | + } | |
| 77 | + const float inv = 1.0f / max(S, 1e-12f); | |
| 78 | + float dot = 0.0f; | |
| 79 | + for (uint j = 0; j < E; ++j) | |
| 80 | + if (kept[j]) dot += dout[j] * in[j] * inv; | |
| 81 | + for (uint j = 0; j < E; ++j) | |
| 82 | + if (kept[j]) dp[j] += (dout[j] - dot) * inv; | |
| 83 | +} | |
| 84 | + | |
| 85 | +// 12-byte layout matching the host-side struct (uint3 would pad to 16). | |
| 86 | +struct RowScaleParams { uint C, E, e; }; | |
| 87 | + | |
| 88 | +// out[i, c] = x[i, c] * G[i, e] — scale each row of X by one gate column. | |
| 89 | +// Flat over N*C. | |
| 90 | +kernel void row_scale_f32(device const float* X [[buffer(0)]], | |
| 91 | + device const float* G [[buffer(1)]], | |
| 92 | + device float* OUT [[buffer(2)]], | |
| 93 | + constant RowScaleParams& p [[buffer(3)]], | |
| 94 | + uint gid [[thread_position_in_grid]]) { | |
| 95 | + const uint i = gid / p.C; | |
| 96 | + OUT[gid] = X[gid] * G[ulong(i) * p.E + p.e]; | |
| 97 | +} | |
| 98 | + | |
| 99 | +// dst[i, c] += x[i, c] * G[i, e] — accumulating variant (dx of row_scale). | |
| 100 | +kernel void row_scale_acc_f32(device const float* X [[buffer(0)]], | |
| 101 | + device const float* G [[buffer(1)]], | |
| 102 | + device float* DST [[buffer(2)]], | |
| 103 | + constant RowScaleParams& p [[buffer(3)]], | |
| 104 | + uint gid [[thread_position_in_grid]]) { | |
| 105 | + const uint i = gid / p.C; | |
| 106 | + DST[gid] = fma(X[gid], G[ulong(i) * p.E + p.e], DST[gid]); | |
| 107 | +} | |
| 108 | + | |
| 109 | +// dG[i, e] += dot(dout[i, :], x[i, :]) — gate gradient, one thread per row. | |
| 110 | +kernel void row_scale_gate_bwd_f32(device const float* DOUT [[buffer(0)]], | |
| 111 | + device const float* X [[buffer(1)]], | |
| 112 | + device float* DG [[buffer(2)]], | |
| 113 | + constant RowScaleParams& p [[buffer(3)]], | |
| 114 | + uint row [[thread_position_in_grid]]) { | |
| 115 | + device const float* dout = DOUT + ulong(row) * p.C; | |
| 116 | + device const float* x = X + ulong(row) * p.C; | |
| 117 | + float acc = 0.0f; | |
| 118 | + for (uint j = 0; j < p.C; ++j) acc += dout[j] * x[j]; | |
| 119 | + DG[ulong(row) * p.E + p.e] += acc; | |
| 120 | +} | |
added
src/kernels/quant.metal
+37 −0
@@ -0,0 +1,37 @@ | ||
| 1 | +// Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// | |
| 3 | +// Quantization-aware-training kernel: fake-quantize a weight matrix row by | |
| 4 | +// row (per-output-channel scales). One thread per row, two strided passes | |
| 5 | +// over the row (reduce scale, then quantize). Rows are weight-matrix sized | |
| 6 | +// (C = in_features, a few thousand max) and this runs once per layer per | |
| 7 | +// forward, so a simple thread-per-row layout is plenty. | |
| 8 | +// | |
| 9 | +// Backward is the straight-through estimator (identity), so there is no | |
| 10 | +// backward kernel — autograd routes dout straight into the master weight's | |
| 11 | +// grad. | |
| 12 | +#include <metal_stdlib> | |
| 13 | +using namespace metal; | |
| 14 | + | |
| 15 | +// p = (C, mode); mode 0 = int8 (absmax/127), mode 1 = ternary BitNet-style | |
| 16 | +// (scale = mean|w|, values in {-s, 0, +s}). | |
| 17 | +kernel void fake_quant_f32(device const float* W [[buffer(0)]], | |
| 18 | + device float* OUT [[buffer(1)]], | |
| 19 | + constant uint2& p [[buffer(2)]], | |
| 20 | + uint row [[thread_position_in_grid]]) { | |
| 21 | + const uint C = p.x; | |
| 22 | + device const float* w = W + ulong(row) * C; | |
| 23 | + device float* out = OUT + ulong(row) * C; | |
| 24 | + | |
| 25 | + if (p.y == 0) { // int8: symmetric absmax | |
| 26 | + float amax = 0.0f; | |
| 27 | + for (uint j = 0; j < C; ++j) amax = max(amax, fabs(w[j])); | |
| 28 | + const float s = max(amax / 127.0f, 1e-12f); | |
| 29 | + for (uint j = 0; j < C; ++j) out[j] = rint(w[j] / s) * s; | |
| 30 | + } else { // ternary: BitNet b1.58 absmean | |
| 31 | + float asum = 0.0f; | |
| 32 | + for (uint j = 0; j < C; ++j) asum += fabs(w[j]); | |
| 33 | + const float s = max(asum / float(C), 1e-12f); | |
| 34 | + for (uint j = 0; j < C; ++j) | |
| 35 | + out[j] = clamp(rint(w[j] / s), -1.0f, 1.0f) * s; | |
| 36 | + } | |
| 37 | +} | |
modified
src/nn/attention.h
+26 −1
@@ -28,7 +28,9 @@ public: | ||
| 28 | 28 | n_kv_heads_(cfg.n_kv_heads), |
| 29 | 29 | head_dim_(cfg.head_dim()), |
| 30 | 30 | use_rope_(cfg.use_rope), |
| 31 | − rope_theta_(cfg.rope_theta) { | |
| 31 | + rope_theta_(cfg.rope_theta), | |
| 32 | + qk_norm_(cfg.qk_norm), | |
| 33 | + norm_eps_(cfg.norm_eps) { | |
| 32 | 34 | const float std = 0.02f; |
| 33 | 35 | const int64_t C = cfg.d_model; |
| 34 | 36 | const int64_t Ckv = cfg.n_kv_heads * cfg.head_dim(); |
@@ -36,10 +38,21 @@ public: | ||
| 36 | 38 | wk_ = std::make_unique<Linear>(C, Ckv, false, std, rng); |
| 37 | 39 | wv_ = std::make_unique<Linear>(C, Ckv, false, std, rng); |
| 38 | 40 | wo_ = std::make_unique<Linear>(C, C, false, proj_std, rng); // residual projection |
| 41 | + const ops::QuantMode qm = quant_mode_from(cfg.quant); | |
| 42 | + wq_->set_quant(qm); | |
| 43 | + wk_->set_quant(qm); | |
| 44 | + wv_->set_quant(qm); | |
| 45 | + wo_->set_quant(qm); | |
| 39 | 46 | absorb("wq", *wq_); |
| 40 | 47 | absorb("wk", *wk_); |
| 41 | 48 | absorb("wv", *wv_); |
| 42 | 49 | absorb("wo", *wo_); |
| 50 | + if (qk_norm_) { | |
| 51 | + q_norm_w_ = register_param("q_norm.weight", | |
| 52 | + Tensor::full({cfg.head_dim()}, 1.0f)); | |
| 53 | + k_norm_w_ = register_param("k_norm.weight", | |
| 54 | + Tensor::full({cfg.head_dim()}, 1.0f)); | |
| 55 | + } | |
| 43 | 56 | } |
| 44 | 57 | |
| 45 | 58 | Var forward(const Var& x) const override { |
@@ -51,6 +64,15 @@ public: | ||
| 51 | 64 | Var k = wk_->forward(x2d).reshaped({B, T, Ckv}); |
| 52 | 65 | Var v = wv_->forward(x2d).reshaped({B, T, Ckv}); |
| 53 | 66 | |
| 67 | + if (qk_norm_) { | |
| 68 | + // Per-head RMSNorm before RoPE (Qwen3/Gemma3): normalize over | |
| 69 | + // head_dim by viewing heads as rows. | |
| 70 | + q = ops::rmsnorm(q.reshaped({B * T * n_heads_, head_dim_}), q_norm_w_, | |
| 71 | + norm_eps_).reshaped({B, T, C}); | |
| 72 | + k = ops::rmsnorm(k.reshaped({B * T * n_kv_heads_, head_dim_}), k_norm_w_, | |
| 73 | + norm_eps_).reshaped({B, T, Ckv}); | |
| 74 | + } | |
| 75 | + | |
| 54 | 76 | if (use_rope_) { |
| 55 | 77 | q = ops::rope(q, n_heads_, rope_theta_, 0); |
| 56 | 78 | k = ops::rope(k, n_kv_heads_, rope_theta_, 0); |
@@ -65,7 +87,10 @@ private: | ||
| 65 | 87 | int64_t n_heads_, n_kv_heads_, head_dim_; |
| 66 | 88 | bool use_rope_; |
| 67 | 89 | float rope_theta_; |
| 90 | + bool qk_norm_; | |
| 91 | + float norm_eps_; | |
| 68 | 92 | std::unique_ptr<Linear> wq_, wk_, wv_, wo_; |
| 93 | + Var q_norm_w_, k_norm_w_; // defined only when qk_norm | |
| 69 | 94 | }; |
| 70 | 95 | |
| 71 | 96 | } // namespace forge::nn |
modified
src/nn/linear.cpp
+1 −0
@@ -7,6 +7,7 @@ namespace forge::nn { | ||
| 7 | 7 | |
| 8 | 8 | Var Linear::forward(const Var& x) const { |
| 9 | 9 | Var w = mask_.defined() ? ops::mul(weight_, mask_) : weight_; |
| 10 | + if (quant_ != ops::QuantMode::None) w = ops::fake_quant(w, quant_); | |
| 10 | 11 | Var y = ops::matmul(x, w, /*transpose_a=*/false, /*transpose_b=*/true); |
| 11 | 12 | if (bias_.defined()) y = ops::add_bias(y, bias_); |
| 12 | 13 | return y; |
modified
src/nn/linear.h
+14 −0
@@ -2,9 +2,18 @@ | ||
| 2 | 2 | #pragma once |
| 3 | 3 | |
| 4 | 4 | #include "nn/module.h" |
| 5 | +#include "ops/ops.h" | |
| 6 | + | |
| 7 | +#include <string> | |
| 5 | 8 | |
| 6 | 9 | namespace forge::nn { |
| 7 | 10 | |
| 11 | +inline ops::QuantMode quant_mode_from(const std::string& s) { | |
| 12 | + if (s == "int8") return ops::QuantMode::Int8; | |
| 13 | + if (s == "ternary") return ops::QuantMode::Ternary; | |
| 14 | + return ops::QuantMode::None; | |
| 15 | +} | |
| 16 | + | |
| 8 | 17 | // y = x ⋅ Wᵀ (+ b). Weight stored [out, in] (PyTorch convention), x is |
| 9 | 18 | // [N, in] 2-D — callers flatten [B,T,C] with Var::reshaped. |
| 10 | 19 | // |
@@ -32,12 +41,17 @@ public: | ||
| 32 | 41 | mask_ = mask.defined() ? Var(std::move(mask), /*requires_grad=*/false) : Var(); |
| 33 | 42 | } |
| 34 | 43 | |
| 44 | + // QAT: fake-quantize the weight each forward (STE backward). The master | |
| 45 | + // weight and optimizer state stay f32. | |
| 46 | + void set_quant(ops::QuantMode mode) { quant_ = mode; } | |
| 47 | + | |
| 35 | 48 | const Var& weight() const { return weight_; } |
| 36 | 49 | |
| 37 | 50 | protected: |
| 38 | 51 | Var weight_; |
| 39 | 52 | Var bias_; // undefined if bias disabled |
| 40 | 53 | Var mask_; // undefined if no mask |
| 54 | + ops::QuantMode quant_ = ops::QuantMode::None; | |
| 41 | 55 | }; |
| 42 | 56 | |
| 43 | 57 | } // namespace forge::nn |
modified
src/nn/mlp.h
+82 −3
@@ -6,11 +6,24 @@ | ||
| 6 | 6 | #include "ops/ops.h" |
| 7 | 7 | |
| 8 | 8 | #include <memory> |
| 9 | +#include <string> | |
| 10 | +#include <vector> | |
| 9 | 11 | |
| 10 | 12 | namespace forge::nn { |
| 11 | 13 | |
| 14 | +// Interface so the MoE variant slots into TransformerBlock without branching | |
| 15 | +// at every call site. aux() is the block's auxiliary loss ([1] Var, undefined | |
| 16 | +// when the variant has none). | |
| 17 | +class MLPBase : public Module { | |
| 18 | +public: | |
| 19 | + virtual ~MLPBase() = default; | |
| 20 | + // x: [N, C] 2-D | |
| 21 | + virtual Var forward(const Var& x) const = 0; | |
| 22 | + virtual Var aux() const { return Var(); } | |
| 23 | +}; | |
| 24 | + | |
| 12 | 25 | // SwiGLU: w2( silu(x w1) ⊙ (x w3) ) | GELU: proj( gelu(fc(x)) ) |
| 13 | −class MLP : public Module { | |
| 26 | +class MLP : public MLPBase { | |
| 14 | 27 | public: |
| 15 | 28 | MLP(const ModelConfig& cfg, float proj_std, std::mt19937_64& rng) |
| 16 | 29 | : swiglu_(cfg.activation == "swiglu") { |
@@ -21,13 +34,16 @@ public: | ||
| 21 | 34 | w3_ = std::make_unique<Linear>(C, F, false, std, rng); |
| 22 | 35 | } |
| 23 | 36 | w2_ = std::make_unique<Linear>(F, C, false, proj_std, rng); // residual projection |
| 37 | + const ops::QuantMode qm = quant_mode_from(cfg.quant); | |
| 38 | + w1_->set_quant(qm); | |
| 39 | + if (w3_) w3_->set_quant(qm); | |
| 40 | + w2_->set_quant(qm); | |
| 24 | 41 | absorb("w1", *w1_); |
| 25 | 42 | if (w3_) absorb("w3", *w3_); |
| 26 | 43 | absorb("w2", *w2_); |
| 27 | 44 | } |
| 28 | 45 | |
| 29 | − // x: [N, C] 2-D | |
| 30 | − Var forward(const Var& x) const { | |
| 46 | + Var forward(const Var& x) const override { | |
| 31 | 47 | if (swiglu_) { |
| 32 | 48 | Var gate = ops::silu(w1_->forward(x)); |
| 33 | 49 | Var up = w3_->forward(x); |
@@ -41,4 +57,67 @@ private: | ||
| 41 | 57 | std::unique_ptr<Linear> w1_, w2_, w3_; |
| 42 | 58 | }; |
| 43 | 59 | |
| 60 | +// Mixture-of-experts MLP: softmax router over n_experts expert MLPs (d_ff | |
| 61 | +// each), top-k gates renormalized to sum 1. v1 computes EVERY expert densely | |
| 62 | +// and weights by the (mostly zero) gates — correctness first; token | |
| 63 | +// gather/scatter sparsity is a later optimization. The auxiliary | |
| 64 | +// load-balance loss is the differentiable proxy E · Σ_e (mean_i gate | |
| 65 | +// probs)² — minimized (at 1.0) by a uniform router — scaled by | |
| 66 | +// moe_aux_weight and added to the training loss by Transformer::loss. | |
| 67 | +class MoEMLP : public MLPBase { | |
| 68 | +public: | |
| 69 | + MoEMLP(const ModelConfig& cfg, float proj_std, std::mt19937_64& rng) | |
| 70 | + : n_experts_(cfg.n_experts), top_k_(cfg.moe_top_k), | |
| 71 | + aux_weight_(cfg.moe_aux_weight) { | |
| 72 | + router_ = std::make_unique<Linear>(cfg.d_model, n_experts_, false, 0.02f, rng); | |
| 73 | + absorb("router", *router_); | |
| 74 | + experts_.reserve(size_t(n_experts_)); | |
| 75 | + for (int64_t e = 0; e < n_experts_; ++e) { | |
| 76 | + experts_.push_back(std::make_unique<MLP>(cfg, proj_std, rng)); | |
| 77 | + absorb("experts." + std::to_string(e), *experts_.back()); | |
| 78 | + } | |
| 79 | + // Always-active experts, added ungated (DeepSeek/Kimi style): they | |
| 80 | + // absorb common knowledge so routed experts can specialize. | |
| 81 | + for (int64_t s = 0; s < cfg.n_shared_experts; ++s) { | |
| 82 | + shared_.push_back(std::make_unique<MLP>(cfg, proj_std, rng)); | |
| 83 | + absorb("shared." + std::to_string(s), *shared_.back()); | |
| 84 | + } | |
| 85 | + } | |
| 86 | + | |
| 87 | + Var forward(const Var& x) const override { | |
| 88 | + const int64_t N = x.value().size(0); | |
| 89 | + Var probs = ops::softmax(router_->forward(x)); // [N, E] | |
| 90 | + Var gates = ops::topk_renorm(probs, top_k_); // [N, E], rows sum to 1 | |
| 91 | + | |
| 92 | + Var y = ops::row_scale(experts_[0]->forward(x), gates, 0); | |
| 93 | + for (int64_t e = 1; e < n_experts_; ++e) | |
| 94 | + y = ops::add(y, ops::row_scale(experts_[size_t(e)]->forward(x), gates, e)); | |
| 95 | + for (const auto& s : shared_) y = ops::add(y, s->forward(x)); | |
| 96 | + | |
| 97 | + if (aux_weight_ > 0.0f) { | |
| 98 | + // Column means of probs via a constant 1/N row — every step is an | |
| 99 | + // existing autograd op, so the router gets balance gradients. | |
| 100 | + Var mean_row(Tensor::full({1, N}, 1.0f / float(N)), /*requires_grad=*/false); | |
| 101 | + Var col_mean = ops::matmul(mean_row, probs); // [1, E] | |
| 102 | + Var sq = ops::mul(col_mean, col_mean); // [1, E] | |
| 103 | + Var ones_e(Tensor::full({1, n_experts_}, 1.0f), /*requires_grad=*/false); | |
| 104 | + Var sum = ops::matmul(sq, ones_e, false, true); // [1, 1] | |
| 105 | + aux_ = ops::scale(sum.reshaped({1}), aux_weight_ * float(n_experts_)); | |
| 106 | + } else { | |
| 107 | + aux_ = Var(); | |
| 108 | + } | |
| 109 | + return y; | |
| 110 | + } | |
| 111 | + | |
| 112 | + Var aux() const override { return aux_; } | |
| 113 | + | |
| 114 | +private: | |
| 115 | + int64_t n_experts_, top_k_; | |
| 116 | + float aux_weight_; | |
| 117 | + std::unique_ptr<Linear> router_; | |
| 118 | + std::vector<std::unique_ptr<MLP>> experts_; | |
| 119 | + std::vector<std::unique_ptr<MLP>> shared_; | |
| 120 | + mutable Var aux_; // set by the last forward; consumed by Transformer::loss | |
| 121 | +}; | |
| 122 | + | |
| 44 | 123 | } // namespace forge::nn |
modified
src/nn/transformer.h
+25 −6
@@ -36,8 +36,9 @@ public: | ||
| 36 | 36 | TransformerBlock(const ModelConfig& cfg, float proj_std, std::mt19937_64& rng) |
| 37 | 37 | : norm1_(std::make_unique<Norm>(cfg)), |
| 38 | 38 | attn_(std::make_unique<CausalSelfAttention>(cfg, proj_std, rng)), |
| 39 | − norm2_(std::make_unique<Norm>(cfg)), | |
| 40 | − mlp_(std::make_unique<MLP>(cfg, proj_std, rng)) { | |
| 39 | + norm2_(std::make_unique<Norm>(cfg)) { | |
| 40 | + if (cfg.n_experts > 0) mlp_ = std::make_unique<MoEMLP>(cfg, proj_std, rng); | |
| 41 | + else mlp_ = std::make_unique<MLP>(cfg, proj_std, rng); | |
| 41 | 42 | absorb("norm1", *norm1_); |
| 42 | 43 | absorb("attn", *attn_); |
| 43 | 44 | absorb("norm2", *norm2_); |
@@ -51,11 +52,14 @@ public: | ||
| 51 | 52 | return ops::add(h, m.reshaped({B, T, C})); |
| 52 | 53 | } |
| 53 | 54 | |
| 55 | + // MoE load-balance loss of the last forward (undefined for dense MLP). | |
| 56 | + Var moe_aux() const { return mlp_->aux(); } | |
| 57 | + | |
| 54 | 58 | private: |
| 55 | 59 | std::unique_ptr<Norm> norm1_; |
| 56 | 60 | std::unique_ptr<AttentionBase> attn_; |
| 57 | 61 | std::unique_ptr<Norm> norm2_; |
| 58 | − std::unique_ptr<MLP> mlp_; | |
| 62 | + std::unique_ptr<MLPBase> mlp_; | |
| 59 | 63 | }; |
| 60 | 64 | |
| 61 | 65 | // Decoder-only transformer, entirely shaped by ModelConfig. |
@@ -93,6 +97,8 @@ public: | ||
| 93 | 97 | Var forward(const Tensor& ids) const { |
| 94 | 98 | const int64_t B = ids.shape()[0], T = ids.shape()[1]; |
| 95 | 99 | Var x = tok_emb_->forward(ids); |
| 100 | + if (cfg_.scale_embeddings) | |
| 101 | + x = ops::scale(x, std::sqrt(float(cfg_.d_model))); | |
| 96 | 102 | if (pos_emb_) { |
| 97 | 103 | Tensor pos = Tensor::empty({1, T}, DType::I32); |
| 98 | 104 | for (int64_t t = 0; t < T; ++t) pos.data<int32_t>()[t] = int32_t(t); |
@@ -106,14 +112,27 @@ public: | ||
| 106 | 112 | for (const auto& blk : blocks_) x = blk->forward(x); |
| 107 | 113 | x = final_norm_->forward(x); |
| 108 | 114 | Var x2d = x.reshaped({B * T, cfg_.d_model}); |
| 109 | − return ops::matmul(x2d, lm_head_weight_, false, true); // [B*T, V] | |
| 115 | + Var logits = ops::matmul(x2d, lm_head_weight_, false, true); // [B*T, V] | |
| 116 | + if (cfg_.final_softcap > 0.0f) | |
| 117 | + logits = ops::softcap(logits, cfg_.final_softcap); | |
| 118 | + return logits; | |
| 110 | 119 | } |
| 111 | 120 | |
| 112 | − // targets: [B, T] with ignore_index=-1 → scalar mean CE loss | |
| 121 | + // targets: [B, T] with ignore_index=-1 → scalar mean CE loss. With MoE, | |
| 122 | + // each block's load-balance term is added (also during eval — its scale | |
| 123 | + // is ~moe_aux_weight, negligible next to CE but keeps train/val | |
| 124 | + // comparable). | |
| 113 | 125 | Var loss(const Tensor& ids, const Tensor& targets) const { |
| 114 | 126 | Var logits = forward(ids); |
| 115 | 127 | Tensor tflat = targets; |
| 116 | − return ops::cross_entropy(logits, tflat.view({targets.numel()})); | |
| 128 | + Var l = ops::cross_entropy(logits, tflat.view({targets.numel()})); | |
| 129 | + if (cfg_.n_experts > 0 && cfg_.moe_aux_weight > 0.0f) { | |
| 130 | + for (const auto& blk : blocks_) { | |
| 131 | + Var a = blk->moe_aux(); | |
| 132 | + if (a.defined()) l = ops::add(l, a); | |
| 133 | + } | |
| 134 | + } | |
| 135 | + return l; | |
| 117 | 136 | } |
| 118 | 137 | |
| 119 | 138 | const ModelConfig& config() const { return cfg_; } |
modified
src/ops/cpu/cpu_ops.cpp
+136 −0
@@ -3,10 +3,12 @@ | ||
| 3 | 3 | |
| 4 | 4 | #include <dispatch/dispatch.h> |
| 5 | 5 | |
| 6 | +#include <algorithm> | |
| 6 | 7 | #include <cassert> |
| 7 | 8 | #include <cmath> |
| 8 | 9 | #include <cstdio> |
| 9 | 10 | #include <cstdlib> |
| 11 | +#include <limits> | |
| 10 | 12 | |
| 11 | 13 | namespace forge::cpu { |
| 12 | 14 | |
@@ -179,6 +181,22 @@ void gelu_backward(const Tensor& x, const Tensor& dout, Tensor& dx) { | ||
| 179 | 181 | } |
| 180 | 182 | } |
| 181 | 183 | |
| 184 | +void softcap(const Tensor& x, float cap, Tensor& out) { | |
| 185 | + const float* px = x.data<float>(); | |
| 186 | + float* po = out.data<float>(); | |
| 187 | + for (int64_t i = 0; i < x.numel(); ++i) po[i] = cap * std::tanh(px[i] / cap); | |
| 188 | +} | |
| 189 | + | |
| 190 | +void softcap_backward(const Tensor& y, const Tensor& dout, float cap, Tensor& dx) { | |
| 191 | + const float* py = y.data<float>(); | |
| 192 | + const float* pd = dout.data<float>(); | |
| 193 | + float* pdx = dx.data<float>(); | |
| 194 | + for (int64_t i = 0; i < y.numel(); ++i) { | |
| 195 | + const float t = py[i] / cap; | |
| 196 | + pdx[i] += pd[i] * (1.0f - t * t); | |
| 197 | + } | |
| 198 | +} | |
| 199 | + | |
| 182 | 200 | // ---- norms -------------------------------------------------------------------- |
| 183 | 201 | |
| 184 | 202 | void rmsnorm(const Tensor& x, const Tensor& w, float eps, Tensor& out) { |
@@ -319,6 +337,124 @@ void softmax_backward(const Tensor& p, const Tensor& dout, Tensor& dx) { | ||
| 319 | 337 | } |
| 320 | 338 | } |
| 321 | 339 | |
| 340 | +// ---- QAT / MoE ------------------------------------------------------------------ | |
| 341 | + | |
| 342 | +void fake_quant(const Tensor& w, int mode, Tensor& out) { | |
| 343 | + const int64_t R = w.size(0), C = w.size(1); | |
| 344 | + const float* pw = w.data<float>(); | |
| 345 | + float* po = out.data<float>(); | |
| 346 | + for (int64_t i = 0; i < R; ++i) { | |
| 347 | + const float* row = pw + i * C; | |
| 348 | + float* orow = po + i * C; | |
| 349 | + if (mode == 0) { // int8: symmetric absmax | |
| 350 | + float amax = 0.0f; | |
| 351 | + for (int64_t j = 0; j < C; ++j) amax = std::max(amax, std::fabs(row[j])); | |
| 352 | + const float s = std::max(amax / 127.0f, 1e-12f); | |
| 353 | + for (int64_t j = 0; j < C; ++j) orow[j] = std::rint(row[j] / s) * s; | |
| 354 | + } else { // ternary: BitNet b1.58 absmean | |
| 355 | + float asum = 0.0f; | |
| 356 | + for (int64_t j = 0; j < C; ++j) asum += std::fabs(row[j]); | |
| 357 | + const float s = std::max(asum / float(C), 1e-12f); | |
| 358 | + for (int64_t j = 0; j < C; ++j) | |
| 359 | + orow[j] = std::min(1.0f, std::max(-1.0f, std::rint(row[j] / s))) * s; | |
| 360 | + } | |
| 361 | + } | |
| 362 | +} | |
| 363 | + | |
| 364 | +namespace { | |
| 365 | +// Kept set of the k largest entries, ties to the lower index (matches Metal). | |
| 366 | +void topk_select(const float* row, int64_t E, int64_t k, bool* kept, float* sum) { | |
| 367 | + for (int64_t j = 0; j < E; ++j) kept[j] = false; | |
| 368 | + float S = 0.0f; | |
| 369 | + for (int64_t sel = 0; sel < k; ++sel) { | |
| 370 | + float best = -std::numeric_limits<float>::max(); | |
| 371 | + int64_t arg = 0; | |
| 372 | + for (int64_t j = 0; j < E; ++j) | |
| 373 | + if (!kept[j] && row[j] > best) { best = row[j]; arg = j; } | |
| 374 | + kept[arg] = true; | |
| 375 | + S += best; | |
| 376 | + } | |
| 377 | + *sum = S; | |
| 378 | +} | |
| 379 | +} // namespace | |
| 380 | + | |
| 381 | +void topk_renorm(const Tensor& p, int64_t k, Tensor& out) { | |
| 382 | + const int64_t E = p.shape().back(); | |
| 383 | + const int64_t N = p.numel() / E; | |
| 384 | + const float* pp = p.data<float>(); | |
| 385 | + float* po = out.data<float>(); | |
| 386 | + bool kbuf[64]; | |
| 387 | + for (int64_t i = 0; i < N; ++i) { | |
| 388 | + const float* row = pp + i * E; | |
| 389 | + float S = 0.0f; | |
| 390 | + topk_select(row, E, k, kbuf, &S); | |
| 391 | + const float inv = 1.0f / std::max(S, 1e-12f); | |
| 392 | + for (int64_t j = 0; j < E; ++j) po[i * E + j] = kbuf[j] ? row[j] * inv : 0.0f; | |
| 393 | + } | |
| 394 | +} | |
| 395 | + | |
| 396 | +void topk_renorm_backward(const Tensor& p, const Tensor& dout, int64_t k, Tensor& dp) { | |
| 397 | + const int64_t E = p.shape().back(); | |
| 398 | + const int64_t N = p.numel() / E; | |
| 399 | + const float* pp = p.data<float>(); | |
| 400 | + const float* pd = dout.data<float>(); | |
| 401 | + float* pdp = dp.data<float>(); | |
| 402 | + bool kbuf[64]; | |
| 403 | + for (int64_t i = 0; i < N; ++i) { | |
| 404 | + const float* row = pp + i * E; | |
| 405 | + const float* drow = pd + i * E; | |
| 406 | + float S = 0.0f; | |
| 407 | + topk_select(row, E, k, kbuf, &S); | |
| 408 | + const float inv = 1.0f / std::max(S, 1e-12f); | |
| 409 | + float dot = 0.0f; | |
| 410 | + for (int64_t j = 0; j < E; ++j) | |
| 411 | + if (kbuf[j]) dot += drow[j] * row[j] * inv; | |
| 412 | + for (int64_t j = 0; j < E; ++j) | |
| 413 | + if (kbuf[j]) pdp[i * E + j] += (drow[j] - dot) * inv; | |
| 414 | + } | |
| 415 | +} | |
| 416 | + | |
| 417 | +void row_scale(const Tensor& x, const Tensor& gates, int64_t e, Tensor& out) { | |
| 418 | + const int64_t C = x.shape().back(); | |
| 419 | + const int64_t N = x.numel() / C; | |
| 420 | + const int64_t E = gates.shape().back(); | |
| 421 | + const float* px = x.data<float>(); | |
| 422 | + const float* pg = gates.data<float>(); | |
| 423 | + float* po = out.data<float>(); | |
| 424 | + for (int64_t i = 0; i < N; ++i) { | |
| 425 | + const float s = pg[i * E + e]; | |
| 426 | + for (int64_t j = 0; j < C; ++j) po[i * C + j] = px[i * C + j] * s; | |
| 427 | + } | |
| 428 | +} | |
| 429 | + | |
| 430 | +void row_scale_accumulate(const Tensor& x, const Tensor& gates, int64_t e, Tensor& dst) { | |
| 431 | + const int64_t C = x.shape().back(); | |
| 432 | + const int64_t N = x.numel() / C; | |
| 433 | + const int64_t E = gates.shape().back(); | |
| 434 | + const float* px = x.data<float>(); | |
| 435 | + const float* pg = gates.data<float>(); | |
| 436 | + float* pd = dst.data<float>(); | |
| 437 | + for (int64_t i = 0; i < N; ++i) { | |
| 438 | + const float s = pg[i * E + e]; | |
| 439 | + for (int64_t j = 0; j < C; ++j) pd[i * C + j] += px[i * C + j] * s; | |
| 440 | + } | |
| 441 | +} | |
| 442 | + | |
| 443 | +void row_scale_gate_backward(const Tensor& dout, const Tensor& x, int64_t e, | |
| 444 | + Tensor& dgates) { | |
| 445 | + const int64_t C = x.shape().back(); | |
| 446 | + const int64_t N = x.numel() / C; | |
| 447 | + const int64_t E = dgates.shape().back(); | |
| 448 | + const float* pd = dout.data<float>(); | |
| 449 | + const float* px = x.data<float>(); | |
| 450 | + float* pg = dgates.data<float>(); | |
| 451 | + for (int64_t i = 0; i < N; ++i) { | |
| 452 | + float acc = 0.0f; | |
| 453 | + for (int64_t j = 0; j < C; ++j) acc += pd[i * C + j] * px[i * C + j]; | |
| 454 | + pg[i * E + e] += acc; | |
| 455 | + } | |
| 456 | +} | |
| 457 | + | |
| 322 | 458 | // ---- embedding ------------------------------------------------------------------ |
| 323 | 459 | |
| 324 | 460 | void embedding(const Tensor& weight, const Tensor& ids, Tensor& out) { |
modified
src/ops/cpu/cpu_ops.h
+18 −0
@@ -36,6 +36,9 @@ void silu(const Tensor& x, Tensor& out); | ||
| 36 | 36 | void silu_backward(const Tensor& x, const Tensor& dout, Tensor& dx); |
| 37 | 37 | void gelu(const Tensor& x, Tensor& out); // tanh approximation (GPT-2) |
| 38 | 38 | void gelu_backward(const Tensor& x, const Tensor& dout, Tensor& dx); |
| 39 | +void softcap(const Tensor& x, float cap, Tensor& out); // cap*tanh(x/cap) | |
| 40 | +// dx += dout * (1 - (y/cap)^2) — takes the forward OUTPUT y | |
| 41 | +void softcap_backward(const Tensor& y, const Tensor& dout, float cap, Tensor& dx); | |
| 39 | 42 | |
| 40 | 43 | // ---- norms (row-wise over the last dim) ----------------------------------- |
| 41 | 44 | // x: [N, C], w: [C] (b: [C] for layernorm), out: [N, C] |
@@ -78,6 +81,21 @@ void attention_backward(const Tensor& q, const Tensor& k, const Tensor& v, | ||
| 78 | 81 | int64_t n_heads, int64_t n_kv_heads, float scale, |
| 79 | 82 | Tensor& dq, Tensor& dk, Tensor& dv); |
| 80 | 83 | |
| 84 | +// ---- QAT / MoE -------------------------------------------------------------- | |
| 85 | +// Per-row fake quantization of a 2-D weight; mode 0 = int8 (absmax/127), | |
| 86 | +// mode 1 = ternary (absmean, BitNet-style). Backward is STE — identity. | |
| 87 | +void fake_quant(const Tensor& w, int mode, Tensor& out); | |
| 88 | +// Keep top-k per row of p [N,E], renormalize kept entries to sum 1, zero the | |
| 89 | +// rest (ties broken by lower index, same as the Metal kernel). | |
| 90 | +void topk_renorm(const Tensor& p, int64_t k, Tensor& out); | |
| 91 | +void topk_renorm_backward(const Tensor& p, const Tensor& dout, int64_t k, Tensor& dp); | |
| 92 | +// out[i,:] = x[i,:] * gates[i,e]; _accumulate does dst += (dx path); | |
| 93 | +// gate_backward does dgates[i,e] += dot(dout[i,:], x[i,:]). | |
| 94 | +void row_scale(const Tensor& x, const Tensor& gates, int64_t e, Tensor& out); | |
| 95 | +void row_scale_accumulate(const Tensor& x, const Tensor& gates, int64_t e, Tensor& dst); | |
| 96 | +void row_scale_gate_backward(const Tensor& dout, const Tensor& x, int64_t e, | |
| 97 | + Tensor& dgates); | |
| 98 | + | |
| 81 | 99 | // ---- fused softmax cross-entropy ------------------------------------------- |
| 82 | 100 | // logits: [N, V]; targets: [N] i32 (ignore_index = -1). Returns mean loss |
| 83 | 101 | // over valid rows. dlogits (if non-null) receives (softmax − onehot)/n_valid |
modified
src/ops/metal/metal_ops.cpp
+60 −0
@@ -218,6 +218,14 @@ void gelu(const Tensor& x, Tensor& out) { | ||
| 218 | 218 | encode_flat("gelu_f32", {&x, &out}, nullptr, 0, x.numel()); |
| 219 | 219 | } |
| 220 | 220 | |
| 221 | +void softcap(const Tensor& x, float cap, Tensor& out) { | |
| 222 | + encode_flat("softcap_f32", {&x, &out}, &cap, sizeof(cap), x.numel()); | |
| 223 | +} | |
| 224 | + | |
| 225 | +void softcap_backward(const Tensor& y, const Tensor& dout, float cap, Tensor& dx) { | |
| 226 | + encode_flat("softcap_bwd_f32", {&y, &dout, &dx}, &cap, sizeof(cap), y.numel()); | |
| 227 | +} | |
| 228 | + | |
| 221 | 229 | // ---- row reductions -------------------------------------------------------------- |
| 222 | 230 | |
| 223 | 231 | void softmax(const Tensor& x, Tensor& out) { |
@@ -269,6 +277,58 @@ void layernorm(const Tensor& x, const Tensor& w, const Tensor& b, float eps, Ten | ||
| 269 | 277 | } |
| 270 | 278 | |
| 271 | 279 | |
| 280 | +// ---- QAT / MoE ------------------------------------------------------------------- | |
| 281 | + | |
| 282 | +void fake_quant(const Tensor& w, int mode, Tensor& out) { | |
| 283 | + check(w.ndim() == 2 && w.numel() == out.numel(), "fake_quant: bad shapes"); | |
| 284 | + const uint32_t p[2] = {uint32_t(w.size(1)), uint32_t(mode)}; | |
| 285 | + encode_flat("fake_quant_f32", {&w, &out}, p, sizeof(p), w.size(0)); | |
| 286 | +} | |
| 287 | + | |
| 288 | +void softmax_backward(const Tensor& p, const Tensor& dout, Tensor& dx) { | |
| 289 | + const int64_t C = p.shape().back(); | |
| 290 | + const uint32_t c32 = uint32_t(C); | |
| 291 | + encode_flat("softmax_bwd_f32", {&p, &dout, &dx}, &c32, sizeof(c32), p.numel() / C); | |
| 292 | +} | |
| 293 | + | |
| 294 | +void topk_renorm(const Tensor& p, int64_t k, Tensor& out) { | |
| 295 | + const int64_t E = p.shape().back(); | |
| 296 | + check(E <= 64, "topk_renorm: E must be <= 64 (kernel's kept[] bound)"); | |
| 297 | + const uint32_t pr[2] = {uint32_t(E), uint32_t(k)}; | |
| 298 | + encode_flat("topk_renorm_f32", {&p, &out}, pr, sizeof(pr), p.numel() / E); | |
| 299 | +} | |
| 300 | + | |
| 301 | +void topk_renorm_backward(const Tensor& p, const Tensor& dout, int64_t k, Tensor& dp) { | |
| 302 | + const int64_t E = p.shape().back(); | |
| 303 | + check(E <= 64, "topk_renorm_backward: E must be <= 64"); | |
| 304 | + const uint32_t pr[2] = {uint32_t(E), uint32_t(k)}; | |
| 305 | + encode_flat("topk_renorm_bwd_f32", {&p, &dout, &dp}, pr, sizeof(pr), p.numel() / E); | |
| 306 | +} | |
| 307 | + | |
| 308 | +namespace { | |
| 309 | +struct RowScaleParams { uint32_t C, E, e; }; | |
| 310 | +} // namespace | |
| 311 | + | |
| 312 | +void row_scale(const Tensor& x, const Tensor& gates, int64_t e, Tensor& out) { | |
| 313 | + const RowScaleParams p{uint32_t(x.shape().back()), uint32_t(gates.shape().back()), | |
| 314 | + uint32_t(e)}; | |
| 315 | + encode_flat("row_scale_f32", {&x, &gates, &out}, &p, sizeof(p), x.numel()); | |
| 316 | +} | |
| 317 | + | |
| 318 | +void row_scale_accumulate(const Tensor& x, const Tensor& gates, int64_t e, Tensor& dst) { | |
| 319 | + const RowScaleParams p{uint32_t(x.shape().back()), uint32_t(gates.shape().back()), | |
| 320 | + uint32_t(e)}; | |
| 321 | + encode_flat("row_scale_acc_f32", {&x, &gates, &dst}, &p, sizeof(p), x.numel()); | |
| 322 | +} | |
| 323 | + | |
| 324 | +void row_scale_gate_backward(const Tensor& dout, const Tensor& x, int64_t e, | |
| 325 | + Tensor& dgates) { | |
| 326 | + const RowScaleParams p{uint32_t(x.shape().back()), uint32_t(dgates.shape().back()), | |
| 327 | + uint32_t(e)}; | |
| 328 | + encode_flat("row_scale_gate_bwd_f32", {&dout, &x, &dgates}, &p, sizeof(p), | |
| 329 | + x.numel() / x.shape().back()); | |
| 330 | +} | |
| 331 | + | |
| 272 | 332 | // ---- backward / training ops --------------------------------------------------- |
| 273 | 333 | |
| 274 | 334 | void accumulate(Tensor& dst, const Tensor& src) { |
modified
src/ops/metal/metal_ops.h
+18 −0
@@ -88,6 +88,8 @@ void scale(const Tensor& a, float s, Tensor& out); | ||
| 88 | 88 | void add_bias(const Tensor& x, const Tensor& bias, Tensor& out); |
| 89 | 89 | void silu(const Tensor& x, Tensor& out); |
| 90 | 90 | void gelu(const Tensor& x, Tensor& out); |
| 91 | +void softcap(const Tensor& x, float cap, Tensor& out); // cap*tanh(x/cap) | |
| 92 | +void softcap_backward(const Tensor& y, const Tensor& dout, float cap, Tensor& dx); | |
| 91 | 93 | |
| 92 | 94 | void softmax(const Tensor& x, Tensor& out); // rows = last dim |
| 93 | 95 | void rmsnorm(const Tensor& x, const Tensor& w, float eps, Tensor& out); |
@@ -140,6 +142,22 @@ void flash_attention_backward(const Tensor& q, const Tensor& k, const Tensor& v, | ||
| 140 | 142 | void cross_entropy(const Tensor& logits, const Tensor& targets, int64_t n_valid, |
| 141 | 143 | Tensor& losses, Tensor& loss_out, Tensor* dlogits); |
| 142 | 144 | |
| 145 | +// ---- QAT / MoE -------------------------------------------------------------- | |
| 146 | +// Per-row fake quantization of a 2-D weight; mode 0 = int8 (absmax/127), | |
| 147 | +// mode 1 = ternary (absmean, BitNet-style). Backward is STE — no kernel. | |
| 148 | +void fake_quant(const Tensor& w, int mode, Tensor& out); | |
| 149 | +// dx += p ∘ (dout − dot(dout, p)) per row; thread-per-row, small last dims. | |
| 150 | +void softmax_backward(const Tensor& p, const Tensor& dout, Tensor& dx); | |
| 151 | +// Keep top-k per row of p [N,E], renormalize kept to sum 1, zero the rest. | |
| 152 | +void topk_renorm(const Tensor& p, int64_t k, Tensor& out); | |
| 153 | +void topk_renorm_backward(const Tensor& p, const Tensor& dout, int64_t k, Tensor& dp); | |
| 154 | +// out[i,:] = x[i,:] * gates[i,e]; the _accumulate variant does dst += (dx path). | |
| 155 | +void row_scale(const Tensor& x, const Tensor& gates, int64_t e, Tensor& out); | |
| 156 | +void row_scale_accumulate(const Tensor& x, const Tensor& gates, int64_t e, Tensor& dst); | |
| 157 | +// dgates[i,e] += dot(dout[i,:], x[i,:]) | |
| 158 | +void row_scale_gate_backward(const Tensor& dout, const Tensor& x, int64_t e, | |
| 159 | + Tensor& dgates); | |
| 160 | + | |
| 143 | 161 | // Fused optimizer update for one tensor; all state f32. |
| 144 | 162 | void adamw_step(Tensor& w, const Tensor& g, Tensor& m, Tensor& v, |
| 145 | 163 | float lr, float beta1, float beta2, int64_t t, float eps, float wd, |
modified
src/ops/ops.cpp
+96 −0
@@ -208,6 +208,102 @@ Var gelu(const Var& x) { | ||
| 208 | 208 | return result; |
| 209 | 209 | } |
| 210 | 210 | |
| 211 | +Var softcap(const Var& x, float cap) { | |
| 212 | + Tensor out = Tensor::empty(x.value().shape()); | |
| 213 | + if (gpu()) metal::softcap(x.value(), cap, out); | |
| 214 | + else cpu::softcap(x.value(), cap, out); | |
| 215 | + | |
| 216 | + const bool needs = grad_needed({&x}); | |
| 217 | + Var result(std::move(out), needs); | |
| 218 | + if (needs) { | |
| 219 | + Tape::get().record([x, cap, result]() { | |
| 220 | + if (!x.requires_grad()) return; | |
| 221 | + if (gpu()) metal::softcap_backward(result.value(), result.grad(), cap, x.grad()); | |
| 222 | + else cpu::softcap_backward(result.value(), result.grad(), cap, x.grad()); | |
| 223 | + }); | |
| 224 | + } | |
| 225 | + return result; | |
| 226 | +} | |
| 227 | + | |
| 228 | +Var softmax(const Var& x) { | |
| 229 | + Tensor out = Tensor::empty(x.value().shape()); | |
| 230 | + if (gpu()) metal::softmax(x.value(), out); | |
| 231 | + else cpu::softmax(x.value(), out); | |
| 232 | + | |
| 233 | + const bool needs = grad_needed({&x}); | |
| 234 | + Var result(std::move(out), needs); | |
| 235 | + if (needs) { | |
| 236 | + Tape::get().record([x, result]() { | |
| 237 | + if (!x.requires_grad()) return; | |
| 238 | + if (gpu()) metal::softmax_backward(result.value(), result.grad(), x.grad()); | |
| 239 | + else cpu::softmax_backward(result.value(), result.grad(), x.grad()); | |
| 240 | + }); | |
| 241 | + } | |
| 242 | + return result; | |
| 243 | +} | |
| 244 | + | |
| 245 | +Var fake_quant(const Var& w, QuantMode mode) { | |
| 246 | + if (mode == QuantMode::None) return w; | |
| 247 | + const int m = mode == QuantMode::Int8 ? 0 : 1; | |
| 248 | + Tensor out = Tensor::empty(w.value().shape()); | |
| 249 | + if (gpu()) metal::fake_quant(w.value(), m, out); | |
| 250 | + else cpu::fake_quant(w.value(), m, out); | |
| 251 | + | |
| 252 | + const bool needs = grad_needed({&w}); | |
| 253 | + Var result(std::move(out), needs); | |
| 254 | + if (needs) { | |
| 255 | + // Straight-through estimator: d(quant(w))/dw ≈ I. | |
| 256 | + Tape::get().record([w, result]() { | |
| 257 | + if (w.requires_grad()) accumulate(w.grad(), result.grad()); | |
| 258 | + }); | |
| 259 | + } | |
| 260 | + return result; | |
| 261 | +} | |
| 262 | + | |
| 263 | +Var topk_renorm(const Var& probs, int64_t k) { | |
| 264 | + Tensor out = Tensor::empty(probs.value().shape()); | |
| 265 | + if (gpu()) metal::topk_renorm(probs.value(), k, out); | |
| 266 | + else cpu::topk_renorm(probs.value(), k, out); | |
| 267 | + | |
| 268 | + const bool needs = grad_needed({&probs}); | |
| 269 | + Var result(std::move(out), needs); | |
| 270 | + if (needs) { | |
| 271 | + Tape::get().record([probs, k, result]() { | |
| 272 | + if (!probs.requires_grad()) return; | |
| 273 | + if (gpu()) | |
| 274 | + metal::topk_renorm_backward(probs.value(), result.grad(), k, probs.grad()); | |
| 275 | + else | |
| 276 | + cpu::topk_renorm_backward(probs.value(), result.grad(), k, probs.grad()); | |
| 277 | + }); | |
| 278 | + } | |
| 279 | + return result; | |
| 280 | +} | |
| 281 | + | |
| 282 | +Var row_scale(const Var& x, const Var& gates, int64_t e) { | |
| 283 | + Tensor out = Tensor::empty(x.value().shape()); | |
| 284 | + if (gpu()) metal::row_scale(x.value(), gates.value(), e, out); | |
| 285 | + else cpu::row_scale(x.value(), gates.value(), e, out); | |
| 286 | + | |
| 287 | + const bool needs = grad_needed({&x, &gates}); | |
| 288 | + Var result(std::move(out), needs); | |
| 289 | + if (needs) { | |
| 290 | + Tape::get().record([x, gates, e, result]() { | |
| 291 | + const Tensor& dout = result.grad(); | |
| 292 | + if (x.requires_grad()) { | |
| 293 | + if (gpu()) metal::row_scale_accumulate(dout, gates.value(), e, x.grad()); | |
| 294 | + else cpu::row_scale_accumulate(dout, gates.value(), e, x.grad()); | |
| 295 | + } | |
| 296 | + if (gates.requires_grad()) { | |
| 297 | + if (gpu()) | |
| 298 | + metal::row_scale_gate_backward(dout, x.value(), e, gates.grad()); | |
| 299 | + else | |
| 300 | + cpu::row_scale_gate_backward(dout, x.value(), e, gates.grad()); | |
| 301 | + } | |
| 302 | + }); | |
| 303 | + } | |
| 304 | + return result; | |
| 305 | +} | |
| 306 | + | |
| 211 | 307 | Var rmsnorm(const Var& x, const Var& w, float eps) { |
| 212 | 308 | Tensor out = Tensor::empty(x.value().shape()); |
| 213 | 309 | if (gpu()) metal::rmsnorm(x.value(), w.value(), eps, out); |
modified
src/ops/ops.h
+19 −0
@@ -32,6 +32,25 @@ Var scale(const Var& a, float s); | ||
| 32 | 32 | Var silu(const Var& x); |
| 33 | 33 | Var gelu(const Var& x); |
| 34 | 34 | |
| 35 | +// Gemma-style soft capping: cap * tanh(x / cap). Bounds logits smoothly. | |
| 36 | +Var softcap(const Var& x, float cap); | |
| 37 | + | |
| 38 | +// Row softmax over the last dim (autograd; the attention op has its own | |
| 39 | +// fused softmax — this one is for the MoE router's small rows). | |
| 40 | +Var softmax(const Var& x); | |
| 41 | + | |
| 42 | +// QAT: per-row fake quantization of a 2-D weight. Forward quantizes | |
| 43 | +// (int8 absmax / ternary absmean); backward is the straight-through | |
| 44 | +// estimator — gradients flow unchanged into the f32 master weight. | |
| 45 | +enum class QuantMode { None, Int8, Ternary }; | |
| 46 | +Var fake_quant(const Var& w, QuantMode mode); | |
| 47 | + | |
| 48 | +// MoE gating: keep top-k per row, renormalize kept entries to sum 1, zero | |
| 49 | +// the rest. Gradients flow through kept entries only. | |
| 50 | +Var topk_renorm(const Var& probs, int64_t k); | |
| 51 | +// y[i,:] = x[i,:] * gates[i,e] — weight expert e's output by its gate column. | |
| 52 | +Var row_scale(const Var& x, const Var& gates, int64_t e); | |
| 53 | + | |
| 35 | 54 | Var rmsnorm(const Var& x, const Var& w, float eps); |
| 36 | 55 | Var layernorm(const Var& x, const Var& w, const Var& b, float eps); |
| 37 | 56 | |
modified
tests/test_ops.cpp
+99 −0
@@ -264,6 +264,78 @@ void test_metal_rowops() { | ||
| 264 | 264 | } |
| 265 | 265 | } |
| 266 | 266 | |
| 267 | +void test_metal_moe_quant_ops() { | |
| 268 | + std::printf("metal QAT/MoE op parity\n"); | |
| 269 | + std::mt19937 rng(4242); | |
| 270 | + | |
| 271 | + // fake_quant: both modes, odd and wide rows | |
| 272 | + for (int mode : {0, 1}) { | |
| 273 | + forge::Tensor w = forge::Tensor::empty({33, 257}); | |
| 274 | + fill_random(w, rng, -0.2f, 0.2f); | |
| 275 | + forge::Tensor ref = forge::Tensor::empty(w.shape()); | |
| 276 | + forge::Tensor gpu = forge::Tensor::empty(w.shape()); | |
| 277 | + forge::cpu::fake_quant(w, mode, ref); | |
| 278 | + forge::metal::fake_quant(w, mode, gpu); | |
| 279 | + forge::metal::sync(); | |
| 280 | + expect_close(gpu, ref, mode == 0 ? "fake_quant int8" : "fake_quant ternary"); | |
| 281 | + } | |
| 282 | + | |
| 283 | + // topk_renorm forward + backward, row_scale family | |
| 284 | + const int64_t N = 130, E = 8, C = 37, K = 2; | |
| 285 | + forge::Tensor probs = forge::Tensor::empty({N, E}); | |
| 286 | + fill_random(probs, rng, 0.01f, 1.0f); | |
| 287 | + forge::Tensor ref = forge::Tensor::empty({N, E}); | |
| 288 | + forge::Tensor gpu = forge::Tensor::empty({N, E}); | |
| 289 | + forge::cpu::topk_renorm(probs, K, ref); | |
| 290 | + forge::metal::topk_renorm(probs, K, gpu); | |
| 291 | + forge::metal::sync(); | |
| 292 | + expect_close(gpu, ref, "topk_renorm fwd"); | |
| 293 | + | |
| 294 | + forge::Tensor dout = forge::Tensor::empty({N, E}); | |
| 295 | + fill_random(dout, rng); | |
| 296 | + forge::Tensor dref = forge::Tensor::zeros({N, E}); | |
| 297 | + forge::Tensor dgpu = forge::Tensor::zeros({N, E}); | |
| 298 | + forge::cpu::topk_renorm_backward(probs, dout, K, dref); | |
| 299 | + forge::metal::topk_renorm_backward(probs, dout, K, dgpu); | |
| 300 | + forge::metal::sync(); | |
| 301 | + expect_close(dgpu, dref, "topk_renorm bwd"); | |
| 302 | + | |
| 303 | + forge::Tensor x = forge::Tensor::empty({N, C}); | |
| 304 | + fill_random(x, rng); | |
| 305 | + forge::Tensor yref = forge::Tensor::empty({N, C}); | |
| 306 | + forge::Tensor ygpu = forge::Tensor::empty({N, C}); | |
| 307 | + forge::cpu::row_scale(x, ref, 3, yref); | |
| 308 | + forge::metal::row_scale(x, ref, 3, ygpu); | |
| 309 | + forge::metal::sync(); | |
| 310 | + expect_close(ygpu, yref, "row_scale fwd"); | |
| 311 | + | |
| 312 | + forge::Tensor aref = forge::Tensor::zeros({N, C}); | |
| 313 | + forge::Tensor agpu = forge::Tensor::zeros({N, C}); | |
| 314 | + forge::cpu::row_scale_accumulate(x, ref, 3, aref); | |
| 315 | + forge::metal::row_scale_accumulate(x, ref, 3, agpu); | |
| 316 | + forge::metal::sync(); | |
| 317 | + expect_close(agpu, aref, "row_scale accumulate"); | |
| 318 | + | |
| 319 | + forge::Tensor dxo = forge::Tensor::empty({N, C}); | |
| 320 | + fill_random(dxo, rng); | |
| 321 | + forge::Tensor gref = forge::Tensor::zeros({N, E}); | |
| 322 | + forge::Tensor ggpu = forge::Tensor::zeros({N, E}); | |
| 323 | + forge::cpu::row_scale_gate_backward(dxo, x, 3, gref); | |
| 324 | + forge::metal::row_scale_gate_backward(dxo, x, 3, ggpu); | |
| 325 | + forge::metal::sync(); | |
| 326 | + expect_close(ggpu, gref, "row_scale gate bwd"); | |
| 327 | + | |
| 328 | + // generic softmax backward (router-sized rows) | |
| 329 | + forge::Tensor sm = forge::Tensor::empty({N, E}); | |
| 330 | + forge::cpu::softmax(probs, sm); | |
| 331 | + forge::Tensor sref = forge::Tensor::zeros({N, E}); | |
| 332 | + forge::Tensor sgpu = forge::Tensor::zeros({N, E}); | |
| 333 | + forge::cpu::softmax_backward(sm, dout, sref); | |
| 334 | + forge::metal::softmax_backward(sm, dout, sgpu); | |
| 335 | + forge::metal::sync(); | |
| 336 | + expect_close(sgpu, sref, "softmax bwd"); | |
| 337 | +} | |
| 338 | + | |
| 267 | 339 | void test_batched_encoding() { |
| 268 | 340 | std::printf("batched encoding (many dispatches, one sync)\n"); |
| 269 | 341 | std::mt19937 rng(55); |
@@ -502,7 +574,34 @@ int main() { | ||
| 502 | 574 | cfg.norm = "layernorm"; cfg.activation = "gelu"; cfg.use_rope = false; |
| 503 | 575 | cfg.n_kv_heads = 2; cfg.tied_embeddings = false; |
| 504 | 576 | test_backend_parity_model("layernorm/gelu/pos-emb/mha", cfg); |
| 577 | + | |
| 578 | + // QAT: full model with fake-quantized linears (STE backward) | |
| 579 | + forge::ModelConfig qcfg; | |
| 580 | + qcfg.n_layers = 2; qcfg.d_model = 16; qcfg.n_heads = 2; qcfg.n_kv_heads = 1; | |
| 581 | + qcfg.d_ff = 24; qcfg.vocab_size = 11; qcfg.context_length = 8; | |
| 582 | + qcfg.tied_embeddings = true; | |
| 583 | + qcfg.quant = "ternary"; | |
| 584 | + test_backend_parity_model("QAT ternary linears", qcfg); | |
| 585 | + qcfg.quant = "int8"; | |
| 586 | + test_backend_parity_model("QAT int8 linears", qcfg); | |
| 587 | + | |
| 588 | + // MoE: router + top-2 of 4 experts + 1 shared expert + aux loss | |
| 589 | + forge::ModelConfig mcfg; | |
| 590 | + mcfg.n_layers = 2; mcfg.d_model = 16; mcfg.n_heads = 2; mcfg.n_kv_heads = 1; | |
| 591 | + mcfg.d_ff = 24; mcfg.vocab_size = 11; mcfg.context_length = 8; | |
| 592 | + mcfg.tied_embeddings = true; | |
| 593 | + mcfg.n_experts = 4; mcfg.moe_top_k = 2; mcfg.n_shared_experts = 1; | |
| 594 | + test_backend_parity_model("MoE 4+1shared top-2 + aux", mcfg); | |
| 595 | + | |
| 596 | + // Architecture-variant knobs: QK-norm + logit softcap + embed scaling | |
| 597 | + forge::ModelConfig vcfg; | |
| 598 | + vcfg.n_layers = 2; vcfg.d_model = 16; vcfg.n_heads = 2; vcfg.n_kv_heads = 1; | |
| 599 | + vcfg.d_ff = 24; vcfg.vocab_size = 11; vcfg.context_length = 8; | |
| 600 | + vcfg.tied_embeddings = true; | |
| 601 | + vcfg.qk_norm = true; vcfg.final_softcap = 30.0f; vcfg.scale_embeddings = true; | |
| 602 | + test_backend_parity_model("qk-norm + softcap + embed-scale", vcfg); | |
| 505 | 603 | } |
| 604 | + test_metal_moe_quant_ops(); | |
| 506 | 605 | test_flash_attention(); |
| 507 | 606 | test_adamw_kernel(); |
| 508 | 607 | |
| 509 | 608 | |