Skip to main content

cortiq_engine/
egemma2.rs

1//! EmbeddingGemma 2 (`google/embeddinggemma-2`, `model_type:
2//! embedding_gemma2`) — the text encoder of a 768-d multimodal embedding
3//! model, read from a `.cmf` packed by `cortiq convert`.
4//!
5//! The forward reproduces transformers' `EmbeddingGemma2Model` followed by
6//! sentence-transformers' mean pooling and L2 normalization:
7//!
8//! 1. **Tokens.** `[BOS] + tokenize(prompt + text) + [EOS]` (the tokenizer's
9//!    own post-processor), capped at 8192 tokens. The task prompts are the
10//!    sentence-transformers table carried in the header; `Document` is
11//!    `title: {title | none} | text: `.
12//! 2. **Embedding.** `E[id] · sqrt(512)`, in f32.
13//! 3. **Per-layer inputs (projection-only PLE).** `P = RMSNorm_w(reshape(
14//!    x · W_pleᵀ · 512^-½, [T, 24, 512]))` from the merged input embeddings,
15//!    one shared norm weight. There is no per-layer token table.
16//! 4. **24 bidirectional layers**, five sliding to one full:
17//!    * sliding: 4 query heads over 2 key heads of 256, RoPE θ = 10⁴, a
18//!      token sees keys with `|i - j| ≤ 512`;
19//!    * full (layers 5, 11, 17, 23): 4 query heads over ONE key head of 512,
20//!      RoPE θ = 10⁶ over the whole head, every token sees every token.
21//!
22//!    Attention is unscaled (`scaling = 1`, the q/k RMS-norms carry the
23//!    scale), values are RMS-normalized without a weight, and the block is
24//!    the Gemma sandwich: `h += N(attn(N(h)))`, `h += N(FFN(N(h)))` with a
25//!    gelu-tanh gated FFN, then the per-layer-input gate
26//!    `h += N(W_proj(gelu(W_gate h) · P_i))`, then `h *= layer_scalar_i`.
27//!    Every RMS-norm multiplies by its weight plainly (not `1 + w`).
28//! 5. **Head.** Final RMS-norm → 512→768 projection per token → mean over
29//!    every token (BOS, prompt and EOS included) → L2. The projection is
30//!    linear and bias-free, so it is applied once to the pooled mean.
31//! 6. **Matryoshka.** The first 512/256/128 coordinates, L2-normalized again.
32//!
33//! Every step is checked against the float32 reference in
34//! `tests/egemma2_parity.rs` (`CORTIQ_EGEMMA2_MODEL` + `CORTIQ_EGEMMA2_REF`).
35//! The arithmetic is f32 on the host (Accelerate on macOS); sequences of a
36//! batch are packed back to back so every token-wise projection is one GEMM,
37//! and attention runs per sequence, so a batch embeds exactly as its
38//! members would alone.
39
40use crate::ltxdit::{Shared, gelu_tanh, rows};
41use crate::pool::Pool;
42use crate::qtensor::QTensor;
43use crate::tokenizer::Tokenizer;
44use cortiq_core::CmfModel;
45use cortiq_core::types::TensorDtype;
46use std::sync::{Arc, Mutex};
47
48/// The architecture name a packed file carries.
49pub const ARCH_NAME: &str = "embedding_gemma2";
50/// The model's context, shared by every modality (the card's 8192).
51pub const MAX_TOKENS: usize = 8192;
52/// The Matryoshka dimensions the model was trained to truncate to.
53pub const MATRYOSHKA_DIMS: [usize; 4] = [768, 512, 256, 128];
54/// Sequences are packed into one forward up to this many tokens.
55const PACK_TOKENS: usize = 8192;
56/// Query rows per attention block: sliding layers (a block's key span is
57/// its rows plus the window, so smaller blocks waste less) and full ones.
58const QBLOCK: usize = 128;
59const QBLOCK_FULL: usize = 512;
60/// Sequences up to this long attend on the pool, one (sequence, head) per
61/// work item; longer ones go through blocked GEMMs.
62const SHORT_SEQ: usize = 96;
63
64pub(crate) const EPS: f64 = 1e-6;
65
66/// Below this many tokens a batch runs on the device alone.
67#[cfg(target_os = "macos")]
68const CO_MIN_TOKENS: usize = 2048;
69
70/// The share of a device batch's tokens the host computes beside it
71/// (`CMF_EGEMMA2_CPU_SHARE`, default 0 = device only). Measured on the M4
72/// (in-process, three interleaved rounds): 0.2 cost 3-4% on both 256 × 32
73/// and 64 × 512 tokens — the busy CPU slows the GPU part by more than it
74/// adds — so the co-run stays an opt-in.
75#[cfg(target_os = "macos")]
76fn cpu_share() -> f64 {
77    static V: std::sync::OnceLock<f64> = std::sync::OnceLock::new();
78    *V.get_or_init(|| {
79        std::env::var("CMF_EGEMMA2_CPU_SHARE")
80            .ok()
81            .and_then(|v| v.parse::<f64>().ok())
82            .map(|v| v.clamp(0.0, 0.9))
83            .unwrap_or(0.0)
84    })
85}
86
87/// `CMF_EGEMMA2_PROF=1`: per-forward time split (projections / attention
88/// core / the rest) on stderr.
89pub(crate) mod prof {
90    use std::sync::atomic::{AtomicU64, Ordering};
91    pub static LIN: AtomicU64 = AtomicU64::new(0);
92    pub static ATTN: AtomicU64 = AtomicU64::new(0);
93    pub fn on() -> bool {
94        static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
95        *ON.get_or_init(|| std::env::var("CMF_EGEMMA2_PROF").is_ok_and(|v| v == "1"))
96    }
97    pub fn add(c: &AtomicU64, t: std::time::Instant) {
98        c.fetch_add(t.elapsed().as_micros() as u64, Ordering::Relaxed);
99    }
100    pub fn take(c: &AtomicU64) -> f64 {
101        c.swap(0, Ordering::Relaxed) as f64 / 1e3
102    }
103}
104
105/// The sentence-transformers prompt table of the release
106/// (`config_sentence_transformers.json`), used when a file carries none.
107pub const DEFAULT_PROMPTS: &[(&str, &str)] = &[
108    ("BitextMining", "task: search result | query: "),
109    ("Classification", "task: classification | query: "),
110    ("Clustering", "task: clustering | query: "),
111    ("CodeRetrieval", "task: code retrieval | query: "),
112    ("Document", "title: none | text: "),
113    ("FactChecking", "task: fact checking | query: "),
114    ("InstructionRetrieval", "task: code retrieval | query: "),
115    ("MultilabelClassification", "task: classification | query: "),
116    ("PairClassification", "task: sentence similarity | query: "),
117    ("QuestionAnswering", "task: question answering | query: "),
118    ("Reranking", "task: search result | query: "),
119    ("Retrieval", "task: search result | query: "),
120    ("Retrieval-document", "title: none | text: "),
121    ("Retrieval-query", "task: search result | query: "),
122    ("STS", "task: sentence similarity | query: "),
123    ("SearchQuery", "task: search result | query: "),
124    ("SentenceSimilarity", "task: sentence similarity | query: "),
125    ("Summarization", "task: sentence similarity | query: "),
126    ("document", "title: none | text: "),
127    ("query", "task: search result | query: "),
128];
129
130/// The prefix every Document prompt starts with; a title replaces `none`.
131const DOC_PREFIX: &str = "title: none | text: ";
132
133/// Is this container an EmbeddingGemma 2 pack?
134pub fn is_embedding_gemma2(model: &CmfModel) -> bool {
135    model.header.arch.arch_name == ARCH_NAME
136}
137
138// ------------------------------------------------------------ pure helpers
139
140/// L2-normalize in place (a zero vector stays zero).
141pub fn l2_normalize(v: &mut [f32]) {
142    let n = v
143        .iter()
144        .map(|&x| (x as f64) * (x as f64))
145        .sum::<f64>()
146        .sqrt();
147    if n > 0.0 {
148        for x in v.iter_mut() {
149            *x = (*x as f64 / n) as f32;
150        }
151    }
152}
153
154/// Matryoshka truncation: the first `dim` coordinates, L2-normalized again.
155/// `dim` must be one of [`MATRYOSHKA_DIMS`] (the model was trained for
156/// those) and at most the vector's length.
157pub fn matryoshka(v: &[f32], dim: usize) -> Result<Vec<f32>, String> {
158    if !MATRYOSHKA_DIMS.contains(&dim) {
159        return Err(format!(
160            "dimensions {dim}: EmbeddingGemma 2 supports 768, 512, 256 or 128"
161        ));
162    }
163    if dim > v.len() {
164        return Err(format!("dimensions {dim} > embedding size {}", v.len()));
165    }
166    let mut out = v[..dim].to_vec();
167    l2_normalize(&mut out);
168    Ok(out)
169}
170
171/// Mean over the rows of `x` (`[n, d]`), accumulated in f64.
172pub fn mean_pool(x: &[f32], n: usize, d: usize) -> Vec<f32> {
173    let mut acc = vec![0f64; d];
174    for row in x.chunks_exact(d).take(n) {
175        for (a, &v) in acc.iter_mut().zip(row) {
176            *a += v as f64;
177        }
178    }
179    let inv = 1.0 / n.max(1) as f64;
180    acc.into_iter().map(|a| (a * inv) as f32).collect()
181}
182
183/// Cosine similarity of two vectors.
184pub fn cosine(a: &[f32], b: &[f32]) -> f64 {
185    let (mut ab, mut aa, mut bb) = (0f64, 0f64, 0f64);
186    for (&x, &y) in a.iter().zip(b) {
187        ab += x as f64 * y as f64;
188        aa += x as f64 * x as f64;
189        bb += y as f64 * y as f64;
190    }
191    if aa == 0.0 || bb == 0.0 {
192        0.0
193    } else {
194        ab / (aa.sqrt() * bb.sqrt())
195    }
196}
197
198/// The task-prompt table.
199#[derive(Clone, Debug)]
200pub struct Prompts {
201    table: Vec<(String, String)>,
202}
203
204impl Default for Prompts {
205    fn default() -> Self {
206        Prompts {
207            table: DEFAULT_PROMPTS
208                .iter()
209                .map(|(a, b)| (a.to_string(), b.to_string()))
210                .collect(),
211        }
212    }
213}
214
215impl Prompts {
216    /// From a sentence-transformers config (`{"prompts": {name: prefix}}`).
217    pub fn from_st_config(cfg: &serde_json::Value) -> Option<Prompts> {
218        let obj = cfg.get("prompts")?.as_object()?;
219        let mut table: Vec<(String, String)> = obj
220            .iter()
221            .filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_string())))
222            .collect();
223        table.sort();
224        if table.is_empty() {
225            None
226        } else {
227            Some(Prompts { table })
228        }
229    }
230
231    /// The prefix for `name`: exact match first, then case-insensitive.
232    pub fn get(&self, name: &str) -> Option<&str> {
233        self.table
234            .iter()
235            .find(|(k, _)| k == name)
236            .or_else(|| {
237                self.table
238                    .iter()
239                    .find(|(k, _)| k.eq_ignore_ascii_case(name))
240            })
241            .map(|(_, v)| v.as_str())
242    }
243
244    pub fn names(&self) -> Vec<&str> {
245        self.table.iter().map(|(k, _)| k.as_str()).collect()
246    }
247
248    /// The text the model sees for one input.
249    ///
250    /// * `prompt` (a raw prefix) wins over everything;
251    /// * a named prompt is prepended; a Document prompt takes `title`
252    ///   (`title: {title} | text: `, `title: none` without one);
253    /// * a `title` with no prompt name means the Document prompt;
254    /// * neither: the text as given (the release sets no default prompt).
255    pub fn format(
256        &self,
257        text: &str,
258        prompt_name: Option<&str>,
259        title: Option<&str>,
260        prompt: Option<&str>,
261    ) -> Result<String, String> {
262        if let Some(p) = prompt {
263            if title.is_some() {
264                return Err("title applies to the Document prompt, not to a raw prompt".into());
265            }
266            return Ok(format!("{p}{text}"));
267        }
268        let prefix = match prompt_name {
269            Some(n) => self.get(n).ok_or_else(|| {
270                format!(
271                    "unknown prompt name '{n}' (known: {})",
272                    self.names().join(", ")
273                )
274            })?,
275            None if title.is_some() => DOC_PREFIX,
276            None => return Ok(text.to_string()),
277        };
278        match title {
279            Some(t) => {
280                let rest = prefix.strip_prefix("title: none").ok_or_else(|| {
281                    format!(
282                        "title applies to the Document prompt, not to '{}'",
283                        prompt_name.unwrap_or("")
284                    )
285                })?;
286                let t = t.trim();
287                let t = if t.is_empty() { "none" } else { t };
288                Ok(format!("title: {t}{rest}{text}"))
289            }
290            None => Ok(format!("{prefix}{text}")),
291        }
292    }
293}
294
295/// `[BOS] + ids + [EOS]`, the body truncated so the whole stays within
296/// `max_tokens` (the special tokens are kept, as a truncating HF tokenizer
297/// keeps them).
298pub fn wrap_ids(ids: &[u32], bos: u32, eos: u32, max_tokens: usize) -> Vec<u32> {
299    let body = ids.len().min(max_tokens.saturating_sub(2));
300    let mut out = Vec::with_capacity(body + 2);
301    out.push(bos);
302    out.extend_from_slice(&ids[..body]);
303    out.push(eos);
304    out
305}
306
307// ------------------------------------------------------------ weights
308
309/// A projection `y = x·Wᵀ`: owned f32 (the exact profiles dequantize once)
310/// or a mapped quantized tensor on the engine's kernels.
311pub(crate) enum Mat {
312    F32 {
313        w: Vec<f32>,
314        rows: usize,
315        cols: usize,
316    },
317    Q(QTensor),
318}
319
320impl Mat {
321    /// `dequant`: widen a quantized matrix to f32 once (Accelerate GEMM,
322    /// 4 bytes a weight) instead of running the quantized kernels on it.
323    pub(crate) fn load(model: &Arc<CmfModel>, name: &str, dequant: bool) -> Result<Mat, String> {
324        let e = model
325            .tensor(name)
326            .ok_or_else(|| format!("missing tensor {name}"))?;
327        if dequant && e.shape.len() == 2 {
328            let (rows, cols) = (e.shape[0], e.shape[1]);
329            return Ok(Mat::F32 {
330                w: vecf(model, name)?,
331                rows,
332                cols,
333            });
334        }
335        Ok(match QTensor::from_model(model, name)? {
336            QTensor::F32 { data, rows, cols } => Mat::F32 {
337                w: data,
338                rows,
339                cols,
340            },
341            q => Mat::Q(q),
342        })
343    }
344
345    pub(crate) fn rows(&self) -> usize {
346        match self {
347            Mat::F32 { rows, .. } => *rows,
348            Mat::Q(q) => q.rows(),
349        }
350    }
351
352    /// The f32 weights, when the matrix holds them.
353    #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
354    pub(crate) fn f32_weights(&self) -> Option<&[f32]> {
355        match self {
356            Mat::F32 { w, .. } => Some(w),
357            Mat::Q(_) => None,
358        }
359    }
360
361    pub(crate) fn apply(&self, x: &[f32], n: usize, pool: Option<&Pool>) -> Vec<f32> {
362        let t0 = std::time::Instant::now();
363        let mut out = vec![0f32; n * self.rows()];
364        match self {
365            Mat::F32 { w, rows, cols } => gemm_nt(x, w, &mut out, n, *cols, *rows, pool),
366            Mat::Q(q) => q.matmat(x, n, &mut out, pool),
367        }
368        prof::add(&prof::LIN, t0);
369        out
370    }
371}
372
373/// Is Apple's Accelerate the f32 GEMM (macOS, unless `CMF_ACCEL=0`)?
374pub(crate) fn accelerate() -> bool {
375    cfg!(target_os = "macos") && std::env::var("CMF_ACCEL").as_deref() != Ok("0")
376}
377
378/// `y[n,m] = x[n,k] · w[m,k]ᵀ`, row-major, f32. Accelerate on macOS;
379/// elsewhere `matrixmultiply`'s packed kernels (AVX/FMA/AVX-512, NEON) on
380/// blocks of the output across the pool — the engine's portable fallback
381/// is a dot product per output (8.6x slower measured on the M4 with
382/// `CMF_ACCEL=0`).
383pub(crate) fn gemm_nt(
384    x: &[f32],
385    w: &[f32],
386    y: &mut [f32],
387    n: usize,
388    k: usize,
389    m: usize,
390    pool: Option<&Pool>,
391) {
392    assert!(x.len() >= n * k && w.len() >= m * k && y.len() >= n * m);
393    if accelerate() {
394        return crate::fcd_ops::gemm_nt_host(x, w, y, n, k, m, pool);
395    }
396    gemm_nt_portable(x, w, y, n, k, m, pool)
397}
398
399/// [`gemm_nt`] without Accelerate (compiled everywhere, so the macOS tests
400/// cover it).
401fn gemm_nt_portable(
402    x: &[f32],
403    w: &[f32],
404    y: &mut [f32],
405    n: usize,
406    k: usize,
407    m: usize,
408    pool: Option<&Pool>,
409) {
410    assert!(x.len() >= n * k && w.len() >= m * k && y.len() >= n * m);
411    if n == 0 || m == 0 {
412        return;
413    }
414    // Row blocks when there are enough rows to share; otherwise blocks of
415    // output columns (a single query's projections).
416    let threads = pool.map(|p| p.n_workers()).unwrap_or(1).max(1);
417    let by_rows = n >= 32 * threads || n >= m;
418    let (len, unit) = if by_rows { (n, 32) } else { (m, 64) };
419    let chunk = len.div_ceil(threads).div_ceil(unit) * unit;
420    let blocks = len.div_ceil(chunk);
421    // the output's address, shared with the workers as an integer
422    let ybase = y.as_mut_ptr() as usize;
423    let run = |b: usize| {
424        let yp = ybase as *mut f32;
425        let (lo, hi) = (b * chunk, ((b + 1) * chunk).min(len));
426        // SAFETY: blocks write disjoint rows (or columns) of y; the strides
427        // describe x[n,k] row-major, wᵀ[k,m] (w row-major) and y[n,m].
428        unsafe {
429            if by_rows {
430                matrixmultiply::sgemm(
431                    hi - lo,
432                    k,
433                    m,
434                    1.0,
435                    x.as_ptr().add(lo * k),
436                    k as isize,
437                    1,
438                    w.as_ptr(),
439                    1,
440                    k as isize,
441                    0.0,
442                    yp.add(lo * m),
443                    m as isize,
444                    1,
445                );
446            } else {
447                matrixmultiply::sgemm(
448                    n,
449                    k,
450                    hi - lo,
451                    1.0,
452                    x.as_ptr(),
453                    k as isize,
454                    1,
455                    w.as_ptr().add(lo * k),
456                    1,
457                    k as isize,
458                    0.0,
459                    yp.add(lo),
460                    m as isize,
461                    1,
462                );
463            }
464        }
465    };
466    if blocks <= 1 {
467        run(0);
468    } else {
469        rows(pool, blocks, &|s, e| (s..e).for_each(run));
470    }
471}
472
473/// The token table, read a row at a time. A bf16 table stays in the mapping
474/// (half a gigabyte of f32 otherwise).
475enum Table {
476    Bf16 {
477        model: Arc<CmfModel>,
478        idx: usize,
479        rows: usize,
480        cols: usize,
481    },
482    Q(QTensor),
483}
484
485impl Table {
486    fn load(model: &Arc<CmfModel>, name: &str) -> Result<Table, String> {
487        let idx = model
488            .tensor_index(name)
489            .ok_or_else(|| format!("missing tensor {name}"))?;
490        let e = &model.tensors[idx];
491        if e.shape.len() != 2 {
492            return Err(format!("{name}: expected a 2-D table"));
493        }
494        if e.dtype == TensorDtype::Bf16 {
495            return Ok(Table::Bf16 {
496                model: model.clone(),
497                idx,
498                rows: e.shape[0],
499                cols: e.shape[1],
500            });
501        }
502        Ok(Table::Q(QTensor::from_model(model, name)?))
503    }
504
505    fn rows(&self) -> usize {
506        match self {
507            Table::Bf16 { rows, .. } => *rows,
508            Table::Q(q) => q.rows(),
509        }
510    }
511
512    fn row(&self, r: usize, dst: &mut [f32]) {
513        match self {
514            Table::Bf16 {
515                model, idx, cols, ..
516            } => {
517                let bytes = model.entry_bytes(&model.tensors[*idx]);
518                let src = &bytes[r * cols * 2..(r + 1) * cols * 2];
519                for (d, c) in dst.iter_mut().zip(src.chunks_exact(2)) {
520                    *d = cortiq_core::quant::bf16_to_f32(u16::from_le_bytes([c[0], c[1]]));
521                }
522            }
523            Table::Q(q) => q.row_f32(r, dst),
524        }
525    }
526}
527
528pub(crate) fn vecf(model: &CmfModel, name: &str) -> Result<Vec<f32>, String> {
529    crate::dit::cmf_f32(model, name)
530}
531
532struct Layer {
533    q: Mat,
534    k: Mat,
535    v: Mat,
536    o: Mat,
537    q_norm: Vec<f32>,
538    k_norm: Vec<f32>,
539    in_norm: Vec<f32>,
540    post_attn_norm: Vec<f32>,
541    pre_ff_norm: Vec<f32>,
542    post_ff_norm: Vec<f32>,
543    gate: Mat,
544    up: Mat,
545    down: Mat,
546    ple_gate: Mat,
547    ple_proj: Mat,
548    ple_norm: Vec<f32>,
549    /// this layer's `[512, 512]` slice of the per-layer-input projection
550    ple_in: Mat,
551    scalar: f32,
552    head_dim: usize,
553    q_heads: usize,
554    kv_heads: usize,
555    /// `Some(w)`: a token attends to keys with `|i - j| <= w`
556    window: Option<usize>,
557    theta: f32,
558}
559
560/// RoPE tables for one `(head_dim, theta)`: `[pos, head_dim/2]`, computed
561/// the way the reference does (f32 inverse frequencies, f32 angles).
562struct Rot {
563    cos: Vec<f32>,
564    sin: Vec<f32>,
565    half: usize,
566}
567
568impl Rot {
569    fn build(len: usize, head_dim: usize, theta: f32) -> Rot {
570        let half = head_dim / 2;
571        let inv: Vec<f32> = (0..half)
572            .map(|j| 1.0f32 / theta.powf((2 * j) as f32 / head_dim as f32))
573            .collect();
574        let mut cos = vec![0f32; len * half];
575        let mut sin = vec![0f32; len * half];
576        for p in 0..len {
577            for (j, &f) in inv.iter().enumerate() {
578                let a = (p as f32 * f) as f64;
579                cos[p * half + j] = a.cos() as f32;
580                sin[p * half + j] = a.sin() as f32;
581            }
582        }
583        Rot { cos, sin, half }
584    }
585
586    /// `x·cos + rotate_half(x)·sin` over the whole head.
587    fn apply(&self, pos: usize, x: &mut [f32]) {
588        let h = self.half;
589        let (c, s) = (
590            &self.cos[pos * h..(pos + 1) * h],
591            &self.sin[pos * h..(pos + 1) * h],
592        );
593        for i in 0..h {
594            let (a, b) = (x[i], x[i + h]);
595            x[i] = a * c[i] - b * s[i];
596            x[i + h] = b * c[i] + a * s[i];
597        }
598    }
599}
600
601/// RMS-normalize `x` (`[n, d]`) row by row, times `w` when given.
602pub(crate) fn rms_rows(x: &[f32], w: Option<&[f32]>, d: usize, pool: Option<&Pool>) -> Vec<f32> {
603    let n = x.len() / d;
604    let mut out = vec![0f32; x.len()];
605    let dst = Shared(out.as_mut_ptr());
606    rows(pool, n, &|s, e| {
607        let o = unsafe { dst.at(s * d, (e - s) * d) };
608        for (orow, xrow) in o.chunks_exact_mut(d).zip(x[s * d..e * d].chunks_exact(d)) {
609            rms_into(xrow, w, orow);
610        }
611    });
612    out
613}
614
615#[inline]
616pub(crate) fn rms_into(x: &[f32], w: Option<&[f32]>, out: &mut [f32]) {
617    let ss = x.iter().map(|&v| (v as f64) * (v as f64)).sum::<f64>() / x.len() as f64;
618    let inv = 1.0 / (ss + EPS).sqrt();
619    match w {
620        Some(w) => {
621            for ((o, &v), &g) in out.iter_mut().zip(x).zip(w) {
622                *o = (v as f64 * inv) as f32 * g;
623            }
624        }
625        None => {
626            for (o, &v) in out.iter_mut().zip(x) {
627                *o = (v as f64 * inv) as f32;
628            }
629        }
630    }
631}
632
633/// `h += rms_w(y)` row by row.
634pub(crate) fn add_normed(h: &mut [f32], y: &[f32], w: &[f32], d: usize, pool: Option<&Pool>) {
635    let n = h.len() / d;
636    let dst = Shared(h.as_mut_ptr());
637    rows(pool, n, &|s, e| {
638        let hh = unsafe { dst.at(s * d, (e - s) * d) };
639        let mut tmp = vec![0f32; d];
640        for (hrow, yrow) in hh.chunks_exact_mut(d).zip(y[s * d..e * d].chunks_exact(d)) {
641            rms_into(yrow, Some(w), &mut tmp);
642            for (a, &b) in hrow.iter_mut().zip(&tmp) {
643                *a += b;
644            }
645        }
646    });
647}
648
649/// `x[i] = gelu_tanh(x[i]) * y[i]`.
650///
651/// `0.5·x·(1 + tanh z)` is `x·σ(2z)`, so one exponential (NEON, |rel err| <
652/// 2e-7) and one division replace libm's `tanh` — which, at 2560 values a
653/// token a layer, was most of the forward outside the GEMMs.
654#[inline]
655fn gelu_mul_slice(x: &mut [f32], y: &[f32]) {
656    #[allow(unused_mut)] // only the NEON body advances it
657    let mut i = 0usize;
658    #[cfg(target_arch = "aarch64")]
659    // SAFETY: every lane read/written is below `x.len()`, `y` is as long.
660    unsafe {
661        use core::arch::aarch64::*;
662        debug_assert!(y.len() >= x.len());
663        let k = vdupq_n_f32(-2.0 * 0.797_884_56);
664        let c = vdupq_n_f32(0.044715);
665        let one = vdupq_n_f32(1.0);
666        while i + 4 <= x.len() {
667            let v = vld1q_f32(x.as_ptr().add(i));
668            let v3 = vmulq_f32(vmulq_f32(v, v), v);
669            let e = crate::attention::vexpq_f32(vmulq_f32(k, vfmaq_f32(v, c, v3)));
670            let g = vdivq_f32(v, vaddq_f32(one, e));
671            vst1q_f32(
672                x.as_mut_ptr().add(i),
673                vmulq_f32(g, vld1q_f32(y.as_ptr().add(i))),
674            );
675            i += 4;
676        }
677    }
678    for (a, &b) in x[i..].iter_mut().zip(&y[i..]) {
679        *a = gelu_tanh(*a) * b;
680    }
681}
682
683/// `a = gelu_tanh(a) * b`, elementwise, across the pool.
684pub(crate) fn gelu_mul(a: &mut [f32], b: &[f32], pool: Option<&Pool>) {
685    let n = a.len();
686    let grain = 16384usize;
687    let dst = Shared(a.as_mut_ptr());
688    rows(pool, n.div_ceil(grain), &|s, e| {
689        let (lo, hi) = (s * grain, (e * grain).min(n));
690        let r = unsafe { dst.at(lo, hi - lo) };
691        gelu_mul_slice(r, &b[lo..hi]);
692    });
693}
694
695/// `y[n,m] = x[n,k] · w[m,k]ᵀ` where row `r` of `w` starts at `w[r·ldw]`.
696fn gemm_nt_strided(x: &[f32], w: &[f32], ldw: usize, y: &mut [f32], n: usize, k: usize, m: usize) {
697    assert!(m == 0 || w.len() >= (m - 1) * ldw + k);
698    assert!(x.len() >= n * k && y.len() >= n * m);
699    if !accelerate() {
700        if n > 0 && m > 0 {
701            // SAFETY: bounds checked above; wᵀ[k,m] has row stride 1 and
702            // column stride ldw
703            unsafe {
704                matrixmultiply::sgemm(
705                    n,
706                    k,
707                    m,
708                    1.0,
709                    x.as_ptr(),
710                    k as isize,
711                    1,
712                    w.as_ptr(),
713                    1,
714                    ldw as isize,
715                    0.0,
716                    y.as_mut_ptr(),
717                    m as isize,
718                    1,
719                );
720            }
721        }
722        return;
723    }
724    if ldw == k {
725        return crate::fcd_ops::gemm_nt_host(x, &w[..m * k], y, n, k, m, None);
726    }
727    #[cfg(target_os = "macos")]
728    {
729        #[link(name = "Accelerate", kind = "framework")]
730        unsafe extern "C" {
731            fn cblas_sgemm(
732                order: i32,
733                ta: i32,
734                tb: i32,
735                m: i32,
736                n: i32,
737                k: i32,
738                alpha: f32,
739                a: *const f32,
740                lda: i32,
741                b: *const f32,
742                ldb: i32,
743                beta: f32,
744                c: *mut f32,
745                ldc: i32,
746            );
747        }
748        // SAFETY: the bounds were checked above; row-major (101), x·wᵀ.
749        unsafe {
750            cblas_sgemm(
751                101,
752                111,
753                112,
754                n as i32,
755                m as i32,
756                k as i32,
757                1.0,
758                x.as_ptr(),
759                k as i32,
760                w.as_ptr(),
761                ldw as i32,
762                0.0,
763                y.as_mut_ptr(),
764                m as i32,
765            );
766        }
767    }
768}
769
770/// Softmax of `row` in place (finite inputs) — NEON on aarch64.
771pub(crate) fn softmax(row: &mut [f32]) {
772    #[cfg(target_arch = "aarch64")]
773    {
774        crate::attention::softmax_row(row);
775    }
776    #[cfg(not(target_arch = "aarch64"))]
777    softmax_scalar(row);
778}
779
780#[cfg_attr(target_arch = "aarch64", allow(dead_code))]
781fn softmax_scalar(row: &mut [f32]) {
782    let mx = row.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
783    let mut den = 0f64;
784    for r in row.iter_mut() {
785        *r = (*r - mx).exp();
786        den += *r as f64;
787    }
788    let inv = (1.0 / den) as f32;
789    for r in row.iter_mut() {
790        *r *= inv;
791    }
792}
793
794// ------------------------------------------------------------ the model
795
796/// The EmbeddingGemma 2 text encoder (and the shared pieces every modality
797/// path goes through: the tokenizer, the prompt table, the head).
798pub struct EmbeddingGemma2 {
799    /// the device forward (macOS / Metal); first, so it drops before the
800    /// host weights it wraps
801    #[cfg(target_os = "macos")]
802    gpu: Option<crate::gpu_metal::egemma2::TextGpu>,
803    /// run the host forward even when the device one is up (A/B, tests)
804    host_only: std::sync::atomic::AtomicBool,
805    tok: Tokenizer,
806    prompts: Prompts,
807    embed: Table,
808    layers: Vec<Layer>,
809    ple_norm: Vec<f32>,
810    norm: Vec<f32>,
811    head: Mat,
812    hidden: usize,
813    embed_scale: f32,
814    n_ple: usize,
815    pub dim: usize,
816    pub max_tokens: usize,
817    pub bos: u32,
818    pub eos: u32,
819    pool: Option<Arc<Pool>>,
820    /// one forward at a time: the pool is not re-entrant
821    busy: Mutex<()>,
822    /// the codec profile the file was packed with (provenance `quant`)
823    pub quant: String,
824}
825
826/// One text to embed.
827#[derive(Clone, Debug, Default)]
828pub struct TextInput {
829    pub text: String,
830    pub prompt_name: Option<String>,
831    pub title: Option<String>,
832    /// a raw prefix, instead of a named prompt
833    pub prompt: Option<String>,
834}
835
836impl TextInput {
837    pub fn plain(text: impl Into<String>) -> Self {
838        TextInput {
839            text: text.into(),
840            ..Default::default()
841        }
842    }
843}
844
845impl EmbeddingGemma2 {
846    /// Load from an EmbeddingGemma 2 pack. `pool`: the worker pool for the
847    /// row-parallel work (`Pool::from_env()` when `None` is not wanted).
848    pub fn load(model: &Arc<CmfModel>, pool: Option<Arc<Pool>>) -> Result<Self, String> {
849        if !is_embedding_gemma2(model) {
850            return Err(format!(
851                "not an EmbeddingGemma 2 file (arch '{}')",
852                model.header.arch.arch_name
853            ));
854        }
855        let prov = model
856            .header
857            .provenance
858            .clone()
859            .unwrap_or(serde_json::Value::Null);
860        let eg = &prov["embedding_gemma2"];
861        let tc = &eg["config"]["text_config"];
862        let g = |k: &str, d: u64| tc.get(k).and_then(|v| v.as_u64()).unwrap_or(d) as usize;
863        let hidden = g("hidden_size", 512);
864        let n_layers = g("num_hidden_layers", 24);
865        let n_heads = g("num_attention_heads", 4);
866        let head_dim = g("head_dim", 256);
867        let kv_heads = g("num_key_value_heads", 2);
868        let window = g("sliding_window", 512);
869        let types: Vec<bool> = match tc.get("layer_types").and_then(|v| v.as_array()) {
870            Some(a) => a
871                .iter()
872                .map(|s| s.as_str() == Some("full_attention"))
873                .collect(),
874            None => (0..n_layers).map(|i| i % 6 == 5).collect(),
875        };
876        let theta = |kind: &str, d: f64| {
877            tc["rope_parameters"][kind]["rope_theta"]
878                .as_f64()
879                .unwrap_or(d) as f32
880        };
881        let (theta_slide, theta_full) = (
882            theta("sliding_attention", 1e4),
883            theta("full_attention", 1e6),
884        );
885        let per_layer = &tc["per_layer_config"];
886        let ple_dim = g("hidden_size_per_layer_input", 512);
887        // A quantized text stack is widened to f32 at load: 130 M weights are
888        // ~0.5 GB of RAM, and the f32 GEMM runs ~2.5x faster than the q8
889        // kernels on these shapes (the token table stays mapped either way).
890        // `CMF_EGEMMA2_LOWMEM=1` keeps the quantized kernels.
891        let dequant = !std::env::var("CMF_EGEMMA2_LOWMEM").is_ok_and(|v| v == "1");
892
893        // The per-layer-input projection, split into one [ple_dim, hidden]
894        // matrix per layer (each layer reads only its own slice).
895        let ple_all = vecf(
896            model,
897            "language_model.ple.per_layer_model_projection.weight",
898        )?;
899        if ple_all.len() != n_layers * ple_dim * hidden {
900            return Err(format!(
901                "per_layer_model_projection: {} values, expected {}",
902                ple_all.len(),
903                n_layers * ple_dim * hidden
904            ));
905        }
906
907        let mut layers = Vec::with_capacity(n_layers);
908        for i in 0..n_layers {
909            let p = format!("language_model.layers.{i}");
910            let full = types.get(i).copied().unwrap_or(i % 6 == 5);
911            let key = format!("{i:02}");
912            let lc = &per_layer[key.as_str()];
913            let hd = lc["head_dim"]
914                .as_u64()
915                .map(|v| v as usize)
916                .unwrap_or(if full { head_dim * 2 } else { head_dim });
917            let nkv = lc["num_key_value_heads"]
918                .as_u64()
919                .map(|v| v as usize)
920                .unwrap_or(if full { 1 } else { kv_heads });
921            let ld = |s: &str| Mat::load(model, &format!("{p}.{s}"), dequant);
922            let q = ld("self_attn.q_proj.weight")?;
923            let k = ld("self_attn.k_proj.weight")?;
924            if q.rows() != n_heads * hd || k.rows() != nkv * hd {
925                return Err(format!(
926                    "layer {i}: q/k rows {}/{} do not match {n_heads}x{hd} / {nkv}x{hd}",
927                    q.rows(),
928                    k.rows()
929                ));
930            }
931            layers.push(Layer {
932                q,
933                k,
934                v: ld("self_attn.v_proj.weight")?,
935                o: ld("self_attn.o_proj.weight")?,
936                q_norm: vecf(model, &format!("{p}.self_attn.q_norm.weight"))?,
937                k_norm: vecf(model, &format!("{p}.self_attn.k_norm.weight"))?,
938                in_norm: vecf(model, &format!("{p}.input_layernorm.weight"))?,
939                post_attn_norm: vecf(model, &format!("{p}.post_attention_layernorm.weight"))?,
940                pre_ff_norm: vecf(model, &format!("{p}.pre_feedforward_layernorm.weight"))?,
941                post_ff_norm: vecf(model, &format!("{p}.post_feedforward_layernorm.weight"))?,
942                gate: ld("mlp.gate_proj.weight")?,
943                up: ld("mlp.up_proj.weight")?,
944                down: ld("mlp.down_proj.weight")?,
945                ple_gate: ld("ple_block.per_layer_input_gate.weight")?,
946                ple_proj: ld("ple_block.per_layer_projection.weight")?,
947                ple_norm: vecf(
948                    model,
949                    &format!("{p}.ple_block.post_per_layer_input_norm.weight"),
950                )?,
951                ple_in: Mat::F32 {
952                    w: ple_all[i * ple_dim * hidden..(i + 1) * ple_dim * hidden].to_vec(),
953                    rows: ple_dim,
954                    cols: hidden,
955                },
956                scalar: vecf(model, &format!("{p}.layer_scalar"))?[0],
957                head_dim: hd,
958                q_heads: n_heads,
959                kv_heads: nkv,
960                window: if full { None } else { Some(window) },
961                theta: if full { theta_full } else { theta_slide },
962            });
963        }
964        drop(ple_all);
965
966        let vocab = model
967            .vocab
968            .as_deref()
969            .ok_or("EmbeddingGemma 2 file has no embedded tokenizer")?;
970        let tok = Tokenizer::from_bytes(vocab).map_err(|e| format!("tokenizer: {e}"))?;
971        let cfg = &eg["config"];
972        let bos = tc["bos_token_id"]
973            .as_u64()
974            .or_else(|| cfg["bos_token_id"].as_u64())
975            .unwrap_or(2) as u32;
976        let eos = tc["eos_token_id"]
977            .as_u64()
978            .or_else(|| cfg["eos_token_id"].as_u64())
979            .unwrap_or(1) as u32;
980        let prompts = Prompts::from_st_config(&eg["sentence_transformers"]).unwrap_or_default();
981        let head = Mat::load(model, "language_model.embedding_projection.weight", true)?;
982        let embed = Table::load(model, "language_model.embed_tokens.weight")?;
983        let dim = head.rows();
984        // mutated only where the device path exists
985        #[cfg_attr(not(target_os = "macos"), allow(unused_mut))]
986        let mut enc = EmbeddingGemma2 {
987            #[cfg(target_os = "macos")]
988            gpu: None,
989            host_only: std::sync::atomic::AtomicBool::new(false),
990            tok,
991            prompts,
992            embed,
993            layers,
994            ple_norm: vecf(model, "language_model.ple.per_layer_projection_norm.weight")?,
995            norm: vecf(model, "language_model.norm.weight")?,
996            head,
997            hidden,
998            embed_scale: (hidden as f32).sqrt(),
999            n_ple: ple_dim,
1000            dim,
1001            max_tokens: eg["max_tokens"]
1002                .as_u64()
1003                .map(|v| v as usize)
1004                .unwrap_or(MAX_TOKENS),
1005            bos,
1006            eos,
1007            pool,
1008            busy: Mutex::new(()),
1009            quant: prov["quant"].as_str().unwrap_or("").to_string(),
1010        };
1011        #[cfg(target_os = "macos")]
1012        if dequant && crate::gpu_metal::egemma2::enabled() {
1013            match enc.device_encoder() {
1014                Ok(g) => enc.gpu = Some(g),
1015                Err(e) => tracing::info!("egemma2: Metal text path off ({e}); the CPU runs it"),
1016            }
1017        }
1018        Ok(enc)
1019    }
1020
1021    /// The device forward over this encoder's own f32 weights.
1022    #[cfg(target_os = "macos")]
1023    fn device_encoder(&self) -> Result<crate::gpu_metal::egemma2::TextGpu, String> {
1024        use crate::gpu_metal::egemma2::{LayerSpec, TextGpu, TextSpec};
1025        fn f(m: &Mat) -> Result<&[f32], String> {
1026            m.f32_weights()
1027                .ok_or_else(|| "a quantized matrix (low-memory mode)".to_string())
1028        }
1029        let mut ropes: Vec<(usize, u32, Vec<f32>, Vec<f32>)> = Vec::new();
1030        let mut layers = Vec::with_capacity(self.layers.len());
1031        let mut inter = 0usize;
1032        for l in &self.layers {
1033            let key = (l.head_dim, l.theta.to_bits());
1034            let rope = match ropes.iter().position(|r| (r.0, r.1) == key) {
1035                Some(i) => i,
1036                None => {
1037                    let r = Rot::build(self.max_tokens, l.head_dim, l.theta);
1038                    ropes.push((key.0, key.1, r.cos, r.sin));
1039                    ropes.len() - 1
1040                }
1041            };
1042            inter = l.gate.rows();
1043            layers.push(LayerSpec {
1044                q: f(&l.q)?,
1045                k: f(&l.k)?,
1046                v: f(&l.v)?,
1047                o: f(&l.o)?,
1048                gate: f(&l.gate)?,
1049                up: f(&l.up)?,
1050                down: f(&l.down)?,
1051                ple_in: f(&l.ple_in)?,
1052                ple_gate: f(&l.ple_gate)?,
1053                ple_proj: f(&l.ple_proj)?,
1054                q_norm: &l.q_norm,
1055                k_norm: &l.k_norm,
1056                in_norm: &l.in_norm,
1057                post_attn_norm: &l.post_attn_norm,
1058                pre_ff_norm: &l.pre_ff_norm,
1059                post_ff_norm: &l.post_ff_norm,
1060                ple_norm: &l.ple_norm,
1061                head_dim: l.head_dim,
1062                q_heads: l.q_heads,
1063                kv_heads: l.kv_heads,
1064                window: l.window,
1065                scalar: l.scalar,
1066                rope,
1067            });
1068        }
1069        TextGpu::new(&TextSpec {
1070            hidden: self.hidden,
1071            inter,
1072            n_ple: self.n_ple,
1073            eps: EPS as f32,
1074            ple_scale: (self.hidden as f32).powf(-0.5),
1075            ple_norm: &self.ple_norm,
1076            norm: &self.norm,
1077            layers,
1078            ropes: ropes.into_iter().map(|(hd, _, c, s)| (hd, c, s)).collect(),
1079            max_tokens: self.max_tokens,
1080        })
1081    }
1082
1083    /// Run the host forward (`true`) even when the device one is up.
1084    pub fn force_host(&self, on: bool) {
1085        self.host_only
1086            .store(on, std::sync::atomic::Ordering::Relaxed);
1087    }
1088
1089    /// Does the forward run on the device (Metal)?
1090    pub fn on_device(&self) -> bool {
1091        #[cfg(target_os = "macos")]
1092        let up = self.gpu.is_some();
1093        #[cfg(not(target_os = "macos"))]
1094        let up = false;
1095        up && !self.host_only.load(std::sync::atomic::Ordering::Relaxed)
1096    }
1097
1098    pub fn prompts(&self) -> &Prompts {
1099        &self.prompts
1100    }
1101
1102    pub fn tokenizer(&self) -> &Tokenizer {
1103        &self.tok
1104    }
1105
1106    /// The model-facing text of one input (prompt and title applied).
1107    pub fn format(&self, input: &TextInput) -> Result<String, String> {
1108        self.prompts.format(
1109            &input.text,
1110            input.prompt_name.as_deref(),
1111            input.title.as_deref(),
1112            input.prompt.as_deref(),
1113        )
1114    }
1115
1116    /// `[BOS] + tokens(text) + [EOS]`, capped at `max_tokens`.
1117    pub fn tokenize(&self, full_text: &str) -> Vec<u32> {
1118        wrap_ids(
1119            &self.tok.encode(full_text),
1120            self.bos,
1121            self.eos,
1122            self.max_tokens,
1123        )
1124    }
1125
1126    /// Token ids of one input, ready for [`embed_ids`](Self::embed_ids).
1127    pub fn input_ids(&self, input: &TextInput) -> Result<Vec<u32>, String> {
1128        Ok(self.tokenize(&self.format(input)?))
1129    }
1130
1131    /// Embed texts: unit-length 768-d vectors, one per input, in order.
1132    pub fn embed_texts(&self, inputs: &[TextInput]) -> Result<Vec<Vec<f32>>, String> {
1133        let ids: Vec<Vec<u32>> = inputs
1134            .iter()
1135            .map(|i| self.input_ids(i))
1136            .collect::<Result<_, _>>()?;
1137        self.embed_ids(&ids)
1138    }
1139
1140    /// Embed already-tokenized sequences (each including BOS/EOS). Returns
1141    /// unit-length vectors of `self.dim`, one per sequence, in order.
1142    pub fn embed_ids(&self, seqs: &[Vec<u32>]) -> Result<Vec<Vec<f32>>, String> {
1143        let vocab = self.embed.rows();
1144        for (i, s) in seqs.iter().enumerate() {
1145            if s.is_empty() {
1146                return Err(format!("input {i}: no tokens"));
1147            }
1148            if s.len() > self.max_tokens {
1149                return Err(format!(
1150                    "input {i}: {} tokens > the model's {} token context",
1151                    s.len(),
1152                    self.max_tokens
1153                ));
1154            }
1155            if let Some(&bad) = s.iter().find(|&&t| t as usize >= vocab) {
1156                return Err(format!("input {i}: token id {bad} outside the vocabulary"));
1157            }
1158        }
1159        let _g = self.busy.lock().unwrap_or_else(|e| e.into_inner());
1160        let mut out: Vec<Option<Vec<f32>>> = vec![None; seqs.len()];
1161        // Pack in input order up to PACK_TOKENS per forward.
1162        let mut start = 0usize;
1163        while start < seqs.len() {
1164            let mut end = start;
1165            let mut toks = 0usize;
1166            while end < seqs.len() && (end == start || toks + seqs[end].len() <= PACK_TOKENS) {
1167                toks += seqs[end].len();
1168                end += 1;
1169            }
1170            let x = self.embed_rows(&seqs[start..end]);
1171            let lens: Vec<usize> = seqs[start..end].iter().map(|s| s.len()).collect();
1172            let pooled = self.forward_pooled_locked(x, &lens);
1173            for (k, v) in pooled.into_iter().enumerate() {
1174                out[start + k] = Some(v);
1175            }
1176            start = end;
1177        }
1178        Ok(out.into_iter().map(|v| v.unwrap()).collect())
1179    }
1180
1181    /// The scaled token embeddings of packed sequences, `[sum T, hidden]`
1182    /// (`E[id]·sqrt(hidden)`): the text half of a merged multimodal input.
1183    pub fn embed_rows(&self, seqs: &[Vec<u32>]) -> Vec<f32> {
1184        let d = self.hidden;
1185        let n: usize = seqs.iter().map(|s| s.len()).sum();
1186        let mut x = vec![0f32; n * d];
1187        for (row, &id) in x.chunks_exact_mut(d).zip(seqs.iter().flatten()) {
1188            self.embed.row(id as usize, row);
1189            for v in row.iter_mut() {
1190                *v *= self.embed_scale;
1191            }
1192        }
1193        x
1194    }
1195
1196    /// The model width (512).
1197    pub fn hidden(&self) -> usize {
1198        self.hidden
1199    }
1200
1201    /// The forward over merged input embeddings `x` (`[sum lens, hidden]`,
1202    /// sequences back to back — token rows from [`embed_rows`](Self::embed_rows),
1203    /// media soft tokens unscaled in their placeholder rows): one
1204    /// unit-length vector per sequence.
1205    pub fn embed_merged(&self, x: Vec<f32>, lens: &[usize]) -> Vec<Vec<f32>> {
1206        let _g = self.busy.lock().unwrap_or_else(|e| e.into_inner());
1207        self.forward_pooled_locked(x, lens)
1208    }
1209
1210    fn forward_pooled_locked(&self, x: Vec<f32>, lens: &[usize]) -> Vec<Vec<f32>> {
1211        #[cfg(target_os = "macos")]
1212        if let Some(g) = self.gpu.as_ref().filter(|_| self.on_device()) {
1213            let d = self.hidden;
1214            let head = |pooled: Vec<f32>| -> Vec<Vec<f32>> {
1215                let nseq = pooled.len() / d;
1216                let y = self.head.apply(&pooled, nseq, self.pool.as_deref());
1217                y.chunks_exact(self.dim)
1218                    .map(|r| {
1219                        let mut v = r.to_vec();
1220                        l2_normalize(&mut v);
1221                        v
1222                    })
1223                    .collect()
1224            };
1225            // With `CMF_EGEMMA2_CPU_SHARE` the CPU takes the last sequences
1226            // of a big batch while the device runs the rest (off by
1227            // default: see `cpu_share`).
1228            let n: usize = lens.iter().sum();
1229            let share = cpu_share();
1230            let mut split = lens.len();
1231            if share > 0.0 && lens.len() >= 2 && n >= CO_MIN_TOKENS {
1232                let want = (n as f64 * share) as usize;
1233                let mut tail = 0usize;
1234                while split > 1 && tail + lens[split - 1] <= want {
1235                    split -= 1;
1236                    tail += lens[split];
1237                }
1238            }
1239            let gpu_rows: usize = lens[..split].iter().sum();
1240            let (xg, xc) = x.split_at(gpu_rows * d);
1241            let (gpu, host) = std::thread::scope(|sc| {
1242                let job = sc.spawn(|| g.forward(xg, &lens[..split]));
1243                let host = (split < lens.len()).then(|| {
1244                    let t = std::time::Instant::now();
1245                    let v = self.forward_host(xc.to_vec(), &lens[split..]);
1246                    (v, t.elapsed().as_secs_f64())
1247                });
1248                (job.join(), host)
1249            });
1250            match gpu {
1251                Ok(Ok(pooled)) => {
1252                    let mut out = head(pooled);
1253                    if let Some((v, secs)) = host {
1254                        if prof::on() {
1255                            eprintln!(
1256                                "egemma2 co-run: host {} seqs / {} tokens in {:.1} ms beside the device",
1257                                lens.len() - split,
1258                                n - gpu_rows,
1259                                secs * 1e3
1260                            );
1261                        }
1262                        out.extend(v);
1263                    }
1264                    return out;
1265                }
1266                Ok(Err(e)) => {
1267                    tracing::warn!("egemma2: Metal forward failed ({e}); the CPU runs it")
1268                }
1269                Err(_) => tracing::warn!("egemma2: Metal forward panicked; the CPU runs it"),
1270            }
1271        }
1272        self.forward_host(x, lens)
1273    }
1274
1275    /// The host forward (Accelerate / the engine's GEMM, the pool).
1276    fn forward_host(&self, x: Vec<f32>, lens: &[usize]) -> Vec<Vec<f32>> {
1277        let t_fwd = std::time::Instant::now();
1278        // the media towers run their projections through `Mat` too: start
1279        // this forward's split from zero
1280        let _ = (prof::take(&prof::LIN), prof::take(&prof::ATTN));
1281        let pool = self.pool.as_deref();
1282        let d = self.hidden;
1283        let n: usize = lens.iter().sum();
1284        debug_assert_eq!(x.len(), n * d);
1285        let maxlen = lens.iter().copied().max().unwrap_or(0);
1286        // segment starts and per-row positions
1287        let mut segs = Vec::with_capacity(lens.len());
1288        let mut pos = Vec::with_capacity(n);
1289        let mut off = 0usize;
1290        for &l in lens {
1291            segs.push((off, l));
1292            pos.extend(0..l);
1293            off += l;
1294        }
1295        let mut rots: Vec<((usize, u32), Rot)> = Vec::new();
1296        for l in &self.layers {
1297            let key = (l.head_dim, l.theta.to_bits());
1298            if !rots.iter().any(|(k, _)| *k == key) {
1299                rots.push((key, Rot::build(maxlen, l.head_dim, l.theta)));
1300            }
1301        }
1302        let ple_scale = (d as f32).powf(-0.5);
1303
1304        let mut h = x.clone();
1305        for l in &self.layers {
1306            let rot = &rots
1307                .iter()
1308                .find(|(k, _)| *k == (l.head_dim, l.theta.to_bits()))
1309                .unwrap()
1310                .1;
1311            // ── attention
1312            let a = rms_rows(&h, Some(&l.in_norm), d, pool);
1313            let attn = self.attention(l, &a, n, &segs, &pos, rot, pool);
1314            add_normed(&mut h, &attn, &l.post_attn_norm, d, pool);
1315            // ── gated FFN
1316            let m = rms_rows(&h, Some(&l.pre_ff_norm), d, pool);
1317            let mut g = l.gate.apply(&m, n, pool);
1318            let u = l.up.apply(&m, n, pool);
1319            gelu_mul(&mut g, &u, pool);
1320            let f = l.down.apply(&g, n, pool);
1321            add_normed(&mut h, &f, &l.post_ff_norm, d, pool);
1322            // ── per-layer input: this layer's slice of the PLE projection
1323            let mut pin = l.ple_in.apply(&x, n, pool);
1324            for v in pin.iter_mut() {
1325                *v *= ple_scale;
1326            }
1327            let pin = rms_rows(&pin, Some(&self.ple_norm), self.n_ple, pool);
1328            let mut z = l.ple_gate.apply(&h, n, pool);
1329            gelu_mul(&mut z, &pin, pool);
1330            let z = l.ple_proj.apply(&z, n, pool);
1331            add_normed(&mut h, &z, &l.ple_norm, d, pool);
1332            for v in h.iter_mut() {
1333                *v *= l.scalar;
1334            }
1335        }
1336        let hn = rms_rows(&h, Some(&self.norm), d, pool);
1337        let out = segs
1338            .iter()
1339            .map(|&(s, l)| {
1340                let mean = mean_pool(&hn[s * d..(s + l) * d], l, d);
1341                let mut v = self.head.apply(&mean, 1, pool);
1342                l2_normalize(&mut v);
1343                v
1344            })
1345            .collect();
1346        let (lin, attn) = (prof::take(&prof::LIN), prof::take(&prof::ATTN));
1347        if prof::on() {
1348            let total = t_fwd.elapsed().as_secs_f64() * 1e3;
1349            eprintln!(
1350                "egemma2 forward: {n} tokens / {} seqs in {total:.1} ms — projections {lin:.1}, attention core {attn:.1}, rest {:.1}",
1351                lens.len(),
1352                total - lin - attn
1353            );
1354        }
1355        out
1356    }
1357
1358    /// Self-attention of one layer over packed sequences; `a` is the
1359    /// normalized input `[n, hidden]`. Returns `o_proj(attn)`, `[n, hidden]`.
1360    #[allow(clippy::too_many_arguments)]
1361    fn attention(
1362        &self,
1363        l: &Layer,
1364        a: &[f32],
1365        n: usize,
1366        segs: &[(usize, usize)],
1367        pos: &[usize],
1368        rot: &Rot,
1369        pool: Option<&Pool>,
1370    ) -> Vec<f32> {
1371        let hd = l.head_dim;
1372        let (nq, nkv) = (l.q_heads, l.kv_heads);
1373        let (qw, kw) = (nq * hd, nkv * hd);
1374        let mut q = l.q.apply(a, n, pool);
1375        let mut k = l.k.apply(a, n, pool);
1376        let mut v = l.v.apply(a, n, pool);
1377        // q/k: RMS-norm with weight per head, then RoPE; v: RMS-norm, no weight
1378        {
1379            let (qp, kp, vp) = (
1380                Shared(q.as_mut_ptr()),
1381                Shared(k.as_mut_ptr()),
1382                Shared(v.as_mut_ptr()),
1383            );
1384            rows(pool, n, &|s, e| {
1385                let mut tmp = vec![0f32; hd];
1386                for t in s..e {
1387                    let qr = unsafe { qp.at(t * qw, qw) };
1388                    for hh in qr.chunks_exact_mut(hd) {
1389                        rms_into(hh, Some(&l.q_norm), &mut tmp);
1390                        hh.copy_from_slice(&tmp);
1391                        rot.apply(pos[t], hh);
1392                    }
1393                    let kr = unsafe { kp.at(t * kw, kw) };
1394                    for hh in kr.chunks_exact_mut(hd) {
1395                        rms_into(hh, Some(&l.k_norm), &mut tmp);
1396                        hh.copy_from_slice(&tmp);
1397                        rot.apply(pos[t], hh);
1398                    }
1399                    let vr = unsafe { vp.at(t * kw, kw) };
1400                    for hh in vr.chunks_exact_mut(hd) {
1401                        rms_into(hh, None, &mut tmp);
1402                        hh.copy_from_slice(&tmp);
1403                    }
1404                }
1405            });
1406        }
1407        let t_core = std::time::Instant::now();
1408        let mut out = vec![0f32; n * qw];
1409        let group = nq / nkv;
1410        // Short sequences: one (sequence, head) pair per work item, all of
1411        // them across the pool, each computed serially — a batch of queries
1412        // is thousands of tiny attentions, and a GEMM call (plus a pool
1413        // dispatch) per pair cost more than the arithmetic.
1414        let items: Vec<(usize, usize)> = segs
1415            .iter()
1416            .filter(|&&(_, len)| len <= SHORT_SEQ)
1417            .flat_map(|&(s0, len)| (0..nq).map(move |h| (s0 * nq + h, len)))
1418            .collect();
1419        if !items.is_empty() {
1420            let op = Shared(out.as_mut_ptr());
1421            let window = l.window;
1422            let (q, k, v) = (&q, &k, &v);
1423            rows(pool, items.len(), &|s, e| {
1424                let mut p = vec![0f32; SHORT_SEQ];
1425                for &(code, len) in &items[s..e] {
1426                    let (s0, qh) = (code / nq, code % nq);
1427                    let kvh = qh / group;
1428                    for i in 0..len {
1429                        let (lo, hi) = match window {
1430                            Some(w) => (i.saturating_sub(w), (i + w + 1).min(len)),
1431                            None => (0, len),
1432                        };
1433                        let qi = &q[(s0 + i) * qw + qh * hd..][..hd];
1434                        let row = &mut p[..hi - lo];
1435                        for (j, r) in (lo..hi).zip(row.iter_mut()) {
1436                            *r =
1437                                crate::attention::dot_f32(qi, &k[(s0 + j) * kw + kvh * hd..][..hd]);
1438                        }
1439                        softmax(row);
1440                        // SAFETY: (row, head) slices are disjoint across items
1441                        let o = unsafe { op.at((s0 + i) * qw + qh * hd, hd) };
1442                        o.iter_mut().for_each(|x| *x = 0.0);
1443                        for (j, &w) in (lo..hi).zip(row.iter()) {
1444                            crate::attention::axpy_f32(o, &v[(s0 + j) * kw + kvh * hd..][..hd], w);
1445                        }
1446                    }
1447                }
1448            });
1449        }
1450        // Long sequences: per (sequence, key head), blocked GEMMs. With
1451        // Accelerate one item at a time, its GEMMs and the softmax rows on
1452        // the pool; with the portable GEMM (single-threaded per call) the
1453        // items themselves go across the pool.
1454        let long: Vec<(usize, usize, usize)> = segs
1455            .iter()
1456            .filter(|&&(_, len)| len > SHORT_SEQ)
1457            .flat_map(|&(s0, len)| (0..nkv).map(move |kvh| (s0, len, kvh)))
1458            .collect();
1459        let op = Shared(out.as_mut_ptr());
1460        let (q, k, v) = (&q, &k, &v);
1461        let window = l.window;
1462        let attend = |s0: usize, len: usize, kvh: usize, inner: Option<&Pool>| {
1463            // this key head's keys [len, hd] and values transposed [hd, len]
1464            let mut kh = vec![0f32; len * hd];
1465            let mut vt = vec![0f32; hd * len];
1466            for t in 0..len {
1467                kh[t * hd..(t + 1) * hd].copy_from_slice(&k[(s0 + t) * kw + kvh * hd..][..hd]);
1468                let vr = &v[(s0 + t) * kw + kvh * hd..][..hd];
1469                for (c, &val) in vr.iter().enumerate() {
1470                    vt[c * len + t] = val;
1471                }
1472            }
1473            let qblock = if window.is_some() {
1474                QBLOCK
1475            } else {
1476                QBLOCK_FULL
1477            };
1478            let mut qb = vec![0f32; qblock.min(len) * hd];
1479            for qh in kvh * group..(kvh + 1) * group {
1480                let mut i0 = 0usize;
1481                while i0 < len {
1482                    let i1 = (i0 + qblock).min(len);
1483                    let nb = i1 - i0;
1484                    let (k0, k1) = match window {
1485                        Some(w) => (i0.saturating_sub(w), (i1 - 1 + w + 1).min(len)),
1486                        None => (0, len),
1487                    };
1488                    let kr = k1 - k0;
1489                    for i in 0..nb {
1490                        qb[i * hd..(i + 1) * hd]
1491                            .copy_from_slice(&q[(s0 + i0 + i) * qw + qh * hd..][..hd]);
1492                    }
1493                    let mut sc = vec![0f32; nb * kr];
1494                    gemm_nt(
1495                        &qb[..nb * hd],
1496                        &kh[k0 * hd..k1 * hd],
1497                        &mut sc,
1498                        nb,
1499                        hd,
1500                        kr,
1501                        None,
1502                    );
1503                    // softmax over each row's allowed span; zero outside
1504                    let sp = Shared(sc.as_mut_ptr());
1505                    rows(inner, nb, &|s, e| {
1506                        for i in s..e {
1507                            let row = unsafe { sp.at(i * kr, kr) };
1508                            let qi = i0 + i;
1509                            let (lo, hi) = match window {
1510                                Some(w) => (qi.saturating_sub(w), (qi + w + 1).min(len)),
1511                                None => (0, len),
1512                            };
1513                            let (lo, hi) = (lo - k0, hi - k0);
1514                            softmax(&mut row[lo..hi]);
1515                            row[..lo].iter_mut().for_each(|x| *x = 0.0);
1516                            row[hi..].iter_mut().for_each(|x| *x = 0.0);
1517                        }
1518                    });
1519                    // P · V over V's columns k0..k1 (rows of `vt`, stride len)
1520                    let mut ob = vec![0f32; nb * hd];
1521                    gemm_nt_strided(&sc, &vt[k0..], len, &mut ob, nb, kr, hd);
1522                    for i in 0..nb {
1523                        // SAFETY: (row, q head) slices are disjoint across items
1524                        let o = unsafe { op.at((s0 + i0 + i) * qw + qh * hd, hd) };
1525                        o.copy_from_slice(&ob[i * hd..(i + 1) * hd]);
1526                    }
1527                    i0 = i1;
1528                }
1529            }
1530        };
1531        if accelerate() || long.len() < 2 {
1532            for &(s0, len, kvh) in &long {
1533                attend(s0, len, kvh, pool);
1534            }
1535        } else {
1536            rows(pool, long.len(), &|s, e| {
1537                for &(s0, len, kvh) in &long[s..e] {
1538                    attend(s0, len, kvh, None);
1539                }
1540            });
1541        }
1542        prof::add(&prof::ATTN, t_core);
1543        l.o.apply(&out, n, pool)
1544    }
1545}
1546
1547#[cfg(test)]
1548mod tests {
1549    use super::*;
1550
1551    #[test]
1552    fn matryoshka_truncates_and_renormalizes() {
1553        let mut v: Vec<f32> = (0..768)
1554            .map(|i| ((i * 37 % 101) as f32 - 50.0) / 7.0)
1555            .collect();
1556        l2_normalize(&mut v);
1557        for d in MATRYOSHKA_DIMS {
1558            let t = matryoshka(&v, d).unwrap();
1559            assert_eq!(t.len(), d);
1560            let n: f64 = t.iter().map(|&x| (x as f64) * (x as f64)).sum();
1561            assert!((n - 1.0).abs() < 1e-6, "dim {d}: |v|² = {n}");
1562            // same direction as the plain prefix
1563            assert!(cosine(&t, &v[..d]) > 0.999_999);
1564        }
1565        assert_eq!(matryoshka(&v, 768).unwrap(), v);
1566        assert!(matryoshka(&v, 300).is_err());
1567        assert!(matryoshka(&v[..256], 512).is_err());
1568    }
1569
1570    /// The portable GEMM (row blocks, column blocks, one block, strided)
1571    /// against a plain triple loop.
1572    #[test]
1573    fn portable_gemm_matches_naive() {
1574        let pool = crate::pool::Pool::new(4);
1575        let mut st = 7u64;
1576        let mut rnd = || {
1577            st = st
1578                .wrapping_mul(6364136223846793005)
1579                .wrapping_add(1442695040888963407);
1580            ((st >> 40) as f32 / (1u64 << 24) as f32) - 0.5
1581        };
1582        for (n, k, m) in [
1583            (1, 512, 1024),
1584            (3, 64, 200),
1585            (300, 96, 70),
1586            (517, 33, 129),
1587            (64, 512, 512),
1588        ] {
1589            let x: Vec<f32> = (0..n * k).map(|_| rnd()).collect();
1590            let w: Vec<f32> = (0..m * k).map(|_| rnd()).collect();
1591            let mut want = vec![0f32; n * m];
1592            for i in 0..n {
1593                for j in 0..m {
1594                    want[i * m + j] = (0..k)
1595                        .map(|t| x[i * k + t] as f64 * w[j * k + t] as f64)
1596                        .sum::<f64>() as f32;
1597                }
1598            }
1599            for p in [None, Some(&pool)] {
1600                let mut y = vec![f32::NAN; n * m];
1601                gemm_nt_portable(&x, &w, &mut y, n, k, m, p);
1602                let err = y
1603                    .iter()
1604                    .zip(&want)
1605                    .fold(0f32, |a, (g, w)| a.max((g - w).abs()));
1606                assert!(err < 1e-4, "{n}x{k}x{m}: {err}");
1607            }
1608        }
1609        // strided rows of w (an attention head inside a wider row)
1610        let (n, k, m, ldw) = (5usize, 16usize, 7usize, 40usize);
1611        let x: Vec<f32> = (0..n * k).map(|_| rnd()).collect();
1612        let w: Vec<f32> = (0..m * ldw).map(|_| rnd()).collect();
1613        let mut y = vec![0f32; n * m];
1614        gemm_nt_strided(&x, &w, ldw, &mut y, n, k, m);
1615        for i in 0..n {
1616            for j in 0..m {
1617                let r: f32 = (0..k).map(|t| x[i * k + t] * w[j * ldw + t]).sum();
1618                assert!((y[i * m + j] - r).abs() < 1e-5);
1619            }
1620        }
1621    }
1622
1623    #[test]
1624    fn mean_pool_averages_rows() {
1625        let x = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0];
1626        assert_eq!(mean_pool(&x, 3, 2), vec![3.0, 4.0]);
1627        assert_eq!(mean_pool(&x, 1, 2), vec![1.0, 2.0]);
1628    }
1629
1630    #[test]
1631    fn l2_normalize_unit_and_zero() {
1632        let mut v = vec![3.0f32, 4.0];
1633        l2_normalize(&mut v);
1634        assert_eq!(v, vec![0.6, 0.8]);
1635        let mut z = vec![0.0f32; 4];
1636        l2_normalize(&mut z);
1637        assert_eq!(z, vec![0.0; 4]);
1638    }
1639
1640    #[test]
1641    fn prompts_follow_sentence_transformers() {
1642        let p = Prompts::default();
1643        assert_eq!(
1644            p.format(
1645                "What causes the northern lights?",
1646                Some("SearchQuery"),
1647                None,
1648                None
1649            )
1650            .unwrap(),
1651            "task: search result | query: What causes the northern lights?"
1652        );
1653        assert_eq!(
1654            p.format("body", Some("Document"), None, None).unwrap(),
1655            "title: none | text: body"
1656        );
1657        assert_eq!(
1658            p.format("body", Some("Document"), Some("Aurora borealis"), None)
1659                .unwrap(),
1660            "title: Aurora borealis | text: body"
1661        );
1662        // a title alone means the Document prompt
1663        assert_eq!(
1664            p.format("body", None, Some("T"), None).unwrap(),
1665            "title: T | text: body"
1666        );
1667        assert_eq!(
1668            p.format("body", Some("document"), Some(""), None).unwrap(),
1669            "title: none | text: body"
1670        );
1671        // case-insensitive fallback, aliases
1672        assert_eq!(
1673            p.format("x", Some("sts"), None, None).unwrap(),
1674            "task: sentence similarity | query: x"
1675        );
1676        assert_eq!(
1677            p.format("x", Some("CodeRetrieval"), None, None).unwrap(),
1678            "task: code retrieval | query: x"
1679        );
1680        // no prompt: the text as is
1681        assert_eq!(p.format("plain", None, None, None).unwrap(), "plain");
1682        // raw prefix
1683        assert_eq!(
1684            p.format("x", Some("SearchQuery"), None, Some("custom: "))
1685                .unwrap(),
1686            "custom: x"
1687        );
1688        assert!(p.format("x", Some("NoSuchTask"), None, None).is_err());
1689        assert!(p.format("x", Some("SearchQuery"), Some("T"), None).is_err());
1690    }
1691
1692    #[test]
1693    fn prompts_read_the_st_config() {
1694        let cfg =
1695            serde_json::json!({"prompts": {"query": "q: ", "document": "title: none | text: "}});
1696        let p = Prompts::from_st_config(&cfg).unwrap();
1697        assert_eq!(p.get("query"), Some("q: "));
1698        assert_eq!(
1699            p.format("b", Some("document"), Some("t"), None).unwrap(),
1700            "title: t | text: b"
1701        );
1702        assert!(Prompts::from_st_config(&serde_json::json!({})).is_none());
1703    }
1704
1705    #[test]
1706    fn wrap_ids_adds_specials_and_caps() {
1707        assert_eq!(wrap_ids(&[5, 6], 2, 1, 8192), vec![2, 5, 6, 1]);
1708        let long: Vec<u32> = (10..30).collect();
1709        let w = wrap_ids(&long, 2, 1, 8);
1710        assert_eq!(w.len(), 8);
1711        assert_eq!(w[0], 2);
1712        assert_eq!(*w.last().unwrap(), 1);
1713        assert_eq!(&w[1..7], &long[..6]);
1714    }
1715
1716    #[test]
1717    fn rope_rotates_halves() {
1718        let r = Rot::build(4, 8, 10000.0);
1719        let mut x = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
1720        r.apply(0, &mut x);
1721        assert_eq!(x[0], 1.0); // position 0 is the identity
1722        r.apply(1, &mut x);
1723        // first frequency is 1 rad per position: (cos 1, ..., sin 1 at +half)
1724        assert!((x[0] - 1f32.cos()).abs() < 1e-6);
1725        assert!((x[4] - 1f32.sin()).abs() < 1e-6);
1726    }
1727
1728    #[test]
1729    fn softmax_is_a_distribution() {
1730        let mut r = vec![1.0f32, 2.0, 3.0, -50.0];
1731        softmax(&mut r);
1732        let s: f32 = r.iter().sum();
1733        assert!((s - 1.0).abs() < 1e-6);
1734        assert!(r[2] > r[1] && r[1] > r[0]);
1735    }
1736}