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    pub(crate) fn from_vec(w: Vec<f32>, rows: usize, cols: usize) -> Self {
248        Mat { w, rows, cols }
249    }
250
251    pub(crate) fn weights(&self) -> &[f32] {
252        &self.w
253    }
254
255    /// `y[n, rows] = x[n, cols] · Wᵀ (+ bias)`.
256    pub(crate) fn matmat(&self, x: &[f32], n: usize, bias: Option<&[f32]>, y: &mut [f32], pool: Option<&Pool>) {
257        let (k, m) = (self.cols, self.rows);
258        if device_gemm_on() && crate::gpu::vision_gemm_nt(x, &self.w, y, n, k, m) {
259            GEMM_DEV.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
260        } else {
261            GEMM_HOST.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
262            crate::fcd_ops::gemm_nt_host(x, &self.w, y, n, k, m, pool);
263        }
264        if let Some(b) = bias {
265            add_bias(y, b, pool);
266        }
267    }
268}
269
270/// GEMMs the device took / refused (the `CMF_OV2_PROF` report).
271pub(crate) static GEMM_DEV: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
272pub(crate) static GEMM_HOST: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
273
274/// `CMF_OV2_GPU=0` keeps the tower's GEMMs and attention on the host.
275fn device_gemm_on() -> bool {
276    std::env::var("CMF_OV2_GPU").as_deref() != Ok("0") && crate::gpu::enabled_here()
277}
278
279fn add_bias(y: &mut [f32], b: &[f32], pool: Option<&Pool>) {
280    let d = b.len();
281    let rows = y.len() / d;
282    let dst = crate::pool::SendMut::new(y.as_mut_ptr());
283    let f = |lo: usize, hi: usize| {
284        for r in lo..hi {
285            // SAFETY: disjoint rows per worker.
286            let row = unsafe { std::slice::from_raw_parts_mut(dst.at(r * d), d) };
287            for (v, &bb) in row.iter_mut().zip(b) {
288                *v += bb;
289            }
290        }
291    };
292    match pool {
293        Some(p) => p.run_rows(rows, &f),
294        None => f(0, rows),
295    }
296}
297
298/// Row-wise LayerNorm with weight and bias (two-pass variance, f64 sums).
299pub(crate) fn layer_norm_rows(x: &[f32], w: &[f32], b: &[f32], eps: f64, out: &mut [f32], pool: Option<&Pool>) {
300    let d = w.len();
301    let rows = x.len() / d;
302    let dst = crate::pool::SendMut::new(out.as_mut_ptr());
303    let f = |lo: usize, hi: usize| {
304        for r in lo..hi {
305            let src = &x[r * d..(r + 1) * d];
306            // SAFETY: disjoint rows per worker.
307            let o = unsafe { std::slice::from_raw_parts_mut(dst.at(r * d), d) };
308            let mean = src.iter().map(|&v| v as f64).sum::<f64>() / d as f64;
309            let var = src.iter().map(|&v| (v as f64 - mean).powi(2)).sum::<f64>() / d as f64;
310            let inv = 1.0 / (var + eps).sqrt();
311            for i in 0..d {
312                o[i] = ((src[i] as f64 - mean) * inv) as f32 * w[i] + b[i];
313            }
314        }
315    };
316    match pool {
317        Some(p) => p.run_rows(rows, &f),
318        None => f(0, rows),
319    }
320}
321
322fn erf(x: f32) -> f32 {
323    crate::lfm3_vl::erf(x as f64) as f32
324}
325
326/// Exact (erf) GELU in place.
327pub(crate) fn gelu_rows(y: &mut [f32], pool: Option<&Pool>) {
328    let n = y.len();
329    let chunk = 1 << 14;
330    let dst = crate::pool::SendMut::new(y.as_mut_ptr());
331    let f = |lo: usize, hi: usize| {
332        for c in lo..hi {
333            let a = c * chunk;
334            let b = (a + chunk).min(n);
335            // SAFETY: disjoint chunks per worker.
336            let s = unsafe { std::slice::from_raw_parts_mut(dst.at(a), b - a) };
337            for v in s {
338                *v = 0.5 * *v * (1.0 + erf(*v * std::f32::consts::FRAC_1_SQRT_2));
339            }
340        }
341    };
342    let chunks = n.div_ceil(chunk);
343    match pool {
344        Some(p) => p.run_rows(chunks, &f),
345        None => f(0, chunks),
346    }
347}
348
349struct Layer {
350    ln1_w: Vec<f32>,
351    ln1_b: Vec<f32>,
352    qkv: Mat,
353    qkv_b: Vec<f32>,
354    proj: Mat,
355    proj_b: Vec<f32>,
356    ln2_w: Vec<f32>,
357    ln2_b: Vec<f32>,
358    fc1: Mat,
359    fc1_b: Vec<f32>,
360    fc2: Mat,
361    fc2_b: Vec<f32>,
362}
363
364/// The OneVision tower and merger.
365pub struct Ov2Tower {
366    pub cfg: Ov2Config,
367    patch: Mat,
368    ln_pre_w: Vec<f32>,
369    ln_pre_b: Vec<f32>,
370    layers: Vec<Layer>,
371    ln_q_w: Vec<f32>,
372    ln_q_b: Vec<f32>,
373    mlp0: Mat,
374    mlp0_b: Vec<f32>,
375    mlp2: Mat,
376    mlp2_b: Vec<f32>,
377    pool: Option<Arc<Pool>>,
378}
379
380fn vec_of(model: &Arc<CmfModel>, name: &str, len: usize) -> Result<Vec<f32>, String> {
381    let v = crate::dit::cmf_f32(model, name)?;
382    if v.len() != len {
383        return Err(format!("tensor '{name}' has {} values, expected {len}", v.len()));
384    }
385    Ok(v)
386}
387
388impl Ov2Tower {
389    pub fn from_model(model: &Arc<CmfModel>, pool: Option<Arc<Pool>>) -> Result<Self, String> {
390        let cfg = Ov2Config::from_model(model)?;
391        let (d, inter) = (cfg.hidden, cfg.intermediate);
392        let merged = d * cfg.merge_size * cfg.merge_size;
393        let mut layers = Vec::with_capacity(cfg.layers);
394        for i in 0..cfg.layers {
395            let p = format!("visual.encoder.layers.{i}");
396            layers.push(Layer {
397                ln1_w: vec_of(model, &format!("{p}.layer_norm1.weight"), d)?,
398                ln1_b: vec_of(model, &format!("{p}.layer_norm1.bias"), d)?,
399                qkv: Mat::load(model, &format!("{p}.self_attn.qkv.weight"), 3 * d, d)?,
400                qkv_b: vec_of(model, &format!("{p}.self_attn.qkv.bias"), 3 * d)?,
401                proj: Mat::load(model, &format!("{p}.self_attn.proj.weight"), d, d)?,
402                proj_b: vec_of(model, &format!("{p}.self_attn.proj.bias"), d)?,
403                ln2_w: vec_of(model, &format!("{p}.layer_norm2.weight"), d)?,
404                ln2_b: vec_of(model, &format!("{p}.layer_norm2.bias"), d)?,
405                fc1: Mat::load(model, &format!("{p}.mlp.fc1.weight"), inter, d)?,
406                fc1_b: vec_of(model, &format!("{p}.mlp.fc1.bias"), inter)?,
407                fc2: Mat::load(model, &format!("{p}.mlp.fc2.weight"), d, inter)?,
408                fc2_b: vec_of(model, &format!("{p}.mlp.fc2.bias"), d)?,
409            });
410        }
411        Ok(Ov2Tower {
412            patch: Mat::load(model, PATCH, d, cfg.patch_dim())?,
413            ln_pre_w: vec_of(model, "visual.layernorm_pre.weight", d)?,
414            ln_pre_b: vec_of(model, "visual.layernorm_pre.bias", d)?,
415            layers,
416            ln_q_w: vec_of(model, "visual.merger.ln_q.weight", d)?,
417            ln_q_b: vec_of(model, "visual.merger.ln_q.bias", d)?,
418            mlp0: Mat::load(model, "visual.merger.mlp.0.weight", merged, merged)?,
419            mlp0_b: vec_of(model, "visual.merger.mlp.0.bias", merged)?,
420            mlp2: Mat::load(model, "visual.merger.mlp.2.weight", cfg.text_hidden, merged)?,
421            mlp2_b: vec_of(model, "visual.merger.mlp.2.bias", cfg.text_hidden)?,
422            cfg,
423            pool,
424        })
425    }
426
427    /// cos / sin of the 64 rotary lanes of every patch, `[n, head_dim]`, in
428    /// merge-block row order.
429    fn rope_tables(&self, gt: usize, gh: usize, gw: usize) -> (Vec<f32>, Vec<f32>) {
430        let c = &self.cfg;
431        let hd = c.head_dim;
432        let half = hd / 2;
433        let unit = half / 16;
434        let (nt, nh, nw) = (4 * unit, 6 * unit, 6 * unit);
435        let inv = |n: usize| -> Vec<f32> {
436            (0..n)
437                .map(|i| (1.0 / c.rope_theta.powf(i as f64 / n as f64)) as f32)
438                .collect()
439        };
440        let (it, ih, iw) = (inv(nt), inv(nh), inv(nw));
441        let m = c.merge_size;
442        let n = gt * gh * gw;
443        let (mut cos, mut sin) = (vec![0f32; n * hd], vec![0f32; n * hd]);
444        let mut k = 0usize;
445        let mut ang = vec![0f32; half];
446        for t in 0..gt {
447            for a in 0..gh / m {
448                for b in 0..gw / m {
449                    for mh in 0..m {
450                        for mw in 0..m {
451                            let (r, col) = ((a * m + mh) as f32, (b * m + mw) as f32);
452                            // f32 outer products, as torch builds them.
453                            for (j, &f) in it.iter().enumerate() {
454                                ang[j] = t as f32 * f;
455                            }
456                            for (j, &f) in ih.iter().enumerate() {
457                                ang[nt + j] = r * f;
458                            }
459                            for (j, &f) in iw.iter().enumerate() {
460                                ang[nt + nh + j] = col * f;
461                            }
462                            for j in 0..hd {
463                                let (s, co) = ang[j % half].sin_cos();
464                                cos[k * hd + j] = co;
465                                sin[k * hd + j] = s;
466                            }
467                            k += 1;
468                        }
469                    }
470                }
471            }
472        }
473        (cos, sin)
474    }
475
476    /// Split the fused qkv rows into head-major planes, rotating q and k.
477    fn split_rope(&self, qkv: &[f32], n: usize, cos: &[f32], sin: &[f32], qh: &mut [f32], kh: &mut [f32], vh: &mut [f32]) {
478        let (nh, hd) = (self.cfg.heads, self.cfg.head_dim);
479        let d = nh * hd;
480        let (qp, kp, vp) = (
481            crate::pool::SendMut::new(qh.as_mut_ptr()),
482            crate::pool::SendMut::new(kh.as_mut_ptr()),
483            crate::pool::SendMut::new(vh.as_mut_ptr()),
484        );
485        let f = |lo: usize, hi: usize| {
486            for p in lo..hi {
487                let row = &qkv[p * 3 * d..(p + 1) * 3 * d];
488                let (cs, sn) = (&cos[p * hd..(p + 1) * hd], &sin[p * hd..(p + 1) * hd]);
489                for h in 0..nh {
490                    let dst = (h * n + p) * hd;
491                    // SAFETY: (head, patch) rows are disjoint per worker.
492                    let (q, k, v) = unsafe {
493                        (
494                            std::slice::from_raw_parts_mut(qp.at(dst), hd),
495                            std::slice::from_raw_parts_mut(kp.at(dst), hd),
496                            std::slice::from_raw_parts_mut(vp.at(dst), hd),
497                        )
498                    };
499                    let (qs, ks, vs) = (
500                        &row[h * hd..(h + 1) * hd],
501                        &row[d + h * hd..d + (h + 1) * hd],
502                        &row[2 * d + h * hd..2 * d + (h + 1) * hd],
503                    );
504                    for i in (0..hd).step_by(2) {
505                        q[i] = qs[i] * cs[i] - qs[i + 1] * sn[i];
506                        q[i + 1] = qs[i + 1] * cs[i + 1] + qs[i] * sn[i + 1];
507                        k[i] = ks[i] * cs[i] - ks[i + 1] * sn[i];
508                        k[i + 1] = ks[i + 1] * cs[i + 1] + ks[i] * sn[i + 1];
509                    }
510                    v.copy_from_slice(vs);
511                }
512            }
513        };
514        match self.pool.as_deref() {
515            Some(pool) => pool.run_rows(n, &f),
516            None => f(0, n),
517        }
518    }
519
520    /// Bidirectional attention over head-major planes → `[n, heads·hd]`.
521    fn attend(&self, qh: &[f32], kh: &[f32], vh: &[f32], n: usize, out: &mut [f32]) {
522        let (nh, hd) = (self.cfg.heads, self.cfg.head_dim);
523        let scale = (hd as f32).powf(-0.5);
524        if device_gemm_on() && n >= 64 && crate::gpu::dit_attention(qh, kh, vh, nh, nh, n, hd, scale, out) {
525            return;
526        }
527        attend_host(qh, kh, vh, nh, hd, n, scale, out, self.pool.as_deref());
528    }
529
530    /// Patch rows `[n, 768]` in merge-block order → merged rows
531    /// `[n/4, text_hidden]`.
532    pub fn forward(&self, input: &VisualInput) -> Result<Vec<f32>, String> {
533        let c = &self.cfg;
534        let (gt, gh, gw) = (input.grid_t, input.grid_h, input.grid_w);
535        let n = gt * gh * gw;
536        let m2 = c.merge_size * c.merge_size;
537        if input.rows.len() != n * c.patch_dim() || n % m2 != 0 {
538            return Err("onevision2: patch rows do not match the grid".into());
539        }
540        let pool = self.pool.as_deref();
541        let (d, inter) = (c.hidden, c.intermediate);
542        let prof = std::env::var("CMF_OV2_PROF").is_ok();
543        let t0 = std::time::Instant::now();
544        let mut stamps: Vec<(&str, f64)> = Vec::new();
545        let mut stamp = |what: &'static str, t: &mut std::time::Instant| {
546            stamps.push((what, t.elapsed().as_secs_f64()));
547            *t = std::time::Instant::now();
548        };
549        let mut ts = std::time::Instant::now();
550        let mut x = vec![0f32; n * d];
551        self.patch.matmat(&input.rows, n, None, &mut x, pool);
552        stamp("patch", &mut ts);
553        let mut xn = vec![0f32; n * d];
554        layer_norm_rows(&x, &self.ln_pre_w, &self.ln_pre_b, c.eps, &mut xn, pool);
555        std::mem::swap(&mut x, &mut xn);
556        let (cos, sin) = self.rope_tables(gt, gh, gw);
557        stamp("ln_pre+rope", &mut ts);
558        let mut qkv = vec![0f32; n * 3 * d];
559        let (mut qh, mut kh, mut vh) = (vec![0f32; n * d], vec![0f32; n * d], vec![0f32; n * d]);
560        let mut att = vec![0f32; n * d];
561        let mut tmp = vec![0f32; n * d];
562        let mut ff = vec![0f32; n * inter];
563        let (mut t_mm, mut t_att) = (0f64, 0f64);
564        // The whole encoder stack resident on the device when it takes it.
565        let resident = device_gemm_on() && {
566            let layers = self
567                .layers
568                .iter()
569                .map(|l| crate::gpu::VitLayer {
570                    ln1_w: &l.ln1_w,
571                    ln1_b: &l.ln1_b,
572                    qkv: &l.qkv.w,
573                    qkv_b: &l.qkv_b,
574                    proj: &l.proj.w,
575                    proj_b: &l.proj_b,
576                    ln2_w: &l.ln2_w,
577                    ln2_b: &l.ln2_b,
578                    fc1: &l.fc1.w,
579                    fc1_b: &l.fc1_b,
580                    fc2: &l.fc2.w,
581                    fc2_b: &l.fc2_b,
582                })
583                .collect();
584            let args = crate::gpu::VitArgs {
585                key: self as *const Self as u64,
586                layers,
587                n,
588                d,
589                heads: c.heads,
590                head_dim: c.head_dim,
591                inter,
592                eps: c.eps,
593                gelu_tanh: false,
594                quick_gelu: false,
595                rope_halves: false,
596                cos: &cos,
597                sin: &sin,
598                prof,
599            };
600            crate::gpu::vit_encode(&args, &mut x)
601        };
602        stamp("encoder", &mut ts);
603        for l in self.layers.iter().filter(|_| !resident) {
604            layer_norm_rows(&x, &l.ln1_w, &l.ln1_b, c.eps, &mut xn, pool);
605            let tm = std::time::Instant::now();
606            l.qkv.matmat(&xn, n, Some(&l.qkv_b), &mut qkv, pool);
607            t_mm += tm.elapsed().as_secs_f64();
608            self.split_rope(&qkv, n, &cos, &sin, &mut qh, &mut kh, &mut vh);
609            let ta = std::time::Instant::now();
610            self.attend(&qh, &kh, &vh, n, &mut att);
611            t_att += ta.elapsed().as_secs_f64();
612            let tm = std::time::Instant::now();
613            l.proj.matmat(&att, n, Some(&l.proj_b), &mut tmp, pool);
614            for (a, &b) in x.iter_mut().zip(&tmp) {
615                *a += b;
616            }
617            layer_norm_rows(&x, &l.ln2_w, &l.ln2_b, c.eps, &mut xn, pool);
618            l.fc1.matmat(&xn, n, Some(&l.fc1_b), &mut ff, pool);
619            gelu_rows(&mut ff, pool);
620            l.fc2.matmat(&ff, n, Some(&l.fc2_b), &mut tmp, pool);
621            t_mm += tm.elapsed().as_secs_f64();
622            for (a, &b) in x.iter_mut().zip(&tmp) {
623                *a += b;
624            }
625        }
626        layer_norm_rows(&x, &self.ln_q_w, &self.ln_q_b, c.eps, &mut xn, pool);
627        let nm = n / m2;
628        let mut h1 = vec![0f32; nm * d * m2];
629        self.mlp0.matmat(&xn, nm, Some(&self.mlp0_b), &mut h1, pool);
630        gelu_rows(&mut h1, pool);
631        let mut out = vec![0f32; nm * c.text_hidden];
632        self.mlp2.matmat(&h1, nm, Some(&self.mlp2_b), &mut out, pool);
633        stamp("merger", &mut ts);
634        if prof {
635            eprintln!("onevision2 stages: {}", stamps.iter().map(|(w, s)| format!("{w} {s:.3} s")).collect::<Vec<_>>().join(", "));
636            eprintln!(
637                "onevision2: {n} patches, tower {:.3} s (gemm {:.3} s: {} on the device, {} on the host; attention {:.3} s)",
638                t0.elapsed().as_secs_f64(),
639                t_mm,
640                GEMM_DEV.load(std::sync::atomic::Ordering::Relaxed),
641                GEMM_HOST.load(std::sync::atomic::Ordering::Relaxed),
642                t_att
643            );
644        }
645        Ok(out)
646    }
647}
648
649/// Bidirectional attention over head-major planes on the host.
650#[allow(clippy::too_many_arguments)]
651pub(crate) fn attend_host(qh: &[f32], kh: &[f32], vh: &[f32], nh: usize, hd: usize, n: usize, scale: f32, out: &mut [f32], pool: Option<&Pool>) {
652    let tile = n.min(1024);
653    let mut vt = vec![0f32; hd * n];
654    let mut qs = vec![0f32; tile * hd];
655    let mut sc = vec![0f32; tile * n];
656    let mut oh = vec![0f32; tile * hd];
657    for h in 0..nh {
658        let kp = &kh[h * n * hd..(h + 1) * n * hd];
659        for j in 0..n {
660            for e in 0..hd {
661                vt[e * n + j] = vh[(h * n + j) * hd + e];
662            }
663        }
664        for t0 in (0..n).step_by(tile) {
665            let tq = tile.min(n - t0);
666            for i in 0..tq {
667                for e in 0..hd {
668                    qs[i * hd + e] = qh[(h * n + t0 + i) * hd + e] * scale;
669                }
670            }
671            crate::fcd_ops::gemm_nt_host(&qs[..tq * hd], kp, &mut sc[..tq * n], tq, hd, n, pool);
672            let sp = crate::pool::SendMut::new(sc.as_mut_ptr());
673            let f = |lo: usize, hi: usize| {
674                for i in lo..hi {
675                    // SAFETY: disjoint score rows per worker.
676                    let row = unsafe { std::slice::from_raw_parts_mut(sp.at(i * n), n) };
677                    let mx = row.iter().fold(f32::NEG_INFINITY, |m, &x| m.max(x));
678                    let mut sum = 0f32;
679                    for x in row.iter_mut() {
680                        *x = (*x - mx).exp();
681                        sum += *x;
682                    }
683                    let inv = 1.0 / sum;
684                    for x in row.iter_mut() {
685                        *x *= inv;
686                    }
687                }
688            };
689            match pool {
690                Some(p) => p.run_rows(tq, &f),
691                None => f(0, tq),
692            }
693            crate::fcd_ops::gemm_nt_host(&sc[..tq * n], &vt, &mut oh[..tq * hd], tq, n, hd, pool);
694            for i in 0..tq {
695                let r = t0 + i;
696                out[r * nh * hd + h * hd..r * nh * hd + (h + 1) * hd].copy_from_slice(&oh[i * hd..(i + 1) * hd]);
697            }
698        }
699    }
700}
701
702/// The tower of `model`, built once and kept for the next request (a server
703/// answers many images with one file; the f32 matrices and their device
704/// copies are the expensive part).
705fn tower_for(model: &Arc<CmfModel>) -> Result<Arc<Ov2Tower>, String> {
706    static TOWER: std::sync::Mutex<Option<(usize, Arc<Ov2Tower>)>> = std::sync::Mutex::new(None);
707    let key = Arc::as_ptr(model) as usize;
708    let mut g = TOWER.lock().unwrap();
709    if let Some((k, t)) = g.as_ref() {
710        if *k == key {
711            return Ok(t.clone());
712        }
713    }
714    let t = Arc::new(Ov2Tower::from_model(model, crate::pool::Pool::from_env())?);
715    *g = Some((key, t.clone()));
716    Ok(t)
717}
718
719/// The prepared prompt: token ids and, with media, one row per position.
720pub struct PreparedOv2Prompt {
721    pub token_ids: Vec<u32>,
722    pub rows: Option<Vec<f32>>,
723}
724
725/// Replace every image part of `messages` with `{"type": "image"}` (what
726/// the template renders as `<|vision_start|><|image_pad|><|vision_end|>`)
727/// and collect the sources in order. Video and audio are refused.
728fn normalize(messages: &[Value]) -> Result<(Vec<Value>, Vec<Value>), String> {
729    let mut messages = messages.to_vec();
730    let mut items = Vec::new();
731    for message in &mut messages {
732        let Some(parts) = message.get_mut("content").and_then(Value::as_array_mut) else {
733            continue;
734        };
735        for part in parts {
736            if part.is_string() {
737                continue;
738            }
739            let typ = part.get("type").and_then(Value::as_str).unwrap_or("");
740            if typ == "image" || typ == "image_url" || part.get("image").is_some() || part.get("image_url").is_some() {
741                let source = part.get("image_url").or_else(|| part.get("image")).unwrap_or(part);
742                items.push(match source.as_str() {
743                    Some(url) => json!({"url": url}),
744                    None => source.clone(),
745                });
746                *part = json!({"type": "image"});
747            } else if typ != "text" && part.get("text").is_none() {
748                return Err(format!("Mage-VL: unsupported content part '{typ}' (text and images only)"));
749            }
750        }
751    }
752    Ok((messages, items))
753}
754
755/// Whether any message carries an image part.
756pub fn has_media(messages: &[Value]) -> Result<bool, String> {
757    Ok(!normalize(messages)?.1.is_empty())
758}
759
760/// [`prepare`] on the pipeline's own file.
761pub fn prepare_for_pipeline(
762    pipeline: &Pipeline,
763    messages: &[Value],
764    tools: Option<&[Value]>,
765    thinking: Option<bool>,
766    image_max_pixels: Option<usize>,
767) -> Result<PreparedOv2Prompt, String> {
768    let model = pipeline.model.as_ref().ok_or("Mage-VL images require a file-backed pipeline")?;
769    prepare(model, pipeline, messages, tools, thinking, image_max_pixels)
770}
771
772/// Render `messages` with the file's template, run every image through the
773/// tower, expand each `<|image_pad|>` to the image's merged-token count the
774/// way `Qwen2_5_VLProcessor` does, and assemble the prompt rows.
775pub fn prepare(
776    model: &Arc<CmfModel>,
777    pipeline: &Pipeline,
778    messages: &[Value],
779    tools: Option<&[Value]>,
780    thinking: Option<bool>,
781    image_max_pixels: Option<usize>,
782) -> Result<PreparedOv2Prompt, String> {
783    let (messages, items) = normalize(messages)?;
784    let tok = &pipeline.tokenizer;
785    if items.is_empty() {
786        let ids = tok.try_apply_chat_template_json(&messages, tools, thinking)?;
787        return Ok(PreparedOv2Prompt { token_ids: ids, rows: None });
788    }
789    let text = tok
790        .render_chat_json(&messages, tools, thinking)
791        .ok_or("Mage-VL: the chat template failed to render")?;
792    let tower = tower_for(model)?;
793    let cfg = &tower.cfg;
794    if cfg.text_hidden != pipeline.hidden_size {
795        return Err("Mage-VL: merger width differs from the text model".into());
796    }
797    // CMF_OV2_DUMP=DIR: each image's patch rows and tower rows as raw
798    // little-endian f32 (`pixels_I.f32`, `rows_I.f32`) for parity checks.
799    let dump = std::env::var_os("CMF_OV2_DUMP").map(std::path::PathBuf::from);
800    const PAD: &str = "<|image_pad|>";
801    let mut sources = items.into_iter();
802    let mut expanded = String::with_capacity(text.len());
803    let mut media_rows: Vec<f32> = Vec::new();
804    let mut rest = text.as_str();
805    let mut k = 0usize;
806    while let Some(at) = rest.find(PAD) {
807        expanded.push_str(&rest[..at]);
808        rest = &rest[at + PAD.len()..];
809        let source = sources.next().ok_or("Mage-VL: more <|image_pad|> placeholders than images")?;
810        let t0 = std::time::Instant::now();
811        let frame = media::decode_rgb(&media::load_image_bytes(&source)?)?;
812        let input = prepare_image(&frame, cfg, image_max_pixels)?;
813        let rows = tower.forward(&input)?;
814        let tokens = input.patches() / (cfg.merge_size * cfg.merge_size);
815        if let Some(dir) = &dump {
816            let write = |name: String, v: &[f32]| {
817                let bytes: Vec<u8> = v.iter().flat_map(|x| x.to_le_bytes()).collect();
818                std::fs::write(dir.join(name), bytes).map_err(|e| format!("CMF_OV2_DUMP: {e}"))
819            };
820            write(format!("pixels_{k}.f32"), &input.rows)?;
821            write(format!("rows_{k}.f32"), &rows)?;
822        }
823        if dump.is_some() || std::env::var_os("CMF_OV2_PROF").is_some() {
824            eprintln!(
825                "mage-vl: image {k} {}x{} resized to {}x{}, grid {}x{}, {tokens} tokens, tower {:.2} s",
826                frame.width,
827                frame.height,
828                input.resized.1,
829                input.resized.0,
830                input.grid_h,
831                input.grid_w,
832                t0.elapsed().as_secs_f64()
833            );
834        }
835        expanded.push_str(&PAD.repeat(tokens));
836        media_rows.extend(rows);
837        k += 1;
838    }
839    expanded.push_str(rest);
840    if sources.next().is_some() {
841        return Err("Mage-VL: the template rendered fewer placeholders than images".into());
842    }
843    let ids = tok.with_bos(tok.encode(&expanded));
844    let h = pipeline.hidden_size;
845    let mut rows = Vec::with_capacity(ids.len() * h);
846    let mut at = 0usize;
847    for &id in &ids {
848        if id == cfg.image_token_id {
849            let row = media_rows.get(at..at + h).ok_or("Mage-VL: placeholder and image row counts differ")?;
850            rows.extend_from_slice(row);
851            at += h;
852        } else {
853            rows.extend(pipeline.embed_id(id));
854        }
855    }
856    if at != media_rows.len() {
857        return Err("Mage-VL: unused image rows".into());
858    }
859    Ok(PreparedOv2Prompt { token_ids: ids, rows: Some(rows) })
860}
861
862#[cfg(test)]
863mod tests {
864    use super::*;
865
866    fn cfg() -> Ov2Config {
867        let config = json!({
868            "image_token_id": 151655,
869            "text_config": {"hidden_size": 2560},
870            "vision_config": {
871                "hidden_size": 1024, "num_attention_heads": 16, "num_hidden_layers": 24,
872                "intermediate_size": 4096, "patch_size": 16, "spatial_merge_size": 2,
873                "temporal_patch_size": 1, "layer_norm_eps": 1e-6, "rope_theta": 10000.0,
874                "hidden_act": "gelu", "layer_norm_type": "layer_norm", "use_head": false
875            }
876        });
877        let processor = json!({
878            "image_mean": [0.48145466, 0.4578275, 0.40821073],
879            "image_std": [0.26862954, 0.26130258, 0.27577711],
880            "min_pixels": 3136, "max_pixels": 4000000, "patch_size": 16, "merge_size": 2,
881            "rescale_factor": 0.00392156862745098
882        });
883        Ov2Config::from_json(&config, &processor).unwrap()
884    }
885
886    #[test]
887    fn config_reads_the_checkpoint_geometry() {
888        let c = cfg();
889        assert_eq!((c.head_dim, c.patch_dim(), c.text_hidden), (64, 768, 2560));
890        assert_eq!((c.min_pixels, c.max_pixels), (3136, 4_000_000));
891    }
892
893    #[test]
894    fn image_parts_become_template_images_in_order() {
895        let msgs = vec![json!({"role": "user", "content": [
896            {"type": "image_url", "image_url": {"url": "a.png"}},
897            {"type": "text", "text": "hi"},
898            {"type": "image", "image": "b.png"}
899        ]})];
900        let (out, items) = normalize(&msgs).unwrap();
901        assert_eq!(items, vec![json!({"url": "a.png"}), json!({"url": "b.png"})]);
902        assert_eq!(out[0]["content"][0], json!({"type": "image"}));
903        assert!(normalize(&[json!({"role": "user", "content": [{"type": "audio", "audio": "x"}]})]).is_err());
904    }
905}