Skip to main content

cortiq_engine/
inference.rs

1//! CPU forward-pass primitives: RMS-norm, SiLU, sparse SwiGLU FFN.
2
3use crate::pool::Pool;
4use cortiq_core::types::NormStyle;
5
6/// SiLU activation function.
7#[inline(always)]
8/// tanh-approximated GELU (Gemma's GeGLU gate; HF `gelu_pytorch_tanh`).
9/// Exact (erf) GELU, `0.5·x·(1 + erf(x/√2))` — HF `hidden_act = "gelu"`.
10/// erf by Abramowitz–Stegun 7.1.26 (|error| ≤ 1.5e-7), the same formula the
11/// GPU kernels use (WGSL and MSL have no erf).
12#[inline]
13pub fn gelu_erf(x: f32) -> f32 {
14    0.5 * x * (1.0 + erf_f32(x * std::f32::consts::FRAC_1_SQRT_2))
15}
16
17/// erf(x) by Abramowitz–Stegun 7.1.26, |error| ≤ 1.5e-7.
18#[inline]
19pub fn erf_f32(x: f32) -> f32 {
20    let a = x.abs();
21    let t = 1.0 / (1.0 + 0.327_591_1 * a);
22    let y = 1.0
23        - (((((1.061_405_4 * t - 1.453_152_1) * t + 1.421_413_8) * t - 0.284_496_74) * t
24            + 0.254_829_6)
25            * t)
26            * (-a * a).exp();
27    if x < 0.0 { -y } else { y }
28}
29
30pub fn gelu_tanh(x: f32) -> f32 {
31    const C: f32 = 0.797_884_6; // √(2/π)
32    0.5 * x * (1.0 + (C * (x + 0.044_715 * x * x * x)).tanh())
33}
34
35pub fn silu(x: f32) -> f32 {
36    x / (1.0 + (-x).exp())
37}
38
39/// RMS normalization with explicit weight semantics.
40///
41/// - `NormStyle::Qwen`  (Llama family): `x̂ · w`
42/// - `NormStyle::Gemma`:                `x̂ · (1 + w)`
43///
44/// Applying the wrong style corrupts every normalization in the
45/// forward pass — the style comes from the model arch, never guessed.
46pub fn rms_norm(input: &[f32], weight: &[f32], eps: f64, style: NormStyle) -> Vec<f32> {
47    let mut out = vec![0.0f32; input.len()];
48    rms_norm_into(input, weight, eps, style, &mut out);
49    out
50}
51
52/// `rms_norm` writing into a caller-owned buffer — the decode hot path
53/// calls this twice per layer; the returning variant allocated each time.
54pub fn rms_norm_into(input: &[f32], weight: &[f32], eps: f64, style: NormStyle, out: &mut [f32]) {
55    let n = input.len();
56    debug_assert_eq!(out.len(), n);
57    let mean_sq: f64 = input.iter().map(|&x| (x as f64) * (x as f64)).sum::<f64>() / n as f64;
58    let inv_rms = 1.0 / (mean_sq + eps).sqrt() as f32;
59    match style {
60        NormStyle::Qwen => {
61            for (o, (&x, &w)) in out.iter_mut().zip(input.iter().zip(weight)) {
62                *o = x * inv_rms * w;
63            }
64        }
65        NormStyle::Gemma => {
66            for (o, (&x, &w)) in out.iter_mut().zip(input.iter().zip(weight)) {
67                *o = x * inv_rms * (1.0 + w);
68            }
69        }
70    }
71}
72
73/// Fused residual addition and RMSNorm into a caller-owned buffer:
74/// `h += delta`, then `out = rms_norm(h, weight)`.
75/// This avoids a separate pass over `h` for the residual addition and sum of squares.
76pub fn add_rmsnorm_fused_into(
77    h: &mut [f32],
78    delta: &[f32],
79    weight: &[f32],
80    eps: f64,
81    style: NormStyle,
82    out: &mut [f32],
83) {
84    let n = h.len();
85    debug_assert_eq!(delta.len(), n);
86    debug_assert_eq!(out.len(), n);
87    let mut mean_sq = 0.0f64;
88    for (hv, &dv) in h.iter_mut().zip(delta) {
89        let val = *hv + dv;
90        *hv = val;
91        mean_sq += (val as f64) * (val as f64);
92    }
93    mean_sq /= n as f64;
94    let inv_rms = 1.0 / (mean_sq + eps).sqrt() as f32;
95    match style {
96        NormStyle::Qwen => {
97            for (o, (&x, &w)) in out.iter_mut().zip(h.iter().zip(weight)) {
98                *o = x * inv_rms * w;
99            }
100        }
101        NormStyle::Gemma => {
102            for (o, (&x, &w)) in out.iter_mut().zip(h.iter().zip(weight)) {
103                *o = x * inv_rms * (1.0 + w);
104            }
105        }
106    }
107}
108
109/// Sparse SwiGLU FFN: compute only `active_indices` neurons.
110///
111/// `out = down_projᵀ[·, active] · (silu(gate_proj[active, ·]·h) ⊙ up_proj[active, ·]·h)`
112///
113/// With a full index list this is bit-identical to the dense path —
114/// masking is an execution schedule, not an approximation.
115pub fn sparse_ffn_forward(
116    hidden_states: &[f32],
117    gate_proj_full: &[f32], // [intermediate_size, hidden_size]
118    up_proj_full: &[f32],   // [intermediate_size, hidden_size]
119    down_proj_full: &[f32], // [hidden_size, intermediate_size]
120    hidden_size: usize,
121    intermediate_size: usize,
122    active_indices: &[u16],
123    pool: Option<&Pool>,
124) -> Vec<f32> {
125    let n_active = active_indices.len();
126    if n_active == 0 {
127        return vec![0.0; hidden_states.len()];
128    }
129
130    let seq_len = hidden_states.len() / hidden_size;
131    let mut output = vec![0.0f32; seq_len * hidden_size];
132
133    for s in 0..seq_len {
134        let h = &hidden_states[s * hidden_size..(s + 1) * hidden_size];
135
136        // Gather active rows of gate/up, fuse silu(gate)·up. Each active
137        // neuron is independent → row-parallel, bit-identical to serial.
138        let mut act = vec![0.0f32; n_active];
139        let neuron_act = |ai: usize| -> f32 {
140            let idx = active_indices[ai];
141            let row = idx as usize * hidden_size;
142            if row + hidden_size > gate_proj_full.len() {
143                return 0.0;
144            }
145            let mut gate_sum = 0.0f32;
146            let mut up_sum = 0.0f32;
147            for k in 0..hidden_size {
148                gate_sum += gate_proj_full[row + k] * h[k];
149                up_sum += up_proj_full[row + k] * h[k];
150            }
151            silu(gate_sum) * up_sum
152        };
153        match pool {
154            Some(pool) if n_active >= 256 => {
155                let act_ptr = SendMut(act.as_mut_ptr());
156                pool.run(&move |widx, n| {
157                    let chunk = n_active.div_ceil(n);
158                    let start = widx * chunk;
159                    let end = (start + chunk).min(n_active);
160                    for ai in start..end {
161                        // SAFETY: disjoint index ranges per worker.
162                        unsafe { *act_ptr.at(ai) = neuron_act(ai) };
163                    }
164                });
165            }
166            _ => {
167                for (ai, dst) in act.iter_mut().enumerate() {
168                    *dst = neuron_act(ai);
169                }
170            }
171        }
172
173        // Scatter through the active columns of down_proj.
174        let out = &mut output[s * hidden_size..(s + 1) * hidden_size];
175        for (ai, &idx) in active_indices.iter().enumerate() {
176            let val = act[ai];
177            if val.abs() < 1e-12 {
178                continue;
179            }
180            for k in 0..hidden_size {
181                out[k] += down_proj_full[k * intermediate_size + idx as usize] * val;
182            }
183        }
184    }
185
186    output
187}
188
189/// Fused two-position sparse FFN: gate/up/down weight rows are streamed
190/// from memory ONCE for both positions. Bit-identical to two single
191/// calls (per-position accumulation order is unchanged).
192#[allow(clippy::too_many_arguments)]
193pub fn sparse_ffn_forward_pair(
194    h1: &[f32],
195    h2: &[f32],
196    gate_proj_full: &[f32],
197    up_proj_full: &[f32],
198    down_proj_full: &[f32],
199    hidden_size: usize,
200    intermediate_size: usize,
201    active_indices: &[u16],
202    pool: Option<&Pool>,
203) -> (Vec<f32>, Vec<f32>) {
204    let n_active = active_indices.len();
205    let mut out1 = vec![0.0f32; hidden_size];
206    let mut out2 = vec![0.0f32; hidden_size];
207    if n_active == 0 {
208        return (out1, out2);
209    }
210
211    let mut act1 = vec![0.0f32; n_active];
212    let mut act2 = vec![0.0f32; n_active];
213    let neuron_pair = |ai: usize| -> (f32, f32) {
214        let idx = active_indices[ai];
215        let row = idx as usize * hidden_size;
216        if row + hidden_size > gate_proj_full.len() {
217            return (0.0, 0.0);
218        }
219        let (mut g1, mut u1, mut g2, mut u2) = (0.0f32, 0.0f32, 0.0f32, 0.0f32);
220        for k in 0..hidden_size {
221            let gw = gate_proj_full[row + k];
222            let uw = up_proj_full[row + k];
223            g1 += gw * h1[k];
224            g2 += gw * h2[k];
225            u1 += uw * h1[k];
226            u2 += uw * h2[k];
227        }
228        (silu(g1) * u1, silu(g2) * u2)
229    };
230    match pool {
231        Some(pool) if n_active >= 256 => {
232            let a1 = SendMut(act1.as_mut_ptr());
233            let a2 = SendMut(act2.as_mut_ptr());
234            pool.run(&move |widx, n| {
235                let chunk = n_active.div_ceil(n);
236                let start = widx * chunk;
237                let end = (start + chunk).min(n_active);
238                for ai in start..end {
239                    let (v1, v2) = neuron_pair(ai);
240                    // SAFETY: disjoint index ranges per worker.
241                    unsafe {
242                        *a1.at(ai) = v1;
243                        *a2.at(ai) = v2;
244                    }
245                }
246            });
247        }
248        _ => {
249            for ai in 0..n_active {
250                let (v1, v2) = neuron_pair(ai);
251                act1[ai] = v1;
252                act2[ai] = v2;
253            }
254        }
255    }
256
257    // Down scatter: one pass over the active columns for both positions.
258    for (ai, &idx) in active_indices.iter().enumerate() {
259        let (v1, v2) = (act1[ai], act2[ai]);
260        if v1.abs() < 1e-12 && v2.abs() < 1e-12 {
261            continue;
262        }
263        for k in 0..hidden_size {
264            let dw = down_proj_full[k * intermediate_size + idx as usize];
265            out1[k] += dw * v1;
266            out2[k] += dw * v2;
267        }
268    }
269    (out1, out2)
270}
271
272#[derive(Clone, Copy)]
273struct SendMut(*mut f32);
274unsafe impl Send for SendMut {}
275unsafe impl Sync for SendMut {}
276
277impl SendMut {
278    /// See pool::SendMut::at — captures the Sync wrapper, not the field.
279    #[inline]
280    fn at(self, i: usize) -> *mut f32 {
281        unsafe { self.0.add(i) }
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn test_silu() {
291        assert!((silu(0.0) - 0.0).abs() < 1e-6);
292        assert!((silu(1.0) - 0.7310586).abs() < 1e-4);
293    }
294
295    #[test]
296    fn rms_norm_qwen_multiplies_by_w() {
297        let input = vec![1.0, 2.0, 3.0, 4.0];
298        let weight = vec![1.0; 4]; // identity weight in Qwen semantics
299        let out = rms_norm(&input, &weight, 1e-6, NormStyle::Qwen);
300        let rms = (30.0_f64 / 4.0).sqrt() as f32;
301        assert!((out[0] - 1.0 / rms).abs() < 1e-4);
302
303        // w = 2 doubles the output — x̂·w, not x̂·(1+w).
304        let out2 = rms_norm(&input, &[2.0; 4], 1e-6, NormStyle::Qwen);
305        assert!((out2[0] - 2.0 / rms).abs() < 1e-4);
306    }
307
308    #[test]
309    fn rms_norm_gemma_adds_one() {
310        let input = vec![1.0, 2.0, 3.0, 4.0];
311        let weight = vec![0.0; 4]; // identity weight in Gemma semantics
312        let out = rms_norm(&input, &weight, 1e-6, NormStyle::Gemma);
313        let rms = (30.0_f64 / 4.0).sqrt() as f32;
314        assert!((out[0] - 1.0 / rms).abs() < 1e-4);
315    }
316
317    #[test]
318    fn test_sparse_ffn_full_active() {
319        let hidden = 4;
320        let inter = 8;
321        let h = vec![1.0f32; hidden];
322        let gate = vec![0.1f32; inter * hidden];
323        let up = vec![0.1f32; inter * hidden];
324        let down = vec![0.1f32; hidden * inter];
325        let active: Vec<u16> = (0..inter as u16).collect();
326
327        let out = sparse_ffn_forward(&h, &gate, &up, &down, hidden, inter, &active, None);
328        assert_eq!(out.len(), hidden);
329        assert!(out.iter().all(|&v| v.abs() > 1e-6));
330    }
331
332    #[test]
333    fn test_sparse_ffn_half_active() {
334        let hidden = 4;
335        let inter = 8;
336        let h = vec![1.0f32; hidden];
337        let gate = vec![0.1f32; inter * hidden];
338        let up = vec![0.1f32; inter * hidden];
339        let down = vec![0.1f32; hidden * inter];
340
341        let full: Vec<u16> = (0..inter as u16).collect();
342        let half: Vec<u16> = (0..inter as u16 / 2).collect();
343
344        let out_full = sparse_ffn_forward(&h, &gate, &up, &down, hidden, inter, &full, None);
345        let out_half = sparse_ffn_forward(&h, &gate, &up, &down, hidden, inter, &half, None);
346
347        let mag_full: f32 = out_full.iter().map(|v| v.abs()).sum();
348        let mag_half: f32 = out_half.iter().map(|v| v.abs()).sum();
349        assert!(mag_half < mag_full);
350        assert!(mag_half > 0.0);
351    }
352}