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