Skip to main content

memra_engine/
vision.rs

1//! Vision tower for Qwen3.8-27B multimodal input (lane/vision, 2026-08-15).
2//!
3//! The qwen3_5_vision ViT (depth 27, hidden 1152, heads 16, gelu_pytorch_tanh, patch 16,
4//! spatial_merge 2, temporal_patch 2, LEARNED pos embeddings on a 48x48 grid) lives in the
5//! official checkpoint's `outside.safetensors` (the unquantized shard) — the quantized
6//! trunks (ct-NVFP4 etc.) strip it. `MEMRA_VISION_DIR` points at any directory carrying
7//! that shard; the tower output is plain [n_tokens, out_width] embeddings (out_width =
8//! merger fc2 out_features, derived from the shard — 5120 on q38-27B, 2048 on
9//! Ornith-1.5-35B-A3B), so vision requests serve on any trunk whose n_embd matches the
10//! shard's. Text side uses standard sequential rope (rope_scaling is null on these
11//! models — no M-RoPE), so spliced image tokens take ordinary positions.
12//!
13//! v1 posture: correctness-first — cuBLASLt f32 GEMMs (`Engine::linear` + bias epilogue),
14//! `sdpa_naive(causal=false)` for the bidirectional attention, host-side permutes between
15//! stages (the tower is a small fraction of a vision request; optimize later). Parity gate:
16//! merger-output cosine vs the HF reference per VISION-LANE.md.
17
18use crate::Engine;
19use cudarc::driver::CudaSlice;
20use memra_gguf::dequant::bf16_to_f32;
21use memra_gguf::safetensors::StShard;
22use std::path::Path;
23
24pub const V_HIDDEN: usize = 1152;
25pub const V_HEADS: usize = 16;
26pub const V_HEAD_DIM: usize = V_HIDDEN / V_HEADS; // 72
27pub const V_INTER: usize = 4304;
28pub const V_DEPTH: usize = 27;
29pub const V_PATCH: usize = 16;
30pub const V_MERGE: usize = 2;
31pub const V_TEMPORAL: usize = 2;
32pub const V_POS_GRID: usize = 48; // 2304 learned positions = 48x48
33pub const V_PATCH_IN: usize = 3 * V_TEMPORAL * V_PATCH * V_PATCH; // 1536
34pub const V_MERGED_IN: usize = V_HIDDEN * V_MERGE * V_MERGE; // 4608
35const LN_EPS: f32 = 1e-6;
36
37/// Mixed-embedding prime overlay: image embeddings that replace `<|image_pad|>` token
38/// embeddings at prompt-relative positions during `prime_cache_overlaid`. `rows` holds all
39/// images' merger outputs concatenated ([total_rows, n_embd]); each span is
40/// `(prompt_pos, row_off, n_rows)` — rows `[row_off, row_off+n_rows)` land at prompt
41/// positions `[prompt_pos, prompt_pos+n_rows)`. Spans must not overlap.
42pub struct EmbedOverlay {
43    pub rows: CudaSlice<f32>,
44    pub spans: Vec<(usize, usize, usize)>,
45}
46
47impl EmbedOverlay {
48    /// Sub-window for a prime call covering prompt-relative `[off, off+len)`: spans clipped
49    /// and rebased so the callee sees call-relative positions (the serve prefill tick primes
50    /// a prompt across multiple `prime_cache_overlaid` calls). `rows` is an Arc clone, not a
51    /// copy. None = no image rows in this window (caller may prime plain).
52    pub fn window(&self, off: usize, len: usize) -> Option<EmbedOverlay> {
53        let spans: Vec<(usize, usize, usize)> = self
54            .spans
55            .iter()
56            .filter_map(|&(pos, row_off, n_rows)| {
57                let lo = pos.max(off);
58                let hi = (pos + n_rows).min(off + len);
59                (lo < hi).then(|| (lo - off, row_off + (lo - pos), hi - lo))
60            })
61            .collect();
62        (!spans.is_empty()).then(|| EmbedOverlay {
63            rows: self.rows.clone(),
64            spans,
65        })
66    }
67}
68
69struct Lin {
70    w: CudaSlice<f32>,
71    b: CudaSlice<f32>,
72    in_f: usize,
73    out_f: usize,
74}
75
76struct VisBlock {
77    norm1_w: CudaSlice<f32>,
78    norm1_b: CudaSlice<f32>,
79    norm2_w: CudaSlice<f32>,
80    norm2_b: CudaSlice<f32>,
81    qkv: Lin,
82    proj: Lin,
83    fc1: Lin,
84    fc2: Lin,
85}
86
87pub struct VisionTower {
88    patch: Lin,
89    /// Host copy of the learned pos table [2304, 1152] — bilinear-interpolated per grid.
90    pos: Vec<f32>,
91    blocks: Vec<VisBlock>,
92    merger_norm_w: CudaSlice<f32>,
93    merger_norm_b: CudaSlice<f32>,
94    merger_fc1: Lin,
95    merger_fc2: Lin,
96}
97
98fn read_f32(sh: &StShard, name: &str) -> Result<Vec<f32>, Box<dyn std::error::Error>> {
99    let (info, raw) = sh
100        .raw(name)
101        .ok_or_else(|| format!("vision tensor missing: {name}"))?;
102    match info.dtype.as_str() {
103        "BF16" => Ok(raw
104            .chunks_exact(2)
105            .map(|c| bf16_to_f32(u16::from_le_bytes([c[0], c[1]])))
106            .collect()),
107        "F32" => Ok(raw
108            .chunks_exact(4)
109            .map(|c| f32::from_le_bytes(c.try_into().unwrap()))
110            .collect()),
111        other => Err(format!("vision tensor {name}: unsupported dtype {other}").into()),
112    }
113}
114
115fn load_lin(
116    e: &Engine,
117    sh: &StShard,
118    stem: &str,
119    in_f: usize,
120    out_f: usize,
121) -> Result<Lin, Box<dyn std::error::Error>> {
122    let w = read_f32(sh, &format!("{stem}.weight"))?;
123    let b = read_f32(sh, &format!("{stem}.bias"))?;
124    assert_eq!(w.len(), in_f * out_f, "{stem}.weight shape");
125    assert_eq!(b.len(), out_f, "{stem}.bias shape");
126    Ok(Lin {
127        w: e.htod(&w)?,
128        b: e.htod(&b)?,
129        in_f,
130        out_f,
131    })
132}
133
134impl VisionTower {
135    /// Load the tower from a directory containing `outside.safetensors`.
136    pub fn load(e: &Engine, dir: &Path) -> Result<Self, Box<dyn std::error::Error>> {
137        let sh = StShard::open(dir.join("outside.safetensors"))?;
138        let p = "model.visual";
139        let patch = {
140            // conv [1152, 3, 2, 16, 16] flattens to Linear 1536 -> 1152 (HF patchify order:
141            // channel-major within the (c, t, h, w) patch — the preprocessor emits the
142            // matching flat order).
143            let w = read_f32(&sh, &format!("{p}.patch_embed.proj.weight"))?;
144            let b = read_f32(&sh, &format!("{p}.patch_embed.proj.bias"))?;
145            assert_eq!(w.len(), V_HIDDEN * V_PATCH_IN);
146            Lin {
147                w: e.htod(&w)?,
148                b: e.htod(&b)?,
149                in_f: V_PATCH_IN,
150                out_f: V_HIDDEN,
151            }
152        };
153        let pos = read_f32(&sh, &format!("{p}.pos_embed.weight"))?;
154        assert_eq!(pos.len(), V_POS_GRID * V_POS_GRID * V_HIDDEN);
155        let mut blocks = Vec::with_capacity(V_DEPTH);
156        for il in 0..V_DEPTH {
157            let bp = format!("{p}.blocks.{il}");
158            blocks.push(VisBlock {
159                norm1_w: e.htod(&read_f32(&sh, &format!("{bp}.norm1.weight"))?)?,
160                norm1_b: e.htod(&read_f32(&sh, &format!("{bp}.norm1.bias"))?)?,
161                norm2_w: e.htod(&read_f32(&sh, &format!("{bp}.norm2.weight"))?)?,
162                norm2_b: e.htod(&read_f32(&sh, &format!("{bp}.norm2.bias"))?)?,
163                qkv: load_lin(e, &sh, &format!("{bp}.attn.qkv"), V_HIDDEN, 3 * V_HIDDEN)?,
164                proj: load_lin(e, &sh, &format!("{bp}.attn.proj"), V_HIDDEN, V_HIDDEN)?,
165                fc1: load_lin(e, &sh, &format!("{bp}.mlp.linear_fc1"), V_HIDDEN, V_INTER)?,
166                fc2: load_lin(e, &sh, &format!("{bp}.mlp.linear_fc2"), V_INTER, V_HIDDEN)?,
167            });
168        }
169        let merger_norm_w = e.htod(&read_f32(&sh, &format!("{p}.merger.norm.weight"))?)?;
170        let merger_norm_b = e.htod(&read_f32(&sh, &format!("{p}.merger.norm.bias"))?)?;
171        let merger_fc1 = load_lin(
172            e,
173            &sh,
174            &format!("{p}.merger.linear_fc1"),
175            V_MERGED_IN,
176            V_MERGED_IN,
177        )?;
178        let merger_fc2 = {
179            // Output width is the TRUNK's embedding width (5120 on q38, 2048 on ornith15) —
180            // derived from the shard's merger shape, never assumed. Admission compares the
181            // serving trunk's n_embd against `out_width()`.
182            let w = read_f32(&sh, &format!("{p}.merger.linear_fc2.weight"))?;
183            let b = read_f32(&sh, &format!("{p}.merger.linear_fc2.bias"))?;
184            assert_eq!(w.len() % V_MERGED_IN, 0, "merger.linear_fc2.weight shape");
185            let out_f = w.len() / V_MERGED_IN;
186            assert_eq!(b.len(), out_f, "merger.linear_fc2.bias shape");
187            Lin {
188                w: e.htod(&w)?,
189                b: e.htod(&b)?,
190                in_f: V_MERGED_IN,
191                out_f,
192            }
193        };
194        eprintln!(
195            "[vision] tower loaded from {} ({} blocks, out_width {}, f32-resident)",
196            dir.display(),
197            V_DEPTH,
198            merger_fc2.out_f
199        );
200        Ok(Self {
201            patch,
202            pos,
203            blocks,
204            merger_norm_w,
205            merger_norm_b,
206            merger_fc1,
207            merger_fc2,
208        })
209    }
210
211    /// Embedding width this tower emits per merged token (the merger fc2 out_features,
212    /// i.e. the trunk n_embd of the checkpoint the shard came from).
213    pub fn out_width(&self) -> usize {
214        self.merger_fc2.out_f
215    }
216
217    fn linear_bias(
218        &self,
219        e: &Engine,
220        x: &CudaSlice<f32>,
221        l: &Lin,
222        m: usize,
223    ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
224        let mut y = e.linear(x, &l.w, m, l.in_f, l.out_f)?;
225        // Row-broadcast bias via add_row_inplace (per-row launch; the tower is small and
226        // v1 is correctness-first — the cuBLASLt bias epilogue is the later optimization).
227        for r in 0..m {
228            e.add_row_inplace(&mut y, &l.b, l.out_f, r * l.out_f)?;
229        }
230        Ok(y)
231    }
232
233    /// Bilinear-interpolate the 48x48 learned pos table to [gh, gw] and return host
234    /// [gh*gw, 1152] (added to the patch embeddings).
235    fn pos_for_grid(&self, gh: usize, gw: usize) -> Vec<f32> {
236        let g = V_POS_GRID as f32;
237        let mut out = vec![0f32; gh * gw * V_HIDDEN];
238        for y in 0..gh {
239            for x in 0..gw {
240                // HF fast_pos_embed_interpolate: linspace(0, 47, g) == align_corners=TRUE
241                let sy = if gh > 1 {
242                    y as f32 * (g - 1.0) / (gh as f32 - 1.0)
243                } else {
244                    0.0
245                };
246                let sx = if gw > 1 {
247                    x as f32 * (g - 1.0) / (gw as f32 - 1.0)
248                } else {
249                    0.0
250                };
251                let (y0, x0) = (sy.floor() as usize, sx.floor() as usize);
252                let (y1, x1) = ((y0 + 1).min(V_POS_GRID - 1), (x0 + 1).min(V_POS_GRID - 1));
253                let (fy, fx) = (sy - y0 as f32, sx - x0 as f32);
254                let dst = &mut out[(y * gw + x) * V_HIDDEN..(y * gw + x + 1) * V_HIDDEN];
255                for c in 0..V_HIDDEN {
256                    let p00 = self.pos[(y0 * V_POS_GRID + x0) * V_HIDDEN + c];
257                    let p01 = self.pos[(y0 * V_POS_GRID + x1) * V_HIDDEN + c];
258                    let p10 = self.pos[(y1 * V_POS_GRID + x0) * V_HIDDEN + c];
259                    let p11 = self.pos[(y1 * V_POS_GRID + x1) * V_HIDDEN + c];
260                    dst[c] = p00 * (1.0 - fy) * (1.0 - fx)
261                        + p01 * (1.0 - fy) * fx
262                        + p10 * fy * (1.0 - fx)
263                        + p11 * fy * fx;
264                }
265            }
266        }
267        out
268    }
269
270    /// Forward one image's patches -> [gh*gw/4, 5120] merged embeddings (device).
271    /// `patches` is host [gh*gw, 1536] in the preprocessor's (c, t, ph, pw) flat order.
272    pub fn forward(
273        &self,
274        e: &Engine,
275        patches: &[f32],
276        gh: usize,
277        gw: usize,
278    ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
279        self.forward_seq(e, patches, 1, gh, gw)
280    }
281
282    /// Forward `groups` temporal groups of one video (or a single image at groups=1):
283    /// host patches [groups*gh*gw, 1536], frame-major -> [groups*gh*gw/4, 5120] merged
284    /// embeddings, frame-major. HF cu_seqlens law (vision_utils.get_vision_cu_seqlens,
285    /// merge_temporal=False — the qwen2_vl/qwen3_vl/qwen3_5 convention): EACH temporal
286    /// group is its own attention segment, and pos table / rope / merger are all
287    /// frame-local too — so a video is exactly its groups run through the single-image
288    /// forward, concatenated. (Joint clip attention is the kimi_k25 convention only;
289    /// parity receipt: joint span scored mean_cos 0.92 vs the HF oracle, per-group 1.0.)
290    pub fn forward_seq(
291        &self,
292        e: &Engine,
293        patches: &[f32],
294        groups: usize,
295        gh: usize,
296        gw: usize,
297    ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
298        let n = groups * gh * gw;
299        assert_eq!(patches.len(), n * V_PATCH_IN, "patch buffer shape");
300        if groups > 1 {
301            let frame = gh * gw;
302            let out_per = frame / (V_MERGE * V_MERGE) * self.merger_fc2.out_f;
303            let mut out = e.uninit(groups * out_per)?;
304            for g in 0..groups {
305                let emb = self.forward_one(
306                    e,
307                    &patches[g * frame * V_PATCH_IN..(g + 1) * frame * V_PATCH_IN],
308                    gh,
309                    gw,
310                )?;
311                e.dtod_copy_into(&emb, &mut out, g * out_per)?;
312            }
313            return Ok(out);
314        }
315        self.forward_one(e, patches, gh, gw)
316    }
317
318    /// One attention segment (a single image, or one temporal group of a video).
319    fn forward_one(
320        &self,
321        e: &Engine,
322        patches: &[f32],
323        gh: usize,
324        gw: usize,
325    ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
326        let groups = 1usize;
327        let n = gh * gw;
328        assert_eq!(patches.len(), n * V_PATCH_IN, "patch buffer shape");
329        if n > 12288 {
330            return Err(format!(
331                "vision segment {n} patches exceeds the sdpa shared-memory ceiling (12288); \
332                 lower the pixel budget"
333            )
334            .into());
335        }
336        let xd = e.htod(patches)?;
337        let mut x = self.linear_bias(e, &xd, &self.patch, n)?;
338        // + interpolated pos embed (same table every temporal group)
339        let pos_one = self.pos_for_grid(gh, gw);
340        let mut pos = Vec::with_capacity(n * V_HIDDEN);
341        for _ in 0..groups {
342            pos.extend_from_slice(&pos_one);
343        }
344        let pos_d = e.htod(&pos)?;
345        let mut x2 = e.zeros(n * V_HIDDEN)?;
346        e.add(&x, &pos_d, &mut x2, n * V_HIDDEN)?;
347        x = x2;
348        // dev-only stage dumps for the HF parity bisect (row-major grid order, f32 LE)
349        let dbg = std::env::var("MEMRA_VISION_DEBUG").ok();
350        let dump = |tag: &str, buf: &[f32]| {
351            if let Some(dir) = dbg.as_deref() {
352                let raw: Vec<u8> = buf.iter().flat_map(|v| v.to_le_bytes()).collect();
353                let _ = std::fs::write(format!("{dir}/rust_{tag}.bin"), raw);
354            }
355        };
356        if dbg.is_some() {
357            dump("pre_blocks", &e.dtoh(&x)?);
358        }
359        let scale = 1.0 / (V_HEAD_DIM as f32).sqrt();
360        // 2D vision rope (Qwen3_5VisionRotaryEmbedding, theta 10000): per token (y, x) the
361        // head_dim/2 = 36 rotation angles are [y * inv_freq[0..18], x * inv_freq[0..18]],
362        // GPT-NeoX pairing (d, d+36). Same table for every block/head — precompute cos/sin.
363        let half = V_HEAD_DIM / 2; // 36
364        let quarter = half / 2; // 18
365        let inv_freq: Vec<f32> = (0..quarter)
366            .map(|i| 10000f32.powf(-(i as f32) / quarter as f32))
367            .collect();
368        let mut rope_cos = vec![0f32; n * half];
369        let mut rope_sin = vec![0f32; n * half];
370        for t in 0..n {
371            let f = t % (gh * gw); // frame-local index (rope has no temporal axis here)
372            let (y, x) = (f / gw, f % gw);
373            for d in 0..half {
374                let f = if d < quarter {
375                    y as f32 * inv_freq[d]
376                } else {
377                    x as f32 * inv_freq[d - quarter]
378                };
379                rope_cos[t * half + d] = f.cos();
380                rope_sin[t * half + d] = f.sin();
381            }
382        }
383        for (ib, blk) in self.blocks.iter().enumerate() {
384            // attn: ln1 -> qkv -> sdpa(causal=false) -> proj -> +res
385            let mut h = e.zeros(n * V_HIDDEN)?;
386            e.layer_norm_bias(&x, &blk.norm1_w, &blk.norm1_b, &mut h, V_HIDDEN, n, LN_EPS)?;
387            let qkv = self.linear_bias(e, &h, &blk.qkv, n)?;
388            // sdpa_naive consumes token-major [T, n_head, head_dim] — exactly the qkv GEMM
389            // row layout, so q/k/v are column splits of each row (no permute). Host pass
390            // applies the vision rope to q/k on the way (v untouched).
391            let qkv_h = e.dtoh(&qkv)?;
392            let mut qh = vec![0f32; n * V_HIDDEN];
393            let mut kh = vec![0f32; n * V_HIDDEN];
394            let mut vh = vec![0f32; n * V_HIDDEN];
395            for t in 0..n {
396                let row = &qkv_h[t * 3 * V_HIDDEN..(t + 1) * 3 * V_HIDDEN];
397                let dst = t * V_HIDDEN;
398                vh[dst..dst + V_HIDDEN].copy_from_slice(&row[2 * V_HIDDEN..3 * V_HIDDEN]);
399                for hd in 0..V_HEADS {
400                    let o = hd * V_HEAD_DIM;
401                    // rotate-half pairs (d, d+36), angles shared across heads
402                    for d in 0..half {
403                        let (c, sn) = (rope_cos[t * half + d], rope_sin[t * half + d]);
404                        let (qa, qb) = (row[o + d], row[o + d + half]);
405                        qh[dst + o + d] = qa * c - qb * sn;
406                        qh[dst + o + d + half] = qb * c + qa * sn;
407                        let (ka, kb) = (row[V_HIDDEN + o + d], row[V_HIDDEN + o + d + half]);
408                        kh[dst + o + d] = ka * c - kb * sn;
409                        kh[dst + o + d + half] = kb * c + ka * sn;
410                    }
411                }
412            }
413            let (qd, kd, vd) = (e.htod(&qh)?, e.htod(&kh)?, e.htod(&vh)?);
414            let mut od = e.zeros(n * V_HIDDEN)?;
415            e.sdpa_naive(
416                &qd, &kd, &vd, &mut od, V_HEAD_DIM, V_HEADS, V_HEADS, n, n, scale, false,
417            )?;
418            let attn = self.linear_bias(e, &od, &blk.proj, n)?;
419            let mut xr = e.zeros(n * V_HIDDEN)?;
420            e.add(&x, &attn, &mut xr, n * V_HIDDEN)?;
421            // mlp: ln2 -> fc1 -> gelu_tanh -> fc2 -> +res
422            let mut h2 = e.zeros(n * V_HIDDEN)?;
423            e.layer_norm_bias(
424                &xr,
425                &blk.norm2_w,
426                &blk.norm2_b,
427                &mut h2,
428                V_HIDDEN,
429                n,
430                LN_EPS,
431            )?;
432            let f1 = self.linear_bias(e, &h2, &blk.fc1, n)?;
433            let mut g = e.zeros(n * V_INTER)?;
434            e.gelu_tanh(&f1, &mut g, n * V_INTER)?;
435            let f2 = self.linear_bias(e, &g, &blk.fc2, n)?;
436            let mut xn = e.zeros(n * V_HIDDEN)?;
437            e.add(&xr, &f2, &mut xn, n * V_HIDDEN)?;
438            x = xn;
439            if dbg.is_some() && ib == 0 {
440                dump("blk0", &e.dtoh(&x)?);
441            }
442        }
443        if dbg.is_some() {
444            dump("post_blocks", &e.dtoh(&x)?);
445        }
446        // merger: LN over [n, 1152], then 2x2 spatial concat -> [n/4, 4608] -> fc1 -> gelu -> fc2
447        let mut ln = e.zeros(n * V_HIDDEN)?;
448        e.layer_norm_bias(
449            &x,
450            &self.merger_norm_w,
451            &self.merger_norm_b,
452            &mut ln,
453            V_HIDDEN,
454            n,
455            LN_EPS,
456        )?;
457        let lh = e.dtoh(&ln)?;
458        let (mh, mw) = (gh / V_MERGE, gw / V_MERGE);
459        let nm = groups * mh * mw;
460        let mut merged = vec![0f32; nm * V_MERGED_IN];
461        for g in 0..groups {
462            for my in 0..mh {
463                for mx in 0..mw {
464                    let out_t = (g * mh + my) * mw + mx;
465                    let dst = &mut merged[out_t * V_MERGED_IN..(out_t + 1) * V_MERGED_IN];
466                    for sy in 0..V_MERGE {
467                        for sx in 0..V_MERGE {
468                            let t = g * gh * gw + (my * V_MERGE + sy) * gw + (mx * V_MERGE + sx);
469                            let seg = (sy * V_MERGE + sx) * V_HIDDEN;
470                            dst[seg..seg + V_HIDDEN]
471                                .copy_from_slice(&lh[t * V_HIDDEN..(t + 1) * V_HIDDEN]);
472                        }
473                    }
474                }
475            }
476        }
477        let md = e.htod(&merged)?;
478        let f1 = self.linear_bias(e, &md, &self.merger_fc1, nm)?;
479        let mut g = e.zeros(nm * V_MERGED_IN)?;
480        e.gelu_tanh(&f1, &mut g, nm * V_MERGED_IN)?;
481        self.linear_bias(e, &g, &self.merger_fc2, nm)
482    }
483}