Skip to main content

cortiq_engine/
onevision2.rs

1//! Mage-VL-Cowork (`llava_onevision2`): the OneVision vision encoder, its
2//! patch merger into the Qwen3 text model, the Qwen2-VL image preprocessing
3//! and the prompt assembly that replaces the `<|image_pad|>` placeholders
4//! with the tower's rows.
5//!
6//! The text model is plain Qwen3 with one-dimensional positions (the
7//! checkpoint's `LlavaOnevision2Model.forward` builds `arange` position ids
8//! even for image tokens), so the prompt rows go through the ordinary
9//! embedded-prompt prefill. A file is vision-capable when it carries
10//! `visual.*` and the checkpoint and processor configs under
11//! `provenance.onevision2`.
12//!
13//! ## Preprocessing (`Qwen2VLImageProcessorFast`)
14//!
15//! 1. **Resize** to `smart_resize(h, w, factor 32, min_pixels, max_pixels)`
16//!    with torchvision's uint8 antialiased bicubic
17//!    (`egemma2_vision::resize_bicubic_aa`).
18//! 2. **Standardize** with the CLIP mean and std (the 1/255 rescale folded
19//!    in, as the fast processor does).
20//! 3. **Patchify** like Qwen2-VL with a temporal patch of 1: 16×16 patches,
21//!    rows in 2×2 merge-block order, each row `(c, py, px)`.
22//!
23//! ## The tower (`LlavaOnevision2VisionPretrainedModel`)
24//!
25//! * a bias-free linear over the 768-wide patch row, then `layernorm_pre`;
26//! * 24 pre-norm blocks: LayerNorm → fused `qkv` (with bias) → RoPE →
27//!   bidirectional attention over the whole image → `proj`; LayerNorm →
28//!   `fc1` → exact GELU → `fc2`;
29//! * RoPE is three-axis (t, h, w with a 4:6:6 split of the 32 frequencies,
30//!   θ = 10000, each axis `θ^(-i/n_axis)`), the 32 angles concatenated with
31//!   themselves to 64 and applied with the INTERLEAVED `rotate_half`
32//!   (`(x0, x1) → (-x1, x0)` on adjacent lanes) — so the two lanes of a pair
33//!   use different angles, exactly as the checkpoint does. Patch positions
34//!   are the raster `(0, row, col)` of each patch, reordered into merge
35//!   blocks with the rows;
36//! * the merger LayerNorms each row, joins the four rows of a merge block
37//!   into one 4096-wide row and runs `mlp.0 → exact GELU → mlp.2` to the
38//!   text width.
39//!
40//! The matrices are stored f16 and run in f32: on the device GEMM where
41//! one exists (`gpu::vision_gemm_nt`), else the host GEMM; attention on
42//! `gpu::dit_attention`, else the host.
43//!
44//! Parity: `scripts/four/mage_vl_ref.py` dumps the processor's pixels, the
45//! tower rows and the image-prompt logits from Transformers 4.57.3.
46
47use crate::Pipeline;
48use crate::media::{self, RgbFrame};
49use crate::mimo_vision::{MimoProcessorConfig, VisualInput, VisualKind};
50use crate::pool::Pool;
51use cortiq_core::CmfModel;
52use serde_json::{Value, json};
53use std::sync::Arc;
54
55/// Tensor whose presence marks a vision-capable Mage-VL file.
56pub const PATCH: &str = "visual.embeddings.patch_embedding.weight";
57
58/// Everything the vision path reads from `provenance.onevision2`.
59#[derive(Clone, Debug)]
60pub struct Ov2Config {
61    pub hidden: usize,
62    pub heads: usize,
63    pub head_dim: usize,
64    pub layers: usize,
65    pub intermediate: usize,
66    pub patch_size: usize,
67    pub merge_size: usize,
68    pub temporal_patch_size: usize,
69    pub eps: f64,
70    pub rope_theta: f64,
71    pub text_hidden: usize,
72    pub image_token_id: u32,
73    pub min_pixels: usize,
74    pub max_pixels: usize,
75    pub mean: [f32; 3],
76    pub std: [f32; 3],
77    pub rescale: f32,
78}
79
80/// Whether `model` carries the OneVision tower.
81pub fn has_vision(model: &CmfModel) -> bool {
82    model.header.arch.arch_name == "llava_onevision2" && model.tensor(PATCH).is_some()
83}
84
85fn need_usize(v: &Value, key: &str) -> Result<usize, String> {
86    v.get(key)
87        .and_then(Value::as_u64)
88        .map(|n| n as usize)
89        .ok_or_else(|| format!("onevision2 config: missing '{key}'"))
90}
91
92fn triple(v: &Value, key: &str) -> Result<[f32; 3], String> {
93    let a = v
94        .get(key)
95        .and_then(Value::as_array)
96        .filter(|a| a.len() == 3)
97        .ok_or_else(|| format!("onevision2 processor: '{key}' must hold 3 numbers"))?;
98    let mut out = [0f32; 3];
99    for (o, x) in out.iter_mut().zip(a) {
100        *o = x
101            .as_f64()
102            .ok_or_else(|| format!("onevision2 processor: '{key}' must hold numbers"))? as f32;
103    }
104    Ok(out)
105}
106
107impl Ov2Config {
108    pub fn from_model(model: &CmfModel) -> Result<Self, String> {
109        let p = model
110            .header
111            .provenance
112            .as_ref()
113            .and_then(|p| p.get("onevision2"))
114            .ok_or("onevision2: the file carries no provenance.onevision2 (reconvert it)")?;
115        let cfg = p.get("config").ok_or("onevision2: provenance has no config")?;
116        let processor = p.get("processor").ok_or("onevision2: provenance has no processor")?;
117        Self::from_json(cfg, processor)
118    }
119
120    pub fn from_json(cfg: &Value, processor: &Value) -> Result<Self, String> {
121        let vc = cfg.get("vision_config").ok_or("onevision2: config has no vision_config")?;
122        let tc = cfg.get("text_config").ok_or("onevision2: config has no text_config")?;
123        let hidden = need_usize(vc, "hidden_size")?;
124        let heads = need_usize(vc, "num_attention_heads")?;
125        if heads == 0 || hidden % heads != 0 || (hidden / heads) % 32 != 0 {
126            return Err("onevision2: head_dim must split 4:6:6 into whole frequencies".into());
127        }
128        let norm = vc.get("layer_norm_type").and_then(Value::as_str).unwrap_or("layer_norm");
129        if norm != "layer_norm" || vc.get("use_head").and_then(Value::as_bool).unwrap_or(false) {
130            return Err("onevision2: only the LayerNorm tower without a pooling head is supported".into());
131        }
132        let act = vc.get("hidden_act").and_then(Value::as_str).unwrap_or("gelu");
133        if act != "gelu" {
134            return Err(format!("onevision2: unsupported tower activation '{act}'"));
135        }
136        let c = Ov2Config {
137            hidden,
138            heads,
139            head_dim: hidden / heads,
140            layers: need_usize(vc, "num_hidden_layers")?,
141            intermediate: need_usize(vc, "intermediate_size")?,
142            patch_size: need_usize(vc, "patch_size")?,
143            merge_size: need_usize(vc, "spatial_merge_size")?,
144            temporal_patch_size: need_usize(vc, "temporal_patch_size")?,
145            eps: vc.get("layer_norm_eps").and_then(Value::as_f64).unwrap_or(1e-6),
146            rope_theta: vc.get("rope_theta").and_then(Value::as_f64).unwrap_or(10000.0),
147            text_hidden: need_usize(tc, "hidden_size")?,
148            image_token_id: need_usize(cfg, "image_token_id")? as u32,
149            min_pixels: processor
150                .get("min_pixels")
151                .and_then(Value::as_u64)
152                .or_else(|| processor.pointer("/size/shortest_edge").and_then(Value::as_u64))
153                .ok_or("onevision2 processor: missing min_pixels")? as usize,
154            max_pixels: processor
155                .get("max_pixels")
156                .and_then(Value::as_u64)
157                .or_else(|| processor.pointer("/size/longest_edge").and_then(Value::as_u64))
158                .ok_or("onevision2 processor: missing max_pixels")? as usize,
159            mean: triple(processor, "image_mean")?,
160            std: triple(processor, "image_std")?,
161            rescale: processor
162                .get("rescale_factor")
163                .and_then(Value::as_f64)
164                .unwrap_or(1.0 / 255.0) as f32,
165        };
166        if c.temporal_patch_size != 1 {
167            return Err("onevision2: only temporal_patch_size 1 (still images) is supported".into());
168        }
169        if need_usize(processor, "patch_size")? != c.patch_size
170            || need_usize(processor, "merge_size")? != c.merge_size
171        {
172            return Err("onevision2: processor and tower patch geometry disagree".into());
173        }
174        Ok(c)
175    }
176
177    fn patch_cfg(&self) -> MimoProcessorConfig {
178        MimoProcessorConfig {
179            patch_size: self.patch_size,
180            merge_size: self.merge_size,
181            temporal_patch_size: self.temporal_patch_size,
182            ..Default::default()
183        }
184    }
185
186    fn patch_dim(&self) -> usize {
187        3 * self.temporal_patch_size * self.patch_size * self.patch_size
188    }
189}
190
191/// Preprocess one image into tower rows. `max_pixels` lowers the
192/// processor's ceiling (`--image-max-pixels`).
193pub fn prepare_image(
194    frame: &RgbFrame,
195    cfg: &Ov2Config,
196    max_pixels: Option<usize>,
197) -> Result<VisualInput, String> {
198    let factor = cfg.patch_size * cfg.merge_size;
199    let max_px = max_pixels.map_or(cfg.max_pixels, |m| m.min(cfg.max_pixels));
200    let (hb, wb) = crate::lfm3_vl::smart_resize(frame.height, frame.width, factor, cfg.min_pixels, max_px)?;
201    let resized = if (hb, wb) == (frame.height, frame.width) {
202        frame.clone()
203    } else {
204        crate::egemma2_vision::resize_bicubic_aa(frame, hb, wb)
205    };
206    let plane = hb * wb;
207    let mut chw = vec![0f32; 3 * plane];
208    for c in 0..3 {
209        let m = cfg.mean[c] / cfg.rescale;
210        let s = cfg.std[c] / cfg.rescale;
211        for (i, px) in resized.data.chunks_exact(3).enumerate() {
212            chw[c * plane + i] = (px[c] as f32 - m) / s;
213        }
214    }
215    let (rows, grid_t, grid_h, grid_w) = crate::mimo_vision::patchify(&[chw.as_slice()], hb, wb, &cfg.patch_cfg())?;
216    Ok(VisualInput {
217        kind: VisualKind::Image,
218        rows,
219        grid_t,
220        grid_h,
221        grid_w,
222        patch_dim: cfg.patch_dim(),
223        merge_size: cfg.merge_size,
224        timestamps: Vec::new(),
225        frame_indices: Vec::new(),
226        resized: (hb, wb),
227    })
228}
229
230/// A tower matrix as an f32 operand: the device GEMM when the backend has
231/// one and the job is big enough, the host GEMM otherwise.
232pub(crate) struct Mat {
233    w: Vec<f32>,
234    rows: usize,
235    cols: usize,
236}
237
238impl Mat {
239    pub(crate) fn load(model: &Arc<CmfModel>, name: &str, rows: usize, cols: usize) -> Result<Self, String> {
240        let w = crate::dit::cmf_f32(model, name)?;
241        if w.len() != rows * cols {
242            return Err(format!("tensor '{name}' has {} values, expected [{rows}, {cols}]", w.len()));
243        }
244        Ok(Mat { w, rows, cols })
245    }
246
247    /// `y[n, rows] = x[n, cols] · Wᵀ (+ bias)`.
248    pub(crate) fn matmat(&self, x: &[f32], n: usize, bias: Option<&[f32]>, y: &mut [f32], pool: Option<&Pool>) {
249        let (k, m) = (self.cols, self.rows);
250        if device_gemm_on() && crate::gpu::vision_gemm_nt(x, &self.w, y, n, k, m) {
251            GEMM_DEV.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
252        } else {
253            GEMM_HOST.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
254            crate::fcd_ops::gemm_nt_host(x, &self.w, y, n, k, m, pool);
255        }
256        if let Some(b) = bias {
257            add_bias(y, b, pool);
258        }
259    }
260}
261
262/// GEMMs the device took / refused (the `CMF_OV2_PROF` report).
263pub(crate) static GEMM_DEV: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
264pub(crate) static GEMM_HOST: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
265
266/// `CMF_OV2_GPU=0` keeps the tower's GEMMs and attention on the host.
267fn device_gemm_on() -> bool {
268    std::env::var("CMF_OV2_GPU").as_deref() != Ok("0") && crate::gpu::enabled_here()
269}
270
271fn add_bias(y: &mut [f32], b: &[f32], pool: Option<&Pool>) {
272    let d = b.len();
273    let rows = y.len() / d;
274    let dst = crate::pool::SendMut::new(y.as_mut_ptr());
275    let f = |lo: usize, hi: usize| {
276        for r in lo..hi {
277            // SAFETY: disjoint rows per worker.
278            let row = unsafe { std::slice::from_raw_parts_mut(dst.at(r * d), d) };
279            for (v, &bb) in row.iter_mut().zip(b) {
280                *v += bb;
281            }
282        }
283    };
284    match pool {
285        Some(p) => p.run_rows(rows, &f),
286        None => f(0, rows),
287    }
288}
289
290/// Row-wise LayerNorm with weight and bias (two-pass variance, f64 sums).
291pub(crate) fn layer_norm_rows(x: &[f32], w: &[f32], b: &[f32], eps: f64, out: &mut [f32], pool: Option<&Pool>) {
292    let d = w.len();
293    let rows = x.len() / d;
294    let dst = crate::pool::SendMut::new(out.as_mut_ptr());
295    let f = |lo: usize, hi: usize| {
296        for r in lo..hi {
297            let src = &x[r * d..(r + 1) * d];
298            // SAFETY: disjoint rows per worker.
299            let o = unsafe { std::slice::from_raw_parts_mut(dst.at(r * d), d) };
300            let mean = src.iter().map(|&v| v as f64).sum::<f64>() / d as f64;
301            let var = src.iter().map(|&v| (v as f64 - mean).powi(2)).sum::<f64>() / d as f64;
302            let inv = 1.0 / (var + eps).sqrt();
303            for i in 0..d {
304                o[i] = ((src[i] as f64 - mean) * inv) as f32 * w[i] + b[i];
305            }
306        }
307    };
308    match pool {
309        Some(p) => p.run_rows(rows, &f),
310        None => f(0, rows),
311    }
312}
313
314fn erf(x: f32) -> f32 {
315    crate::lfm3_vl::erf(x as f64) as f32
316}
317
318/// Exact (erf) GELU in place.
319pub(crate) fn gelu_rows(y: &mut [f32], pool: Option<&Pool>) {
320    let n = y.len();
321    let chunk = 1 << 14;
322    let dst = crate::pool::SendMut::new(y.as_mut_ptr());
323    let f = |lo: usize, hi: usize| {
324        for c in lo..hi {
325            let a = c * chunk;
326            let b = (a + chunk).min(n);
327            // SAFETY: disjoint chunks per worker.
328            let s = unsafe { std::slice::from_raw_parts_mut(dst.at(a), b - a) };
329            for v in s {
330                *v = 0.5 * *v * (1.0 + erf(*v * std::f32::consts::FRAC_1_SQRT_2));
331            }
332        }
333    };
334    let chunks = n.div_ceil(chunk);
335    match pool {
336        Some(p) => p.run_rows(chunks, &f),
337        None => f(0, chunks),
338    }
339}
340
341struct Layer {
342    ln1_w: Vec<f32>,
343    ln1_b: Vec<f32>,
344    qkv: Mat,
345    qkv_b: Vec<f32>,
346    proj: Mat,
347    proj_b: Vec<f32>,
348    ln2_w: Vec<f32>,
349    ln2_b: Vec<f32>,
350    fc1: Mat,
351    fc1_b: Vec<f32>,
352    fc2: Mat,
353    fc2_b: Vec<f32>,
354}
355
356/// The OneVision tower and merger.
357pub struct Ov2Tower {
358    pub cfg: Ov2Config,
359    patch: Mat,
360    ln_pre_w: Vec<f32>,
361    ln_pre_b: Vec<f32>,
362    layers: Vec<Layer>,
363    ln_q_w: Vec<f32>,
364    ln_q_b: Vec<f32>,
365    mlp0: Mat,
366    mlp0_b: Vec<f32>,
367    mlp2: Mat,
368    mlp2_b: Vec<f32>,
369    pool: Option<Arc<Pool>>,
370}
371
372fn vec_of(model: &Arc<CmfModel>, name: &str, len: usize) -> Result<Vec<f32>, String> {
373    let v = crate::dit::cmf_f32(model, name)?;
374    if v.len() != len {
375        return Err(format!("tensor '{name}' has {} values, expected {len}", v.len()));
376    }
377    Ok(v)
378}
379
380impl Ov2Tower {
381    pub fn from_model(model: &Arc<CmfModel>, pool: Option<Arc<Pool>>) -> Result<Self, String> {
382        let cfg = Ov2Config::from_model(model)?;
383        let (d, inter) = (cfg.hidden, cfg.intermediate);
384        let merged = d * cfg.merge_size * cfg.merge_size;
385        let mut layers = Vec::with_capacity(cfg.layers);
386        for i in 0..cfg.layers {
387            let p = format!("visual.encoder.layers.{i}");
388            layers.push(Layer {
389                ln1_w: vec_of(model, &format!("{p}.layer_norm1.weight"), d)?,
390                ln1_b: vec_of(model, &format!("{p}.layer_norm1.bias"), d)?,
391                qkv: Mat::load(model, &format!("{p}.self_attn.qkv.weight"), 3 * d, d)?,
392                qkv_b: vec_of(model, &format!("{p}.self_attn.qkv.bias"), 3 * d)?,
393                proj: Mat::load(model, &format!("{p}.self_attn.proj.weight"), d, d)?,
394                proj_b: vec_of(model, &format!("{p}.self_attn.proj.bias"), d)?,
395                ln2_w: vec_of(model, &format!("{p}.layer_norm2.weight"), d)?,
396                ln2_b: vec_of(model, &format!("{p}.layer_norm2.bias"), d)?,
397                fc1: Mat::load(model, &format!("{p}.mlp.fc1.weight"), inter, d)?,
398                fc1_b: vec_of(model, &format!("{p}.mlp.fc1.bias"), inter)?,
399                fc2: Mat::load(model, &format!("{p}.mlp.fc2.weight"), d, inter)?,
400                fc2_b: vec_of(model, &format!("{p}.mlp.fc2.bias"), d)?,
401            });
402        }
403        Ok(Ov2Tower {
404            patch: Mat::load(model, PATCH, d, cfg.patch_dim())?,
405            ln_pre_w: vec_of(model, "visual.layernorm_pre.weight", d)?,
406            ln_pre_b: vec_of(model, "visual.layernorm_pre.bias", d)?,
407            layers,
408            ln_q_w: vec_of(model, "visual.merger.ln_q.weight", d)?,
409            ln_q_b: vec_of(model, "visual.merger.ln_q.bias", d)?,
410            mlp0: Mat::load(model, "visual.merger.mlp.0.weight", merged, merged)?,
411            mlp0_b: vec_of(model, "visual.merger.mlp.0.bias", merged)?,
412            mlp2: Mat::load(model, "visual.merger.mlp.2.weight", cfg.text_hidden, merged)?,
413            mlp2_b: vec_of(model, "visual.merger.mlp.2.bias", cfg.text_hidden)?,
414            cfg,
415            pool,
416        })
417    }
418
419    /// cos / sin of the 64 rotary lanes of every patch, `[n, head_dim]`, in
420    /// merge-block row order.
421    fn rope_tables(&self, gt: usize, gh: usize, gw: usize) -> (Vec<f32>, Vec<f32>) {
422        let c = &self.cfg;
423        let hd = c.head_dim;
424        let half = hd / 2;
425        let unit = half / 16;
426        let (nt, nh, nw) = (4 * unit, 6 * unit, 6 * unit);
427        let inv = |n: usize| -> Vec<f32> {
428            (0..n)
429                .map(|i| (1.0 / c.rope_theta.powf(i as f64 / n as f64)) as f32)
430                .collect()
431        };
432        let (it, ih, iw) = (inv(nt), inv(nh), inv(nw));
433        let m = c.merge_size;
434        let n = gt * gh * gw;
435        let (mut cos, mut sin) = (vec![0f32; n * hd], vec![0f32; n * hd]);
436        let mut k = 0usize;
437        let mut ang = vec![0f32; half];
438        for t in 0..gt {
439            for a in 0..gh / m {
440                for b in 0..gw / m {
441                    for mh in 0..m {
442                        for mw in 0..m {
443                            let (r, col) = ((a * m + mh) as f32, (b * m + mw) as f32);
444                            // f32 outer products, as torch builds them.
445                            for (j, &f) in it.iter().enumerate() {
446                                ang[j] = t as f32 * f;
447                            }
448                            for (j, &f) in ih.iter().enumerate() {
449                                ang[nt + j] = r * f;
450                            }
451                            for (j, &f) in iw.iter().enumerate() {
452                                ang[nt + nh + j] = col * f;
453                            }
454                            for j in 0..hd {
455                                let (s, co) = ang[j % half].sin_cos();
456                                cos[k * hd + j] = co;
457                                sin[k * hd + j] = s;
458                            }
459                            k += 1;
460                        }
461                    }
462                }
463            }
464        }
465        (cos, sin)
466    }
467
468    /// Split the fused qkv rows into head-major planes, rotating q and k.
469    fn split_rope(&self, qkv: &[f32], n: usize, cos: &[f32], sin: &[f32], qh: &mut [f32], kh: &mut [f32], vh: &mut [f32]) {
470        let (nh, hd) = (self.cfg.heads, self.cfg.head_dim);
471        let d = nh * hd;
472        let (qp, kp, vp) = (
473            crate::pool::SendMut::new(qh.as_mut_ptr()),
474            crate::pool::SendMut::new(kh.as_mut_ptr()),
475            crate::pool::SendMut::new(vh.as_mut_ptr()),
476        );
477        let f = |lo: usize, hi: usize| {
478            for p in lo..hi {
479                let row = &qkv[p * 3 * d..(p + 1) * 3 * d];
480                let (cs, sn) = (&cos[p * hd..(p + 1) * hd], &sin[p * hd..(p + 1) * hd]);
481                for h in 0..nh {
482                    let dst = (h * n + p) * hd;
483                    // SAFETY: (head, patch) rows are disjoint per worker.
484                    let (q, k, v) = unsafe {
485                        (
486                            std::slice::from_raw_parts_mut(qp.at(dst), hd),
487                            std::slice::from_raw_parts_mut(kp.at(dst), hd),
488                            std::slice::from_raw_parts_mut(vp.at(dst), hd),
489                        )
490                    };
491                    let (qs, ks, vs) = (
492                        &row[h * hd..(h + 1) * hd],
493                        &row[d + h * hd..d + (h + 1) * hd],
494                        &row[2 * d + h * hd..2 * d + (h + 1) * hd],
495                    );
496                    for i in (0..hd).step_by(2) {
497                        q[i] = qs[i] * cs[i] - qs[i + 1] * sn[i];
498                        q[i + 1] = qs[i + 1] * cs[i + 1] + qs[i] * sn[i + 1];
499                        k[i] = ks[i] * cs[i] - ks[i + 1] * sn[i];
500                        k[i + 1] = ks[i + 1] * cs[i + 1] + ks[i] * sn[i + 1];
501                    }
502                    v.copy_from_slice(vs);
503                }
504            }
505        };
506        match self.pool.as_deref() {
507            Some(pool) => pool.run_rows(n, &f),
508            None => f(0, n),
509        }
510    }
511
512    /// Bidirectional attention over head-major planes → `[n, heads·hd]`.
513    fn attend(&self, qh: &[f32], kh: &[f32], vh: &[f32], n: usize, out: &mut [f32]) {
514        let (nh, hd) = (self.cfg.heads, self.cfg.head_dim);
515        let scale = (hd as f32).powf(-0.5);
516        if device_gemm_on() && n >= 64 && crate::gpu::dit_attention(qh, kh, vh, nh, nh, n, hd, scale, out) {
517            return;
518        }
519        let pool = self.pool.as_deref();
520        let tile = n.min(1024);
521        let mut vt = vec![0f32; hd * n];
522        let mut qs = vec![0f32; tile * hd];
523        let mut sc = vec![0f32; tile * n];
524        let mut oh = vec![0f32; tile * hd];
525        for h in 0..nh {
526            let kp = &kh[h * n * hd..(h + 1) * n * hd];
527            for j in 0..n {
528                for e in 0..hd {
529                    vt[e * n + j] = vh[(h * n + j) * hd + e];
530                }
531            }
532            for t0 in (0..n).step_by(tile) {
533                let tq = tile.min(n - t0);
534                for i in 0..tq {
535                    for e in 0..hd {
536                        qs[i * hd + e] = qh[(h * n + t0 + i) * hd + e] * scale;
537                    }
538                }
539                crate::fcd_ops::gemm_nt_host(&qs[..tq * hd], kp, &mut sc[..tq * n], tq, hd, n, pool);
540                let sp = crate::pool::SendMut::new(sc.as_mut_ptr());
541                let f = |lo: usize, hi: usize| {
542                    for i in lo..hi {
543                        // SAFETY: disjoint score rows per worker.
544                        let row = unsafe { std::slice::from_raw_parts_mut(sp.at(i * n), n) };
545                        let mx = row.iter().fold(f32::NEG_INFINITY, |m, &x| m.max(x));
546                        let mut sum = 0f32;
547                        for x in row.iter_mut() {
548                            *x = (*x - mx).exp();
549                            sum += *x;
550                        }
551                        let inv = 1.0 / sum;
552                        for x in row.iter_mut() {
553                            *x *= inv;
554                        }
555                    }
556                };
557                match pool {
558                    Some(p) => p.run_rows(tq, &f),
559                    None => f(0, tq),
560                }
561                crate::fcd_ops::gemm_nt_host(&sc[..tq * n], &vt, &mut oh[..tq * hd], tq, n, hd, pool);
562                for i in 0..tq {
563                    let r = t0 + i;
564                    out[r * nh * hd + h * hd..r * nh * hd + (h + 1) * hd].copy_from_slice(&oh[i * hd..(i + 1) * hd]);
565                }
566            }
567        }
568    }
569
570    /// Patch rows `[n, 768]` in merge-block order → merged rows
571    /// `[n/4, text_hidden]`.
572    pub fn forward(&self, input: &VisualInput) -> Result<Vec<f32>, String> {
573        let c = &self.cfg;
574        let (gt, gh, gw) = (input.grid_t, input.grid_h, input.grid_w);
575        let n = gt * gh * gw;
576        let m2 = c.merge_size * c.merge_size;
577        if input.rows.len() != n * c.patch_dim() || n % m2 != 0 {
578            return Err("onevision2: patch rows do not match the grid".into());
579        }
580        let pool = self.pool.as_deref();
581        let (d, inter) = (c.hidden, c.intermediate);
582        let prof = std::env::var("CMF_OV2_PROF").is_ok();
583        let t0 = std::time::Instant::now();
584        let mut stamps: Vec<(&str, f64)> = Vec::new();
585        let mut stamp = |what: &'static str, t: &mut std::time::Instant| {
586            stamps.push((what, t.elapsed().as_secs_f64()));
587            *t = std::time::Instant::now();
588        };
589        let mut ts = std::time::Instant::now();
590        let mut x = vec![0f32; n * d];
591        self.patch.matmat(&input.rows, n, None, &mut x, pool);
592        stamp("patch", &mut ts);
593        let mut xn = vec![0f32; n * d];
594        layer_norm_rows(&x, &self.ln_pre_w, &self.ln_pre_b, c.eps, &mut xn, pool);
595        std::mem::swap(&mut x, &mut xn);
596        let (cos, sin) = self.rope_tables(gt, gh, gw);
597        stamp("ln_pre+rope", &mut ts);
598        let mut qkv = vec![0f32; n * 3 * d];
599        let (mut qh, mut kh, mut vh) = (vec![0f32; n * d], vec![0f32; n * d], vec![0f32; n * d]);
600        let mut att = vec![0f32; n * d];
601        let mut tmp = vec![0f32; n * d];
602        let mut ff = vec![0f32; n * inter];
603        let (mut t_mm, mut t_att) = (0f64, 0f64);
604        // The whole encoder stack resident on the device when it takes it.
605        let resident = device_gemm_on() && {
606            let layers = self
607                .layers
608                .iter()
609                .map(|l| crate::gpu::VitLayer {
610                    ln1_w: &l.ln1_w,
611                    ln1_b: &l.ln1_b,
612                    qkv: &l.qkv.w,
613                    qkv_b: &l.qkv_b,
614                    proj: &l.proj.w,
615                    proj_b: &l.proj_b,
616                    ln2_w: &l.ln2_w,
617                    ln2_b: &l.ln2_b,
618                    fc1: &l.fc1.w,
619                    fc1_b: &l.fc1_b,
620                    fc2: &l.fc2.w,
621                    fc2_b: &l.fc2_b,
622                })
623                .collect();
624            let args = crate::gpu::VitArgs {
625                key: self as *const Self as u64,
626                layers,
627                n,
628                d,
629                heads: c.heads,
630                head_dim: c.head_dim,
631                inter,
632                eps: c.eps,
633                gelu_tanh: false,
634                rope_halves: false,
635                cos: &cos,
636                sin: &sin,
637                prof,
638            };
639            crate::gpu::vit_encode(&args, &mut x)
640        };
641        stamp("encoder", &mut ts);
642        for l in self.layers.iter().filter(|_| !resident) {
643            layer_norm_rows(&x, &l.ln1_w, &l.ln1_b, c.eps, &mut xn, pool);
644            let tm = std::time::Instant::now();
645            l.qkv.matmat(&xn, n, Some(&l.qkv_b), &mut qkv, pool);
646            t_mm += tm.elapsed().as_secs_f64();
647            self.split_rope(&qkv, n, &cos, &sin, &mut qh, &mut kh, &mut vh);
648            let ta = std::time::Instant::now();
649            self.attend(&qh, &kh, &vh, n, &mut att);
650            t_att += ta.elapsed().as_secs_f64();
651            let tm = std::time::Instant::now();
652            l.proj.matmat(&att, n, Some(&l.proj_b), &mut tmp, pool);
653            for (a, &b) in x.iter_mut().zip(&tmp) {
654                *a += b;
655            }
656            layer_norm_rows(&x, &l.ln2_w, &l.ln2_b, c.eps, &mut xn, pool);
657            l.fc1.matmat(&xn, n, Some(&l.fc1_b), &mut ff, pool);
658            gelu_rows(&mut ff, pool);
659            l.fc2.matmat(&ff, n, Some(&l.fc2_b), &mut tmp, pool);
660            t_mm += tm.elapsed().as_secs_f64();
661            for (a, &b) in x.iter_mut().zip(&tmp) {
662                *a += b;
663            }
664        }
665        layer_norm_rows(&x, &self.ln_q_w, &self.ln_q_b, c.eps, &mut xn, pool);
666        let nm = n / m2;
667        let mut h1 = vec![0f32; nm * d * m2];
668        self.mlp0.matmat(&xn, nm, Some(&self.mlp0_b), &mut h1, pool);
669        gelu_rows(&mut h1, pool);
670        let mut out = vec![0f32; nm * c.text_hidden];
671        self.mlp2.matmat(&h1, nm, Some(&self.mlp2_b), &mut out, pool);
672        stamp("merger", &mut ts);
673        if prof {
674            eprintln!("onevision2 stages: {}", stamps.iter().map(|(w, s)| format!("{w} {s:.3} s")).collect::<Vec<_>>().join(", "));
675            eprintln!(
676                "onevision2: {n} patches, tower {:.3} s (gemm {:.3} s: {} on the device, {} on the host; attention {:.3} s)",
677                t0.elapsed().as_secs_f64(),
678                t_mm,
679                GEMM_DEV.load(std::sync::atomic::Ordering::Relaxed),
680                GEMM_HOST.load(std::sync::atomic::Ordering::Relaxed),
681                t_att
682            );
683        }
684        Ok(out)
685    }
686}
687
688/// The tower of `model`, built once and kept for the next request (a server
689/// answers many images with one file; the f32 matrices and their device
690/// copies are the expensive part).
691fn tower_for(model: &Arc<CmfModel>) -> Result<Arc<Ov2Tower>, String> {
692    static TOWER: std::sync::Mutex<Option<(usize, Arc<Ov2Tower>)>> = std::sync::Mutex::new(None);
693    let key = Arc::as_ptr(model) as usize;
694    let mut g = TOWER.lock().unwrap();
695    if let Some((k, t)) = g.as_ref() {
696        if *k == key {
697            return Ok(t.clone());
698        }
699    }
700    let t = Arc::new(Ov2Tower::from_model(model, crate::pool::Pool::from_env())?);
701    *g = Some((key, t.clone()));
702    Ok(t)
703}
704
705/// The prepared prompt: token ids and, with media, one row per position.
706pub struct PreparedOv2Prompt {
707    pub token_ids: Vec<u32>,
708    pub rows: Option<Vec<f32>>,
709}
710
711/// Replace every image part of `messages` with `{"type": "image"}` (what
712/// the template renders as `<|vision_start|><|image_pad|><|vision_end|>`)
713/// and collect the sources in order. Video and audio are refused.
714fn normalize(messages: &[Value]) -> Result<(Vec<Value>, Vec<Value>), String> {
715    let mut messages = messages.to_vec();
716    let mut items = Vec::new();
717    for message in &mut messages {
718        let Some(parts) = message.get_mut("content").and_then(Value::as_array_mut) else {
719            continue;
720        };
721        for part in parts {
722            if part.is_string() {
723                continue;
724            }
725            let typ = part.get("type").and_then(Value::as_str).unwrap_or("");
726            if typ == "image" || typ == "image_url" || part.get("image").is_some() || part.get("image_url").is_some() {
727                let source = part.get("image_url").or_else(|| part.get("image")).unwrap_or(part);
728                items.push(match source.as_str() {
729                    Some(url) => json!({"url": url}),
730                    None => source.clone(),
731                });
732                *part = json!({"type": "image"});
733            } else if typ != "text" && part.get("text").is_none() {
734                return Err(format!("Mage-VL: unsupported content part '{typ}' (text and images only)"));
735            }
736        }
737    }
738    Ok((messages, items))
739}
740
741/// Whether any message carries an image part.
742pub fn has_media(messages: &[Value]) -> Result<bool, String> {
743    Ok(!normalize(messages)?.1.is_empty())
744}
745
746/// [`prepare`] on the pipeline's own file.
747pub fn prepare_for_pipeline(
748    pipeline: &Pipeline,
749    messages: &[Value],
750    tools: Option<&[Value]>,
751    thinking: Option<bool>,
752    image_max_pixels: Option<usize>,
753) -> Result<PreparedOv2Prompt, String> {
754    let model = pipeline.model.as_ref().ok_or("Mage-VL images require a file-backed pipeline")?;
755    prepare(model, pipeline, messages, tools, thinking, image_max_pixels)
756}
757
758/// Render `messages` with the file's template, run every image through the
759/// tower, expand each `<|image_pad|>` to the image's merged-token count the
760/// way `Qwen2_5_VLProcessor` does, and assemble the prompt rows.
761pub fn prepare(
762    model: &Arc<CmfModel>,
763    pipeline: &Pipeline,
764    messages: &[Value],
765    tools: Option<&[Value]>,
766    thinking: Option<bool>,
767    image_max_pixels: Option<usize>,
768) -> Result<PreparedOv2Prompt, String> {
769    let (messages, items) = normalize(messages)?;
770    let tok = &pipeline.tokenizer;
771    if items.is_empty() {
772        let ids = tok.try_apply_chat_template_json(&messages, tools, thinking)?;
773        return Ok(PreparedOv2Prompt { token_ids: ids, rows: None });
774    }
775    let text = tok
776        .render_chat_json(&messages, tools, thinking)
777        .ok_or("Mage-VL: the chat template failed to render")?;
778    let tower = tower_for(model)?;
779    let cfg = &tower.cfg;
780    if cfg.text_hidden != pipeline.hidden_size {
781        return Err("Mage-VL: merger width differs from the text model".into());
782    }
783    // CMF_OV2_DUMP=DIR: each image's patch rows and tower rows as raw
784    // little-endian f32 (`pixels_I.f32`, `rows_I.f32`) for parity checks.
785    let dump = std::env::var_os("CMF_OV2_DUMP").map(std::path::PathBuf::from);
786    const PAD: &str = "<|image_pad|>";
787    let mut sources = items.into_iter();
788    let mut expanded = String::with_capacity(text.len());
789    let mut media_rows: Vec<f32> = Vec::new();
790    let mut rest = text.as_str();
791    let mut k = 0usize;
792    while let Some(at) = rest.find(PAD) {
793        expanded.push_str(&rest[..at]);
794        rest = &rest[at + PAD.len()..];
795        let source = sources.next().ok_or("Mage-VL: more <|image_pad|> placeholders than images")?;
796        let t0 = std::time::Instant::now();
797        let frame = media::decode_rgb(&media::load_image_bytes(&source)?)?;
798        let input = prepare_image(&frame, cfg, image_max_pixels)?;
799        let rows = tower.forward(&input)?;
800        let tokens = input.patches() / (cfg.merge_size * cfg.merge_size);
801        if let Some(dir) = &dump {
802            let write = |name: String, v: &[f32]| {
803                let bytes: Vec<u8> = v.iter().flat_map(|x| x.to_le_bytes()).collect();
804                std::fs::write(dir.join(name), bytes).map_err(|e| format!("CMF_OV2_DUMP: {e}"))
805            };
806            write(format!("pixels_{k}.f32"), &input.rows)?;
807            write(format!("rows_{k}.f32"), &rows)?;
808        }
809        if dump.is_some() || std::env::var_os("CMF_OV2_PROF").is_some() {
810            eprintln!(
811                "mage-vl: image {k} {}x{} resized to {}x{}, grid {}x{}, {tokens} tokens, tower {:.2} s",
812                frame.width,
813                frame.height,
814                input.resized.1,
815                input.resized.0,
816                input.grid_h,
817                input.grid_w,
818                t0.elapsed().as_secs_f64()
819            );
820        }
821        expanded.push_str(&PAD.repeat(tokens));
822        media_rows.extend(rows);
823        k += 1;
824    }
825    expanded.push_str(rest);
826    if sources.next().is_some() {
827        return Err("Mage-VL: the template rendered fewer placeholders than images".into());
828    }
829    let ids = tok.with_bos(tok.encode(&expanded));
830    let h = pipeline.hidden_size;
831    let mut rows = Vec::with_capacity(ids.len() * h);
832    let mut at = 0usize;
833    for &id in &ids {
834        if id == cfg.image_token_id {
835            let row = media_rows.get(at..at + h).ok_or("Mage-VL: placeholder and image row counts differ")?;
836            rows.extend_from_slice(row);
837            at += h;
838        } else {
839            rows.extend(pipeline.embed_id(id));
840        }
841    }
842    if at != media_rows.len() {
843        return Err("Mage-VL: unused image rows".into());
844    }
845    Ok(PreparedOv2Prompt { token_ids: ids, rows: Some(rows) })
846}
847
848#[cfg(test)]
849mod tests {
850    use super::*;
851
852    fn cfg() -> Ov2Config {
853        let config = json!({
854            "image_token_id": 151655,
855            "text_config": {"hidden_size": 2560},
856            "vision_config": {
857                "hidden_size": 1024, "num_attention_heads": 16, "num_hidden_layers": 24,
858                "intermediate_size": 4096, "patch_size": 16, "spatial_merge_size": 2,
859                "temporal_patch_size": 1, "layer_norm_eps": 1e-6, "rope_theta": 10000.0,
860                "hidden_act": "gelu", "layer_norm_type": "layer_norm", "use_head": false
861            }
862        });
863        let processor = json!({
864            "image_mean": [0.48145466, 0.4578275, 0.40821073],
865            "image_std": [0.26862954, 0.26130258, 0.27577711],
866            "min_pixels": 3136, "max_pixels": 4000000, "patch_size": 16, "merge_size": 2,
867            "rescale_factor": 0.00392156862745098
868        });
869        Ov2Config::from_json(&config, &processor).unwrap()
870    }
871
872    #[test]
873    fn config_reads_the_checkpoint_geometry() {
874        let c = cfg();
875        assert_eq!((c.head_dim, c.patch_dim(), c.text_hidden), (64, 768, 2560));
876        assert_eq!((c.min_pixels, c.max_pixels), (3136, 4_000_000));
877    }
878
879    #[test]
880    fn image_parts_become_template_images_in_order() {
881        let msgs = vec![json!({"role": "user", "content": [
882            {"type": "image_url", "image_url": {"url": "a.png"}},
883            {"type": "text", "text": "hi"},
884            {"type": "image", "image": "b.png"}
885        ]})];
886        let (out, items) = normalize(&msgs).unwrap();
887        assert_eq!(items, vec![json!({"url": "a.png"}), json!({"url": "b.png"})]);
888        assert_eq!(out[0]["content"][0], json!({"type": "image"}));
889        assert!(normalize(&[json!({"role": "user", "content": [{"type": "audio", "audio": "x"}]})]).is_err());
890    }
891}