Skip to main content

cortiq_engine/
egemma2_vision.rs

1//! EmbeddingGemma 2 vision: the `gemma4_vision` tower, its projection into
2//! the text model's input space, and the image / video preprocessing of
3//! `EmbeddingGemma2Processor` — so an image, a video or an interleaved
4//! text-and-media input lands in the same 768-d space as text.
5//!
6//! ## Preprocessing (`Gemma4ImageProcessor`, `EmbeddingGemma2VideoProcessor`)
7//!
8//! 1. **RGB.** Alpha is dropped (PIL's `convert("RGB")`), grey replicated.
9//! 2. **Resize** to the largest size whose sides are multiples of 48 px
10//!    (`patch 16 × pooling 3`) holding at most `budget · 9` patches, the
11//!    aspect ratio kept (`get_aspect_ratio_preserving_size`); an image that
12//!    already has that size is left alone. The resampler is torch's uint8
13//!    antialiased bicubic (`_upsample_bicubic2d_aa`, Keys a = -0.5, int16
14//!    fixed-point weights, horizontal pass then vertical, each rounded to
15//!    uint8) — what torchvision runs on a uint8 tensor, byte for byte.
16//! 3. **Patches.** `v/255`, then the tower's own `2·(v - 0.5)`; 16×16
17//!    patches in row-major order, each flattened `(py, px, rgb)`; patch
18//!    `(x, y)` is its column and row.
19//!
20//! The soft-token budget is 70, 140, 280 (default), 560 or 1120 per image;
21//! the image yields `(W/48)·(H/48)` tokens. Video frames are sampled at
22//! 1 fps (`int(i · fps_src)` for `i < int(frames / fps_src)`, at least one),
23//! cut to 32 frames evenly when longer, and each frame is an image at a
24//! budget of 140.
25//!
26//! ## The tower (`Gemma4VisionModel`, 16 layers, width 768, 12 heads of 64)
27//!
28//! * patch embedding: `x·W_inᵀ + P_x[x] + P_y[y]` (two 10240-row tables);
29//! * every layer is the Gemma sandwich with gelu-tanh gated FFN:
30//!   `h += N(attn(N(h)))`, `h += N(FFN(N(h)))`, RMS norms multiplying by
31//!   their weight;
32//! * attention is bidirectional over the image's own patches, unscaled
33//!   (the q/k RMS norms carry the scale), values RMS-normalized without a
34//!   weight, and 2-D RoPE (θ = 100): the first 32 channels of a head
35//!   rotate with the patch column, the last 32 with its row, each half
36//!   `rotate_half` over 16 frequencies;
37//! * pooling: the mean of each 3×3 block of patches, times `sqrt(768)`;
38//! * `embed_vision`: RMS norm without weight, then 768→512 — the rows that
39//!   replace the `<|image|>` / `<|video|>` placeholders in the text model's
40//!   input (not multiplied by the token embedding scale).
41//!
42//! Padding patches of the reference processor are masked out of attention
43//! and pooling there, so the tower here simply runs each image on its real
44//! patches. Images are packed back to back for the projections (one GEMM
45//! per weight over every patch of a batch); attention runs per image on the
46//! host in f32 (scores, softmax and value mix pipelined so the matrix unit
47//! and the vector units overlap), and on Metal's flash kernel for the
48//! largest images (4096+ patches: budgets 560, 1120), where the n² term
49//! dominates
50//! (`CMF_EGEMMA2_VISION_GPU=0` keeps everything on the host, `=1` sends
51//! every image to the device).
52
53use crate::egemma2::{Mat, add_normed, gelu_mul, rms_into, rms_rows, softmax, vecf};
54use crate::ltxdit::{Shared, rows};
55use crate::media::RgbFrame;
56use crate::pool::Pool;
57use cortiq_core::CmfModel;
58use serde_json::Value;
59use std::path::Path;
60use std::sync::{Arc, Mutex};
61
62/// The soft-token budgets the processor accepts.
63pub const BUDGETS: [usize; 5] = [70, 140, 280, 560, 1120];
64/// Patches are packed into one tower forward up to this many rows.
65const PACK_PATCHES: usize = 10_080;
66/// Scores per host attention block (32 MB of f32), and the fewest query
67/// rows a block takes.
68const SCORE_BLOCK: usize = 8 << 20;
69const QBLOCK_MIN: usize = 256;
70/// Query rows per step when the steps run across the pool (no Accelerate).
71const ITEM_QBLOCK: usize = 128;
72/// Images with at least this many patches attend on Metal when it is up
73/// (budgets 560 and 1120 of a square-ish image).
74const GPU_MIN_PATCHES: usize = 4096;
75
76/// `CMF_EGEMMA2_PROF=1`: per-forward time split of the tower on stderr.
77mod vprof {
78    use std::sync::atomic::{AtomicU64, Ordering};
79    pub const QKV: usize = 0;
80    pub const NORM_ROPE: usize = 1;
81    pub const CORE: usize = 2;
82    pub const O: usize = 3;
83    pub const MLP: usize = 4;
84    pub const GELU: usize = 5;
85    pub const NORMS: usize = 6;
86    const NAMES: [&str; 7] = [
87        "q/k/v",
88        "norm+rope",
89        "attention core",
90        "o",
91        "mlp",
92        "gelu",
93        "norms",
94    ];
95    static T: [AtomicU64; 7] = [const { AtomicU64::new(0) }; 7];
96    pub fn on() -> bool {
97        static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
98        *ON.get_or_init(|| std::env::var("CMF_EGEMMA2_PROF").is_ok_and(|v| v == "1"))
99    }
100    pub fn add(i: usize, t: std::time::Instant) {
101        if on() {
102            T[i].fetch_add(t.elapsed().as_micros() as u64, Ordering::Relaxed);
103        }
104    }
105    pub fn take() -> String {
106        NAMES
107            .iter()
108            .enumerate()
109            .map(|(i, n)| format!("{n} {:.1}", T[i].swap(0, Ordering::Relaxed) as f64 / 1e3))
110            .collect::<Vec<_>>()
111            .join(", ")
112    }
113}
114
115// ------------------------------------------------------------ processor
116
117/// The processor settings (`processor_config.json`).
118#[derive(Clone, Debug, PartialEq)]
119pub struct VisionProcessor {
120    pub patch: usize,
121    pub pool_k: usize,
122    /// soft tokens per image (`image_processor.max_soft_tokens`)
123    pub image_budget: usize,
124    /// soft tokens per video frame (`video_processor.max_soft_tokens`)
125    pub video_budget: usize,
126    /// frames sampled per second of video
127    pub video_fps: f64,
128    /// sampled frames are cut to this many, evenly (`overflow_strategy`)
129    pub max_frames: usize,
130    /// `uniform` (default) or `truncate`
131    pub overflow_truncate: bool,
132}
133
134impl Default for VisionProcessor {
135    fn default() -> Self {
136        VisionProcessor {
137            patch: 16,
138            pool_k: 3,
139            image_budget: 280,
140            video_budget: 140,
141            video_fps: 1.0,
142            max_frames: 32,
143            overflow_truncate: false,
144        }
145    }
146}
147
148impl VisionProcessor {
149    /// From `processor_config.json` (missing fields keep the defaults).
150    pub fn from_config(cfg: &Value) -> Self {
151        let mut p = VisionProcessor::default();
152        let u = |v: &Value, k: &str| v.get(k).and_then(|x| x.as_u64()).map(|x| x as usize);
153        let ip = &cfg["image_processor"];
154        let vp = &cfg["video_processor"];
155        if let Some(x) = u(ip, "patch_size") {
156            p.patch = x;
157        }
158        if let Some(x) = u(ip, "pooling_kernel_size") {
159            p.pool_k = x;
160        }
161        if let Some(x) = u(ip, "max_soft_tokens").or_else(|| u(cfg, "image_seq_length")) {
162            p.image_budget = x;
163        }
164        if let Some(x) = u(vp, "max_soft_tokens") {
165            p.video_budget = x;
166        }
167        if let Some(x) = vp.get("fps").and_then(|x| x.as_f64()) {
168            p.video_fps = x;
169        }
170        if let Some(x) = u(vp, "max_frames") {
171            p.max_frames = x;
172        }
173        p.overflow_truncate =
174            vp.get("overflow_strategy").and_then(|x| x.as_str()) == Some("truncate");
175        p
176    }
177
178    /// `budget` checked against the five the model was trained for.
179    pub fn check_budget(budget: usize) -> Result<usize, String> {
180        if BUDGETS.contains(&budget) {
181            Ok(budget)
182        } else {
183            Err(format!(
184                "soft-token budget {budget}: use 70, 140, 280, 560 or 1120"
185            ))
186        }
187    }
188}
189
190/// `get_aspect_ratio_preserving_size`: the `(height, width)` an image of
191/// `height × width` is resized to so it holds at most `max_patches` patches
192/// with both sides multiples of `patch · k`.
193pub fn target_size(
194    height: usize,
195    width: usize,
196    patch: usize,
197    max_patches: usize,
198    k: usize,
199) -> Result<(usize, usize), String> {
200    let total_px = (height * width) as f64;
201    let target_px = (max_patches * patch * patch) as f64;
202    let factor = (target_px / total_px).sqrt();
203    let (ih, iw) = (factor * height as f64, factor * width as f64);
204    let side = patch * k;
205    let mut th = (ih / side as f64).floor() as usize * side;
206    let mut tw = (iw / side as f64).floor() as usize * side;
207    if th == 0 && tw == 0 {
208        return Err(format!(
209            "a {width}x{height} image cannot be resized to a non-empty patch grid"
210        ));
211    }
212    let max_side = (max_patches / (k * k)) * side;
213    if th == 0 {
214        th = side;
215        tw = ((width as f64 / height as f64).floor() as usize * side).min(max_side);
216    } else if tw == 0 {
217        tw = side;
218        th = ((height as f64 / width as f64).floor() as usize * side).min(max_side);
219    }
220    if (th * tw) as f64 > target_px {
221        return Err(format!(
222            "resizing {width}x{height} to {tw}x{th} exceeds {max_patches} patches"
223        ));
224    }
225    Ok((th, tw))
226}
227
228// ------------------------------------------------------------ resize
229
230/// Keys' cubic, a = -0.5 (torch `HelperInterpCubic::aa_filter`).
231fn cubic(x: f64) -> f64 {
232    const A: f64 = -0.5;
233    let x = x.abs();
234    if x < 1.0 {
235        ((A + 2.0) * x - (A + 3.0)) * x * x + 1.0
236    } else if x < 2.0 {
237        ((A * x - 5.0 * A) * x + 8.0 * A) * x - 4.0 * A
238    } else {
239        0.0
240    }
241}
242
243/// One axis of torch's antialiased bicubic: per output index the first
244/// source index, the tap count and the int16 weights, plus the weights'
245/// fixed-point precision (`_compute_index_ranges_int16_weights`).
246struct AxisTaps {
247    xmin: Vec<usize>,
248    xsize: Vec<usize>,
249    w: Vec<i16>,
250    stride: usize,
251    precision: u32,
252}
253
254fn axis_taps(in_size: usize, out_size: usize) -> AxisTaps {
255    let scale = in_size as f64 / out_size as f64;
256    let support = if scale >= 1.0 { 2.0 * scale } else { 2.0 };
257    let max_interp = support.ceil() as usize * 2 + 1;
258    let invscale = if scale >= 1.0 { 1.0 / scale } else { 1.0 };
259    let mut wf = vec![0f64; out_size * max_interp];
260    let mut xmin = vec![0usize; out_size];
261    let mut xsize = vec![0usize; out_size];
262    let mut wt_max = 0f64;
263    for i in 0..out_size {
264        let center = scale * (i as f64 + 0.5);
265        let lo = ((center - support + 0.5) as i64).max(0);
266        let hi = ((center + support + 0.5) as i64).min(in_size as i64);
267        let n = (hi - lo).clamp(0, max_interp as i64) as usize;
268        let row = &mut wf[i * max_interp..(i + 1) * max_interp];
269        let mut total = 0f64;
270        for (j, w) in row.iter_mut().enumerate().take(n) {
271            *w = cubic((j as f64 + lo as f64 - center + 0.5) * invscale);
272            total += *w;
273        }
274        if total != 0.0 {
275            for w in row.iter_mut().take(n) {
276                *w /= total;
277            }
278        }
279        for &w in row.iter() {
280            wt_max = wt_max.max(w);
281        }
282        xmin[i] = lo as usize;
283        xsize[i] = n;
284    }
285    let mut precision = 0u32;
286    while precision < 22 {
287        let next = (0.5 + wt_max * (1u64 << (precision + 1)) as f64) as i64;
288        if next >= 1 << 15 {
289            break;
290        }
291        precision += 1;
292    }
293    let mul = (1u64 << precision) as f64;
294    let w = wf
295        .iter()
296        .map(|&v| {
297            let v = v * mul;
298            (if v < 0.0 {
299                (-0.5 + v) as i32
300            } else {
301                (0.5 + v) as i32
302            }) as i16
303        })
304        .collect();
305    AxisTaps {
306        xmin,
307        xsize,
308        w,
309        stride: max_interp,
310        precision,
311    }
312}
313
314/// One resampling pass over `src` (`[outer][len_in][inner]`, uint8), along
315/// the middle axis, into `[outer][len_out][inner]`.
316fn resample_axis(
317    src: &[u8],
318    outer: usize,
319    len_in: usize,
320    inner: usize,
321    taps: &AxisTaps,
322    len_out: usize,
323) -> Vec<u8> {
324    let mut out = vec![0u8; outer * len_out * inner];
325    let round = 1i64 << (taps.precision.max(1) - 1);
326    for o in 0..outer {
327        let s = &src[o * len_in * inner..(o + 1) * len_in * inner];
328        let d = &mut out[o * len_out * inner..(o + 1) * len_out * inner];
329        for i in 0..len_out {
330            let (x0, n) = (taps.xmin[i], taps.xsize[i]);
331            let w = &taps.w[i * taps.stride..i * taps.stride + n];
332            for c in 0..inner {
333                let mut acc = round;
334                for (j, &wj) in w.iter().enumerate() {
335                    acc += s[(x0 + j) * inner + c] as i64 * wj as i64;
336                }
337                d[i * inner + c] = (acc >> taps.precision).clamp(0, 255) as u8;
338            }
339        }
340    }
341    out
342}
343
344/// torch / torchvision's uint8 antialiased bicubic resize (`antialias =
345/// True`, `align_corners = False`): horizontal pass, then vertical, each
346/// rounded to uint8; an unchanged axis is skipped.
347pub fn resize_bicubic_aa(img: &RgbFrame, height: usize, width: usize) -> RgbFrame {
348    let (h0, w0) = (img.height, img.width);
349    let mut cur = img.data.clone();
350    if width != w0 {
351        cur = resample_axis(&cur, h0, w0, 3, &axis_taps(w0, width), width);
352    }
353    if height != h0 {
354        cur = resample_axis(&cur, 1, h0, width * 3, &axis_taps(h0, height), height);
355    }
356    RgbFrame {
357        width,
358        height,
359        data: cur,
360    }
361}
362
363// ------------------------------------------------------------ patches
364
365/// One image (or video frame) ready for the tower: its patch grid and the
366/// patch rows, `[pw·ph, 3·patch²]`, already in the tower's `[-1, 1]`.
367#[derive(Clone, Debug)]
368pub struct VisionInput {
369    pub pw: usize,
370    pub ph: usize,
371    pub pixels: Vec<f32>,
372}
373
374impl VisionInput {
375    pub fn n_patches(&self) -> usize {
376        self.pw * self.ph
377    }
378
379    /// Soft tokens this input becomes (`(pw/k)·(ph/k)`).
380    pub fn n_soft(&self, k: usize) -> usize {
381        (self.pw / k) * (self.ph / k)
382    }
383}
384
385/// Patchify an image whose sides are multiples of `patch`: `v/255` (f32,
386/// the processor's `rescale`), then `2·(v - 0.5)` (the patch embedder).
387pub fn patchify(img: &RgbFrame, patch: usize) -> VisionInput {
388    let (pw, ph) = (img.width / patch, img.height / patch);
389    let dim = 3 * patch * patch;
390    // the processor's rescale_factor (1/255 as Python's float), as f32
391    let scale = (1.0f64 / 255.0) as f32;
392    let mut pixels = vec![0f32; pw * ph * dim];
393    for py in 0..ph {
394        for px in 0..pw {
395            let dst = &mut pixels[(py * pw + px) * dim..(py * pw + px + 1) * dim];
396            for yy in 0..patch {
397                let row = (py * patch + yy) * img.width + px * patch;
398                let src = &img.data[row * 3..(row + patch) * 3];
399                for (d, &v) in dst[yy * patch * 3..(yy + 1) * patch * 3]
400                    .iter_mut()
401                    .zip(src)
402                {
403                    *d = 2.0 * (v as f32 * scale - 0.5);
404                }
405            }
406        }
407    }
408    VisionInput { pw, ph, pixels }
409}
410
411impl VisionProcessor {
412    /// The size an image of `w × h` is resized to at `budget` soft tokens.
413    pub fn resized_size(
414        &self,
415        w: usize,
416        h: usize,
417        budget: usize,
418    ) -> Result<(usize, usize), String> {
419        let (th, tw) = target_size(
420            h,
421            w,
422            self.patch,
423            budget * self.pool_k * self.pool_k,
424            self.pool_k,
425        )?;
426        Ok((tw, th))
427    }
428
429    /// Resize (when the size changes) and patchify one image.
430    pub fn prepare_image(&self, img: &RgbFrame, budget: usize) -> Result<VisionInput, String> {
431        VisionProcessor::check_budget(budget)?;
432        let (tw, th) = self.resized_size(img.width, img.height, budget)?;
433        if (tw, th) == (img.width, img.height) {
434            return Ok(patchify(img, self.patch));
435        }
436        Ok(patchify(&resize_bicubic_aa(img, th, tw), self.patch))
437    }
438
439    /// Video frames (already sampled, one size): each resized to the size
440    /// frame 0 takes at `budget`, then patchified.
441    pub fn prepare_frames(
442        &self,
443        frames: &[RgbFrame],
444        budget: usize,
445    ) -> Result<Vec<VisionInput>, String> {
446        let first = frames.first().ok_or("a video needs at least one frame")?;
447        if let Some((i, f)) = frames
448            .iter()
449            .enumerate()
450            .find(|(_, f)| (f.width, f.height) != (first.width, first.height))
451        {
452            return Err(format!(
453                "video frame {i} is {}x{}, frame 0 is {}x{}: all frames must share one size",
454                f.width, f.height, first.width, first.height
455            ));
456        }
457        VisionProcessor::check_budget(budget)?;
458        let (tw, th) = self.resized_size(first.width, first.height, budget)?;
459        Ok(frames
460            .iter()
461            .map(|f| {
462                if (tw, th) == (f.width, f.height) {
463                    patchify(f, self.patch)
464                } else {
465                    patchify(&resize_bicubic_aa(f, th, tw), self.patch)
466                }
467            })
468            .collect())
469    }
470
471    /// The frames of a `total`-frame video at `src_fps` the processor keeps:
472    /// `int(i · src_fps / fps)` for `i < max(1, int(total / src_fps · fps))`,
473    /// then cut to `max_frames` (evenly, `np.linspace(..., dtype=int)`, or
474    /// the first ones under `truncate`). `src_fps = None` (frames with no
475    /// rate) keeps every frame and applies only the cap.
476    pub fn sample_indices(&self, total: usize, src_fps: Option<f64>) -> Vec<usize> {
477        if total == 0 {
478            return Vec::new();
479        }
480        let mut idx: Vec<usize> = match src_fps.filter(|f| *f > 0.0 && f.is_finite()) {
481            None => (0..total).collect(),
482            Some(f) => {
483                let step = f / self.video_fps;
484                let duration = total as f64 / f;
485                let n = ((duration * self.video_fps) as usize).max(1);
486                (0..n)
487                    .map(|i| ((i as f64 * step) as usize).min(total - 1))
488                    .collect()
489            }
490        };
491        let m = self.max_frames;
492        if m > 0 && idx.len() > m {
493            if self.overflow_truncate {
494                idx.truncate(m);
495            } else {
496                // np.linspace(0, len-1, m, dtype=int): i·step, the last
497                // exactly the end, floored
498                let last = idx.len() - 1;
499                let step = if m > 1 {
500                    last as f64 / (m - 1) as f64
501                } else {
502                    0.0
503                };
504                idx = (0..m)
505                    .map(|i| {
506                        let t = if i + 1 == m && m > 1 {
507                            last
508                        } else {
509                            (i as f64 * step).floor() as usize
510                        };
511                        idx[t.min(last)]
512                    })
513                    .collect();
514            }
515        }
516        idx
517    }
518}
519
520// ------------------------------------------------------------ video
521
522/// A decoded video: the sampled frames and where they came from.
523#[derive(Clone, Debug)]
524pub struct DecodedVideo {
525    pub frames: Vec<RgbFrame>,
526    /// source frame index of each kept frame
527    pub indices: Vec<usize>,
528    pub total_frames: usize,
529    pub src_fps: Option<f64>,
530    /// `frames-dir`, `y4m` or `ffmpeg`
531    pub decoder: &'static str,
532}
533
534/// Decode and sample a video the way the processor does.
535///
536/// * a **directory** of images (png/jpg/webp/gif/ppm, natural name order)
537///   is a list of frames: with `fps` they play at that rate and are sampled
538///   to 1 fps; without it they are taken as already sampled (every frame
539///   kept, cut to 32);
540/// * a **`.y4m`** stream is read natively (its own rate, BT.601 YUV → RGB);
541/// * anything else (**mp4, webm, mov, mkv, …**) goes through the `ffmpeg`
542///   and `ffprobe` executables — decoded to rgb24 and sampled exactly as the
543///   reference's PyAV path (`frames / average_rate` is the duration; frames
544///   as stored, no rotation applied). ffmpeg's YUV → RGB rounding differs
545///   from PyAV's by up to 2 levels (cosine 0.9993 against the reference
546///   computed from PyAV's frames). There is no in-process codec: without
547///   ffmpeg on `PATH`, pass a frame directory or a y4m.
548pub fn decode_video(
549    path: &Path,
550    fps: Option<f64>,
551    proc: &VisionProcessor,
552) -> Result<DecodedVideo, String> {
553    use crate::mimo_vision::VideoSource;
554    if path.is_dir() {
555        let src = VideoSource::frame_dir(path, fps.unwrap_or(1.0))?;
556        let total = src.frame_count();
557        let indices = proc.sample_indices(total, fps);
558        let frames = indices
559            .iter()
560            .map(|&i| src.read_frame(i))
561            .collect::<Result<Vec<_>, _>>()?;
562        return Ok(DecodedVideo {
563            frames,
564            indices,
565            total_frames: total,
566            src_fps: fps,
567            decoder: "frames-dir",
568        });
569    }
570    let ext = path
571        .extension()
572        .and_then(|e| e.to_str())
573        .unwrap_or("")
574        .to_ascii_lowercase();
575    if ext == "y4m" {
576        let src = VideoSource::open(path, fps)?;
577        let total = src.frame_count();
578        let f = src.fps();
579        let indices = proc.sample_indices(total, Some(f));
580        let frames = indices
581            .iter()
582            .map(|&i| src.read_frame(i))
583            .collect::<Result<Vec<_>, _>>()?;
584        return Ok(DecodedVideo {
585            frames,
586            indices,
587            total_frames: total,
588            src_fps: Some(f),
589            decoder: "y4m",
590        });
591    }
592    decode_with_ffmpeg(path, fps, proc)
593}
594
595/// What ffprobe says about a video's first stream.
596struct Probe {
597    width: usize,
598    height: usize,
599    fps: f64,
600    /// the container's frame count, when it keeps one
601    frames: Option<usize>,
602    duration: Option<f64>,
603}
604
605fn ffprobe_stream(path: &Path) -> Result<Probe, String> {
606    let out = std::process::Command::new("ffprobe")
607        .args([
608            "-v",
609            "error",
610            "-select_streams",
611            "v:0",
612            "-show_entries",
613            "stream=width,height,avg_frame_rate,r_frame_rate,nb_frames,duration:format=duration",
614            "-of",
615            "json",
616        ])
617        .arg(path)
618        .output()
619        .map_err(|e| {
620            format!(
621                "{}: decoding this video needs ffmpeg/ffprobe on PATH ({e}); \
622                 or pass a directory of frames or a .y4m",
623                path.display()
624            )
625        })?;
626    if !out.status.success() {
627        return Err(format!(
628            "ffprobe {}: {}",
629            path.display(),
630            String::from_utf8_lossy(&out.stderr).trim()
631        ));
632    }
633    let v: Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("ffprobe json: {e}"))?;
634    let s = &v["streams"][0];
635    let w = s["width"].as_u64().ok_or("ffprobe: no video stream")? as usize;
636    let h = s["height"].as_u64().ok_or("ffprobe: no video stream")? as usize;
637    let rate = |k: &str| -> Option<f64> {
638        let r = s[k].as_str()?;
639        let (n, d) = r.split_once('/').unwrap_or((r, "1"));
640        let (n, d): (f64, f64) = (n.parse().ok()?, d.parse().ok()?);
641        (d > 0.0 && n > 0.0).then(|| n / d)
642    };
643    let fps = rate("avg_frame_rate")
644        .or_else(|| rate("r_frame_rate"))
645        .ok_or("ffprobe: the video has no frame rate")?;
646    let nb = s["nb_frames"]
647        .as_str()
648        .and_then(|x| x.parse::<usize>().ok())
649        .filter(|&n| n > 0);
650    let dur = s["duration"]
651        .as_str()
652        .or_else(|| v["format"]["duration"].as_str())
653        .and_then(|x| x.parse::<f64>().ok());
654    Ok(Probe {
655        width: w,
656        height: h,
657        fps,
658        frames: nb,
659        duration: dur,
660    })
661}
662
663fn decode_with_ffmpeg(
664    path: &Path,
665    fps_override: Option<f64>,
666    proc: &VisionProcessor,
667) -> Result<DecodedVideo, String> {
668    use std::io::Read;
669    let Probe {
670        width: w,
671        height: h,
672        fps: probed_fps,
673        frames: nb,
674        duration: dur,
675    } = ffprobe_stream(path)?;
676    let fps = fps_override.unwrap_or(probed_fps);
677    // PyAV: total = stream.frames, duration = total / average_rate; a
678    // container that does not count its frames falls back to its duration.
679    let total = nb
680        .or_else(|| dur.map(|d| (d * probed_fps).round() as usize))
681        .ok_or_else(|| format!("{}: frame count unknown", path.display()))?;
682    let indices = proc.sample_indices(total, Some(fps));
683    let last = *indices.last().ok_or("video has no frames")?;
684    let mut child = std::process::Command::new("ffmpeg")
685        // -noautorotate: frames as stored, as PyAV (the reference decoder)
686        // hands them over — a rotation tag is not applied
687        .args(["-v", "error", "-nostdin", "-noautorotate", "-i"])
688        .arg(path)
689        .args([
690            "-map",
691            "0:v:0",
692            "-frames:v",
693            &(last + 1).to_string(),
694            "-f",
695            "rawvideo",
696            "-pix_fmt",
697            "rgb24",
698            "-",
699        ])
700        .stdout(std::process::Stdio::piped())
701        .stderr(std::process::Stdio::piped())
702        .spawn()
703        .map_err(|e| format!("ffmpeg: {e}"))?;
704    let mut stdout = child.stdout.take().ok_or("ffmpeg: no stdout")?;
705    let frame_bytes = w * h * 3;
706    let mut buf = vec![0u8; frame_bytes];
707    let mut frames = Vec::with_capacity(indices.len());
708    let mut kept = Vec::with_capacity(indices.len());
709    let mut want = indices.iter().peekable();
710    let mut i = 0usize;
711    while want.peek().is_some() {
712        match stdout.read_exact(&mut buf) {
713            Ok(()) => {}
714            Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
715            Err(e) => return Err(format!("ffmpeg read: {e}")),
716        }
717        while want.peek() == Some(&&i) {
718            frames.push(RgbFrame::new(w, h, buf.clone())?);
719            kept.push(i);
720            want.next();
721        }
722        i += 1;
723    }
724    drop(stdout);
725    let status = child.wait().map_err(|e| format!("ffmpeg: {e}"))?;
726    if frames.is_empty() {
727        let mut err = String::new();
728        if let Some(mut e) = child.stderr.take() {
729            let _ = e.read_to_string(&mut err);
730        }
731        return Err(format!(
732            "ffmpeg decoded no frames from {} ({status}): {}",
733            path.display(),
734            err.trim()
735        ));
736    }
737    Ok(DecodedVideo {
738        frames,
739        indices: kept,
740        total_frames: total,
741        src_fps: Some(fps),
742        decoder: "ffmpeg",
743    })
744}
745
746// ------------------------------------------------------------ the tower
747
748struct VLayer {
749    q: Mat,
750    k: Mat,
751    v: Mat,
752    o: Mat,
753    q_norm: Vec<f32>,
754    k_norm: Vec<f32>,
755    in_norm: Vec<f32>,
756    post_attn_norm: Vec<f32>,
757    pre_ff_norm: Vec<f32>,
758    post_ff_norm: Vec<f32>,
759    gate: Mat,
760    up: Mat,
761    down: Mat,
762}
763
764/// The `gemma4_vision` tower and `embed_vision` of an EmbeddingGemma 2 pack.
765pub struct VisionTower {
766    /// the encoder layers on the device (macOS / Metal); first, so it drops
767    /// before the host weights it wraps
768    #[cfg(target_os = "macos")]
769    gpu: Option<crate::gpu_metal::egemma2::VisionGpu>,
770    /// run the host layers even when the device ones are up (A/B, tests)
771    host_only: std::sync::atomic::AtomicBool,
772    input_proj: Mat,
773    /// `[2, rows, hidden]`: column table, then row table
774    pos: Vec<f32>,
775    pos_rows: usize,
776    layers: Vec<VLayer>,
777    embed_proj: Mat,
778    hidden: usize,
779    heads: usize,
780    head_dim: usize,
781    /// RoPE inverse frequencies, `head_dim / 4` of them
782    inv_freq: Vec<f32>,
783    pub pool_k: usize,
784    /// the text model's width (the soft tokens' size)
785    pub out_dim: usize,
786    pool: Option<Arc<Pool>>,
787    busy: Mutex<()>,
788    /// images of at least this many patches attend on the device
789    gpu_min_patches: usize,
790}
791
792/// Per-image segment of a packed batch.
793#[derive(Clone, Copy)]
794struct Seg {
795    off: usize,
796    pw: usize,
797    ph: usize,
798}
799
800impl VisionTower {
801    /// Does this pack carry the vision tower?
802    pub fn present(model: &CmfModel) -> bool {
803        model
804            .tensor("vision_tower.patch_embedder.input_proj.weight")
805            .is_some()
806    }
807
808    pub fn load(model: &Arc<CmfModel>, pool: Option<Arc<Pool>>) -> Result<Self, String> {
809        if !Self::present(model) {
810            return Err(
811                "this EmbeddingGemma 2 file has no vision tower (packed with CMF_EGEMMA2_TEXT_ONLY=1?)"
812                    .into(),
813            );
814        }
815        let prov = model.header.provenance.clone().unwrap_or(Value::Null);
816        let vc = &prov["embedding_gemma2"]["config"]["vision_config"];
817        let g = |k: &str, d: u64| vc.get(k).and_then(|v| v.as_u64()).unwrap_or(d) as usize;
818        let hidden = g("hidden_size", 768);
819        let heads = g("num_attention_heads", 12);
820        let head_dim = g("head_dim", 64);
821        let n_layers = g("num_hidden_layers", 16);
822        let pool_k = g("pooling_kernel_size", 3);
823        let theta = vc["rope_parameters"]["rope_theta"]
824            .as_f64()
825            .unwrap_or(100.0) as f32;
826        if vc.get("use_clipped_linears").and_then(|v| v.as_bool()) == Some(true) {
827            return Err("vision tower with clipped linears is not supported".into());
828        }
829        if vc.get("standardize").and_then(|v| v.as_bool()) == Some(true) {
830            return Err("vision tower with standardization is not supported".into());
831        }
832        // Like the text stack: quantized matrices are widened to f32 once
833        // (Accelerate GEMMs); CMF_EGEMMA2_LOWMEM=1 keeps the quantized kernels.
834        let dequant = !std::env::var("CMF_EGEMMA2_LOWMEM").is_ok_and(|v| v == "1");
835        let mut layers = Vec::with_capacity(n_layers);
836        for i in 0..n_layers {
837            let p = format!("vision_tower.encoder.layers.{i}");
838            let ld = |s: &str| Mat::load(model, &format!("{p}.{s}.linear.weight"), dequant);
839            let nv = |s: &str| vecf(model, &format!("{p}.{s}.weight"));
840            layers.push(VLayer {
841                q: ld("self_attn.q_proj")?,
842                k: ld("self_attn.k_proj")?,
843                v: ld("self_attn.v_proj")?,
844                o: ld("self_attn.o_proj")?,
845                q_norm: nv("self_attn.q_norm")?,
846                k_norm: nv("self_attn.k_norm")?,
847                in_norm: nv("input_layernorm")?,
848                post_attn_norm: nv("post_attention_layernorm")?,
849                pre_ff_norm: nv("pre_feedforward_layernorm")?,
850                post_ff_norm: nv("post_feedforward_layernorm")?,
851                gate: ld("mlp.gate_proj")?,
852                up: ld("mlp.up_proj")?,
853                down: ld("mlp.down_proj")?,
854            });
855        }
856        let pos_name = "vision_tower.patch_embedder.position_embedding_table";
857        let pos_shape = model
858            .tensor(pos_name)
859            .ok_or_else(|| format!("missing tensor {pos_name}"))?
860            .shape
861            .clone();
862        if pos_shape.len() != 3 || pos_shape[0] != 2 || pos_shape[2] != hidden {
863            return Err(format!("{pos_name}: unexpected shape {pos_shape:?}"));
864        }
865        let pos = vecf(model, pos_name)?;
866        let embed_proj = Mat::load(model, "embed_vision.embedding_projection.weight", true)?;
867        let quarter = head_dim / 4;
868        // torch: 1 / theta ** (arange(0, head_dim/2, 2) / (head_dim/2)), f32
869        let inv_freq = (0..quarter)
870            .map(|i| 1.0f32 / theta.powf((2 * i) as f32 / (head_dim / 2) as f32))
871            .collect();
872        // Metal's flash attention (f16 operands, f32 sums) wins only where the
873        // n² scores dominate. Attention core on an M4, device vs the
874        // pipelined host path: 4.3 s vs 8.2 s at 9801 patches (budget 1120),
875        // 1.05 s vs 1.68 s at 4761 (560), 0.43 s vs 0.35 s at 2304 (280) —
876        // where the host path, exact f32, stays.
877        // CMF_EGEMMA2_VISION_GPU=0 never, =1 from 256 patches.
878        let gpu_min_patches = match std::env::var("CMF_EGEMMA2_VISION_GPU").as_deref() {
879            Ok("0") => usize::MAX,
880            Ok("1") => 256,
881            _ => GPU_MIN_PATCHES,
882        };
883        // mutated only where the device path exists
884        #[cfg_attr(not(target_os = "macos"), allow(unused_mut))]
885        let mut tower = VisionTower {
886            #[cfg(target_os = "macos")]
887            gpu: None,
888            host_only: std::sync::atomic::AtomicBool::new(false),
889            input_proj: Mat::load(
890                model,
891                "vision_tower.patch_embedder.input_proj.weight",
892                dequant,
893            )?,
894            pos,
895            pos_rows: pos_shape[1],
896            layers,
897            out_dim: embed_proj.rows(),
898            embed_proj,
899            hidden,
900            heads,
901            head_dim,
902            inv_freq,
903            pool_k,
904            pool,
905            busy: Mutex::new(()),
906            gpu_min_patches,
907        };
908        #[cfg(target_os = "macos")]
909        if dequant && crate::gpu_metal::egemma2::enabled() {
910            match tower.device_layers() {
911                Ok(g) => tower.gpu = Some(g),
912                Err(e) => {
913                    tracing::info!("egemma2 vision: Metal layers off ({e}); the CPU runs them")
914                }
915            }
916        }
917        Ok(tower)
918    }
919
920    /// The encoder layers on the device, over this tower's f32 weights.
921    #[cfg(target_os = "macos")]
922    fn device_layers(&self) -> Result<crate::gpu_metal::egemma2::VisionGpu, String> {
923        use crate::gpu_metal::egemma2::{VLayerSpec, VisionGpu};
924        fn f(m: &Mat) -> Result<&[f32], String> {
925            m.f32_weights()
926                .ok_or_else(|| "a quantized matrix (low-memory mode)".to_string())
927        }
928        let specs = self
929            .layers
930            .iter()
931            .map(|l| {
932                Ok(VLayerSpec {
933                    q: f(&l.q)?,
934                    k: f(&l.k)?,
935                    v: f(&l.v)?,
936                    o: f(&l.o)?,
937                    gate: f(&l.gate)?,
938                    up: f(&l.up)?,
939                    down: f(&l.down)?,
940                    q_norm: &l.q_norm,
941                    k_norm: &l.k_norm,
942                    in_norm: &l.in_norm,
943                    post_attn_norm: &l.post_attn_norm,
944                    pre_ff_norm: &l.pre_ff_norm,
945                    post_ff_norm: &l.post_ff_norm,
946                })
947            })
948            .collect::<Result<Vec<_>, String>>()?;
949        VisionGpu::new(
950            &specs,
951            self.hidden,
952            self.heads,
953            self.head_dim,
954            crate::egemma2::EPS as f32,
955        )
956    }
957
958    /// Run the host layers (`true`) even when the device ones are up.
959    pub fn force_host(&self, on: bool) {
960        self.host_only
961            .store(on, std::sync::atomic::Ordering::Relaxed);
962    }
963
964    /// Do the encoder layers run on the device (Metal)?
965    pub fn on_device(&self) -> bool {
966        #[cfg(target_os = "macos")]
967        let up = self.gpu.is_some();
968        #[cfg(not(target_os = "macos"))]
969        let up = false;
970        up && !self.host_only.load(std::sync::atomic::Ordering::Relaxed)
971    }
972
973    /// Soft tokens of each input, `[n_soft, out_dim]` row-major: the rows
974    /// that replace the input's placeholders in the text model.
975    pub fn encode(&self, inputs: &[VisionInput]) -> Result<Vec<Vec<f32>>, String> {
976        let k = self.pool_k;
977        for (i, x) in inputs.iter().enumerate() {
978            if x.pw % k != 0 || x.ph % k != 0 || x.pw == 0 || x.ph == 0 {
979                return Err(format!(
980                    "image {i}: a {}x{} patch grid does not pool by {k}",
981                    x.pw, x.ph
982                ));
983            }
984            if x.pw > self.pos_rows || x.ph > self.pos_rows {
985                return Err(format!(
986                    "image {i}: {}x{} patches exceed the {}-entry position table",
987                    x.pw, x.ph, self.pos_rows
988                ));
989            }
990        }
991        let _g = self.busy.lock().unwrap_or_else(|e| e.into_inner());
992        let mut out = Vec::with_capacity(inputs.len());
993        let mut start = 0usize;
994        while start < inputs.len() {
995            let mut end = start;
996            let mut rows_n = 0usize;
997            while end < inputs.len()
998                && (end == start || rows_n + inputs[end].n_patches() <= PACK_PATCHES)
999            {
1000                rows_n += inputs[end].n_patches();
1001                end += 1;
1002            }
1003            out.extend(self.forward_packed(&inputs[start..end]));
1004            start = end;
1005        }
1006        Ok(out)
1007    }
1008
1009    fn forward_packed(&self, inputs: &[VisionInput]) -> Vec<Vec<f32>> {
1010        let t_fwd = std::time::Instant::now();
1011        let pool = self.pool.as_deref();
1012        let d = self.hidden;
1013        let mut segs = Vec::with_capacity(inputs.len());
1014        let mut off = 0usize;
1015        for x in inputs {
1016            segs.push(Seg {
1017                off,
1018                pw: x.pw,
1019                ph: x.ph,
1020            });
1021            off += x.n_patches();
1022        }
1023        let n = off;
1024        // patch embedding + the two position tables
1025        let pix: Vec<f32> = inputs
1026            .iter()
1027            .flat_map(|x| x.pixels.iter().copied())
1028            .collect();
1029        let mut h = self.input_proj.apply(&pix, n, pool);
1030        drop(pix);
1031        let (tx, ty) = self.pos.split_at(self.pos_rows * d);
1032        for s in &segs {
1033            for py in 0..s.ph {
1034                for px in 0..s.pw {
1035                    let r = &mut h[(s.off + py * s.pw + px) * d..][..d];
1036                    let (ex, ey) = (&tx[px * d..(px + 1) * d], &ty[py * d..(py + 1) * d]);
1037                    for ((v, &a), &b) in r.iter_mut().zip(ex).zip(ey) {
1038                        *v += a + b;
1039                    }
1040                }
1041            }
1042        }
1043        // per-row RoPE angles: (column, row) of every patch
1044        let quarter = self.head_dim / 4;
1045        let mut cos = vec![0f32; n * 2 * quarter];
1046        let mut sin = vec![0f32; n * 2 * quarter];
1047        for s in &segs {
1048            for py in 0..s.ph {
1049                for px in 0..s.pw {
1050                    let t = s.off + py * s.pw + px;
1051                    for (axis, p) in [(0usize, px), (1, py)] {
1052                        for (j, &f) in self.inv_freq.iter().enumerate() {
1053                            let a = (p as f32 * f) as f64;
1054                            cos[(t * 2 + axis) * quarter + j] = a.cos() as f32;
1055                            sin[(t * 2 + axis) * quarter + j] = a.sin() as f32;
1056                        }
1057                    }
1058                }
1059            }
1060        }
1061        #[cfg(target_os = "macos")]
1062        let on_device = match self.gpu.as_ref().filter(|_| self.on_device()) {
1063            Some(g) => {
1064                let lens: Vec<usize> = segs.iter().map(|s| s.pw * s.ph).collect();
1065                match g.forward(&mut h, &lens, &cos, &sin) {
1066                    Ok(()) => true,
1067                    Err(e) => {
1068                        tracing::warn!(
1069                            "egemma2 vision: Metal layers failed ({e}); the CPU runs them"
1070                        );
1071                        false
1072                    }
1073                }
1074            }
1075            None => false,
1076        };
1077        #[cfg(not(target_os = "macos"))]
1078        let on_device = false;
1079        for l in self.layers.iter().filter(|_| !on_device) {
1080            let t0 = std::time::Instant::now();
1081            let a = rms_rows(&h, Some(&l.in_norm), d, pool);
1082            vprof::add(vprof::NORMS, t0);
1083            let attn = self.attention(l, &a, n, &segs, &cos, &sin, pool);
1084            let t0 = std::time::Instant::now();
1085            add_normed(&mut h, &attn, &l.post_attn_norm, d, pool);
1086            let m = rms_rows(&h, Some(&l.pre_ff_norm), d, pool);
1087            vprof::add(vprof::NORMS, t0);
1088            let t0 = std::time::Instant::now();
1089            let mut g = l.gate.apply(&m, n, pool);
1090            let u = l.up.apply(&m, n, pool);
1091            vprof::add(vprof::MLP, t0);
1092            let t0 = std::time::Instant::now();
1093            gelu_mul(&mut g, &u, pool);
1094            drop(u);
1095            vprof::add(vprof::GELU, t0);
1096            let t0 = std::time::Instant::now();
1097            let f = l.down.apply(&g, n, pool);
1098            vprof::add(vprof::MLP, t0);
1099            let t0 = std::time::Instant::now();
1100            add_normed(&mut h, &f, &l.post_ff_norm, d, pool);
1101            vprof::add(vprof::NORMS, t0);
1102        }
1103        // 3x3 average pooling, sqrt(hidden), then embed_vision
1104        let k = self.pool_k;
1105        let root = (d as f32).sqrt();
1106        let inv_k2 = 1.0f32 / (k * k) as f32;
1107        if vprof::on() {
1108            eprintln!(
1109                "egemma2 vision: {n} patches / {} images in {:.1} ms — {}",
1110                segs.len(),
1111                t_fwd.elapsed().as_secs_f64() * 1e3,
1112                vprof::take()
1113            );
1114        }
1115        segs.iter()
1116            .map(|s| {
1117                let (cw, ch) = (s.pw / k, s.ph / k);
1118                let mut pooled = vec![0f32; cw * ch * d];
1119                for py in 0..s.ph {
1120                    for px in 0..s.pw {
1121                        let cell = (py / k) * cw + px / k;
1122                        let src = &h[(s.off + py * s.pw + px) * d..][..d];
1123                        for (o, &v) in pooled[cell * d..(cell + 1) * d].iter_mut().zip(src) {
1124                            *o += v * inv_k2;
1125                        }
1126                    }
1127                }
1128                for v in pooled.iter_mut() {
1129                    *v *= root;
1130                }
1131                let normed = rms_rows(&pooled, None, d, pool);
1132                self.embed_proj.apply(&normed, cw * ch, pool)
1133            })
1134            .collect()
1135    }
1136
1137    /// Bidirectional attention of one layer, per image. Returns `o_proj`.
1138    #[allow(clippy::too_many_arguments)]
1139    fn attention(
1140        &self,
1141        l: &VLayer,
1142        a: &[f32],
1143        n: usize,
1144        segs: &[Seg],
1145        cos: &[f32],
1146        sin: &[f32],
1147        pool: Option<&Pool>,
1148    ) -> Vec<f32> {
1149        let (hd, nh) = (self.head_dim, self.heads);
1150        let w = nh * hd;
1151        let t0 = std::time::Instant::now();
1152        let mut q = l.q.apply(a, n, pool);
1153        let mut k = l.k.apply(a, n, pool);
1154        let mut v = l.v.apply(a, n, pool);
1155        vprof::add(vprof::QKV, t0);
1156        let t0 = std::time::Instant::now();
1157        let quarter = hd / 4;
1158        {
1159            let (qp, kp, vp) = (
1160                Shared(q.as_mut_ptr()),
1161                Shared(k.as_mut_ptr()),
1162                Shared(v.as_mut_ptr()),
1163            );
1164            rows(pool, n, &|s, e| {
1165                let mut tmp = vec![0f32; hd];
1166                for t in s..e {
1167                    let c = &cos[t * 2 * quarter..(t + 1) * 2 * quarter];
1168                    let sn = &sin[t * 2 * quarter..(t + 1) * 2 * quarter];
1169                    let rope = |x: &mut [f32]| {
1170                        // two halves: columns then rows, rotate_half in each
1171                        for axis in 0..2 {
1172                            let part = &mut x[axis * 2 * quarter..(axis + 1) * 2 * quarter];
1173                            let (cc, ss) = (
1174                                &c[axis * quarter..(axis + 1) * quarter],
1175                                &sn[axis * quarter..(axis + 1) * quarter],
1176                            );
1177                            for i in 0..quarter {
1178                                let (x1, x2) = (part[i], part[i + quarter]);
1179                                part[i] = x1 * cc[i] - x2 * ss[i];
1180                                part[i + quarter] = x2 * cc[i] + x1 * ss[i];
1181                            }
1182                        }
1183                    };
1184                    let qr = unsafe { qp.at(t * w, w) };
1185                    for hh in qr.chunks_exact_mut(hd) {
1186                        rms_into(hh, Some(&l.q_norm), &mut tmp);
1187                        hh.copy_from_slice(&tmp);
1188                        rope(hh);
1189                    }
1190                    let kr = unsafe { kp.at(t * w, w) };
1191                    for hh in kr.chunks_exact_mut(hd) {
1192                        rms_into(hh, Some(&l.k_norm), &mut tmp);
1193                        hh.copy_from_slice(&tmp);
1194                        rope(hh);
1195                    }
1196                    let vr = unsafe { vp.at(t * w, w) };
1197                    for hh in vr.chunks_exact_mut(hd) {
1198                        rms_into(hh, None, &mut tmp);
1199                        hh.copy_from_slice(&tmp);
1200                    }
1201                }
1202            });
1203        }
1204        vprof::add(vprof::NORM_ROPE, t0);
1205        let t0 = std::time::Instant::now();
1206        let mut out = vec![0f32; n * w];
1207        for s in segs {
1208            let len = s.pw * s.ph;
1209            let rng = s.off * w..(s.off + len) * w;
1210            if len >= self.gpu_min_patches
1211                && self.attend_gpu(
1212                    &q[rng.clone()],
1213                    &k[rng.clone()],
1214                    &v[rng.clone()],
1215                    len,
1216                    &mut out[rng.clone()],
1217                )
1218            {
1219                continue;
1220            }
1221            attend_host(
1222                &q[rng.clone()],
1223                &k[rng.clone()],
1224                &v[rng.clone()],
1225                len,
1226                nh,
1227                hd,
1228                &mut out[rng],
1229                pool,
1230            );
1231        }
1232        vprof::add(vprof::CORE, t0);
1233        let t0 = std::time::Instant::now();
1234        let r = l.o.apply(&out, n, pool);
1235        vprof::add(vprof::O, t0);
1236        r
1237    }
1238
1239    /// All heads of one image on the device (`gpu::dit_attention`, flash
1240    /// path, scale 1). `false` = refused; the host path runs.
1241    fn attend_gpu(&self, q: &[f32], k: &[f32], v: &[f32], len: usize, out: &mut [f32]) -> bool {
1242        if !crate::gpu::enabled_here() {
1243            return false;
1244        }
1245        let (hd, nh) = (self.head_dim, self.heads);
1246        let w = nh * hd;
1247        let pack = |x: &[f32]| {
1248            let mut p = vec![0f32; len * w];
1249            for t in 0..len {
1250                for hh in 0..nh {
1251                    p[hh * len * hd + t * hd..][..hd].copy_from_slice(&x[t * w + hh * hd..][..hd]);
1252                }
1253            }
1254            p
1255        };
1256        let (qh, kh, vh) = (pack(q), pack(k), pack(v));
1257        crate::gpu::dit_attention(&qh, &kh, &vh, nh, nh, len, hd, 1.0, out)
1258    }
1259}
1260
1261/// `C[m×n] = A[m×k] · op(B)` on strided row-major views: `bt` = B is
1262/// `[n×k]` (used transposed), else `[k×n]`. Accelerate reads the views in
1263/// place; elsewhere they are gathered for the portable GEMM.
1264#[allow(clippy::too_many_arguments)]
1265fn sgemm_view(
1266    a: &[f32],
1267    lda: usize,
1268    b: &[f32],
1269    ldb: usize,
1270    bt: bool,
1271    c: &mut [f32],
1272    ldc: usize,
1273    m: usize,
1274    n: usize,
1275    k: usize,
1276) {
1277    #[cfg(target_os = "macos")]
1278    if crate::egemma2::accelerate() {
1279        #[link(name = "Accelerate", kind = "framework")]
1280        unsafe extern "C" {
1281            fn cblas_sgemm(
1282                order: i32,
1283                ta: i32,
1284                tb: i32,
1285                m: i32,
1286                n: i32,
1287                k: i32,
1288                alpha: f32,
1289                a: *const f32,
1290                lda: i32,
1291                b: *const f32,
1292                ldb: i32,
1293                beta: f32,
1294                c: *mut f32,
1295                ldc: i32,
1296            );
1297        }
1298        assert!(a.len() >= (m - 1) * lda + k);
1299        assert!(if bt {
1300            b.len() >= (n - 1) * ldb + k
1301        } else {
1302            b.len() >= (k - 1) * ldb + n
1303        });
1304        assert!(c.len() >= (m - 1) * ldc + n);
1305        // SAFETY: the views were bounds-checked above; row-major (101).
1306        unsafe {
1307            cblas_sgemm(
1308                101,
1309                111,
1310                if bt { 112 } else { 111 },
1311                m as i32,
1312                n as i32,
1313                k as i32,
1314                1.0,
1315                a.as_ptr(),
1316                lda as i32,
1317                b.as_ptr(),
1318                ldb as i32,
1319                0.0,
1320                c.as_mut_ptr(),
1321                ldc as i32,
1322            );
1323        }
1324        return;
1325    }
1326    sgemm_view_portable(a, lda, b, ldb, bt, c, ldc, m, n, k);
1327}
1328
1329/// [`sgemm_view`] without Accelerate (`matrixmultiply`, one thread a call;
1330/// macOS takes it under `CMF_ACCEL=0`).
1331#[allow(clippy::too_many_arguments)]
1332fn sgemm_view_portable(
1333    a: &[f32],
1334    lda: usize,
1335    b: &[f32],
1336    ldb: usize,
1337    bt: bool,
1338    c: &mut [f32],
1339    ldc: usize,
1340    m: usize,
1341    n: usize,
1342    k: usize,
1343) {
1344    if m == 0 || n == 0 {
1345        return;
1346    }
1347    assert!(a.len() >= (m - 1) * lda + k && c.len() >= (m - 1) * ldc + n);
1348    // B as the [k×n] operand: Bᵀ's (j, r) is b[r·ldb + j], B's is b[j·ldb + r]
1349    let (rsb, csb) = if bt {
1350        assert!(b.len() >= (n - 1) * ldb + k);
1351        (1isize, ldb as isize)
1352    } else {
1353        assert!(k == 0 || b.len() >= (k - 1) * ldb + n);
1354        (ldb as isize, 1isize)
1355    };
1356    // SAFETY: every view was bounds-checked above
1357    unsafe {
1358        matrixmultiply::sgemm(
1359            m,
1360            k,
1361            n,
1362            1.0,
1363            a.as_ptr(),
1364            lda as isize,
1365            1,
1366            b.as_ptr(),
1367            rsb,
1368            csb,
1369            0.0,
1370            c.as_mut_ptr(),
1371            ldc as isize,
1372            1,
1373        );
1374    }
1375}
1376
1377/// Host attention of one image: per head, blocks of queries against every
1378/// key — one GEMM for the scores, the softmax rows across the pool, one
1379/// GEMM for the value mix, on the heads' strided views of q/k/v (no
1380/// gathers). A block holds up to ~8 M scores (32 MB), so a 280-token
1381/// image's 2304 patches go in one block a head.
1382///
1383/// The steps are pipelined: while the pool runs the softmax of block `t`,
1384/// a second thread computes the scores of block `t + 1` and the value mix
1385/// of block `t - 1` — the GEMMs (the matrix unit) and the exponentials
1386/// (the vector units) overlap instead of taking turns.
1387#[allow(clippy::too_many_arguments)]
1388fn attend_host(
1389    q: &[f32],
1390    k: &[f32],
1391    v: &[f32],
1392    len: usize,
1393    nh: usize,
1394    hd: usize,
1395    out: &mut [f32],
1396    pool: Option<&Pool>,
1397) {
1398    if pool.is_some() && !crate::egemma2::accelerate() {
1399        return attend_host_items(q, k, v, len, nh, hd, out, pool);
1400    }
1401    let w = nh * hd;
1402    let qblock = (SCORE_BLOCK / len).clamp(QBLOCK_MIN, len.max(1));
1403    let nblk = len.div_ceil(qblock);
1404    let steps = nh * nblk;
1405    // step -> (head, first row, rows)
1406    let step = |t: usize| {
1407        let (h, b) = (t / nblk, t % nblk);
1408        let i0 = b * qblock;
1409        (h, i0, (i0 + qblock).min(len) - i0)
1410    };
1411    let mut bufs: [Vec<f32>; 3] = std::array::from_fn(|_| vec![0f32; qblock * len]);
1412    let scores = |t: usize, sc: &mut [f32]| {
1413        let (h, i0, nb) = step(t);
1414        sgemm_view(
1415            &q[i0 * w + h * hd..],
1416            w,
1417            &k[h * hd..],
1418            w,
1419            true,
1420            &mut sc[..nb * len],
1421            len,
1422            nb,
1423            len,
1424            hd,
1425        );
1426    };
1427    let outp = Shared(out.as_mut_ptr());
1428    let out_len = out.len();
1429    let mix = |t: usize, sc: &[f32]| {
1430        let (h, i0, nb) = step(t);
1431        // SAFETY: steps write disjoint (rows, head) blocks of `out`
1432        let o = unsafe { outp.at(0, out_len) };
1433        sgemm_view(
1434            &sc[..nb * len],
1435            len,
1436            &v[h * hd..],
1437            w,
1438            false,
1439            &mut o[i0 * w + h * hd..],
1440            w,
1441            nb,
1442            hd,
1443            len,
1444        );
1445    };
1446    let soft = |t: usize, sc: &mut [f32]| {
1447        let (_, _, nb) = step(t);
1448        let sp = Shared(sc.as_mut_ptr());
1449        rows(pool, nb, &|s, e| {
1450            for i in s..e {
1451                softmax(unsafe { sp.at(i * len, len) });
1452            }
1453        });
1454    };
1455    if pool.is_none() || steps < 2 {
1456        let sc = &mut bufs[0];
1457        for t in 0..steps {
1458            scores(t, sc);
1459            soft(t, sc);
1460            mix(t, sc);
1461        }
1462        return;
1463    }
1464    scores(0, &mut bufs[0]);
1465    for t in 0..steps {
1466        // bufs[t % 3] holds the scores of t; t + 1 goes to the next, t - 1
1467        // is in the previous
1468        let [b0, b1, b2] = &mut bufs;
1469        let (cur, next, prev) = match t % 3 {
1470            0 => (b0, b1, b2),
1471            1 => (b1, b2, b0),
1472            _ => (b2, b0, b1),
1473        };
1474        std::thread::scope(|sc| {
1475            sc.spawn(|| {
1476                if t + 1 < steps {
1477                    scores(t + 1, next);
1478                }
1479                if t >= 1 {
1480                    mix(t - 1, prev);
1481                }
1482            });
1483            soft(t, cur);
1484        });
1485    }
1486    mix(steps - 1, &bufs[(steps - 1) % 3]);
1487}
1488
1489/// [`attend_host`] with the portable GEMM, which runs on one thread a
1490/// call: the pipelined form would leave the GEMMs (nearly all the work) on
1491/// one core, so here the (head, block of [`ITEM_QBLOCK`] queries) steps
1492/// themselves go across the pool, each with its own score rows. A 280-token
1493/// image on a 30-worker EPYC 7H12 (Linux): 10.3 -> 3.2 s, its 16 layers'
1494/// attention core ~8 -> 0.65 s; a 4-frame video 10.9 -> 5.1 s.
1495#[allow(clippy::too_many_arguments)]
1496fn attend_host_items(
1497    q: &[f32],
1498    k: &[f32],
1499    v: &[f32],
1500    len: usize,
1501    nh: usize,
1502    hd: usize,
1503    out: &mut [f32],
1504    pool: Option<&Pool>,
1505) {
1506    if len == 0 {
1507        return;
1508    }
1509    let w = nh * hd;
1510    let qblock = ITEM_QBLOCK.min(len);
1511    let nblk = len.div_ceil(qblock);
1512    let outp = Shared(out.as_mut_ptr());
1513    rows(pool, nh * nblk, &|s, e| {
1514        let mut sc = vec![0f32; qblock * len];
1515        let mut ob = vec![0f32; qblock * hd];
1516        for t in s..e {
1517            let (h, i0) = (t / nblk, (t % nblk) * qblock);
1518            let nb = (i0 + qblock).min(len) - i0;
1519            let sc = &mut sc[..nb * len];
1520            sgemm_view(
1521                &q[i0 * w + h * hd..],
1522                w,
1523                &k[h * hd..],
1524                w,
1525                true,
1526                sc,
1527                len,
1528                nb,
1529                len,
1530                hd,
1531            );
1532            for row in sc.chunks_exact_mut(len) {
1533                softmax(row);
1534            }
1535            sgemm_view(sc, len, &v[h * hd..], w, false, &mut ob, hd, nb, hd, len);
1536            for (i, src) in ob[..nb * hd].chunks_exact(hd).enumerate() {
1537                // SAFETY: steps write disjoint (row, head) slices of `out`
1538                unsafe { outp.at((i0 + i) * w + h * hd, hd) }.copy_from_slice(src);
1539            }
1540        }
1541    });
1542}
1543
1544#[cfg(test)]
1545mod tests {
1546    use super::*;
1547
1548    #[test]
1549    fn target_sizes_match_the_processor() {
1550        // (h, w, budget) -> (h, w), from the reference manifest
1551        let cases = [
1552            ((512, 512, 280), (768, 768)),
1553            ((756, 1980, 280), (480, 1296)),
1554            ((941, 530, 280), (1056, 576)),
1555            ((48, 64, 280), (672, 912)),
1556            ((512, 512, 70), (384, 384)),
1557            ((512, 512, 140), (528, 528)),
1558            ((512, 512, 560), (1104, 1104)),
1559            ((512, 512, 1120), (1584, 1584)),
1560            ((360, 640, 140), (384, 720)),
1561        ];
1562        for ((h, w, b), want) in cases {
1563            assert_eq!(
1564                target_size(h, w, 16, b * 9, 3).unwrap(),
1565                want,
1566                "{w}x{h} @ {b}"
1567            );
1568        }
1569        // extreme aspect ratios clamp to one 48-px strip
1570        let (h, w) = target_size(10, 10_000, 16, 280 * 9, 3).unwrap();
1571        assert_eq!(h, 48);
1572        assert!(w <= 280 * 48 && w % 48 == 0);
1573    }
1574
1575    #[test]
1576    fn resize_identity_and_constant() {
1577        let img = RgbFrame::new(4, 3, (0..36).map(|i| (i * 7) as u8).collect()).unwrap();
1578        assert_eq!(resize_bicubic_aa(&img, 3, 4), img);
1579        // a constant image stays constant under any resize
1580        let c = RgbFrame::new(37, 23, vec![131; 37 * 23 * 3]).unwrap();
1581        for (h, w) in [(48, 96), (10, 7), (100, 3)] {
1582            let r = resize_bicubic_aa(&c, h, w);
1583            assert!(r.data.iter().all(|&v| v == 131), "{w}x{h}");
1584        }
1585    }
1586
1587    #[test]
1588    fn patchify_orders_rows_then_columns() {
1589        // 32x16 image: two patches side by side
1590        let mut data = vec![0u8; 32 * 16 * 3];
1591        for y in 0..16 {
1592            for x in 0..32 {
1593                data[(y * 32 + x) * 3] = if x < 16 { 0 } else { 255 };
1594            }
1595        }
1596        let p = patchify(&RgbFrame::new(32, 16, data).unwrap(), 16);
1597        assert_eq!((p.pw, p.ph), (2, 1));
1598        assert_eq!(p.pixels[0], -1.0);
1599        assert_eq!(p.pixels[768], 1.0);
1600        assert_eq!(p.pixels[1], -1.0); // green of the first pixel
1601    }
1602
1603    #[test]
1604    fn frames_sample_at_one_fps() {
1605        let p = VisionProcessor::default();
1606        // the reference video: 115 frames at 25 fps
1607        assert_eq!(p.sample_indices(115, Some(25.0)), vec![0, 25, 50, 75]);
1608        // short clip: one frame
1609        assert_eq!(p.sample_indices(10, Some(25.0)), vec![0]);
1610        // frames with no rate: all of them, capped evenly at 32
1611        let all = p.sample_indices(100, None);
1612        assert_eq!(all.len(), 32);
1613        assert_eq!(all[0], 0);
1614        assert_eq!(*all.last().unwrap(), 99);
1615        // np.linspace(0, 99, 32, dtype=int)
1616        assert_eq!(all[1], 3);
1617        assert_eq!(all[2], 6);
1618        // a long video at 1 fps is cut to 32 evenly
1619        let long = p.sample_indices(30 * 100, Some(30.0));
1620        assert_eq!(long.len(), 32);
1621        assert_eq!(long[0], 0);
1622        assert_eq!(*long.last().unwrap(), 99 * 30);
1623    }
1624
1625    #[test]
1626    fn strided_gemm_views_agree() {
1627        // C = A·Bᵀ and C = A·B on strided views, Accelerate vs the portable
1628        // gather path vs a scalar reference
1629        let (m, n, k, lda, ldb, ldc) = (5usize, 7usize, 9usize, 13usize, 11usize, 10usize);
1630        let a: Vec<f32> = (0..m * lda)
1631            .map(|i| ((i * 7 % 17) as f32 - 8.0) / 8.0)
1632            .collect();
1633        for bt in [true, false] {
1634            let blen = if bt { n * ldb } else { k * ldb };
1635            let b: Vec<f32> = (0..blen)
1636                .map(|i| ((i * 5 % 13) as f32 - 6.0) / 6.0)
1637                .collect();
1638            let mut want = vec![0f32; m * ldc];
1639            for r in 0..m {
1640                for c in 0..n {
1641                    want[r * ldc + c] = (0..k)
1642                        .map(|j| a[r * lda + j] * if bt { b[c * ldb + j] } else { b[j * ldb + c] })
1643                        .sum();
1644                }
1645            }
1646            let mut c1 = vec![0f32; m * ldc];
1647            sgemm_view(&a, lda, &b, ldb, bt, &mut c1, ldc, m, n, k);
1648            let mut c2 = vec![0f32; m * ldc];
1649            sgemm_view_portable(&a, lda, &b, ldb, bt, &mut c2, ldc, m, n, k);
1650            for r in 0..m {
1651                for c in 0..n {
1652                    let w = want[r * ldc + c];
1653                    assert!((c1[r * ldc + c] - w).abs() < 1e-4, "bt={bt} accel");
1654                    assert!((c2[r * ldc + c] - w).abs() < 1e-4, "bt={bt} portable");
1655                }
1656            }
1657        }
1658    }
1659
1660    #[test]
1661    fn host_attention_matches_naive() {
1662        // two heads of 8 over 300 rows: the pipelined block path (with and
1663        // without a pool) and the per-step path of the portable GEMM (300
1664        // rows = two full query blocks and a partial one) against a direct
1665        // softmax(q·kᵀ)·v
1666        let (len, nh, hd) = (300usize, 2usize, 8usize);
1667        let w = nh * hd;
1668        let f = |i: usize, s: usize| (((i * s) % 29) as f32 - 14.0) / 20.0;
1669        let q: Vec<f32> = (0..len * w).map(|i| f(i, 7)).collect();
1670        let k: Vec<f32> = (0..len * w).map(|i| f(i, 11)).collect();
1671        let v: Vec<f32> = (0..len * w).map(|i| f(i, 13)).collect();
1672        let mut want = vec![0f32; len * w];
1673        for h in 0..nh {
1674            for i in 0..len {
1675                let sc: Vec<f64> = (0..len)
1676                    .map(|j| {
1677                        (0..hd)
1678                            .map(|c| (q[i * w + h * hd + c] * k[j * w + h * hd + c]) as f64)
1679                            .sum()
1680                    })
1681                    .collect();
1682                let mx = sc.iter().cloned().fold(f64::MIN, f64::max);
1683                let e: Vec<f64> = sc.iter().map(|x| (x - mx).exp()).collect();
1684                let z: f64 = e.iter().sum();
1685                for c in 0..hd {
1686                    want[i * w + h * hd + c] = (0..len)
1687                        .map(|j| e[j] / z * v[j * w + h * hd + c] as f64)
1688                        .sum::<f64>() as f32;
1689                }
1690            }
1691        }
1692        let pool = crate::pool::Pool::new(3);
1693        for (p, items) in [(None, false), (Some(&pool), false), (Some(&pool), true)] {
1694            let mut out = vec![0f32; len * w];
1695            if items {
1696                attend_host_items(&q, &k, &v, len, nh, hd, &mut out, p);
1697            } else {
1698                attend_host(&q, &k, &v, len, nh, hd, &mut out, p);
1699            }
1700            let err = out
1701                .iter()
1702                .zip(&want)
1703                .map(|(a, b)| (a - b).abs())
1704                .fold(0f32, f32::max);
1705            assert!(err < 1e-5, "max err {err}");
1706        }
1707    }
1708
1709    #[test]
1710    fn processor_config_is_read() {
1711        let cfg = serde_json::json!({
1712            "image_processor": {"max_soft_tokens": 560, "patch_size": 16, "pooling_kernel_size": 3},
1713            "video_processor": {"max_soft_tokens": 70, "fps": 2, "max_frames": 8, "overflow_strategy": "truncate"},
1714        });
1715        let p = VisionProcessor::from_config(&cfg);
1716        assert_eq!(p.image_budget, 560);
1717        assert_eq!(p.video_budget, 70);
1718        assert_eq!(p.video_fps, 2.0);
1719        assert_eq!(p.max_frames, 8);
1720        assert!(p.overflow_truncate);
1721        assert!(VisionProcessor::check_budget(300).is_err());
1722    }
1723}