Skip to main content

forge/backend/
cpu.rs

1//! CPU reference backend. Mathematically identical semantics to the WGSL
2//! kernels; used for testing, verification, and gradient checking.
3
4#[cfg(not(target_arch = "wasm32"))]
5use rayon::prelude::*;
6
7pub fn add(a: &[f32], b: &[f32]) -> Vec<f32> {
8    a.iter().zip(b).map(|(x, y)| x + y).collect()
9}
10
11/// GELU, tanh approximation ("gelu_new") — the variant GPT-2 was trained with.
12pub fn gelu(x: &[f32]) -> Vec<f32> {
13    const C: f32 = 0.797_884_6; // sqrt(2/pi)
14    x.iter()
15        .map(|&v| 0.5 * v * (1.0 + (C * (v + 0.044715 * v * v * v)).tanh()))
16        .collect()
17}
18
19/// Batched matmul matching `shaders/matmul.wgsl`:
20/// `C[b] = alpha * A[b] @ B[b]` (+ bias broadcast over rows).
21/// `a_stride`/`b_stride` are per-batch element strides (0 broadcasts).
22/// With `trans_a`, `A[b]` is stored `[k, m]`; with `trans_b`, `B[b]` is stored
23/// `[n, k]` instead of `[k, n]`.
24#[allow(clippy::too_many_arguments)]
25pub fn matmul(
26    a: &[f32],
27    b: &[f32],
28    bias: Option<&[f32]>,
29    m: usize,
30    k: usize,
31    n: usize,
32    batch: usize,
33    a_stride: usize,
34    b_stride: usize,
35    trans_a: bool,
36    trans_b: bool,
37    alpha: f32,
38) -> Vec<f32> {
39    let mut out = vec![0.0f32; batch * m * n];
40    // rayon on native; serial on wasm32 (no threads there).
41    #[cfg(not(target_arch = "wasm32"))]
42    let rows = out.par_chunks_mut(n);
43    #[cfg(target_arch = "wasm32")]
44    let rows = out.chunks_mut(n);
45    rows.enumerate().for_each(|(row_idx, orow)| {
46        let bat = row_idx / m;
47        let i = row_idx % m;
48        let a_base = bat * a_stride;
49        let b_base = bat * b_stride;
50        let a_at = |kk: usize| {
51            if trans_a {
52                a[a_base + kk * m + i]
53            } else {
54                a[a_base + i * k + kk]
55            }
56        };
57        if trans_b {
58            for (j, o) in orow.iter_mut().enumerate() {
59                let b_row = &b[b_base + j * k..b_base + (j + 1) * k];
60                let dot: f32 = b_row
61                    .iter()
62                    .enumerate()
63                    .map(|(kk, &bv)| a_at(kk) * bv)
64                    .sum();
65                *o = dot * alpha;
66            }
67        } else {
68            for kk in 0..k {
69                let av = a_at(kk);
70                let b_row = &b[b_base + kk * n..b_base + (kk + 1) * n];
71                for (o, &bv) in orow.iter_mut().zip(b_row) {
72                    *o += av * bv;
73                }
74            }
75            if alpha != 1.0 {
76                for o in orow.iter_mut() {
77                    *o *= alpha;
78                }
79            }
80        }
81        if let Some(bias) = bias {
82            for (o, &bb) in orow.iter_mut().zip(bias) {
83                *o += bb;
84            }
85        }
86    });
87    out
88}
89
90/// Append src [h, t, hd] into dst [h, cap, hd] at row offset `len` per head.
91pub fn kv_append(
92    dst: &mut [f32],
93    src: &[f32],
94    h: usize,
95    t: usize,
96    hd: usize,
97    cap: usize,
98    len: usize,
99) {
100    for hh in 0..h {
101        for tt in 0..t {
102            let d0 = hh * cap * hd + (len + tt) * hd;
103            let s0 = hh * t * hd + tt * hd;
104            dst[d0..d0 + hd].copy_from_slice(&src[s0..s0 + hd]);
105        }
106    }
107}
108
109/// Stable softmax over the last dim with optional causal masking, matching
110/// `shaders/softmax.wgsl`. Row r's query position is `r % q_len`; key j is
111/// visible when `j <= query + off`. Masked entries produce 0.
112pub fn softmax(
113    x: &[f32],
114    rows: usize,
115    cols: usize,
116    q_len: usize,
117    causal: bool,
118    off: usize,
119) -> Vec<f32> {
120    let mut out = vec![0.0f32; rows * cols];
121    for r in 0..rows {
122        let base = r * cols;
123        let visible = |j: usize| !causal || j <= (r % q_len) + off;
124        let mut max = f32::NEG_INFINITY;
125        for j in 0..cols {
126            if visible(j) {
127                max = max.max(x[base + j]);
128            }
129        }
130        let mut sum = 0.0f32;
131        for j in 0..cols {
132            if visible(j) {
133                sum += (x[base + j] - max).exp();
134            }
135        }
136        for j in 0..cols {
137            if visible(j) {
138                out[base + j] = (x[base + j] - max).exp() / sum;
139            }
140        }
141    }
142    out
143}
144
145/// LayerNorm over the last dim (biased variance, like PyTorch).
146pub fn layernorm(
147    x: &[f32],
148    gamma: &[f32],
149    beta: &[f32],
150    rows: usize,
151    cols: usize,
152    eps: f32,
153) -> Vec<f32> {
154    let mut out = vec![0.0f32; rows * cols];
155    let nf = cols as f32;
156    for r in 0..rows {
157        let row = &x[r * cols..(r + 1) * cols];
158        let mean: f32 = row.iter().sum::<f32>() / nf;
159        let var: f32 = row.iter().map(|&v| (v - mean) * (v - mean)).sum::<f32>() / nf;
160        let inv_std = 1.0 / (var + eps).sqrt();
161        for j in 0..cols {
162            out[r * cols + j] = (row[j] - mean) * inv_std * gamma[j] + beta[j];
163        }
164    }
165    out
166}
167
168/// Fused token + positional embedding gather.
169pub fn embedding(ids: &[u32], wte: &[f32], wpe: Option<&[f32]>, c: usize, pos: usize) -> Vec<f32> {
170    let mut out = vec![0.0f32; ids.len() * c];
171    for (t, &id) in ids.iter().enumerate() {
172        let dst = &mut out[t * c..(t + 1) * c];
173        dst.copy_from_slice(&wte[id as usize * c..(id as usize + 1) * c]);
174        if let Some(wpe) = wpe {
175            for (d, &pv) in dst.iter_mut().zip(&wpe[(t + pos) * c..(t + pos + 1) * c]) {
176                *d += pv;
177            }
178        }
179    }
180    out
181}
182
183/// qkv: [t, 3c] -> (q, k, v) each [h, t, hd], hd = c / h.
184pub fn split_heads(qkv: &[f32], t: usize, c: usize, h: usize) -> (Vec<f32>, Vec<f32>, Vec<f32>) {
185    let hd = c / h;
186    let mut q = vec![0.0f32; t * c];
187    let mut k = vec![0.0f32; t * c];
188    let mut v = vec![0.0f32; t * c];
189    for hh in 0..h {
190        for tt in 0..t {
191            for d in 0..hd {
192                let dst = hh * t * hd + tt * hd + d;
193                let src_row = tt * 3 * c;
194                let col = hh * hd + d;
195                q[dst] = qkv[src_row + col];
196                k[dst] = qkv[src_row + c + col];
197                v[dst] = qkv[src_row + 2 * c + col];
198            }
199        }
200    }
201    (q, k, v)
202}
203
204/// x: [h, t, hd] -> [t, c], c = h * hd.
205pub fn merge_heads(x: &[f32], t: usize, c: usize, h: usize) -> Vec<f32> {
206    let hd = c / h;
207    let mut out = vec![0.0f32; t * c];
208    for tt in 0..t {
209        for hh in 0..h {
210            for d in 0..hd {
211                out[tt * c + hh * hd + d] = x[hh * t * hd + tt * hd + d];
212            }
213        }
214    }
215    out
216}
217
218// ---- backward / training primitives (roadmap v4, Stages 8-9) ----
219
220/// d/dx of GELU (tanh approximation), applied to upstream dy.
221pub fn gelu_bwd(x: &[f32], dy: &[f32]) -> Vec<f32> {
222    const C: f32 = 0.797_884_6; // sqrt(2/pi)
223    const A: f32 = 0.044715;
224    x.iter()
225        .zip(dy)
226        .map(|(&v, &g)| {
227            let u = C * (v + A * v * v * v);
228            let th = u.tanh();
229            let sech2 = 1.0 - th * th;
230            let d = 0.5 * (1.0 + th) + 0.5 * v * sech2 * C * (1.0 + 3.0 * A * v * v);
231            d * g
232        })
233        .collect()
234}
235
236/// Softmax backward: dx = y * (dy - sum(y * dy)) per row. The causal-masked
237/// forward writes exact zeros at masked entries, so no mask is needed here.
238pub fn softmax_bwd(y: &[f32], dy: &[f32], rows: usize, cols: usize) -> Vec<f32> {
239    let mut out = vec![0.0f32; rows * cols];
240    for r in 0..rows {
241        let base = r * cols;
242        let s: f32 = (0..cols).map(|j| y[base + j] * dy[base + j]).sum();
243        for j in 0..cols {
244            out[base + j] = y[base + j] * (dy[base + j] - s);
245        }
246    }
247    out
248}
249
250/// LayerNorm backward for x: returns dx; gamma/beta gradients are computed
251/// by `layernorm_bwd_dparams`.
252pub fn layernorm_bwd_dx(
253    x: &[f32],
254    gamma: &[f32],
255    dy: &[f32],
256    rows: usize,
257    cols: usize,
258    eps: f32,
259) -> Vec<f32> {
260    let nf = cols as f32;
261    let mut out = vec![0.0f32; rows * cols];
262    for r in 0..rows {
263        let row = &x[r * cols..(r + 1) * cols];
264        let mean: f32 = row.iter().sum::<f32>() / nf;
265        let var: f32 = row.iter().map(|&v| (v - mean) * (v - mean)).sum::<f32>() / nf;
266        let inv_std = 1.0 / (var + eps).sqrt();
267        let mut s1 = 0.0f32; // sum(gamma * dy)
268        let mut s2 = 0.0f32; // sum(gamma * dy * xhat)
269        for j in 0..cols {
270            let gd = gamma[j] * dy[r * cols + j];
271            s1 += gd;
272            s2 += gd * (row[j] - mean) * inv_std;
273        }
274        for j in 0..cols {
275            let xhat = (row[j] - mean) * inv_std;
276            let gd = gamma[j] * dy[r * cols + j];
277            out[r * cols + j] = inv_std * (gd - s1 / nf - xhat * s2 / nf);
278        }
279    }
280    out
281}
282
283/// LayerNorm gamma/beta gradients: dgamma = sum_r dy * xhat, dbeta = sum_r dy.
284pub fn layernorm_bwd_dparams(
285    x: &[f32],
286    dy: &[f32],
287    rows: usize,
288    cols: usize,
289    eps: f32,
290) -> (Vec<f32>, Vec<f32>) {
291    let nf = cols as f32;
292    let mut dgamma = vec![0.0f32; cols];
293    let mut dbeta = vec![0.0f32; cols];
294    for r in 0..rows {
295        let row = &x[r * cols..(r + 1) * cols];
296        let mean: f32 = row.iter().sum::<f32>() / nf;
297        let var: f32 = row.iter().map(|&v| (v - mean) * (v - mean)).sum::<f32>() / nf;
298        let inv_std = 1.0 / (var + eps).sqrt();
299        for j in 0..cols {
300            let d = dy[r * cols + j];
301            dgamma[j] += d * (row[j] - mean) * inv_std;
302            dbeta[j] += d;
303        }
304    }
305    (dgamma, dbeta)
306}
307
308/// Column sums: `[rows, cols]` -> `[cols]`. Bias gradients.
309pub fn sum_rows(x: &[f32], rows: usize, cols: usize) -> Vec<f32> {
310    let mut out = vec![0.0f32; cols];
311    for r in 0..rows {
312        for j in 0..cols {
313            out[j] += x[r * cols + j];
314        }
315    }
316    out
317}
318
319/// dwte scatter-add: `dst[ids[r]] += src[r]` for rows of width `c`.
320pub fn scatter_add_rows(dst: &mut [f32], ids: &[u32], src: &[f32], c: usize) {
321    for (r, &id) in ids.iter().enumerate() {
322        let d0 = id as usize * c;
323        for j in 0..c {
324            dst[d0 + j] += src[r * c + j];
325        }
326    }
327}
328
329/// `-log(probs[r, ids[r]])` per row.
330pub fn gather_nll(probs: &[f32], ids: &[u32], cols: usize) -> Vec<f32> {
331    ids.iter()
332        .enumerate()
333        .map(|(r, &id)| -probs[r * cols + id as usize].max(f32::MIN_POSITIVE).ln())
334        .collect()
335}
336
337/// Cross-entropy backward: dlogits = (probs - onehot(ids)) * scale.
338pub fn ce_bwd(probs: &[f32], ids: &[u32], rows: usize, cols: usize, scale: f32) -> Vec<f32> {
339    let mut out = vec![0.0f32; rows * cols];
340    for r in 0..rows {
341        for j in 0..cols {
342            let onehot = (j as u32 == ids[r]) as u32 as f32;
343            out[r * cols + j] = (probs[r * cols + j] - onehot) * scale;
344        }
345    }
346    out
347}
348
349/// PCG output hash — identical to shaders/dropout.wgsl so masks match
350/// bit-for-bit across backends.
351pub fn pcg_hash(x: u32) -> u32 {
352    let state = x.wrapping_mul(747796405).wrapping_add(2891336453);
353    let word = ((state >> ((state >> 28) + 4)) ^ state).wrapping_mul(277803737);
354    (word >> 22) ^ word
355}
356
357/// Inverted dropout with a counter-based RNG: element i is kept when
358/// hash(seed, i) maps to u >= p, and scaled by `scale` (the caller-computed
359/// 1/(1-p)). Applying the same (seed, p, scale) to dy gives the backward
360/// pass. `scale` is passed in (rather than recomputed here) so the CPU and
361/// WGSL kernel multiply by the exact same bits — see ops::dropout.
362pub fn dropout(x: &[f32], p: f32, scale: f32, seed: u32) -> Vec<f32> {
363    x.iter()
364        .enumerate()
365        .map(|(i, &v)| {
366            let r = pcg_hash(seed ^ (i as u32).wrapping_mul(0x9E37_79B9));
367            let u = (r >> 8) as f32 / 16_777_216.0; // [0, 1)
368            if u >= p { v * scale } else { 0.0 }
369        })
370        .collect()
371}
372
373/// split_heads backward for one of q/k/v: place d [h, t, hd] into the
374/// `which` third of a zeroed [t, 3c] buffer.
375pub fn unsplit_head(d: &[f32], t: usize, c: usize, h: usize, which: usize) -> Vec<f32> {
376    let hd = c / h;
377    let mut out = vec![0.0f32; t * 3 * c];
378    for hh in 0..h {
379        for tt in 0..t {
380            for dd in 0..hd {
381                out[tt * 3 * c + which * c + hh * hd + dd] = d[hh * t * hd + tt * hd + dd];
382            }
383        }
384    }
385    out
386}
387
388/// merge_heads backward: dy [t, c] -> [h, t, hd].
389pub fn unmerge_heads(dy: &[f32], t: usize, c: usize, h: usize) -> Vec<f32> {
390    let hd = c / h;
391    let mut out = vec![0.0f32; t * c];
392    for hh in 0..h {
393        for tt in 0..t {
394            for dd in 0..hd {
395                out[hh * t * hd + tt * hd + dd] = dy[tt * c + hh * hd + dd];
396            }
397        }
398    }
399    out
400}
401
402/// AdamW step (decoupled weight decay), in place. `step` is 1-based.
403#[allow(clippy::too_many_arguments)]
404pub fn adamw(
405    param: &mut [f32],
406    grad: &[f32],
407    m: &mut [f32],
408    v: &mut [f32],
409    lr: f32,
410    beta1: f32,
411    beta2: f32,
412    eps: f32,
413    weight_decay: f32,
414    step: u32,
415) {
416    let bc1 = 1.0 - beta1.powi(step as i32);
417    let bc2 = 1.0 - beta2.powi(step as i32);
418    for i in 0..param.len() {
419        m[i] = beta1 * m[i] + (1.0 - beta1) * grad[i];
420        v[i] = beta2 * v[i] + (1.0 - beta2) * grad[i] * grad[i];
421        let mhat = m[i] / bc1;
422        let vhat = v[i] / bc2;
423        param[i] -= lr * (mhat / (vhat.sqrt() + eps) + weight_decay * param[i]);
424    }
425}