Add architecture-variant waves 1+2: train Mistral/Qwen/Gemma/OLMo-class models by config
Wave 1 (module-level): - rope refactor: kernels read a host-precomputed inv-freq table; unlocks HF-"llama3" rope scaling (rope_scale_*), per-layer theta, and NoPE layers (nope_every, SmolLM3) - attention_bias (Qwen2.5 QKV bias), head_dim decoupled from d_model/n_heads (Qwen3), relu2 activation (nanoGPT-speedrun lineage), norm_placement pre|post|sandwich (OLMo2/Gemma) Wave 2 (attention kernels): - sliding_window + sliding_global_every (Mistral / Gemma3 local:global patterns) in the CPU reference, the unfused Metal kernels, and the fused scalar flash kernels — out-of-window KV blocks are skipped, so cost scales with the window; window > 0 auto-routes off the MMA kernel - attn_softcap (Gemma2): cap*tanh on scores pre-softmax, unfused path, exact tanh' chain in all backwards - rope_theta_global for dual-theta local/global layers (Gemma3) Parity suites cover every knob (fused + unfused paths); gradcheck and overfit stay green. New demo configs: gpt-50m-mistral, gpt-50m-gemma; ARCHITECTURES.md documents the per-family config matrix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 17 changed files with +572 and −139
added
configs/gpt-50m-gemma.json
+44 −0
@@ -0,0 +1,44 @@ | ||
| 1 | +{ | |
| 2 | + "_comment": "Gemma-3-style variant of gpt-50m: 5 local (window 256, theta 10k) : 1 global (theta 1M) attention pattern, QK-norm, sandwich norm, embeddings scaled by sqrt(d_model), GELU. Same data/steps as gpt-50m.", | |
| 3 | + "model": { | |
| 4 | + "name": "gpt-50m-gemma", | |
| 5 | + "n_layers": 12, | |
| 6 | + "d_model": 576, | |
| 7 | + "n_heads": 9, | |
| 8 | + "n_kv_heads": 3, | |
| 9 | + "d_ff": 1536, | |
| 10 | + "vocab_size": 4096, | |
| 11 | + "context_length": 1024, | |
| 12 | + "tied_embeddings": true, | |
| 13 | + "use_rope": true, | |
| 14 | + "rope_theta": 10000.0, | |
| 15 | + "rope_theta_global": 1000000.0, | |
| 16 | + "norm": "rmsnorm", | |
| 17 | + "norm_eps": 1e-06, | |
| 18 | + "activation": "gelu", | |
| 19 | + "dropout": 0.0, | |
| 20 | + "qk_norm": true, | |
| 21 | + "norm_placement": "sandwich", | |
| 22 | + "scale_embeddings": true, | |
| 23 | + "sliding_window": 256, | |
| 24 | + "sliding_global_every": 6 | |
| 25 | + }, | |
| 26 | + "train": { | |
| 27 | + "lr": 0.0005, | |
| 28 | + "min_lr_ratio": 0.1, | |
| 29 | + "warmup_steps": 117, | |
| 30 | + "max_steps": 1170, | |
| 31 | + "beta1": 0.9, | |
| 32 | + "beta2": 0.95, | |
| 33 | + "eps": 1e-08, | |
| 34 | + "weight_decay": 0.1, | |
| 35 | + "grad_clip": 1.0, | |
| 36 | + "batch_size": 8, | |
| 37 | + "grad_accum_steps": 8, | |
| 38 | + "precision": "f32", | |
| 39 | + "checkpoint_every": 200, | |
| 40 | + "eval_every": 100, | |
| 41 | + "eval_batches": 20, | |
| 42 | + "seed": 1337 | |
| 43 | + } | |
| 44 | +} | |
added
configs/gpt-50m-mistral.json
+39 −0
@@ -0,0 +1,39 @@ | ||
| 1 | +{ | |
| 2 | + "_comment": "Mistral-style variant of gpt-50m: sliding-window attention (256 tokens, every layer), GQA 2:1, QKV bias off. Same data/steps as gpt-50m so the only variable is the attention pattern. The fused scalar flash kernel skips out-of-window KV blocks, so attention cost scales with the window, not the context.", | |
| 3 | + "model": { | |
| 4 | + "name": "gpt-50m-mistral", | |
| 5 | + "n_layers": 10, | |
| 6 | + "d_model": 640, | |
| 7 | + "n_heads": 10, | |
| 8 | + "n_kv_heads": 5, | |
| 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 | + "sliding_window": 256 | |
| 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/attention.metal
+25 −6
@@ -18,6 +18,8 @@ struct AttnParams { | ||
| 18 | 18 | uint B, T, H, HKV, HD; |
| 19 | 19 | float scale; |
| 20 | 20 | uint causal; |
| 21 | + uint window; // sliding window (keys kept, self incl.); 0 = full | |
| 22 | + float softcap; // cap*tanh(s/cap) pre-softmax; 0 = off | |
| 21 | 23 | }; |
| 22 | 24 | |
| 23 | 25 | kernel void attention_fwd_f32(device const float* Q [[buffer(0)]], |
@@ -40,29 +42,32 @@ kernel void attention_fwd_f32(device const float* Q [[buffer(0)]], | ||
| 40 | 42 | device const float* qi = Q + (ulong(b) * p.T + i) * Cq + h * p.HD; |
| 41 | 43 | device float* prow_base = P + ((ulong(b) * p.H + h) * p.T + i) * p.T; |
| 42 | 44 | const uint jmax = p.causal ? i : p.T - 1; |
| 45 | + const uint jmin = (p.window > 0 && i + 1 > p.window) ? i + 1 - p.window : 0; | |
| 43 | 46 | |
| 44 | 47 | float m = -FLT_MAX; |
| 45 | − for (uint j = 0; j <= jmax; ++j) { | |
| 48 | + for (uint j = jmin; j <= jmax; ++j) { | |
| 46 | 49 | device const float* kj = K + (ulong(b) * p.T + j) * Ckv + hkv * p.HD; |
| 47 | 50 | float s = 0.0f; |
| 48 | 51 | for (uint d = 0; d < p.HD; ++d) s = fma(qi[d], kj[d], s); |
| 49 | 52 | s *= p.scale; |
| 53 | + if (p.softcap > 0.0f) s = p.softcap * precise::tanh(s / p.softcap); | |
| 50 | 54 | prow_base[j] = s; |
| 51 | 55 | m = max(m, s); |
| 52 | 56 | } |
| 53 | 57 | float sum = 0.0f; |
| 54 | − for (uint j = 0; j <= jmax; ++j) { | |
| 58 | + for (uint j = jmin; j <= jmax; ++j) { | |
| 55 | 59 | const float e = exp(prow_base[j] - m); |
| 56 | 60 | prow_base[j] = e; |
| 57 | 61 | sum += e; |
| 58 | 62 | } |
| 59 | 63 | const float inv = 1.0f / sum; |
| 60 | − for (uint j = 0; j <= jmax; ++j) prow_base[j] *= inv; | |
| 64 | + for (uint j = 0; j < jmin; ++j) prow_base[j] = 0.0f; | |
| 65 | + for (uint j = jmin; j <= jmax; ++j) prow_base[j] *= inv; | |
| 61 | 66 | for (uint j = jmax + 1; j < p.T; ++j) prow_base[j] = 0.0f; |
| 62 | 67 | |
| 63 | 68 | device float* oi = O + (ulong(b) * p.T + i) * Cq + h * p.HD; |
| 64 | 69 | for (uint d = 0; d < p.HD; ++d) oi[d] = 0.0f; |
| 65 | − for (uint j = 0; j <= jmax; ++j) { | |
| 70 | + for (uint j = jmin; j <= jmax; ++j) { | |
| 66 | 71 | const float prob = prow_base[j]; |
| 67 | 72 | if (prob == 0.0f) continue; |
| 68 | 73 | device const float* vj = V + (ulong(b) * p.T + j) * Ckv + hkv * p.HD; |
@@ -126,7 +131,14 @@ kernel void attention_bwd_dq_f32(device const float* Q [[buffer(0)]], | ||
| 126 | 131 | device const float* kj = K + (ulong(b) * p.T + j) * Ckv + hkv * p.HD; |
| 127 | 132 | float dp = 0.0f; |
| 128 | 133 | for (uint d = 0; d < p.HD; ++d) dp = fma(doi[d], vj[d], dp); |
| 129 | − const float ds = prob * (dp - row_dot) * p.scale; | |
| 134 | + float ds = prob * (dp - row_dot) * p.scale; | |
| 135 | + if (p.softcap > 0.0f) { | |
| 136 | + device const float* qq = Q + (ulong(b) * p.T + i) * Cq + h * p.HD; | |
| 137 | + float s = 0.0f; | |
| 138 | + for (uint d = 0; d < p.HD; ++d) s = fma(qq[d], kj[d], s); | |
| 139 | + const float t = precise::tanh(s * p.scale / p.softcap); | |
| 140 | + ds *= 1.0f - t * t; | |
| 141 | + } | |
| 130 | 142 | for (uint d = 0; d < p.HD; ++d) dqi[d] = fma(ds, kj[d], dqi[d]); |
| 131 | 143 | } |
| 132 | 144 | } |
@@ -167,7 +179,14 @@ kernel void attention_bwd_dkv_f32(device const float* Q [[buffer(0)]], | ||
| 167 | 179 | const float row_dot = D[(ulong(b) * p.H + h) * p.T + i]; |
| 168 | 180 | float dp = 0.0f; |
| 169 | 181 | for (uint d = 0; d < p.HD; ++d) dp = fma(doi[d], vj[d], dp); |
| 170 | − const float ds = prob * (dp - row_dot) * p.scale; | |
| 182 | + float ds = prob * (dp - row_dot) * p.scale; | |
| 183 | + if (p.softcap > 0.0f) { | |
| 184 | + device const float* kk = K + (ulong(b) * p.T + j) * Ckv + hkv * p.HD; | |
| 185 | + float s = 0.0f; | |
| 186 | + for (uint d = 0; d < p.HD; ++d) s = fma(qi[d], kk[d], s); | |
| 187 | + const float t = precise::tanh(s * p.scale / p.softcap); | |
| 188 | + ds *= 1.0f - t * t; | |
| 189 | + } | |
| 171 | 190 | for (uint d = 0; d < p.HD; ++d) { |
| 172 | 191 | dvj[d] = fma(prob, doi[d], dvj[d]); |
| 173 | 192 | dkj[d] = fma(ds, qi[d], dkj[d]); |
modified
src/kernels/elementwise.metal
+22 −6
@@ -52,6 +52,21 @@ 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 | +// ReLU² (nanoGPT-speedrun lineage): out = max(x, 0)^2. | |
| 56 | +kernel void relu2_f32(device const float* x [[buffer(0)]], | |
| 57 | + device float* out [[buffer(1)]], | |
| 58 | + uint gid [[thread_position_in_grid]]) { | |
| 59 | + const float v = max(x[gid], 0.0f); | |
| 60 | + out[gid] = v * v; | |
| 61 | +} | |
| 62 | + | |
| 63 | +kernel void relu2_bwd_f32(device const float* x [[buffer(0)]], | |
| 64 | + device const float* dout [[buffer(1)]], | |
| 65 | + device float* dx [[buffer(2)]], | |
| 66 | + uint gid [[thread_position_in_grid]]) { | |
| 67 | + dx[gid] = fma(dout[gid], 2.0f * max(x[gid], 0.0f), dx[gid]); | |
| 68 | +} | |
| 69 | + | |
| 55 | 70 | // Gemma-style logit softcap: out = cap * tanh(x / cap). |
| 56 | 71 | kernel void softcap_f32(device const float* x [[buffer(0)]], |
| 57 | 72 | device float* out [[buffer(1)]], |
@@ -127,13 +142,15 @@ constant bool ROPE_INV [[function_constant(2)]]; | ||
| 127 | 142 | |
| 128 | 143 | struct RopeParams { |
| 129 | 144 | uint T, H, HD; |
| 130 | − float theta; | |
| 131 | 145 | uint pos_offset; |
| 132 | 146 | }; |
| 133 | 147 | |
| 134 | −kernel void rope_f32(device const float* x [[buffer(0)]], | |
| 135 | − device float* out [[buffer(1)]], | |
| 136 | − constant RopeParams& p [[buffer(2)]], | |
| 148 | +// freqs[k] is the per-pair inverse frequency (theta^(-2k/HD), possibly | |
| 149 | +// rescaled — llama3 rope_scaling, per-layer theta). Host-precomputed. | |
| 150 | +kernel void rope_f32(device const float* x [[buffer(0)]], | |
| 151 | + device float* out [[buffer(1)]], | |
| 152 | + constant RopeParams& p [[buffer(2)]], | |
| 153 | + device const float* freqs [[buffer(3)]], | |
| 137 | 154 | uint gid [[thread_position_in_grid]]) { |
| 138 | 155 | // gid indexes (bt, h, k) pairs: one thread per rotated pair |
| 139 | 156 | const uint pairs_per_row = p.H * (p.HD / 2); |
@@ -143,8 +160,7 @@ kernel void rope_f32(device const float* x [[buffer(0)]], | ||
| 143 | 160 | const uint k = rem % (p.HD / 2); |
| 144 | 161 | |
| 145 | 162 | const float pos = float(bt % p.T + p.pos_offset); |
| 146 | − const float freq = pow(p.theta, -2.0f * float(k) / float(p.HD)); | |
| 147 | − const float angle = pos * freq; | |
| 163 | + const float angle = pos * freqs[k]; | |
| 148 | 164 | const float c = cos(angle); |
| 149 | 165 | const float s = ROPE_INV ? -sin(angle) : sin(angle); |
| 150 | 166 | |
modified
src/kernels/flash_attention.metal
+18 −5
@@ -32,6 +32,7 @@ struct FlashParams { | ||
| 32 | 32 | uint B, T, H, HKV; |
| 33 | 33 | float scale; |
| 34 | 34 | uint causal; |
| 35 | + uint window; // sliding window (keys kept, self incl.); 0 = full attention | |
| 35 | 36 | }; |
| 36 | 37 | |
| 37 | 38 | // ---------------------------------------------------------------- forward |
@@ -90,8 +91,15 @@ kernel void flash_attn_fwd(device const float* Q [[buffer(0)]], | ||
| 90 | 91 | const uint row_max = min(qb * TGQ + TGQ - 1, p.T - 1); |
| 91 | 92 | const uint j_end = p.causal ? (row_max + 1) : p.T; |
| 92 | 93 | const uint diag_start = p.causal ? (qb * TGQ) : p.T; // blocks below need no mask |
| 93 | − | |
| 94 | − for (uint jb = 0; jb < j_end; jb += BKV) { | |
| 94 | + // Sliding window: this row's first visible key, and (uniform across the | |
| 95 | + // threadgroup) the first KV block any row here can see — earlier blocks | |
| 96 | + // are skipped outright, so compute scales with the window, not with T. | |
| 97 | + const uint jmin = (p.window > 0 && i + 1 > p.window) ? i + 1 - p.window : 0; | |
| 98 | + const uint tg_jmin = | |
| 99 | + (p.window > 0 && qb * TGQ + 1 > p.window) ? qb * TGQ + 1 - p.window : 0; | |
| 100 | + const uint jb_begin = (tg_jmin / BKV) * BKV; | |
| 101 | + | |
| 102 | + for (uint jb = jb_begin; jb < j_end; jb += BKV) { | |
| 95 | 103 | const uint block_len = min(BKV, j_end - jb); |
| 96 | 104 | |
| 97 | 105 | threadgroup_barrier(mem_flags::mem_threadgroup); |
@@ -109,6 +117,7 @@ kernel void flash_attn_fwd(device const float* Q [[buffer(0)]], | ||
| 109 | 117 | for (uint jr = 0; jr < block_len; ++jr) { |
| 110 | 118 | const uint j = jb + jr; |
| 111 | 119 | if (needs_mask && j > i) break; // rest of the block is masked too |
| 120 | + if (j < jmin) continue; // below this row's window | |
| 112 | 121 | |
| 113 | 122 | threadgroup const float* kj = Ks + jr * HD; |
| 114 | 123 | float s = 0.0f; |
@@ -174,8 +183,9 @@ kernel void flash_attn_bwd_dq(device const float* Q [[buffer(0)]], | ||
| 174 | 183 | const float li = L[(ulong(b) * p.H + h) * p.T + i]; |
| 175 | 184 | const float di = D[(ulong(b) * p.H + h) * p.T + i]; |
| 176 | 185 | const uint jmax = p.causal ? i : (p.T - 1); |
| 186 | + const uint jmin = (p.window > 0 && i + 1 > p.window) ? i + 1 - p.window : 0; | |
| 177 | 187 | |
| 178 | − for (uint j = 0; j <= jmax; ++j) { | |
| 188 | + for (uint j = jmin; j <= jmax; ++j) { | |
| 179 | 189 | device const float* kj = K + (ulong(b) * p.T + j) * Ckv + hkv * HD; |
| 180 | 190 | device const float* vj = V + (ulong(b) * p.T + j) * Ckv + hkv * HD; |
| 181 | 191 | |
@@ -225,10 +235,12 @@ kernel void flash_attn_bwd_dv(device const float* Q [[buffer(0)]], | ||
| 225 | 235 | for (uint d = 0; d < HD; ++d) dv[d] = 0.0f; |
| 226 | 236 | |
| 227 | 237 | const uint imin = p.causal ? j : 0; |
| 238 | + // Sliding window: only queries within `window` of j ever saw it. | |
| 239 | + const uint imax = (p.window > 0) ? min(p.T, j + p.window) : p.T; | |
| 228 | 240 | for (uint r = 0; r < rep; ++r) { |
| 229 | 241 | const uint h = hkv * rep + r; |
| 230 | 242 | device const float* Lh = L + (ulong(b) * p.H + h) * p.T; |
| 231 | − for (uint i = imin; i < p.T; ++i) { | |
| 243 | + for (uint i = imin; i < imax; ++i) { | |
| 232 | 244 | device const float* qi = Q + (ulong(b) * p.T + i) * Cq + h * HD; |
| 233 | 245 | device const float* doi = dO + (ulong(b) * p.T + i) * Cq + h * HD; |
| 234 | 246 | float s = 0.0f; |
@@ -271,11 +283,12 @@ kernel void flash_attn_bwd_dk(device const float* Q [[buffer(0)]], | ||
| 271 | 283 | for (uint d = 0; d < HD; ++d) dk[d] = 0.0f; |
| 272 | 284 | |
| 273 | 285 | const uint imin = p.causal ? j : 0; |
| 286 | + const uint imax = (p.window > 0) ? min(p.T, j + p.window) : p.T; | |
| 274 | 287 | for (uint r = 0; r < rep; ++r) { |
| 275 | 288 | const uint h = hkv * rep + r; |
| 276 | 289 | device const float* Lh = L + (ulong(b) * p.H + h) * p.T; |
| 277 | 290 | device const float* Dh = D + (ulong(b) * p.H + h) * p.T; |
| 278 | − for (uint i = imin; i < p.T; ++i) { | |
| 291 | + for (uint i = imin; i < imax; ++i) { | |
| 279 | 292 | device const float* qi = Q + (ulong(b) * p.T + i) * Cq + h * HD; |
| 280 | 293 | device const float* doi = dO + (ulong(b) * p.T + i) * Cq + h * HD; |
| 281 | 294 | float s = 0.0f, dp = 0.0f; |
modified
src/kernels/flash_attention_mma.metal
+1 −0
@@ -36,6 +36,7 @@ struct FlashParams { | ||
| 36 | 36 | uint B, T, H, HKV; |
| 37 | 37 | float scale; |
| 38 | 38 | uint causal; |
| 39 | + uint window; // unused here: the host routes window > 0 to the scalar kernels | |
| 39 | 40 | }; |
| 40 | 41 | |
| 41 | 42 | // Enumerators, never `constant constexpr`: see RESEARCH.md 7a — the latter is |
modified
src/nn/attention.h
+39 −19
@@ -20,24 +20,31 @@ public: | ||
| 20 | 20 | }; |
| 21 | 21 | |
| 22 | 22 | // Causal multi-head self-attention with GQA and (optional) RoPE. |
| 23 | −// No biases (llama convention). | |
| 23 | +// Per-layer flavour comes from `layer_idx` + config: NoPE layers skip RoPE | |
| 24 | +// (SmolLM3), global layers may use a different rope theta (Gemma 3), and | |
| 25 | +// head_dim may be decoupled from d_model/n_heads (Qwen3). Optional QKV bias | |
| 26 | +// (Qwen2.5, on q/k/v only — llama convention keeps wo bias-free). | |
| 24 | 27 | class CausalSelfAttention : public AttentionBase { |
| 25 | 28 | public: |
| 26 | − CausalSelfAttention(const ModelConfig& cfg, float proj_std, std::mt19937_64& rng) | |
| 29 | + CausalSelfAttention(const ModelConfig& cfg, float proj_std, std::mt19937_64& rng, | |
| 30 | + int64_t layer_idx = 0) | |
| 27 | 31 | : n_heads_(cfg.n_heads), |
| 28 | 32 | n_kv_heads_(cfg.n_kv_heads), |
| 29 | 33 | head_dim_(cfg.head_dim()), |
| 30 | − use_rope_(cfg.use_rope), | |
| 31 | − rope_theta_(cfg.rope_theta), | |
| 34 | + use_rope_(cfg.layer_uses_rope(layer_idx)), | |
| 32 | 35 | qk_norm_(cfg.qk_norm), |
| 33 | − norm_eps_(cfg.norm_eps) { | |
| 36 | + norm_eps_(cfg.norm_eps), | |
| 37 | + window_(cfg.layer_is_global(layer_idx) ? 0 : cfg.sliding_window), | |
| 38 | + attn_softcap_(cfg.attn_softcap) { | |
| 34 | 39 | const float std = 0.02f; |
| 35 | 40 | const int64_t C = cfg.d_model; |
| 36 | − const int64_t Ckv = cfg.n_kv_heads * cfg.head_dim(); | |
| 37 | − wq_ = std::make_unique<Linear>(C, C, false, std, rng); | |
| 38 | − wk_ = std::make_unique<Linear>(C, Ckv, false, std, rng); | |
| 39 | − wv_ = std::make_unique<Linear>(C, Ckv, false, std, rng); | |
| 40 | − wo_ = std::make_unique<Linear>(C, C, false, proj_std, rng); // residual projection | |
| 41 | + const int64_t Cq = cfg.n_heads * head_dim_; | |
| 42 | + const int64_t Ckv = cfg.n_kv_heads * head_dim_; | |
| 43 | + const bool bias = cfg.attention_bias; | |
| 44 | + wq_ = std::make_unique<Linear>(C, Cq, bias, std, rng); | |
| 45 | + wk_ = std::make_unique<Linear>(C, Ckv, bias, std, rng); | |
| 46 | + wv_ = std::make_unique<Linear>(C, Ckv, bias, std, rng); | |
| 47 | + wo_ = std::make_unique<Linear>(Cq, C, false, proj_std, rng); // residual projection | |
| 41 | 48 | const ops::QuantMode qm = quant_mode_from(cfg.quant); |
| 42 | 49 | wq_->set_quant(qm); |
| 43 | 50 | wk_->set_quant(qm); |
@@ -49,18 +56,28 @@ public: | ||
| 49 | 56 | absorb("wo", *wo_); |
| 50 | 57 | if (qk_norm_) { |
| 51 | 58 | q_norm_w_ = register_param("q_norm.weight", |
| 52 | − Tensor::full({cfg.head_dim()}, 1.0f)); | |
| 59 | + Tensor::full({head_dim_}, 1.0f)); | |
| 53 | 60 | k_norm_w_ = register_param("k_norm.weight", |
| 54 | − Tensor::full({cfg.head_dim()}, 1.0f)); | |
| 61 | + Tensor::full({head_dim_}, 1.0f)); | |
| 62 | + } | |
| 63 | + if (use_rope_) { | |
| 64 | + const bool global = cfg.layer_is_global(layer_idx); | |
| 65 | + const float theta = (global && cfg.rope_theta_global > 0.0f) | |
| 66 | + ? cfg.rope_theta_global | |
| 67 | + : cfg.rope_theta; | |
| 68 | + freqs_ = ops::rope_freqs(head_dim_, theta, cfg.rope_scale_factor, | |
| 69 | + cfg.rope_scale_low, cfg.rope_scale_high, | |
| 70 | + cfg.rope_scale_orig_ctx); | |
| 55 | 71 | } |
| 56 | 72 | } |
| 57 | 73 | |
| 58 | 74 | Var forward(const Var& x) const override { |
| 59 | 75 | const int64_t B = x.value().size(0), T = x.value().size(1), C = x.value().size(2); |
| 76 | + const int64_t Cq = n_heads_ * head_dim_; | |
| 60 | 77 | const int64_t Ckv = n_kv_heads_ * head_dim_; |
| 61 | 78 | |
| 62 | 79 | Var x2d = x.reshaped({B * T, C}); |
| 63 | − Var q = wq_->forward(x2d).reshaped({B, T, C}); | |
| 80 | + Var q = wq_->forward(x2d).reshaped({B, T, Cq}); | |
| 64 | 81 | Var k = wk_->forward(x2d).reshaped({B, T, Ckv}); |
| 65 | 82 | Var v = wv_->forward(x2d).reshaped({B, T, Ckv}); |
| 66 | 83 | |
@@ -68,29 +85,32 @@ public: | ||
| 68 | 85 | // Per-head RMSNorm before RoPE (Qwen3/Gemma3): normalize over |
| 69 | 86 | // head_dim by viewing heads as rows. |
| 70 | 87 | q = ops::rmsnorm(q.reshaped({B * T * n_heads_, head_dim_}), q_norm_w_, |
| 71 | − norm_eps_).reshaped({B, T, C}); | |
| 88 | + norm_eps_).reshaped({B, T, Cq}); | |
| 72 | 89 | k = ops::rmsnorm(k.reshaped({B * T * n_kv_heads_, head_dim_}), k_norm_w_, |
| 73 | 90 | norm_eps_).reshaped({B, T, Ckv}); |
| 74 | 91 | } |
| 75 | 92 | |
| 76 | 93 | if (use_rope_) { |
| 77 | − q = ops::rope(q, n_heads_, rope_theta_, 0); | |
| 78 | − k = ops::rope(k, n_kv_heads_, rope_theta_, 0); | |
| 94 | + q = ops::rope(q, n_heads_, freqs_, 0); | |
| 95 | + k = ops::rope(k, n_kv_heads_, freqs_, 0); | |
| 79 | 96 | } |
| 80 | 97 | |
| 81 | 98 | const float scale = 1.0f / std::sqrt(float(head_dim_)); |
| 82 | − Var o = ops::attention(q, k, v, n_heads_, n_kv_heads_, /*causal=*/true, scale); | |
| 83 | − return wo_->forward(o.reshaped({B * T, C})).reshaped({B, T, C}); | |
| 99 | + Var o = ops::attention(q, k, v, n_heads_, n_kv_heads_, /*causal=*/true, scale, | |
| 100 | + window_, attn_softcap_); | |
| 101 | + return wo_->forward(o.reshaped({B * T, Cq})).reshaped({B, T, C}); | |
| 84 | 102 | } |
| 85 | 103 | |
| 86 | 104 | private: |
| 87 | 105 | int64_t n_heads_, n_kv_heads_, head_dim_; |
| 88 | 106 | bool use_rope_; |
| 89 | − float rope_theta_; | |
| 90 | 107 | bool qk_norm_; |
| 91 | 108 | float norm_eps_; |
| 109 | + int64_t window_; // 0 = full attention (global layer or SWA off) | |
| 110 | + float attn_softcap_; | |
| 92 | 111 | std::unique_ptr<Linear> wq_, wk_, wv_, wo_; |
| 93 | 112 | Var q_norm_w_, k_norm_w_; // defined only when qk_norm |
| 113 | + Tensor freqs_; // [head_dim/2], defined only when use_rope | |
| 94 | 114 | }; |
| 95 | 115 | |
| 96 | 116 | } // namespace forge::nn |
modified
src/nn/config.h
+61 −10
@@ -32,6 +32,20 @@ struct ModelConfig { | ||
| 32 | 32 | bool qk_norm = false; // RMSNorm on q/k per head before RoPE (Qwen3/Gemma3) |
| 33 | 33 | float final_softcap = 0.0f; // logits = cap*tanh(logits/cap) (Gemma2: 30) |
| 34 | 34 | bool scale_embeddings = false;// x *= sqrt(d_model) after embedding (Gemma) |
| 35 | + bool attention_bias = false; // bias on q/k/v projections only (Qwen2.5) | |
| 36 | + int64_t head_dim_override = 0;// decouple head_dim from d_model/n_heads (Qwen3) | |
| 37 | + int64_t nope_every = 0; // skip RoPE every Nth layer (SmolLM3: 4) | |
| 38 | + std::string norm_placement = "pre"; // "pre" | "post" (OLMo2) | "sandwich" (Gemma) | |
| 39 | + // RoPE scaling, HF "llama3" style (0 = off). Applied to the inv-freq table. | |
| 40 | + float rope_scale_factor = 0.0f; // llama3: 32 | |
| 41 | + float rope_scale_low = 1.0f; // low_freq_factor | |
| 42 | + float rope_scale_high = 4.0f; // high_freq_factor | |
| 43 | + int64_t rope_scale_orig_ctx = 8192; // original_max_position_embeddings | |
| 44 | + // Sliding-window attention (Mistral/Gemma3). 0 = full attention everywhere. | |
| 45 | + int64_t sliding_window = 0; | |
| 46 | + int64_t sliding_global_every = 0; // every Nth layer is global (Gemma3: 6); 0 = none | |
| 47 | + float rope_theta_global = 0.0f; // theta for global layers (Gemma3: 1e6); 0 = theta | |
| 48 | + float attn_softcap = 0.0f; // cap on attention scores (Gemma2: 50) | |
| 35 | 49 | // Quantization-aware training: linear weights are fake-quantized each |
| 36 | 50 | // forward (per-row scales, straight-through estimator in backward). |
| 37 | 51 | // Master weights and the optimizer stay f32. |
@@ -44,21 +58,36 @@ struct ModelConfig { | ||
| 44 | 58 | float moe_aux_weight = 0.01f; // load-balance loss: E * sum_e mean_gate_e^2 |
| 45 | 59 | int64_t n_shared_experts = 0; // always-active experts (DeepSeek/Kimi style) |
| 46 | 60 | |
| 47 | − int64_t head_dim() const { return d_model / n_heads; } | |
| 61 | + int64_t head_dim() const { | |
| 62 | + return head_dim_override > 0 ? head_dim_override : d_model / n_heads; | |
| 63 | + } | |
| 64 | + | |
| 65 | + // True when layer i (0-based) attends globally; only meaningful with | |
| 66 | + // sliding_window > 0. | |
| 67 | + bool layer_is_global(int64_t i) const { | |
| 68 | + if (sliding_window <= 0) return true; | |
| 69 | + return sliding_global_every > 0 && (i + 1) % sliding_global_every == 0; | |
| 70 | + } | |
| 71 | + bool layer_uses_rope(int64_t i) const { | |
| 72 | + return use_rope && !(nope_every > 0 && (i + 1) % nope_every == 0); | |
| 73 | + } | |
| 48 | 74 | |
| 49 | − // Total parameter count (SwiGLU MLP has three mats, GELU has two). | |
| 75 | + // Total parameter count (SwiGLU MLP has three mats, GELU/ReLU² have two). | |
| 50 | 76 | int64_t num_params() const { |
| 51 | 77 | const int64_t hd = head_dim(); |
| 52 | − const int64_t attn = d_model * d_model // wq | |
| 53 | − + 2 * d_model * n_kv_heads * hd // wk, wv | |
| 54 | − + d_model * d_model; // wo | |
| 78 | + int64_t attn = d_model * n_heads * hd // wq | |
| 79 | + + 2 * d_model * n_kv_heads * hd // wk, wv | |
| 80 | + + n_heads * hd * d_model; // wo | |
| 81 | + if (attention_bias) attn += (n_heads + 2 * n_kv_heads) * hd; | |
| 55 | 82 | const int64_t mlp_one = (activation == "swiglu") |
| 56 | 83 | ? 3 * d_model * d_ff |
| 57 | 84 | : 2 * d_model * d_ff; |
| 58 | 85 | const int64_t mlp = n_experts > 0 |
| 59 | 86 | ? (n_experts + n_shared_experts) * mlp_one + n_experts * d_model |
| 60 | 87 | : mlp_one; // experts + router |
| 61 | − int64_t norms = (norm == "layernorm" ? 2 : 1) * d_model * (2 * n_layers + 1); | |
| 88 | + const int64_t norms_per_layer = norm_placement == "sandwich" ? 4 : 2; | |
| 89 | + int64_t norms = (norm == "layernorm" ? 2 : 1) * d_model * | |
| 90 | + (norms_per_layer * n_layers + 1); | |
| 62 | 91 | if (qk_norm) norms += 2 * head_dim() * n_layers; |
| 63 | 92 | int64_t total = n_layers * (attn + mlp) + norms + vocab_size * d_model; |
| 64 | 93 | if (!tied_embeddings) total += vocab_size * d_model; |
@@ -129,15 +158,37 @@ inline void from_json(const nlohmann::json& j, ModelConfig& c) { | ||
| 129 | 158 | c.qk_norm = j.value("qk_norm", c.qk_norm); |
| 130 | 159 | c.final_softcap = j.value("final_softcap", c.final_softcap); |
| 131 | 160 | c.scale_embeddings = j.value("scale_embeddings", c.scale_embeddings); |
| 161 | + c.attention_bias = j.value("attention_bias", c.attention_bias); | |
| 162 | + c.head_dim_override = j.value("head_dim", c.head_dim_override); | |
| 163 | + c.nope_every = j.value("nope_every", c.nope_every); | |
| 164 | + c.norm_placement = j.value("norm_placement", c.norm_placement); | |
| 165 | + c.rope_scale_factor = j.value("rope_scale_factor", c.rope_scale_factor); | |
| 166 | + c.rope_scale_low = j.value("rope_scale_low", c.rope_scale_low); | |
| 167 | + c.rope_scale_high = j.value("rope_scale_high", c.rope_scale_high); | |
| 168 | + c.rope_scale_orig_ctx = j.value("rope_scale_orig_ctx", c.rope_scale_orig_ctx); | |
| 169 | + c.sliding_window = j.value("sliding_window", c.sliding_window); | |
| 170 | + c.sliding_global_every = j.value("sliding_global_every", c.sliding_global_every); | |
| 171 | + c.rope_theta_global = j.value("rope_theta_global", c.rope_theta_global); | |
| 172 | + c.attn_softcap = j.value("attn_softcap", c.attn_softcap); | |
| 132 | 173 | |
| 133 | − if (c.d_model % c.n_heads != 0) | |
| 134 | − throw std::runtime_error("config: d_model must be divisible by n_heads"); | |
| 174 | + if (c.head_dim_override == 0 && c.d_model % c.n_heads != 0) | |
| 175 | + throw std::runtime_error("config: d_model must be divisible by n_heads " | |
| 176 | + "(or set head_dim explicitly)"); | |
| 135 | 177 | if (c.n_heads % c.n_kv_heads != 0) |
| 136 | 178 | throw std::runtime_error("config: n_heads must be divisible by n_kv_heads"); |
| 179 | + if (c.head_dim() % 2 != 0) | |
| 180 | + throw std::runtime_error("config: head_dim must be even (RoPE pairs)"); | |
| 137 | 181 | if (c.norm != "rmsnorm" && c.norm != "layernorm") |
| 138 | 182 | throw std::runtime_error("config: norm must be rmsnorm or layernorm"); |
| 139 | − if (c.activation != "swiglu" && c.activation != "gelu") | |
| 140 | − throw std::runtime_error("config: activation must be swiglu or gelu"); | |
| 183 | + if (c.activation != "swiglu" && c.activation != "gelu" && c.activation != "relu2") | |
| 184 | + throw std::runtime_error("config: activation must be swiglu, gelu or relu2"); | |
| 185 | + if (c.norm_placement != "pre" && c.norm_placement != "post" && | |
| 186 | + c.norm_placement != "sandwich") | |
| 187 | + throw std::runtime_error("config: norm_placement must be pre, post or sandwich"); | |
| 188 | + if (c.sliding_window < 0 || c.sliding_global_every < 0 || c.nope_every < 0) | |
| 189 | + throw std::runtime_error("config: window/pattern values must be >= 0"); | |
| 190 | + if (c.attn_softcap < 0.0f) | |
| 191 | + throw std::runtime_error("config: attn_softcap must be >= 0"); | |
| 141 | 192 | if (c.quant != "none" && c.quant != "int8" && c.quant != "ternary") |
| 142 | 193 | throw std::runtime_error("config: quant must be none, int8 or ternary"); |
| 143 | 194 | if (c.n_experts < 0) |
modified
src/nn/mlp.h
+5 −4
@@ -22,11 +22,11 @@ public: | ||
| 22 | 22 | virtual Var aux() const { return Var(); } |
| 23 | 23 | }; |
| 24 | 24 | |
| 25 | −// SwiGLU: w2( silu(x w1) ⊙ (x w3) ) | GELU: proj( gelu(fc(x)) ) | |
| 25 | +// SwiGLU: w2( silu(x w1) ⊙ (x w3) ) | GELU/ReLU²: w2( act(x w1) ) | |
| 26 | 26 | class MLP : public MLPBase { |
| 27 | 27 | public: |
| 28 | 28 | MLP(const ModelConfig& cfg, float proj_std, std::mt19937_64& rng) |
| 29 | − : swiglu_(cfg.activation == "swiglu") { | |
| 29 | + : swiglu_(cfg.activation == "swiglu"), relu2_(cfg.activation == "relu2") { | |
| 30 | 30 | const float std = 0.02f; |
| 31 | 31 | const int64_t C = cfg.d_model, F = cfg.d_ff; |
| 32 | 32 | w1_ = std::make_unique<Linear>(C, F, false, std, rng); |
@@ -49,11 +49,12 @@ public: | ||
| 49 | 49 | Var up = w3_->forward(x); |
| 50 | 50 | return w2_->forward(ops::mul(gate, up)); |
| 51 | 51 | } |
| 52 | − return w2_->forward(ops::gelu(w1_->forward(x))); | |
| 52 | + Var h = w1_->forward(x); | |
| 53 | + return w2_->forward(relu2_ ? ops::relu2(h) : ops::gelu(h)); | |
| 53 | 54 | } |
| 54 | 55 | |
| 55 | 56 | private: |
| 56 | − bool swiglu_; | |
| 57 | + bool swiglu_, relu2_; | |
| 57 | 58 | std::unique_ptr<Linear> w1_, w2_, w3_; |
| 58 | 59 | }; |
| 59 | 60 | |
modified
src/nn/transformer.h
+47 −8
@@ -30,12 +30,19 @@ private: | ||
| 30 | 30 | Var w_, b_; |
| 31 | 31 | }; |
| 32 | 32 | |
| 33 | −// Pre-norm block: x += attn(norm1(x)); x += mlp(norm2(x)) | |
| 33 | +// Residual block; norm placement is config-selected: | |
| 34 | +// pre (default): x += attn(norm1(x)); x += mlp(norm2(x)) | |
| 35 | +// post (OLMo 2): x += norm1(attn(x)); x += norm2(mlp(x)) | |
| 36 | +// sandwich (Gemma): x += norm1p(attn(norm1(x))); x += norm2p(mlp(norm2(x))) | |
| 34 | 37 | class TransformerBlock : public Module { |
| 35 | 38 | public: |
| 36 | − TransformerBlock(const ModelConfig& cfg, float proj_std, std::mt19937_64& rng) | |
| 37 | − : norm1_(std::make_unique<Norm>(cfg)), | |
| 38 | − attn_(std::make_unique<CausalSelfAttention>(cfg, proj_std, rng)), | |
| 39 | + TransformerBlock(const ModelConfig& cfg, float proj_std, std::mt19937_64& rng, | |
| 40 | + int64_t layer_idx = 0) | |
| 41 | + : placement_(cfg.norm_placement == "pre" ? Placement::Pre | |
| 42 | + : cfg.norm_placement == "post" ? Placement::Post | |
| 43 | + : Placement::Sandwich), | |
| 44 | + norm1_(std::make_unique<Norm>(cfg)), | |
| 45 | + attn_(std::make_unique<CausalSelfAttention>(cfg, proj_std, rng, layer_idx)), | |
| 39 | 46 | norm2_(std::make_unique<Norm>(cfg)) { |
| 40 | 47 | if (cfg.n_experts > 0) mlp_ = std::make_unique<MoEMLP>(cfg, proj_std, rng); |
| 41 | 48 | else mlp_ = std::make_unique<MLP>(cfg, proj_std, rng); |
@@ -43,23 +50,55 @@ public: | ||
| 43 | 50 | absorb("attn", *attn_); |
| 44 | 51 | absorb("norm2", *norm2_); |
| 45 | 52 | absorb("mlp", *mlp_); |
| 53 | + if (placement_ == Placement::Sandwich) { | |
| 54 | + norm1_post_ = std::make_unique<Norm>(cfg); | |
| 55 | + norm2_post_ = std::make_unique<Norm>(cfg); | |
| 56 | + absorb("norm1_post", *norm1_post_); | |
| 57 | + absorb("norm2_post", *norm2_post_); | |
| 58 | + } | |
| 46 | 59 | } |
| 47 | 60 | |
| 48 | 61 | Var forward(const Var& x) const { |
| 49 | 62 | const int64_t B = x.value().size(0), T = x.value().size(1), C = x.value().size(2); |
| 50 | − Var h = ops::add(x, attn_->forward(norm1_->forward(x))); | |
| 51 | − Var m = mlp_->forward(norm2_->forward(h).reshaped({B * T, C})); | |
| 52 | − return ops::add(h, m.reshaped({B, T, C})); | |
| 63 | + Var a; | |
| 64 | + switch (placement_) { | |
| 65 | + case Placement::Pre: a = attn_->forward(norm1_->forward(x)); break; | |
| 66 | + case Placement::Post: a = norm1_->forward(attn_->forward(x)); break; | |
| 67 | + case Placement::Sandwich: | |
| 68 | + a = norm1_post_->forward(attn_->forward(norm1_->forward(x))); | |
| 69 | + break; | |
| 70 | + } | |
| 71 | + Var h = ops::add(x, a); | |
| 72 | + Var m; | |
| 73 | + switch (placement_) { | |
| 74 | + case Placement::Pre: | |
| 75 | + m = mlp_->forward(norm2_->forward(h).reshaped({B * T, C})) | |
| 76 | + .reshaped({B, T, C}); | |
| 77 | + break; | |
| 78 | + case Placement::Post: | |
| 79 | + m = norm2_->forward( | |
| 80 | + mlp_->forward(h.reshaped({B * T, C})).reshaped({B, T, C})); | |
| 81 | + break; | |
| 82 | + case Placement::Sandwich: | |
| 83 | + m = norm2_post_->forward( | |
| 84 | + mlp_->forward(norm2_->forward(h).reshaped({B * T, C})) | |
| 85 | + .reshaped({B, T, C})); | |
| 86 | + break; | |
| 87 | + } | |
| 88 | + return ops::add(h, m); | |
| 53 | 89 | } |
| 54 | 90 | |
| 55 | 91 | // MoE load-balance loss of the last forward (undefined for dense MLP). |
| 56 | 92 | Var moe_aux() const { return mlp_->aux(); } |
| 57 | 93 | |
| 58 | 94 | private: |
| 95 | + enum class Placement { Pre, Post, Sandwich }; | |
| 96 | + Placement placement_; | |
| 59 | 97 | std::unique_ptr<Norm> norm1_; |
| 60 | 98 | std::unique_ptr<AttentionBase> attn_; |
| 61 | 99 | std::unique_ptr<Norm> norm2_; |
| 62 | 100 | std::unique_ptr<MLPBase> mlp_; |
| 101 | + std::unique_ptr<Norm> norm1_post_, norm2_post_; // sandwich only | |
| 63 | 102 | }; |
| 64 | 103 | |
| 65 | 104 | // Decoder-only transformer, entirely shaped by ModelConfig. |
@@ -79,7 +118,7 @@ public: | ||
| 79 | 118 | absorb("pos_emb", *pos_emb_); |
| 80 | 119 | } |
| 81 | 120 | for (int64_t i = 0; i < cfg.n_layers; ++i) { |
| 82 | − blocks_.push_back(std::make_unique<TransformerBlock>(cfg, proj_std, rng)); | |
| 121 | + blocks_.push_back(std::make_unique<TransformerBlock>(cfg, proj_std, rng, i)); | |
| 83 | 122 | absorb("blocks." + std::to_string(i), *blocks_.back()); |
| 84 | 123 | } |
| 85 | 124 | final_norm_ = std::make_unique<Norm>(cfg); |
modified
src/ops/cpu/cpu_ops.cpp
+46 −15
@@ -181,6 +181,23 @@ void gelu_backward(const Tensor& x, const Tensor& dout, Tensor& dx) { | ||
| 181 | 181 | } |
| 182 | 182 | } |
| 183 | 183 | |
| 184 | +void relu2(const Tensor& x, 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) { | |
| 188 | + const float v = std::max(px[i], 0.0f); | |
| 189 | + po[i] = v * v; | |
| 190 | + } | |
| 191 | +} | |
| 192 | + | |
| 193 | +void relu2_backward(const Tensor& x, const Tensor& dout, Tensor& dx) { | |
| 194 | + const float* px = x.data<float>(); | |
| 195 | + const float* pd = dout.data<float>(); | |
| 196 | + float* pdx = dx.data<float>(); | |
| 197 | + for (int64_t i = 0; i < x.numel(); ++i) | |
| 198 | + pdx[i] += pd[i] * 2.0f * std::max(px[i], 0.0f); | |
| 199 | +} | |
| 200 | + | |
| 184 | 201 | void softcap(const Tensor& x, float cap, Tensor& out) { |
| 185 | 202 | const float* px = x.data<float>(); |
| 186 | 203 | float* po = out.data<float>(); |
@@ -487,7 +504,7 @@ void embedding_backward(const Tensor& ids, const Tensor& dout, Tensor& dweight) | ||
| 487 | 504 | |
| 488 | 505 | namespace { |
| 489 | 506 | void rope_impl(const float* in, float* out, int64_t B, int64_t T, int64_t H, int64_t hd, |
| 490 | − float theta, int64_t pos_offset, bool inverse, bool accumulate) { | |
| 507 | + const float* freqs, int64_t pos_offset, bool inverse, bool accumulate) { | |
| 491 | 508 | const int64_t C = H * hd; |
| 492 | 509 | parallel_rows(B * T, [&](int64_t bt) { |
| 493 | 510 | const int64_t t = bt % T; |
@@ -496,8 +513,7 @@ void rope_impl(const float* in, float* out, int64_t B, int64_t T, int64_t H, int | ||
| 496 | 513 | float* dst = out + bt * C; |
| 497 | 514 | for (int64_t h = 0; h < H; ++h) { |
| 498 | 515 | for (int64_t k = 0; k < hd / 2; ++k) { |
| 499 | − const float freq = std::pow(theta, -2.0f * float(k) / float(hd)); | |
| 500 | − const float angle = pos * freq; | |
| 516 | + const float angle = pos * freqs[k]; | |
| 501 | 517 | const float c = std::cos(angle); |
| 502 | 518 | const float s = inverse ? -std::sin(angle) : std::sin(angle); |
| 503 | 519 | const int64_t i0 = h * hd + 2 * k; |
@@ -517,26 +533,28 @@ void rope_impl(const float* in, float* out, int64_t B, int64_t T, int64_t H, int | ||
| 517 | 533 | } |
| 518 | 534 | } // namespace |
| 519 | 535 | |
| 520 | −void rope(const Tensor& x, int64_t n_heads, float theta, int64_t pos_offset, Tensor& out) { | |
| 536 | +void rope(const Tensor& x, int64_t n_heads, const Tensor& freqs, int64_t pos_offset, | |
| 537 | + Tensor& out) { | |
| 521 | 538 | check(x.ndim() == 3, "rope: expected [B,T,C]"); |
| 522 | 539 | const int64_t hd = x.size(2) / n_heads; |
| 523 | 540 | check(hd % 2 == 0, "rope: head_dim must be even"); |
| 541 | + check(freqs.numel() == hd / 2, "rope: freqs must have head_dim/2 entries"); | |
| 524 | 542 | rope_impl(x.data<float>(), out.data<float>(), x.size(0), x.size(1), n_heads, hd, |
| 525 | − theta, pos_offset, /*inverse=*/false, /*accumulate=*/false); | |
| 543 | + freqs.data<float>(), pos_offset, /*inverse=*/false, /*accumulate=*/false); | |
| 526 | 544 | } |
| 527 | 545 | |
| 528 | −void rope_backward(const Tensor& dout, int64_t n_heads, float theta, int64_t pos_offset, | |
| 529 | − Tensor& dx) { | |
| 546 | +void rope_backward(const Tensor& dout, int64_t n_heads, const Tensor& freqs, | |
| 547 | + int64_t pos_offset, Tensor& dx) { | |
| 530 | 548 | const int64_t hd = dout.size(2) / n_heads; |
| 531 | 549 | rope_impl(dout.data<float>(), dx.data<float>(), dout.size(0), dout.size(1), n_heads, |
| 532 | − hd, theta, pos_offset, /*inverse=*/true, /*accumulate=*/true); | |
| 550 | + hd, freqs.data<float>(), pos_offset, /*inverse=*/true, /*accumulate=*/true); | |
| 533 | 551 | } |
| 534 | 552 | |
| 535 | 553 | // ---- attention ------------------------------------------------------------------- |
| 536 | 554 | |
| 537 | 555 | void attention(const Tensor& q, const Tensor& k, const Tensor& v, |
| 538 | 556 | int64_t n_heads, int64_t n_kv_heads, bool causal, float scale, |
| 539 | − Tensor& out, Tensor* probs_out) { | |
| 557 | + Tensor& out, Tensor* probs_out, int64_t window, float attn_softcap) { | |
| 540 | 558 | check(q.ndim() == 3 && k.ndim() == 3 && v.ndim() == 3, "attention: expected [B,T,C]"); |
| 541 | 559 | const int64_t B = q.size(0), T = q.size(1); |
| 542 | 560 | const int64_t hd = q.size(2) / n_heads; |
@@ -560,29 +578,34 @@ void attention(const Tensor& q, const Tensor& k, const Tensor& v, | ||
| 560 | 578 | for (int64_t i = 0; i < T; ++i) { |
| 561 | 579 | const float* qi = pq + (b * T + i) * Cq + h * hd; |
| 562 | 580 | const int64_t jmax = causal ? i : T - 1; |
| 581 | + // sliding window: attend only to the last `window` keys (incl. self) | |
| 582 | + const int64_t jmin = window > 0 ? std::max<int64_t>(0, i - window + 1) : 0; | |
| 563 | 583 | // scores (masked positions never written; treated as prob 0) |
| 564 | 584 | float m = -INFINITY; |
| 565 | − for (int64_t j = 0; j <= jmax; ++j) { | |
| 585 | + for (int64_t j = jmin; j <= jmax; ++j) { | |
| 566 | 586 | const float* kj = pk + (b * T + j) * Ckv + hkv * hd; |
| 567 | 587 | float s = 0.0f; |
| 568 | 588 | for (int64_t d = 0; d < hd; ++d) s += qi[d] * kj[d]; |
| 569 | 589 | s *= scale; |
| 590 | + if (attn_softcap > 0.0f) | |
| 591 | + s = attn_softcap * std::tanh(s / attn_softcap); | |
| 570 | 592 | P[i * T + j] = s; |
| 571 | 593 | m = std::max(m, s); |
| 572 | 594 | } |
| 573 | 595 | float sum = 0.0f; |
| 574 | − for (int64_t j = 0; j <= jmax; ++j) { | |
| 596 | + for (int64_t j = jmin; j <= jmax; ++j) { | |
| 575 | 597 | const float e = std::exp(P[i * T + j] - m); |
| 576 | 598 | P[i * T + j] = e; |
| 577 | 599 | sum += e; |
| 578 | 600 | } |
| 579 | 601 | const float inv = 1.0f / sum; |
| 580 | − for (int64_t j = 0; j <= jmax; ++j) P[i * T + j] *= inv; | |
| 602 | + for (int64_t j = 0; j < jmin; ++j) P[i * T + j] = 0.0f; | |
| 603 | + for (int64_t j = jmin; j <= jmax; ++j) P[i * T + j] *= inv; | |
| 581 | 604 | for (int64_t j = jmax + 1; j < T; ++j) P[i * T + j] = 0.0f; |
| 582 | 605 | |
| 583 | 606 | float* oi = po + (b * T + i) * Cq + h * hd; |
| 584 | 607 | for (int64_t d = 0; d < hd; ++d) oi[d] = 0.0f; |
| 585 | − for (int64_t j = 0; j <= jmax; ++j) { | |
| 608 | + for (int64_t j = jmin; j <= jmax; ++j) { | |
| 586 | 609 | const float p = P[i * T + j]; |
| 587 | 610 | const float* vj = pv + (b * T + j) * Ckv + hkv * hd; |
| 588 | 611 | for (int64_t d = 0; d < hd; ++d) oi[d] += p * vj[d]; |
@@ -594,7 +617,7 @@ void attention(const Tensor& q, const Tensor& k, const Tensor& v, | ||
| 594 | 617 | void attention_backward(const Tensor& q, const Tensor& k, const Tensor& v, |
| 595 | 618 | const Tensor& probs, const Tensor& out, const Tensor& dout, |
| 596 | 619 | int64_t n_heads, int64_t n_kv_heads, float scale, |
| 597 | − Tensor& dq, Tensor& dk, Tensor& dv) { | |
| 620 | + Tensor& dq, Tensor& dk, Tensor& dv, float attn_softcap) { | |
| 598 | 621 | const int64_t B = q.size(0), T = q.size(1); |
| 599 | 622 | const int64_t hd = q.size(2) / n_heads; |
| 600 | 623 | const int64_t rep = n_heads / n_kv_heads; |
@@ -637,7 +660,15 @@ void attention_backward(const Tensor& q, const Tensor& k, const Tensor& v, | ||
| 637 | 660 | |
| 638 | 661 | float dp = 0.0f; |
| 639 | 662 | for (int64_t d = 0; d < hd; ++d) dp += doi[d] * vj[d]; |
| 640 | − const float ds = p * (dp - row_dot) * scale; | |
| 663 | + float ds = p * (dp - row_dot) * scale; | |
| 664 | + if (attn_softcap > 0.0f) { | |
| 665 | + // chain through s' = cap·tanh(s/cap): recompute the raw | |
| 666 | + // score, factor is 1 − tanh² | |
| 667 | + float s = 0.0f; | |
| 668 | + for (int64_t d = 0; d < hd; ++d) s += qi[d] * kj[d]; | |
| 669 | + const float t = std::tanh(s * scale / attn_softcap); | |
| 670 | + ds *= 1.0f - t * t; | |
| 671 | + } | |
| 641 | 672 | |
| 642 | 673 | for (int64_t d = 0; d < hd; ++d) { |
| 643 | 674 | dvj[d] += p * doi[d]; |
modified
src/ops/cpu/cpu_ops.h
+14 −6
@@ -36,6 +36,8 @@ 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 relu2(const Tensor& x, Tensor& out); // max(x,0)^2 | |
| 40 | +void relu2_backward(const Tensor& x, const Tensor& dout, Tensor& dx); | |
| 39 | 41 | void softcap(const Tensor& x, float cap, Tensor& out); // cap*tanh(x/cap) |
| 40 | 42 | // dx += dout * (1 - (y/cap)^2) — takes the forward OUTPUT y |
| 41 | 43 | void softcap_backward(const Tensor& y, const Tensor& dout, float cap, Tensor& dx); |
@@ -62,24 +64,30 @@ void embedding_backward(const Tensor& ids, const Tensor& dout, Tensor& dweight); | ||
| 62 | 64 | // ---- RoPE (interleaved-pairs / GPT-J convention; see RESEARCH.md §7) ------- |
| 63 | 65 | // x: [B, T, H*hd]; rotates pairs (2k, 2k+1) inside each head. pos_offset |
| 64 | 66 | // shifts absolute positions (KV-cache generation). |
| 65 | −void rope(const Tensor& x, int64_t n_heads, float theta, int64_t pos_offset, Tensor& out); | |
| 67 | +// freqs: [head_dim/2] per-pair inverse frequencies, precomputed by the caller. | |
| 68 | +void rope(const Tensor& x, int64_t n_heads, const Tensor& freqs, int64_t pos_offset, | |
| 69 | + Tensor& out); | |
| 66 | 70 | // Backward of a rotation is the inverse rotation applied to dout. |
| 67 | −void rope_backward(const Tensor& dout, int64_t n_heads, float theta, int64_t pos_offset, | |
| 68 | − Tensor& dx); | |
| 71 | +void rope_backward(const Tensor& dout, int64_t n_heads, const Tensor& freqs, | |
| 72 | + int64_t pos_offset, Tensor& dx); | |
| 69 | 73 | |
| 70 | 74 | // ---- attention (composed reference: scores → mask → softmax → PV) ---------- |
| 71 | 75 | // q: [B, T, H*hd], k/v: [B, T, Hkv*hd], out: [B, T, H*hd]. GQA via |
| 72 | 76 | // kv_head = h / (H / Hkv). If probs_out is non-null it receives the softmax |
| 73 | 77 | // probabilities [B, H, T, T] (needed for backward). |
| 78 | +// window > 0: sliding-window attention (attend to the last `window` keys, | |
| 79 | +// self included). attn_softcap > 0: cap·tanh(score/cap) pre-softmax (Gemma 2). | |
| 74 | 80 | void attention(const Tensor& q, const Tensor& k, const Tensor& v, |
| 75 | 81 | int64_t n_heads, int64_t n_kv_heads, bool causal, float scale, |
| 76 | − Tensor& out, Tensor* probs_out); | |
| 82 | + Tensor& out, Tensor* probs_out, int64_t window = 0, | |
| 83 | + float attn_softcap = 0.0f); | |
| 77 | 84 | // Uses the FlashAttention-2 identity rowsum(dP ∘ P) == dO_i · O_i, so `out` |
| 78 | −// (the forward result) is required. | |
| 85 | +// (the forward result) is required. Masked positions have prob 0, so the | |
| 86 | +// window needs no explicit handling here. | |
| 79 | 87 | void attention_backward(const Tensor& q, const Tensor& k, const Tensor& v, |
| 80 | 88 | const Tensor& probs, const Tensor& out, const Tensor& dout, |
| 81 | 89 | int64_t n_heads, int64_t n_kv_heads, float scale, |
| 82 | − Tensor& dq, Tensor& dk, Tensor& dv); | |
| 90 | + Tensor& dq, Tensor& dk, Tensor& dv, float attn_softcap = 0.0f); | |
| 83 | 91 | |
| 84 | 92 | // ---- QAT / MoE -------------------------------------------------------------- |
| 85 | 93 | // Per-row fake quantization of a 2-D weight; mode 0 = int8 (absmax/127), |
modified
src/ops/metal/metal_ops.cpp
+41 −20
@@ -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 relu2(const Tensor& x, Tensor& out) { | |
| 222 | + encode_flat("relu2_f32", {&x, &out}, nullptr, 0, x.numel()); | |
| 223 | +} | |
| 224 | + | |
| 225 | +void relu2_backward(const Tensor& x, const Tensor& dout, Tensor& dx) { | |
| 226 | + encode_flat("relu2_bwd_f32", {&x, &dout, &dx}, nullptr, 0, x.numel()); | |
| 227 | +} | |
| 228 | + | |
| 221 | 229 | void softcap(const Tensor& x, float cap, Tensor& out) { |
| 222 | 230 | encode_flat("softcap_f32", {&x, &out}, &cap, sizeof(cap), x.numel()); |
| 223 | 231 | } |
@@ -437,14 +445,14 @@ void layernorm_backward(const Tensor& x, const Tensor& w, float eps, | ||
| 437 | 445 | namespace { |
| 438 | 446 | struct RopeParams { |
| 439 | 447 | uint32_t T, H, HD; |
| 440 | − float theta; | |
| 441 | 448 | uint32_t pos_offset; |
| 442 | 449 | }; |
| 443 | 450 | |
| 444 | −void rope_encode(const Tensor& in, Tensor& out, int64_t n_heads, float theta, | |
| 451 | +void rope_encode(const Tensor& in, Tensor& out, int64_t n_heads, const Tensor& freqs, | |
| 445 | 452 | int64_t pos_offset, bool inverse) { |
| 446 | 453 | const int64_t B = in.size(0), T = in.size(1), C = in.size(2); |
| 447 | 454 | const int64_t hd = C / n_heads; |
| 455 | + check(freqs.numel() == hd / 2, "rope: freqs must have head_dim/2 entries"); | |
| 448 | 456 | Device& dev = Device::get(); |
| 449 | 457 | MTL::FunctionConstantValues* constants = MTL::FunctionConstantValues::alloc()->init(); |
| 450 | 458 | // slots 0/1 belong to matmul TA/TB; RoPE uses slot 2 |
@@ -457,21 +465,22 @@ void rope_encode(const Tensor& in, Tensor& out, int64_t n_heads, float theta, | ||
| 457 | 465 | enc->setComputePipelineState(pso); |
| 458 | 466 | enc->setBuffer(in.buffer(), in.buffer_offset(), 0); |
| 459 | 467 | enc->setBuffer(out.buffer(), out.buffer_offset(), 1); |
| 460 | − RopeParams p{uint32_t(T), uint32_t(n_heads), uint32_t(hd), theta, | |
| 461 | − uint32_t(pos_offset)}; | |
| 468 | + RopeParams p{uint32_t(T), uint32_t(n_heads), uint32_t(hd), uint32_t(pos_offset)}; | |
| 462 | 469 | enc->setBytes(&p, sizeof(p), 2); |
| 470 | + enc->setBuffer(freqs.buffer(), freqs.buffer_offset(), 3); | |
| 463 | 471 | const int64_t pairs = B * T * n_heads * (hd / 2); |
| 464 | 472 | enc->dispatchThreads(MTL::Size(NS::UInteger(pairs), 1, 1), MTL::Size(256, 1, 1)); |
| 465 | 473 | } |
| 466 | 474 | } // namespace |
| 467 | 475 | |
| 468 | −void rope(const Tensor& x, int64_t n_heads, float theta, int64_t pos_offset, Tensor& out) { | |
| 469 | − rope_encode(x, out, n_heads, theta, pos_offset, /*inverse=*/false); | |
| 476 | +void rope(const Tensor& x, int64_t n_heads, const Tensor& freqs, int64_t pos_offset, | |
| 477 | + Tensor& out) { | |
| 478 | + rope_encode(x, out, n_heads, freqs, pos_offset, /*inverse=*/false); | |
| 470 | 479 | } |
| 471 | 480 | |
| 472 | −void rope_backward(const Tensor& dout, int64_t n_heads, float theta, int64_t pos_offset, | |
| 473 | − Tensor& dx) { | |
| 474 | − rope_encode(dout, dx, n_heads, theta, pos_offset, /*inverse=*/true); | |
| 481 | +void rope_backward(const Tensor& dout, int64_t n_heads, const Tensor& freqs, | |
| 482 | + int64_t pos_offset, Tensor& dx) { | |
| 483 | + rope_encode(dout, dx, n_heads, freqs, pos_offset, /*inverse=*/true); | |
| 475 | 484 | } |
| 476 | 485 | |
| 477 | 486 | // ---- embedding ------------------------------------------------------------------- |
@@ -517,12 +526,14 @@ struct AttnParams { | ||
| 517 | 526 | uint32_t B, T, H, HKV, HD; |
| 518 | 527 | float scale; |
| 519 | 528 | uint32_t causal; |
| 529 | + uint32_t window; | |
| 530 | + float softcap; | |
| 520 | 531 | }; |
| 521 | 532 | } // namespace |
| 522 | 533 | |
| 523 | 534 | void attention(const Tensor& q, const Tensor& k, const Tensor& v, |
| 524 | 535 | int64_t n_heads, int64_t n_kv_heads, bool causal, float scale, |
| 525 | − Tensor& out, Tensor* probs_out) { | |
| 536 | + Tensor& out, Tensor* probs_out, int64_t window, float attn_softcap) { | |
| 526 | 537 | check(probs_out != nullptr, "attention: probs_out required (unfused path)"); |
| 527 | 538 | const int64_t B = q.size(0), T = q.size(1); |
| 528 | 539 | const int64_t hd = q.size(2) / n_heads; |
@@ -536,7 +547,8 @@ void attention(const Tensor& q, const Tensor& k, const Tensor& v, | ||
| 536 | 547 | enc->setBuffer(out.buffer(), out.buffer_offset(), 3); |
| 537 | 548 | enc->setBuffer(probs_out->buffer(), probs_out->buffer_offset(), 4); |
| 538 | 549 | AttnParams p{uint32_t(B), uint32_t(T), uint32_t(n_heads), uint32_t(n_kv_heads), |
| 539 | − uint32_t(hd), scale, causal ? 1u : 0u}; | |
| 550 | + uint32_t(hd), scale, causal ? 1u : 0u, uint32_t(window), | |
| 551 | + attn_softcap}; | |
| 540 | 552 | enc->setBytes(&p, sizeof(p), 5); |
| 541 | 553 | enc->dispatchThreads(MTL::Size(NS::UInteger(B * n_heads * T), 1, 1), |
| 542 | 554 | MTL::Size(64, 1, 1)); |
@@ -545,12 +557,13 @@ void attention(const Tensor& q, const Tensor& k, const Tensor& v, | ||
| 545 | 557 | void attention_backward(const Tensor& q, const Tensor& k, const Tensor& v, |
| 546 | 558 | const Tensor& probs, const Tensor& out, const Tensor& dout, |
| 547 | 559 | int64_t n_heads, int64_t n_kv_heads, float scale, |
| 548 | − Tensor& dq, Tensor& dk, Tensor& dv) { | |
| 560 | + Tensor& dq, Tensor& dk, Tensor& dv, float attn_softcap) { | |
| 549 | 561 | const int64_t B = q.size(0), T = q.size(1); |
| 550 | 562 | const int64_t hd = q.size(2) / n_heads; |
| 551 | 563 | Device& dev = Device::get(); |
| 564 | + // window is irrelevant here: masked positions carry prob 0 in `probs`. | |
| 552 | 565 | AttnParams p{uint32_t(B), uint32_t(T), uint32_t(n_heads), uint32_t(n_kv_heads), |
| 553 | − uint32_t(hd), scale, 1u}; | |
| 566 | + uint32_t(hd), scale, 1u, 0u, attn_softcap}; | |
| 554 | 567 | |
| 555 | 568 | // D[b,h,i] = dO_i · O_i == rowsum(dP ∘ P): computed once per query row so |
| 556 | 569 | // neither backward kernel needs the O(T) inner recompute. |
@@ -678,6 +691,8 @@ struct FlashParams { | ||
| 678 | 691 | uint32_t B, T, H, HKV; |
| 679 | 692 | float scale; |
| 680 | 693 | uint32_t causal; |
| 694 | + uint32_t window; // sliding window; 0 = full. Scalar kernels only — the | |
| 695 | + // caller routes window > 0 away from MMA. | |
| 681 | 696 | }; |
| 682 | 697 | |
| 683 | 698 | // Must match the INSTANTIATE_FLASH list in flash_attention.metal. |
@@ -697,13 +712,15 @@ bool flash_supported(int64_t head_dim) { | ||
| 697 | 712 | |
| 698 | 713 | void flash_attention(const Tensor& q, const Tensor& k, const Tensor& v, |
| 699 | 714 | int64_t n_heads, int64_t n_kv_heads, bool causal, float scale, |
| 700 | − Tensor& out, Tensor& lse, FlashKernel kernel) { | |
| 715 | + Tensor& out, Tensor& lse, FlashKernel kernel, int64_t window) { | |
| 701 | 716 | const int64_t B = q.size(0), T = q.size(1); |
| 702 | 717 | const int64_t hd = q.size(2) / n_heads; |
| 703 | 718 | check(flash_supported(hd), "flash_attention: unsupported head_dim"); |
| 704 | 719 | |
| 705 | − if (kernel == FlashKernel::Auto) kernel = FlashKernel::MMA; | |
| 720 | + if (kernel == FlashKernel::Auto) | |
| 721 | + kernel = window > 0 ? FlashKernel::Scalar : FlashKernel::MMA; | |
| 706 | 722 | const bool mma = kernel == FlashKernel::MMA; |
| 723 | + check(!(mma && window > 0), "flash_attention: MMA kernel has no window support"); | |
| 707 | 724 | |
| 708 | 725 | Device& dev = Device::get(); |
| 709 | 726 | MTL::ComputePipelineState* pso = dev.pipeline( |
@@ -716,7 +733,7 @@ void flash_attention(const Tensor& q, const Tensor& k, const Tensor& v, | ||
| 716 | 733 | enc->setBuffer(out.buffer(), out.buffer_offset(), 3); |
| 717 | 734 | enc->setBuffer(lse.buffer(), lse.buffer_offset(), 4); |
| 718 | 735 | FlashParams p{uint32_t(B), uint32_t(T), uint32_t(n_heads), uint32_t(n_kv_heads), |
| 719 | − scale, causal ? 1u : 0u}; | |
| 736 | + scale, causal ? 1u : 0u, uint32_t(window)}; | |
| 720 | 737 | enc->setBytes(&p, sizeof(p), 5); |
| 721 | 738 | // One threadgroup per (query block, head, batch). Block size and thread |
| 722 | 739 | // count must match the kernel's enums: the scalar kernel is one thread |
@@ -736,22 +753,26 @@ void flash_attention_backward(const Tensor& q, const Tensor& k, const Tensor& v, | ||
| 736 | 753 | const Tensor& out, const Tensor& lse, const Tensor& dout, |
| 737 | 754 | int64_t n_heads, int64_t n_kv_heads, bool causal, |
| 738 | 755 | float scale, Tensor& dq, Tensor& dk, Tensor& dv, |
| 739 | − FlashKernel kernel) { | |
| 756 | + FlashKernel kernel, int64_t window) { | |
| 740 | 757 | const int64_t B = q.size(0), T = q.size(1); |
| 741 | 758 | const int64_t hd = q.size(2) / n_heads; |
| 742 | 759 | check(flash_supported(hd), "flash_attention_backward: unsupported head_dim"); |
| 743 | − if (kernel == FlashKernel::Auto) kernel = FlashKernel::MMA; | |
| 760 | + if (kernel == FlashKernel::Auto) | |
| 761 | + kernel = window > 0 ? FlashKernel::Scalar : FlashKernel::MMA; | |
| 744 | 762 | const bool mma = kernel == FlashKernel::MMA; |
| 763 | + check(!(mma && window > 0), | |
| 764 | + "flash_attention_backward: MMA kernel has no window support"); | |
| 745 | 765 | |
| 746 | 766 | Device& dev = Device::get(); |
| 747 | 767 | FlashParams p{uint32_t(B), uint32_t(T), uint32_t(n_heads), uint32_t(n_kv_heads), |
| 748 | − scale, causal ? 1u : 0u}; | |
| 768 | + scale, causal ? 1u : 0u, uint32_t(window)}; | |
| 749 | 769 | |
| 750 | 770 | // D[b,h,i] = dO_i . O_i (shared with the unfused path). |
| 751 | 771 | Tensor d_term = Tensor::empty({B, n_heads, T}); |
| 752 | 772 | { |
| 753 | 773 | AttnParams ap{uint32_t(B), uint32_t(T), uint32_t(n_heads), |
| 754 | − uint32_t(n_kv_heads), uint32_t(hd), scale, causal ? 1u : 0u}; | |
| 774 | + uint32_t(n_kv_heads), uint32_t(hd), scale, causal ? 1u : 0u, | |
| 775 | + 0u, 0.0f}; | |
| 755 | 776 | MTL::ComputePipelineState* pso = dev.pipeline("attention_bwd_d_f32"); |
| 756 | 777 | MTL::ComputeCommandEncoder* enc = Stream::get().encoder(); |
| 757 | 778 | enc->setComputePipelineState(pso); |
modified
src/ops/metal/metal_ops.h
+19 −7
@@ -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 relu2(const Tensor& x, Tensor& out); // max(x,0)^2 | |
| 92 | +void relu2_backward(const Tensor& x, const Tensor& dout, Tensor& dx); | |
| 91 | 93 | void softcap(const Tensor& x, float cap, Tensor& out); // cap*tanh(x/cap) |
| 92 | 94 | void softcap_backward(const Tensor& y, const Tensor& dout, float cap, Tensor& dx); |
| 93 | 95 | |
@@ -106,20 +108,26 @@ void rmsnorm_backward(const Tensor& x, const Tensor& w, float eps, | ||
| 106 | 108 | void layernorm_backward(const Tensor& x, const Tensor& w, float eps, |
| 107 | 109 | const Tensor& dout, Tensor& dx, Tensor& dw, Tensor& db); |
| 108 | 110 | |
| 109 | −void rope(const Tensor& x, int64_t n_heads, float theta, int64_t pos_offset, Tensor& out); | |
| 110 | −void rope_backward(const Tensor& dout, int64_t n_heads, float theta, int64_t pos_offset, | |
| 111 | − Tensor& dx); | |
| 111 | +// freqs: [head_dim/2] per-pair inverse frequencies, host-precomputed | |
| 112 | +// (fixed theta, llama3 rope-scaling, per-layer theta — all just tables). | |
| 113 | +void rope(const Tensor& x, int64_t n_heads, const Tensor& freqs, int64_t pos_offset, | |
| 114 | + Tensor& out); | |
| 115 | +void rope_backward(const Tensor& dout, int64_t n_heads, const Tensor& freqs, | |
| 116 | + int64_t pos_offset, Tensor& dx); | |
| 112 | 117 | |
| 113 | 118 | void embedding(const Tensor& weight, const Tensor& ids, Tensor& out); // ids i32 |
| 114 | 119 | void embedding_backward(const Tensor& ids, const Tensor& dout, Tensor& dweight); |
| 115 | 120 | |
| 121 | +// window > 0: sliding-window attention; attn_softcap > 0: cap·tanh pre-softmax | |
| 122 | +// (unfused path only — the fused kernels don't support softcap). | |
| 116 | 123 | void attention(const Tensor& q, const Tensor& k, const Tensor& v, |
| 117 | 124 | int64_t n_heads, int64_t n_kv_heads, bool causal, float scale, |
| 118 | − Tensor& out, Tensor* probs_out); | |
| 125 | + Tensor& out, Tensor* probs_out, int64_t window = 0, | |
| 126 | + float attn_softcap = 0.0f); | |
| 119 | 127 | void attention_backward(const Tensor& q, const Tensor& k, const Tensor& v, |
| 120 | 128 | const Tensor& probs, const Tensor& out, const Tensor& dout, |
| 121 | 129 | int64_t n_heads, int64_t n_kv_heads, float scale, |
| 122 | − Tensor& dq, Tensor& dk, Tensor& dv); | |
| 130 | + Tensor& dq, Tensor& dk, Tensor& dv, float attn_softcap = 0.0f); | |
| 123 | 131 | |
| 124 | 132 | // Fused (flash) attention: stores no T x T probabilities, only the per-row |
| 125 | 133 | // logsumexp `lse` [B, n_heads, T] that the backward re-expands. Supported for |
@@ -128,14 +136,18 @@ bool flash_supported(int64_t head_dim); | ||
| 128 | 136 | // Scalar: one thread per query row. MMA: simdgroup_matrix 8x8 tiles. Same |
| 129 | 137 | // outputs; Auto picks MMA where supported. |
| 130 | 138 | enum 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. | |
| 131 | 141 | void flash_attention(const Tensor& q, const Tensor& k, const Tensor& v, |
| 132 | 142 | int64_t n_heads, int64_t n_kv_heads, bool causal, float scale, |
| 133 | − Tensor& out, Tensor& lse, FlashKernel kernel = FlashKernel::Auto); | |
| 143 | + Tensor& out, Tensor& lse, FlashKernel kernel = FlashKernel::Auto, | |
| 144 | + int64_t window = 0); | |
| 134 | 145 | void flash_attention_backward(const Tensor& q, const Tensor& k, const Tensor& v, |
| 135 | 146 | const Tensor& out, const Tensor& lse, const Tensor& dout, |
| 136 | 147 | int64_t n_heads, int64_t n_kv_heads, bool causal, |
| 137 | 148 | float scale, Tensor& dq, Tensor& dk, Tensor& dv, |
| 138 | − FlashKernel kernel = FlashKernel::Auto); | |
| 149 | + FlashKernel kernel = FlashKernel::Auto, | |
| 150 | + int64_t window = 0); | |
| 139 | 151 | |
| 140 | 152 | // losses: [N] per-row buffer; loss_out: [1] mean over n_valid. dlogits |
| 141 | 153 | // optional (accumulated). ids must be i32. |
modified
src/ops/ops.cpp
+87 −30
@@ -4,6 +4,8 @@ | ||
| 4 | 4 | #include "ops/cpu/cpu_ops.h" |
| 5 | 5 | #include "ops/metal/metal_ops.h" |
| 6 | 6 | |
| 7 | +#include <cmath> | |
| 8 | + | |
| 7 | 9 | namespace forge::ops { |
| 8 | 10 | |
| 9 | 11 | namespace { |
@@ -208,6 +210,23 @@ Var gelu(const Var& x) { | ||
| 208 | 210 | return result; |
| 209 | 211 | } |
| 210 | 212 | |
| 213 | +Var relu2(const Var& x) { | |
| 214 | + Tensor out = Tensor::empty(x.value().shape()); | |
| 215 | + if (gpu()) metal::relu2(x.value(), out); | |
| 216 | + else cpu::relu2(x.value(), out); | |
| 217 | + | |
| 218 | + const bool needs = grad_needed({&x}); | |
| 219 | + Var result(std::move(out), needs); | |
| 220 | + if (needs) { | |
| 221 | + Tape::get().record([x, result]() { | |
| 222 | + if (!x.requires_grad()) return; | |
| 223 | + if (gpu()) metal::relu2_backward(x.value(), result.grad(), x.grad()); | |
| 224 | + else cpu::relu2_backward(x.value(), result.grad(), x.grad()); | |
| 225 | + }); | |
| 226 | + } | |
| 227 | + return result; | |
| 228 | +} | |
| 229 | + | |
| 211 | 230 | Var softcap(const Var& x, float cap) { |
| 212 | 231 | Tensor out = Tensor::empty(x.value().shape()); |
| 213 | 232 | if (gpu()) metal::softcap(x.value(), cap, out); |
@@ -370,25 +389,56 @@ Var embedding(const Var& weight, const Tensor& ids) { | ||
| 370 | 389 | return result; |
| 371 | 390 | } |
| 372 | 391 | |
| 373 | −Var rope(const Var& x, int64_t n_heads, float theta, int64_t pos_offset) { | |
| 392 | +Tensor rope_freqs(int64_t head_dim, float theta, float scale_factor, | |
| 393 | + float low_freq_factor, float high_freq_factor, int64_t original_ctx) { | |
| 394 | + Tensor freqs = Tensor::empty({head_dim / 2}); | |
| 395 | + float* f = freqs.data<float>(); | |
| 396 | + constexpr float kTwoPi = 6.28318530717958647692f; | |
| 397 | + for (int64_t k = 0; k < head_dim / 2; ++k) { | |
| 398 | + float inv = std::pow(theta, -2.0f * float(k) / float(head_dim)); | |
| 399 | + if (scale_factor > 0.0f) { | |
| 400 | + // HF "llama3" rope scaling (modeling_rope_utils.py). | |
| 401 | + const float wavelen = kTwoPi / inv; | |
| 402 | + const float lo = float(original_ctx) / low_freq_factor; // long waves | |
| 403 | + const float hi = float(original_ctx) / high_freq_factor; // short waves | |
| 404 | + if (wavelen > lo) { | |
| 405 | + inv /= scale_factor; | |
| 406 | + } else if (wavelen > hi) { | |
| 407 | + const float s = (float(original_ctx) / wavelen - low_freq_factor) / | |
| 408 | + (high_freq_factor - low_freq_factor); | |
| 409 | + inv = (1.0f - s) * inv / scale_factor + s * inv; | |
| 410 | + } | |
| 411 | + } | |
| 412 | + f[k] = inv; | |
| 413 | + } | |
| 414 | + return freqs; | |
| 415 | +} | |
| 416 | + | |
| 417 | +Var rope(const Var& x, int64_t n_heads, const Tensor& freqs, int64_t pos_offset) { | |
| 374 | 418 | Tensor out = Tensor::empty(x.value().shape()); |
| 375 | − if (gpu()) metal::rope(x.value(), n_heads, theta, pos_offset, out); | |
| 376 | − else cpu::rope(x.value(), n_heads, theta, pos_offset, out); | |
| 419 | + if (gpu()) metal::rope(x.value(), n_heads, freqs, pos_offset, out); | |
| 420 | + else cpu::rope(x.value(), n_heads, freqs, pos_offset, out); | |
| 377 | 421 | |
| 378 | 422 | const bool needs = grad_needed({&x}); |
| 379 | 423 | Var result(std::move(out), needs); |
| 380 | 424 | if (needs) { |
| 381 | − Tape::get().record([x, n_heads, theta, pos_offset, result]() { | |
| 425 | + Tape::get().record([x, n_heads, freqs, pos_offset, result]() { | |
| 382 | 426 | if (!x.requires_grad()) return; |
| 383 | − if (gpu()) metal::rope_backward(result.grad(), n_heads, theta, pos_offset, x.grad()); | |
| 384 | − else cpu::rope_backward(result.grad(), n_heads, theta, pos_offset, x.grad()); | |
| 427 | + if (gpu()) metal::rope_backward(result.grad(), n_heads, freqs, pos_offset, x.grad()); | |
| 428 | + else cpu::rope_backward(result.grad(), n_heads, freqs, pos_offset, x.grad()); | |
| 385 | 429 | }); |
| 386 | 430 | } |
| 387 | 431 | return result; |
| 388 | 432 | } |
| 389 | 433 | |
| 434 | +Var rope(const Var& x, int64_t n_heads, float theta, int64_t pos_offset) { | |
| 435 | + const int64_t hd = x.value().size(2) / n_heads; | |
| 436 | + return rope(x, n_heads, rope_freqs(hd, theta), pos_offset); | |
| 437 | +} | |
| 438 | + | |
| 390 | 439 | Var attention(const Var& q, const Var& k, const Var& v, |
| 391 | − int64_t n_heads, int64_t n_kv_heads, bool causal, float scale) { | |
| 440 | + int64_t n_heads, int64_t n_kv_heads, bool causal, float scale, | |
| 441 | + int64_t window, float attn_softcap) { | |
| 392 | 442 | const int64_t B = q.value().size(0), T = q.value().size(1); |
| 393 | 443 | const int64_t head_dim = q.value().size(2) / n_heads; |
| 394 | 444 | Tensor out = Tensor::empty(q.value().shape()); |
@@ -396,14 +446,17 @@ Var attention(const Var& q, const Var& k, const Var& v, | ||
| 396 | 446 | |
| 397 | 447 | // Fused path: keeps only the per-row logsumexp instead of a [B,H,T,T] |
| 398 | 448 | // probability tensor, so memory is linear in T rather than quadratic. |
| 399 | − if (gpu() && metal::flash_supported(head_dim)) { | |
| 449 | + // Softcap needs the materialized-probs path (the fused kernels' online | |
| 450 | + // softmax has no cap hook yet). | |
| 451 | + if (gpu() && metal::flash_supported(head_dim) && attn_softcap <= 0.0f) { | |
| 400 | 452 | Tensor lse = Tensor::empty({B, n_heads, T}); |
| 401 | 453 | metal::flash_attention(q.value(), k.value(), v.value(), n_heads, n_kv_heads, |
| 402 | − causal, scale, out, lse); | |
| 454 | + causal, scale, out, lse, metal::FlashKernel::Auto, | |
| 455 | + window); | |
| 403 | 456 | Var result(std::move(out), needs); |
| 404 | 457 | if (needs) { |
| 405 | 458 | Tape::get().record( |
| 406 | − [q, k, v, lse, n_heads, n_kv_heads, causal, scale, result]() { | |
| 459 | + [q, k, v, lse, n_heads, n_kv_heads, causal, scale, window, result]() { | |
| 407 | 460 | Tensor dq_scratch, dk_scratch, dv_scratch; |
| 408 | 461 | Tensor& dq = q.requires_grad() ? q.grad() |
| 409 | 462 | : (dq_scratch = Tensor::zeros(q.value().shape())); |
@@ -414,7 +467,8 @@ Var attention(const Var& q, const Var& k, const Var& v, | ||
| 414 | 467 | metal::flash_attention_backward(q.value(), k.value(), v.value(), |
| 415 | 468 | result.value(), lse, result.grad(), |
| 416 | 469 | n_heads, n_kv_heads, causal, scale, |
| 417 | − dq, dk, dv); | |
| 470 | + dq, dk, dv, | |
| 471 | + metal::FlashKernel::Auto, window); | |
| 418 | 472 | }); |
| 419 | 473 | } |
| 420 | 474 | return result; |
@@ -423,30 +477,33 @@ Var attention(const Var& q, const Var& k, const Var& v, | ||
| 423 | 477 | Tensor probs = Tensor::empty({B, n_heads, T, T}); |
| 424 | 478 | if (gpu()) |
| 425 | 479 | metal::attention(q.value(), k.value(), v.value(), n_heads, n_kv_heads, causal, |
| 426 | − scale, out, &probs); | |
| 480 | + scale, out, &probs, window, attn_softcap); | |
| 427 | 481 | else |
| 428 | 482 | cpu::attention(q.value(), k.value(), v.value(), n_heads, n_kv_heads, causal, |
| 429 | − scale, out, &probs); | |
| 483 | + scale, out, &probs, window, attn_softcap); | |
| 430 | 484 | |
| 431 | 485 | Var result(std::move(out), needs); |
| 432 | 486 | if (needs) { |
| 433 | − Tape::get().record([q, k, v, probs, n_heads, n_kv_heads, scale, result]() { | |
| 434 | − Tensor dq_scratch, dk_scratch, dv_scratch; | |
| 435 | − Tensor& dq = q.requires_grad() ? q.grad() | |
| 436 | − : (dq_scratch = Tensor::zeros(q.value().shape())); | |
| 437 | − Tensor& dk = k.requires_grad() ? k.grad() | |
| 438 | − : (dk_scratch = Tensor::zeros(k.value().shape())); | |
| 439 | − Tensor& dv = v.requires_grad() ? v.grad() | |
| 440 | − : (dv_scratch = Tensor::zeros(v.value().shape())); | |
| 441 | − if (gpu()) | |
| 442 | − metal::attention_backward(q.value(), k.value(), v.value(), probs, | |
| 443 | − result.value(), result.grad(), n_heads, | |
| 444 | − n_kv_heads, scale, dq, dk, dv); | |
| 445 | − else | |
| 446 | − cpu::attention_backward(q.value(), k.value(), v.value(), probs, | |
| 447 | − result.value(), result.grad(), n_heads, | |
| 448 | − n_kv_heads, scale, dq, dk, dv); | |
| 449 | − }); | |
| 487 | + Tape::get().record( | |
| 488 | + [q, k, v, probs, n_heads, n_kv_heads, scale, attn_softcap, result]() { | |
| 489 | + Tensor dq_scratch, dk_scratch, dv_scratch; | |
| 490 | + Tensor& dq = q.requires_grad() ? q.grad() | |
| 491 | + : (dq_scratch = Tensor::zeros(q.value().shape())); | |
| 492 | + Tensor& dk = k.requires_grad() ? k.grad() | |
| 493 | + : (dk_scratch = Tensor::zeros(k.value().shape())); | |
| 494 | + Tensor& dv = v.requires_grad() ? v.grad() | |
| 495 | + : (dv_scratch = Tensor::zeros(v.value().shape())); | |
| 496 | + if (gpu()) | |
| 497 | + metal::attention_backward(q.value(), k.value(), v.value(), probs, | |
| 498 | + result.value(), result.grad(), n_heads, | |
| 499 | + n_kv_heads, scale, dq, dk, dv, | |
| 500 | + attn_softcap); | |
| 501 | + else | |
| 502 | + cpu::attention_backward(q.value(), k.value(), v.value(), probs, | |
| 503 | + result.value(), result.grad(), n_heads, | |
| 504 | + n_kv_heads, scale, dq, dk, dv, | |
| 505 | + attn_softcap); | |
| 506 | + }); | |
| 450 | 507 | } |
| 451 | 508 | return result; |
| 452 | 509 | } |
modified
src/ops/ops.h
+18 −3
@@ -31,6 +31,7 @@ Var scale(const Var& a, float s); | ||
| 31 | 31 | |
| 32 | 32 | Var silu(const Var& x); |
| 33 | 33 | Var gelu(const Var& x); |
| 34 | +Var relu2(const Var& x); // max(x,0)^2 — nanoGPT-speedrun activation | |
| 34 | 35 | |
| 35 | 36 | // Gemma-style soft capping: cap * tanh(x / cap). Bounds logits smoothly. |
| 36 | 37 | Var softcap(const Var& x, float cap); |
@@ -57,12 +58,26 @@ Var layernorm(const Var& x, const Var& w, const Var& b, float eps); | ||
| 57 | 58 | // weight [V,C], ids [B,T] (u16/i32) → [B,T,C] |
| 58 | 59 | Var embedding(const Var& weight, const Tensor& ids); |
| 59 | 60 | |
| 60 | −// x [B,T,H*hd], interleaved-pairs RoPE per head | |
| 61 | +// Build the [head_dim/2] inverse-frequency table: theta^(-2k/hd), optionally | |
| 62 | +// rescaled HF-"llama3" style (scale_factor > 0): high-frequency components | |
| 63 | +// untouched, low-frequency divided by factor, smooth blend between. | |
| 64 | +Tensor rope_freqs(int64_t head_dim, float theta, float scale_factor = 0.0f, | |
| 65 | + float low_freq_factor = 1.0f, float high_freq_factor = 4.0f, | |
| 66 | + int64_t original_ctx = 8192); | |
| 67 | + | |
| 68 | +// x [B,T,H*hd], interleaved-pairs RoPE per head. freqs from rope_freqs() | |
| 69 | +// (callers cache it — one table per layer flavour). | |
| 70 | +Var rope(const Var& x, int64_t n_heads, const Tensor& freqs, int64_t pos_offset = 0); | |
| 71 | +// Convenience: plain-theta table built per call (tests / one-offs). | |
| 61 | 72 | Var rope(const Var& x, int64_t n_heads, float theta, int64_t pos_offset = 0); |
| 62 | 73 | |
| 63 | −// q [B,T,H*hd], k/v [B,T,Hkv*hd] → [B,T,H*hd]; causal + GQA | |
| 74 | +// q [B,T,H*hd], k/v [B,T,Hkv*hd] → [B,T,H*hd]; causal + GQA. | |
| 75 | +// window > 0: sliding-window attention (Mistral/Gemma3) — fused scalar path. | |
| 76 | +// attn_softcap > 0: cap·tanh(score/cap) pre-softmax (Gemma2) — routes to the | |
| 77 | +// unfused path (probs materialized), so reserve it for short contexts. | |
| 64 | 78 | Var attention(const Var& q, const Var& k, const Var& v, |
| 65 | − int64_t n_heads, int64_t n_kv_heads, bool causal, float scale); | |
| 79 | + int64_t n_heads, int64_t n_kv_heads, bool causal, float scale, | |
| 80 | + int64_t window = 0, float attn_softcap = 0.0f); | |
| 66 | 81 | |
| 67 | 82 | // logits [N,V], targets [N] (i32/u16, ignore_index=-1) → scalar mean loss |
| 68 | 83 | Var cross_entropy(const Var& logits, const Tensor& targets); |
modified
tests/test_ops.cpp
+46 −0
@@ -600,6 +600,52 @@ int main() { | ||
| 600 | 600 | vcfg.tied_embeddings = true; |
| 601 | 601 | vcfg.qk_norm = true; vcfg.final_softcap = 30.0f; vcfg.scale_embeddings = true; |
| 602 | 602 | test_backend_parity_model("qk-norm + softcap + embed-scale", vcfg); |
| 603 | + | |
| 604 | + // Wave-1 knobs: ReLU² + QKV bias + decoupled head_dim + NoPE + rope scaling | |
| 605 | + forge::ModelConfig w1; | |
| 606 | + w1.n_layers = 2; w1.d_model = 16; w1.n_heads = 2; w1.n_kv_heads = 1; | |
| 607 | + w1.d_ff = 24; w1.vocab_size = 11; w1.context_length = 8; | |
| 608 | + w1.tied_embeddings = true; | |
| 609 | + w1.activation = "relu2"; w1.attention_bias = true; | |
| 610 | + w1.head_dim_override = 10; w1.nope_every = 2; | |
| 611 | + w1.rope_scale_factor = 8.0f; w1.rope_scale_orig_ctx = 4; | |
| 612 | + test_backend_parity_model("relu2 + qkv-bias + head_dim10 + nope + rope-scale", w1); | |
| 613 | + | |
| 614 | + // Norm placements: OLMo2 post and Gemma sandwich | |
| 615 | + forge::ModelConfig np; | |
| 616 | + np.n_layers = 2; np.d_model = 16; np.n_heads = 2; np.n_kv_heads = 2; | |
| 617 | + np.d_ff = 24; np.vocab_size = 11; np.context_length = 8; | |
| 618 | + np.tied_embeddings = true; | |
| 619 | + np.norm_placement = "post"; | |
| 620 | + test_backend_parity_model("post-norm (OLMo2)", np); | |
| 621 | + np.norm_placement = "sandwich"; | |
| 622 | + test_backend_parity_model("sandwich norm (Gemma)", np); | |
| 623 | + | |
| 624 | + // Wave-2: sliding window (Mistral) through the fused scalar path | |
| 625 | + // (head_dim 64), + local/global pattern, + Gemma3 dual rope theta | |
| 626 | + forge::ModelConfig sw; | |
| 627 | + sw.n_layers = 3; sw.d_model = 128; sw.n_heads = 2; sw.n_kv_heads = 1; | |
| 628 | + sw.d_ff = 96; sw.vocab_size = 11; sw.context_length = 8; | |
| 629 | + sw.tied_embeddings = true; | |
| 630 | + sw.sliding_window = 3; sw.sliding_global_every = 2; | |
| 631 | + sw.rope_theta_global = 1e6f; | |
| 632 | + test_backend_parity_model("sliding-window 3 + global-every-2 [hd64]", sw); | |
| 633 | + | |
| 634 | + // Wave-2: attention softcap (Gemma2) — unfused path on both backends | |
| 635 | + forge::ModelConfig sc; | |
| 636 | + sc.n_layers = 2; sc.d_model = 16; sc.n_heads = 2; sc.n_kv_heads = 1; | |
| 637 | + sc.d_ff = 24; sc.vocab_size = 11; sc.context_length = 8; | |
| 638 | + sc.tied_embeddings = true; | |
| 639 | + sc.attn_softcap = 20.0f; | |
| 640 | + test_backend_parity_model("attn softcap 20 (Gemma2)", sc); | |
| 641 | + | |
| 642 | + // Sliding window through the UNFUSED kernel too (head_dim 8) | |
| 643 | + forge::ModelConfig swu; | |
| 644 | + swu.n_layers = 2; swu.d_model = 16; swu.n_heads = 2; swu.n_kv_heads = 2; | |
| 645 | + swu.d_ff = 24; swu.vocab_size = 11; swu.context_length = 8; | |
| 646 | + swu.tied_embeddings = true; | |
| 647 | + swu.sliding_window = 3; swu.attn_softcap = 20.0f; | |
| 648 | + test_backend_parity_model("sliding-window 3 + softcap [unfused hd8]", swu); | |
| 603 | 649 | } |
| 604 | 650 | test_metal_moe_quant_ops(); |
| 605 | 651 | test_flash_attention(); |
| 606 | 652 | |