Skip to main content

cortiq_engine/
g4e.rs

1//! Gemma-4 E-series (E4B) text stack: the gemma4 backbone (plain x̂·w
2//! norms, dual-geometry attention — sliding GQA at `head_dim` with a
3//! local RoPE base, full layers at `global_head_dim` with proportional
4//! RoPE, scale-less V norm, gelu-tanh MLP) plus the two E-series
5//! extensions that the shared layer loop does not model:
6//!
7//! * PLE — per-layer input embeddings. One packed table
8//!   `[L·ple_dim, vocab]` indexed by token id, projected from the input
9//!   embedding through `per_layer_model_projection`, RMS-normalized,
10//!   mixed with the table row (√ple_dim · row, × 1/√2) and injected
11//!   after every block through a gelu gate + projection + norm.
12//! * Shared KV — the LAST `num_kv_shared_layers` layers carry no k/v
13//!   projections at all and attend over the KV cache of the last
14//!   non-shared layer *of the same attention type* (sliding tail layers
15//!   read layer `first_shared-2`, full tail layers `first_shared-1` —
16//!   exactly the `shared_kv_states[layer_type]` semantics of
17//!   transformers and the `n_layer_kv_from_start` reuse of llama.cpp).
18//!
19//! Formulas are ported 1:1 from transformers' modeling_gemma4.py
20//! (decoder layer forward + `project_per_layer_inputs`); every norm is
21//! plain x̂·w with eps inside the mean. Like the gemma-3n stack, this
22//! arch runs its own per-token forward: batched/pair prefill and the
23//! GPU graphs stay off (gates key on `Pipeline::per_token_stack`).
24
25use crate::attention::{self};
26use crate::pool::Pool;
27use crate::qtensor::QTensor;
28
29pub struct G4eGlobals {
30    /// `[vocab, L·ple_dim]` — the packed per-layer embedding table
31    /// (row = token id, columns = layer-major ple rows).
32    pub ple_embed: QTensor,
33    /// `[L·ple_dim, hidden]` — input-embedding → per-layer projection.
34    pub ple_model_proj: QTensor,
35    /// `[ple_dim]` — RMS norm applied to each layer's model projection.
36    pub ple_norm: Vec<f32>,
37    pub ple_vocab: usize,
38    pub ple_dim: usize,
39    pub num_layers: usize,
40    pub hidden: usize,
41    pub rms_eps: f64,
42    /// RoPE inverse frequencies: sliding layers (`head_dim`, local
43    /// theta) and full layers (`global_head_dim`, model theta with the
44    /// proportional partial-rotary prefix — the tail is zero, which is
45    /// identity under `rope_rotate_scaled`).
46    pub inv_freq_local: Vec<f32>,
47    pub inv_freq_global: Vec<f32>,
48    /// Sliding window (tokens, current included).
49    pub window: usize,
50    pub nh: usize,
51    pub nkv: usize,
52}
53
54pub struct G4eLayer {
55    /// Pre-attention norm (HF `input_layernorm`).
56    pub input_norm: Vec<f32>,
57    /// Attention-branch output norm (HF `post_attention_layernorm`).
58    pub attn_out_norm: Vec<f32>,
59    /// Pre-FFN norm (HF `pre_feedforward_layernorm`).
60    pub pre_ffn_norm: Vec<f32>,
61    /// FFN-branch output norm (HF `post_feedforward_layernorm`).
62    pub post_ffw_norm: Vec<f32>,
63    pub wq: QTensor,
64    /// Shared-KV layers carry no k/v projections and read the cache of
65    /// `kv_share_src` instead of appending their own.
66    pub wk: Option<QTensor>,
67    pub wv: Option<QTensor>,
68    pub wo: QTensor,
69    pub q_norm: Vec<f32>,
70    pub k_norm: Option<Vec<f32>>,
71    pub kv_share_src: Option<usize>,
72    pub sliding: bool,
73    /// Per-layer head width: `head_dim` on sliding layers,
74    /// `global_head_dim` on full layers.
75    pub hd: usize,
76    pub gate: QTensor,
77    pub up: QTensor,
78    pub down: QTensor,
79    /// PLE injection: gelu(gate·h) ⊙ ple → proj → norm.
80    pub ple_gate: QTensor,
81    pub ple_proj: QTensor,
82    pub post_ple_norm: Vec<f32>,
83    /// HF `layer_scalar` — per-block output scale (absent = 1.0).
84    pub layer_scalar: Option<f32>,
85}
86
87/// Plain x̂·w RMS norm (eps inside the mean) into a fresh Vec.
88fn rms(x: &[f32], w: &[f32], eps: f64) -> Vec<f32> {
89    let ms: f64 = x.iter().map(|&v| (v as f64) * (v as f64)).sum::<f64>() / x.len() as f64;
90    let inv = (ms + eps).powf(-0.5);
91    x.iter()
92        .zip(w)
93        .map(|(&v, &g)| (v as f64 * inv * g as f64) as f32)
94        .collect()
95}
96
97/// Scale-less x̂ (HF `v_norm` is `Gemma4RMSNorm(with_scale=False)`).
98fn rms_plain(x: &mut [f32], eps: f64) {
99    let ms: f64 = x.iter().map(|&v| (v as f64) * (v as f64)).sum::<f64>() / x.len() as f64;
100    let inv = (ms + eps).powf(-0.5) as f32;
101    for v in x.iter_mut() {
102        *v *= inv;
103    }
104}
105
106/// gelu_pytorch_tanh — `config.hidden_activation` of the text tower.
107fn gelu_tanh(x: f32) -> f32 {
108    let x = x as f64;
109    (0.5 * x * (1.0 + (0.7978845608028654 * (x + 0.044715 * x * x * x)).tanh())) as f32
110}
111
112fn matvec(w: &QTensor, x: &[f32], pool: Option<&Pool>) -> Vec<f32> {
113    let mut o = vec![0.0f32; w.rows()];
114    w.matvec(x, &mut o, pool);
115    o
116}
117
118/// One decoded row of a (possibly quantized) embedding-style table.
119fn matvec_row(t: &QTensor, row: usize) -> Vec<f32> {
120    let mut out = vec![0.0f32; t.cols()];
121    t.row_f32(row, &mut out);
122    out
123}
124
125impl G4eGlobals {
126    /// Extended embedding for one token: `[e(hidden) ++ per_layer_input
127    /// (L·ple_dim)]` — the PLE half needs the token ID, so it rides
128    /// with the embedding instead of threading ids through the forward.
129    /// `e` is the already-scaled (√hidden) token embedding.
130    ///
131    /// HF: `project_per_layer_inputs` = rmsnorm(e · hidden^-0.5) +
132    /// √ple_dim·table[id], all × 1/√2.
133    pub fn extend_embedding(&self, id: u32, e: &[f32], pool: Option<&Pool>) -> Vec<f32> {
134        let (l, d) = (self.num_layers, self.ple_dim);
135        let mut proj = matvec(&self.ple_model_proj, e, pool);
136        let scale = (self.hidden as f32).powf(-0.5);
137        for v in proj.iter_mut() {
138            *v *= scale;
139        }
140        let ple_scale = (d as f32).sqrt();
141        let ple_row: Option<Vec<f32>> = if (id as usize) < self.ple_vocab {
142            Some(matvec_row(&self.ple_embed, id as usize))
143        } else {
144            None
145        };
146        let half = std::f32::consts::FRAC_1_SQRT_2;
147        let mut out = Vec::with_capacity(self.hidden + l * d);
148        out.extend_from_slice(e);
149        for li in 0..l {
150            let pn = rms(&proj[li * d..(li + 1) * d], &self.ple_norm, self.rms_eps);
151            for j in 0..d {
152                let ple = ple_row
153                    .as_ref()
154                    .map(|r| r[li * d + j] * ple_scale)
155                    .unwrap_or(0.0);
156                out.push((pn[j] + ple) * half);
157            }
158        }
159        out
160    }
161}
162
163/// Forward one token through the whole gemma-4-E stack. `ext` is the
164/// extended embedding from `extend_embedding`; `caches` the per-layer
165/// KV caches; returns the pre-final-norm hidden (the caller applies the
166/// final norm + lm_head + final-logit softcap as usual).
167pub fn g4e_forward(
168    g: &G4eGlobals,
169    layers: &[G4eLayer],
170    ext: &[f32],
171    position: usize,
172    caches: &mut [crate::kv_cache::LayerKvCache],
173    num_heads: usize,
174    num_kv_heads: usize,
175    pool: Option<&Pool>,
176) -> Vec<f32> {
177    let hs = g.hidden;
178    let (e, ple) = ext.split_at(hs);
179    let eps = g.rms_eps;
180    let nh = g.nh.min(num_heads).max(num_kv_heads);
181    let nkv = g.nkv.min(num_heads);
182    let hpk = (nh / nkv).max(1);
183    let mut h = e.to_vec();
184    // CMF_LAYER_DUMP=dir: per-position hidden after every block (and the
185    // block input as `l_in`), f32 LE rows — the oracle diff hook.
186    let dump_dir = std::env::var_os("CMF_LAYER_DUMP").map(std::path::PathBuf::from);
187    if let Some(dir) = &dump_dir {
188        let _ = std::fs::create_dir_all(dir);
189        let mut b = Vec::with_capacity(h.len() * 4);
190        for v in &h {
191            b.extend_from_slice(&v.to_le_bytes());
192        }
193        let _ = std::fs::write(dir.join(format!("p{position:06}_l_in.f32")), b);
194    }
195    for (li, lw) in layers.iter().enumerate() {
196        let hd = lw.hd;
197        let inv_freq: &[f32] = if lw.sliding {
198            &g.inv_freq_local
199        } else {
200            &g.inv_freq_global
201        };
202
203        // ── attention ──
204        let xn = rms(&h, &lw.input_norm, eps);
205        let mut q = matvec(&lw.wq, &xn, pool);
206        for hi in 0..nh {
207            let head = &mut q[hi * hd..(hi + 1) * hd];
208            let n = rms(head, &lw.q_norm, eps);
209            head.copy_from_slice(&n);
210            attention::rope_rotate_scaled(head, position, inv_freq, 1.0);
211        }
212        let src = lw.kv_share_src.unwrap_or(li);
213        let attn_dump: Option<String> = std::env::var("CMF_ATTN_DUMP").ok().and_then(|dir| {
214            let want: Vec<usize> = std::env::var("CMF_ATTN_DUMP_LAYERS")
215                .ok()
216                .map(|s| s.split(',').filter_map(|x| x.parse::<usize>().ok()).collect())
217                .unwrap_or_default();
218            (want.contains(&li)).then_some(dir)
219        });
220        let put = |name: &str, v: &[f32]| {
221            if let Some(dir) = &attn_dump {
222                let _ = std::fs::create_dir_all(dir);
223                let mut b = Vec::with_capacity(v.len() * 4);
224                for x in v {
225                    b.extend_from_slice(&x.to_le_bytes());
226                }
227                let _ = std::fs::write(
228                    std::path::Path::new(dir).join(format!("a{position:06}_l{li:02}_{name}.f32")),
229                    b,
230                );
231            }
232        };
233        put("q", &q);
234        put("xn", &xn);
235        if lw.kv_share_src.is_none() {
236            let wk = lw.wk.as_ref().expect("non-shared layer has k_proj");
237            let wv = lw.wv.as_ref().expect("non-shared layer has v_proj");
238            let kn = lw.k_norm.as_ref().expect("non-shared layer has k_norm");
239            let mut k = matvec(wk, &xn, pool);
240            for gi in 0..nkv {
241                let head = &mut k[gi * hd..(gi + 1) * hd];
242                let n = rms(head, kn, eps);
243                head.copy_from_slice(&n);
244                attention::rope_rotate_scaled(head, position, inv_freq, 1.0);
245            }
246            let mut v = matvec(wv, &xn, pool);
247            for gi in 0..nkv {
248                rms_plain(&mut v[gi * hd..(gi + 1) * hd], eps);
249            }
250            put("k", &k);
251            put("v", &v);
252            caches[li].append(&k, &v, &[]);
253        }
254        let window = if lw.sliding { Some(g.window) } else { None };
255        let (mut ao, mut imp) =
256            attention::attend_all_heads(&q, &caches[src], nh, hpk, hd, 1.0, window, 0.0);
257        attention::recycle_buf(&mut imp);
258        put("ao", &ao);
259        let a = matvec(&lw.wo, &ao, pool);
260        attention::recycle_buf(&mut ao);
261
262        // sandwich: h += rms(a, post_attention_layernorm)
263        let an = rms(&a, &lw.attn_out_norm, eps);
264        for (hv, av) in h.iter_mut().zip(&an) {
265            *hv += av;
266        }
267
268        // ── FFN (gelu-tanh gated) ──
269        let xn2 = rms(&h, &lw.pre_ffn_norm, eps);
270        let gate_v = matvec(&lw.gate, &xn2, pool);
271        let up_v = matvec(&lw.up, &xn2, pool);
272        let act: Vec<f32> = gate_v
273            .iter()
274            .zip(&up_v)
275            .map(|(&gv, &uv)| gelu_tanh(gv) * uv)
276            .collect();
277        let ffn = matvec(&lw.down, &act, pool);
278        let ffn_n = rms(&ffn, &lw.post_ffw_norm, eps);
279        for (hv, fv) in h.iter_mut().zip(&ffn_n) {
280            *hv += fv;
281        }
282
283        // ── per-layer input injection (PLE) ──
284        let d = g.ple_dim;
285        let pli = &ple[li * d..(li + 1) * d];
286        let mut gated = matvec(&lw.ple_gate, &h, pool);
287        for gv in gated.iter_mut() {
288            *gv = gelu_tanh(*gv);
289        }
290        let mixed: Vec<f32> = gated
291            .iter()
292            .zip(pli)
293            .map(|(&gv, &pv)| gv * pv)
294            .collect();
295        let proj = matvec(&lw.ple_proj, &mixed, pool);
296        let pn = rms(&proj, &lw.post_ple_norm, eps);
297        for (hv, pv) in h.iter_mut().zip(&pn) {
298            *hv += pv;
299        }
300
301        // ── per-block output scalar (HF `layer_scalar`) ──
302        if let Some(s) = lw.layer_scalar {
303            for hv in h.iter_mut() {
304                *hv *= s;
305            }
306        }
307        if let Some(dir) = &dump_dir {
308            let mut b = Vec::with_capacity(h.len() * 4);
309            for v in &h {
310                b.extend_from_slice(&v.to_le_bytes());
311            }
312            let _ = std::fs::write(dir.join(format!("p{position:06}_l{li:02}.f32")), b);
313        }
314    }
315    h
316}