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::{CudaContext, CudaSlice};
20use memra_gguf::dequant::bf16_to_f32;
21use memra_gguf::safetensors::StShard;
22use std::path::Path;
23use std::sync::Arc;
24
25pub const V_HIDDEN: usize = 1152;
26pub const V_HEADS: usize = 16;
27pub const V_HEAD_DIM: usize = V_HIDDEN / V_HEADS; // 72
28pub const V_INTER: usize = 4304;
29pub const V_DEPTH: usize = 27;
30pub const V_PATCH: usize = 16;
31pub const V_MERGE: usize = 2;
32pub const V_TEMPORAL: usize = 2;
33pub const V_POS_GRID: usize = 48; // 2304 learned positions = 48x48
34pub const V_PATCH_IN: usize = 3 * V_TEMPORAL * V_PATCH * V_PATCH; // 1536
35pub const V_MERGED_IN: usize = V_HIDDEN * V_MERGE * V_MERGE; // 4608
36const LN_EPS: f32 = 1e-6;
37
38/// Overlay publication policy (`MEMRA_VISION_OVERLAY_PUBLISH`, lane/glm53-vision-ppn):
39/// whether the tower's rows are republished into the engine that owns embedding intake.
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum OverlayPublish {
42    /// DEFAULT: publish iff the intake engine's CUDA context differs from the tower's.
43    Auto,
44    /// Always take the publication path, even when the contexts already match — the rig arm
45    /// that exercises the cross-context code on a one-card box (byte identity is the bar).
46    Force,
47    /// Never publish: the pre-lane program. A cross-context intake is then refused by the
48    /// residency law in `splice_into` / the ppN prime, never silently peer-read.
49    Never,
50}
51
52/// Pure resolver for `MEMRA_VISION_OVERLAY_PUBLISH` so unit tests pin the whole arm matrix
53/// without mutating process-global environment (the `pp::peer_probe_startup_policy` pattern).
54/// `None` = unset = Auto; `auto`/`1` = Auto; `force` = Force; `0`/`off` = Never. An
55/// unrecognized value is a REFUSAL, not a silent default — a mistyped door must never decide
56/// a correctness path quietly.
57pub fn overlay_publish_resolve(v: Option<&str>) -> Result<OverlayPublish, String> {
58    match v.map(str::trim) {
59        None | Some("") | Some("auto") | Some("1") => Ok(OverlayPublish::Auto),
60        Some("force") => Ok(OverlayPublish::Force),
61        Some("0") | Some("off") => Ok(OverlayPublish::Never),
62        Some(other) => Err(format!(
63            "MEMRA_VISION_OVERLAY_PUBLISH={other:?} unrecognized (want auto|force|0)"
64        )),
65    }
66}
67
68/// `overlay_publish_resolve` over the live environment.
69pub fn overlay_publish_mode() -> Result<OverlayPublish, Box<dyn std::error::Error>> {
70    let raw = std::env::var("MEMRA_VISION_OVERLAY_PUBLISH").ok();
71    overlay_publish_resolve(raw.as_deref()).map_err(|e| -> Box<dyn std::error::Error> { e.into() })
72}
73
74static OVERLAY_PUBLICATIONS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
75
76/// Overlay publications this process has performed. A gate arm that means to exercise the
77/// publication path asserts this counter MOVED — a same-context run would otherwise take the
78/// zero-copy branch and print PASS having tested nothing (the non-vacuity law).
79pub fn overlay_publications() -> u64 {
80    OVERLAY_PUBLICATIONS.load(std::sync::atomic::Ordering::Relaxed)
81}
82
83/// Mixed-embedding prime overlay: image embeddings that replace `<|image_pad|>` token
84/// embeddings at prompt-relative positions during `prime_cache_overlaid`. `rows` holds all
85/// images' merger outputs concatenated ([total_rows, n_embd]); each span is
86/// `(prompt_pos, row_off, n_rows)` — rows `[row_off, row_off+n_rows)` land at prompt
87/// positions `[prompt_pos, prompt_pos+n_rows)`. Spans must not overlap.
88///
89/// ROW RESIDENCY IS CARRIED, NOT ASSUMED (lane/glm53-vision-ppn, 2026-09-01). `rows` is a
90/// device pointer, and a device pointer is only meaningful inside ONE CUDA context. The
91/// overlay therefore records the context its rows live in, and every consumer checks it. The
92/// pre-lane code proxied that invariant with "the overlay was built on the primary engine AND
93/// stage 0 IS the primary engine" (`std::ptr::eq` on `&Engine`), which is both too weak (two
94/// Engines can share one context — `CudaContext::new` retains the device's PRIMARY context,
95/// so per-stage Engines on one device are one address space) and too strong (it refused the
96/// real 3-card serving shape, where the worker's primary engine follows the LAST pp stage and
97/// stage 0's intake engine is a different device). Context identity is the exact condition.
98pub struct EmbedOverlay {
99    /// `Arc` SO A WINDOW REALLY ALIASES (lane/glm53-vision-ppn follow-up, memra-next#23).
100    /// This field was a bare `CudaSlice<f32>` and `window()` cloned it, on the belief — stated
101    /// in two comments here and one on `Engine::clone_dtod` — that `CudaSlice::clone()` bumps a
102    /// refcount. It does not: in the LOCKED cudarc 0.19.8 (Cargo.lock), `impl Clone for CudaSlice` is
103    /// `try_clone().unwrap()` -> `stream.clone_dtod(self)`, i.e. a full device allocation plus
104    /// D2D copy of every row, and an `unwrap` that PANICS in the GPU worker thread. Stated
105    /// precisely, because the first version of this comment overstated it: the panic is CAUGHT
106    /// (`worker.rs` wraps `run` in `catch_unwind`, marks the worker dead and respawns, reaching
107    /// `exit(EXIT_WORKER_UNRECOVERABLE)` only once respawns are exhausted), so the cost is every
108    /// IN-FLIGHT SESSION on the box plus a respawn, not necessarily immediate process death —
109    /// still an unacceptable outcome for an allocation failure that the caller is already shaped
110    /// to propagate. `window()` runs once per prefill tick AND once per chunk of a chunked ppN
111    /// prime, so the old code also paid several whole-buffer copies per multi-chunk image prompt
112    /// while the docs claimed zero.
113    /// With an `Arc`, a window is a refcount bump for real, and the buffer is freed once when
114    /// the last window drops.
115    pub rows: Arc<CudaSlice<f32>>,
116    pub spans: Vec<(usize, usize, usize)>,
117    /// The CUDA context `rows` ACTUALLY live in — read from the slice itself
118    /// (`CudaSlice::context()`), never from whichever engine the caller thought it used, so the
119    /// label cannot disagree with the pointer (memra-next#24). Held as an `Arc` so the context
120    /// cannot outlive the pointer it owns, and so the struct stays `Send`.
121    ctx: Arc<CudaContext>,
122}
123
124impl EmbedOverlay {
125    /// Wrap rows built on `e`, taking residency FROM THE SLICE and refusing if that disagrees
126    /// with `e`.
127    ///
128    /// WHY NOT JUST RECORD `e.ctx()` (memra-next#24). Allocation goes through
129    /// `Engine::stream()` -> `Gpu::stream()`, which returns the THREAD-LOCAL stream override
130    /// when one is pushed — and that override is not keyed to an engine. So a caller that
131    /// allocates inside a `PpNRt::enter(s)` scope gets a buffer in STAGE s's context while
132    /// believing it used `e`; recording `e.ctx()` would then hand `require_resident` a label
133    /// that vouches for a foreign pointer, which is this lane's own hazard with the label
134    /// inverted. `CudaSlice::context()` is the ground truth, so it is what gets recorded, and a
135    /// disagreement with `e` is a REFUSAL rather than a silently relabelled buffer.
136    pub fn new(
137        e: &Engine,
138        rows: CudaSlice<f32>,
139        spans: Vec<(usize, usize, usize)>,
140    ) -> Result<Self, Box<dyn std::error::Error>> {
141        let ctx = Self::alloc_context_of(e, &rows, "construction")?;
142        Ok(Self {
143            rows: Arc::new(rows),
144            spans,
145            ctx,
146        })
147    }
148
149    /// The context `rows` were really allocated in, refusing if that is not `e`'s.
150    ///
151    /// Shared by `new` and by `new_published`'s SOURCE-side check. The message names the context
152    /// HANDLES as well as the device ordinals, because the same-device/different-context case is
153    /// real (`CudaContext::new_non_primary`, which the residency gate test uses) and an
154    /// ordinal-only message renders as "context of dev0 but the caller believes it used dev0".
155    /// It also states the ambient stage-stream override as the LIKELY cause rather than the
156    /// certain one — a second context on one device is reached other ways.
157    fn alloc_context_of(
158        e: &Engine,
159        rows: &CudaSlice<f32>,
160        site: &str,
161    ) -> Result<Arc<CudaContext>, Box<dyn std::error::Error>> {
162        let ctx = rows.context().clone();
163        if ctx.cu_ctx() != e.ctx().cu_ctx() {
164            return Err(format!(
165                "vision embedding overlay refused at {site}: the rows live in CUDA context \
166                 {:?} (dev{}) but the engine the caller passed runs in context {:?} (dev{}). \
167                 The usual cause is an ambient pp stage-stream override, which redirects \
168                 allocation to the stage's stream — and its context — regardless of which \
169                 engine is called, so build the overlay OUTSIDE any stage scope",
170                ctx.cu_ctx(),
171                ctx.ordinal(),
172                e.ctx().cu_ctx(),
173                e.ctx().ordinal(),
174            )
175            .into());
176        }
177        Ok(ctx)
178    }
179
180    /// The context `rows` live in.
181    pub fn ctx(&self) -> &Arc<CudaContext> {
182        &self.ctx
183    }
184
185    /// true iff `e` can dereference `rows` — i.e. `e` runs in the same CUDA context.
186    pub fn resident_in(&self, e: &Engine) -> bool {
187        e.ctx().cu_ctx() == self.ctx.cu_ctx()
188    }
189
190    /// The residency law as a REFUSAL, shared by every consumer of `rows` (the common splice,
191    /// and gemma4's masked-prefill arm, whose splice arithmetic differs deliberately). One
192    /// message, one law: a site that reads `rows` calls this first.
193    pub fn require_resident(&self, e: &Engine) -> Result<(), Box<dyn std::error::Error>> {
194        if self.resident_in(e) {
195            return Ok(());
196        }
197        Err(format!(
198            "vision embedding overlay refused: overlay rows are resident in the CUDA context \
199             of dev{} but the splice runs on dev{} — a cross-context splice would read a \
200             pointer from another address space. Publish the overlay into the consuming \
201             engine (EmbedOverlay::new_published / MEMRA_VISION_OVERLAY_PUBLISH)",
202            self.ctx.ordinal(),
203            e.ctx().ordinal(),
204        )
205        .into())
206    }
207
208    /// Build an overlay whose rows are resident in the engine that will CONSUME them.
209    ///
210    /// `tower` is the engine the vision tower ran on (where `rows` currently live); `intake`
211    /// is the engine that owns embedding intake for the serving placement
212    /// (`HybridModel::vision_intake_engine` — the primary engine on a single-device or
213    /// streams-off shape, pp stage 0's engine under a per-stage-stream ppN split).
214    ///
215    /// PUBLICATION IS A HOST BOUNCE, deliberately. `tower.dtoh` drains the tower's stream at
216    /// a HOST boundary and `intake.htod` + one `synchronize` place the bytes on the intake
217    /// stream, so the ordering argument needs no event plumbing and no P2P capability: it
218    /// holds on every placement, including `MEMRA_PP_HOST_BOUNCE=1` boxes with no peer
219    /// access. The cost is ONE round trip of `[total_rows, n_embd]` f32 per session (a few
220    /// MiB; ~5 MiB for a 256-row 5120-wide image) against a tower that already host-bounces
221    /// q/k/v and the merger input in EVERY block — a peer D2D twin is a named follow-up, not
222    /// a correctness question. Nothing here is per token: decode never touches an overlay.
223    ///
224    /// THAT COST SENTENCE IS ONLY TRUE SINCE memra-next#23. When this was first written,
225    /// `window()` deep-copied the whole rows buffer on every prefill tick and every prime
226    /// chunk (see the `rows` field), so a multi-chunk image prompt paid several D2D copies that
227    /// the sentence did not mention. `rows` is now an `Arc` and a window is a refcount bump, so
228    /// the publication really is the only copy on this path.
229    ///
230    /// The bytes are moved, never transformed: f32 through the host is bit-exact, which is
231    /// what makes `MEMRA_VISION_OVERLAY_PUBLISH=force` a byte-identity gate arm.
232    ///
233    /// CALL OUTSIDE ANY pp STAGE SCOPE. The ambient stream override is THREAD-LOCAL and applies
234    /// to every engine on the thread, so inside `PpNRt::enter(s)` this upload would bind to
235    /// stage `s`'s stream — which belongs to another context whenever the stages differ. The
236    /// serving caller (`build_vision_overlay`, at the first prefill tick) and the gate arms both
237    /// run outside stage scopes.
238    ///
239    /// THE FREE IS ORDERED TOO — but not by what an earlier version of this comment claimed,
240    /// and this is the sentence a future lane will lean on, so it is worth stating exactly.
241    /// Published rows are freed on THEIR ALLOCATION STREAM (the intake engine's ambient stream)
242    /// — `free_async` on cudarc's async-alloc branch, and `synchronize` + `free_sync` on the
243    /// other, which likewise touches only the allocation stream — while the splice reads them on
244    /// pp stage 0's STAGE stream:
245    /// two streams, one context. What orders the reads before the free is (a) the pipeline
246    /// chain itself — stage 0 feeds stage 1 feeds ... feeds the last stage — and (b) the
247    /// host-synchronizing `dtoh` every prime call ends with in `hyper_prime_tail`, which
248    /// retires all of it before any later host code, including the drop, runs.
249    ///
250    /// It is NOT `publish_all_to`: that orders the CALLER behind stage compute (the other
251    /// direction), and it is `MEMRA_PP_EXIT_PUBLISH`-gated, so leaning on it would make this
252    /// argument evaporate at `=0`. And there is no implicit safety net underneath: cudarc event
253    /// tracking is DISABLED in `Engine::new` unless `MEMRA_EVT=1`, so a drop carries no read
254    /// guard — the manual argument above is all there is. (Corrected by the memra-next peer
255    /// review; the accrace lane, `MEMRA_PP_EXIT_PUBLISH`, is what happens when an ordering
256    /// claim in a comment is merely assumed.)
257    ///
258    /// `mode` is PASSED IN, not read from the environment here (memra-next#25). The door is a
259    /// FAMILY-AGNOSTIC correctness input — every vision family's overlay comes through this
260    /// function — so an unrecognized value has to refuse at BOOT, once, for all of them. When
261    /// this read the env itself, the only boot-time validation was glm5-scoped, so a typo'd
262    /// door on a gemma/qwen/step37 deployment booted clean and then 500'd mid-prefill on the
263    /// first image request: exactly the failure this lane removed for glm5, still live for the
264    /// others. Resolve with `overlay_publish_mode()` at startup and thread the value.
265    pub fn new_published(
266        tower: &Engine,
267        intake: &Engine,
268        mode: OverlayPublish,
269        rows: CudaSlice<f32>,
270        spans: Vec<(usize, usize, usize)>,
271    ) -> Result<Self, Box<dyn std::error::Error>> {
272        // THE SOURCE SIDE IS CHECKED BEFORE THE ROWS ARE READ, not only where they land (peer
273        // review of the merged PR caught this asymmetry). `tower.dtoh` issues its D2H on the
274        // TOWER's ambient stream, so if `rows` had actually been produced in another context —
275        // verbatim the hazard `new` exists to refuse — the copy would be unordered against the
276        // kernels that wrote them and, under UVA, would likely SUCCEED rather than error:
277        // a partially written buffer, published, passing the landing-side check. The whole
278        // thesis here is "check, don't assume", so the read is guarded like every other.
279        let src_ctx = Self::alloc_context_of(tower, &rows, "publication (source side)")?;
280        let same_ctx = src_ctx.cu_ctx() == intake.ctx().cu_ctx();
281        if mode == OverlayPublish::Never || (mode == OverlayPublish::Auto && same_ctx) {
282            return Self::new(tower, rows, spans);
283        }
284        let host = tower.dtoh(&rows)?;
285        drop(rows);
286        let published = intake.htod(&host)?;
287        // The consumer may run on a DIFFERENT stream of the intake context (the ppN stage-0
288        // stage stream, while this upload lands on the intake engine's ambient stream). One
289        // host boundary orders every later stream against this upload; an event would order
290        // only the stream we recorded it on.
291        intake.stream().synchronize()?;
292        OVERLAY_PUBLICATIONS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
293        eprintln!(
294            "[vision] overlay published to the intake engine: dev{} -> dev{} rows={} \
295             elems={} MiB={:.2} mode={mode:?}",
296            tower.ctx().ordinal(),
297            intake.ctx().ordinal(),
298            spans.iter().map(|&(_, _, n)| n).sum::<usize>(),
299            host.len(),
300            (host.len() * std::mem::size_of::<f32>()) as f64 / (1024.0 * 1024.0),
301        );
302        // The upload's landing context is CHECKED, not assumed (memra-next#24): `Self::new`
303        // reads it off the slice and refuses a disagreement with `intake`, which is what makes
304        // the residency label evidence rather than a claim.
305        Self::new(intake, published, spans)
306    }
307
308    /// Sub-window for a prime call covering prompt-relative `[off, off+len)`: spans clipped
309    /// and rebased so the callee sees call-relative positions (the serve prefill tick primes
310    /// a prompt across multiple `prime_cache_overlaid` calls). `rows` is an `Arc` clone — a
311    /// refcount bump, which since memra-next#23 is TRUE rather than merely documented. None =
312    /// no image rows in this window (caller may prime plain).
313    pub fn window(&self, off: usize, len: usize) -> Option<EmbedOverlay> {
314        let spans: Vec<(usize, usize, usize)> = self
315            .spans
316            .iter()
317            .filter_map(|&(pos, row_off, n_rows)| {
318                let lo = pos.max(off);
319                let hi = (pos + n_rows).min(off + len);
320                (lo < hi).then(|| (lo - off, row_off + (lo - pos), hi - lo))
321            })
322            .collect();
323        (!spans.is_empty()).then(|| EmbedOverlay {
324            // Arc::clone: the window ALIASES the parent's rows (no allocation, no D2D copy, no
325            // `unwrap` that could panic the GPU worker), so it necessarily carries the parent's
326            // residency too.
327            rows: Arc::clone(&self.rows),
328            spans,
329            ctx: Arc::clone(&self.ctx),
330        })
331    }
332
333    /// The mixed-embedding splice itself: image rows overwrite placeholder-token embeddings
334    /// inside a prime call's prompt-relative window `[chunk_off, chunk_off + t)`, BEFORE any
335    /// downstream transform (stream expansion, trunk walk). ONE implementation shared by the
336    /// single-engine hyper walk and the ppN stage-0 intake (lane/glm5-vision-default-on) so
337    /// the splice point cannot drift between arms. `embedded` is the `[t, n_embd]` token
338    /// embedding buffer of this call.
339    ///
340    /// RESIDENCY LAW, checked HERE — at the copy, for every arm (serial chunk walk, hyper
341    /// walk, ppN stage-0 intake), rather than at one call site's placement assumption: `rows`
342    /// must live in `e`'s CUDA context. A mismatch is a REFUSAL. Publish the overlay into the
343    /// consuming engine with `EmbedOverlay::new_published` instead of relaxing this.
344    pub fn splice_into(
345        &self,
346        e: &Engine,
347        embedded: &mut CudaSlice<f32>,
348        chunk_off: usize,
349        t: usize,
350        n_embd: usize,
351    ) -> Result<(), Box<dyn std::error::Error>> {
352        self.require_resident(e)?;
353        for &(pos, row_off, n_rows) in &self.spans {
354            let lo = pos.max(chunk_off);
355            let hi = (pos + n_rows).min(chunk_off + t);
356            if lo < hi {
357                let src_row = row_off + (lo - pos);
358                let view = self
359                    .rows
360                    .slice(src_row * n_embd..(src_row + (hi - lo)) * n_embd);
361                e.copy_view_into(
362                    embedded,
363                    (lo - chunk_off) * n_embd,
364                    &view,
365                    (hi - lo) * n_embd,
366                )?;
367            }
368        }
369        Ok(())
370    }
371}
372
373struct Lin {
374    w: CudaSlice<f32>,
375    b: CudaSlice<f32>,
376    in_f: usize,
377    out_f: usize,
378}
379
380struct VisBlock {
381    norm1_w: CudaSlice<f32>,
382    norm1_b: CudaSlice<f32>,
383    norm2_w: CudaSlice<f32>,
384    norm2_b: CudaSlice<f32>,
385    qkv: Lin,
386    proj: Lin,
387    fc1: Lin,
388    fc2: Lin,
389}
390
391pub struct VisionTower {
392    patch: Lin,
393    /// Host copy of the learned pos table [2304, 1152] — bilinear-interpolated per grid.
394    pos: Vec<f32>,
395    blocks: Vec<VisBlock>,
396    merger_norm_w: CudaSlice<f32>,
397    merger_norm_b: CudaSlice<f32>,
398    merger_fc1: Lin,
399    merger_fc2: Lin,
400}
401
402fn read_f32(sh: &StShard, name: &str) -> Result<Vec<f32>, Box<dyn std::error::Error>> {
403    let (info, raw) = sh
404        .raw(name)
405        .ok_or_else(|| format!("vision tensor missing: {name}"))?;
406    match info.dtype.as_str() {
407        "BF16" => Ok(raw
408            .chunks_exact(2)
409            .map(|c| bf16_to_f32(u16::from_le_bytes([c[0], c[1]])))
410            .collect()),
411        "F32" => Ok(raw
412            .chunks_exact(4)
413            .map(|c| f32::from_le_bytes(c.try_into().unwrap()))
414            .collect()),
415        other => Err(format!("vision tensor {name}: unsupported dtype {other}").into()),
416    }
417}
418
419fn load_lin(
420    e: &Engine,
421    sh: &StShard,
422    stem: &str,
423    in_f: usize,
424    out_f: usize,
425) -> Result<Lin, Box<dyn std::error::Error>> {
426    let w = read_f32(sh, &format!("{stem}.weight"))?;
427    let b = read_f32(sh, &format!("{stem}.bias"))?;
428    assert_eq!(w.len(), in_f * out_f, "{stem}.weight shape");
429    assert_eq!(b.len(), out_f, "{stem}.bias shape");
430    Ok(Lin {
431        w: e.htod(&w)?,
432        b: e.htod(&b)?,
433        in_f,
434        out_f,
435    })
436}
437
438impl VisionTower {
439    /// Load the tower from a directory containing `outside.safetensors`.
440    pub fn load(e: &Engine, dir: &Path) -> Result<Self, Box<dyn std::error::Error>> {
441        let sh = StShard::open(dir.join("outside.safetensors"))?;
442        let p = "model.visual";
443        let patch = {
444            // conv [1152, 3, 2, 16, 16] flattens to Linear 1536 -> 1152 (HF patchify order:
445            // channel-major within the (c, t, h, w) patch — the preprocessor emits the
446            // matching flat order).
447            let w = read_f32(&sh, &format!("{p}.patch_embed.proj.weight"))?;
448            let b = read_f32(&sh, &format!("{p}.patch_embed.proj.bias"))?;
449            assert_eq!(w.len(), V_HIDDEN * V_PATCH_IN);
450            Lin {
451                w: e.htod(&w)?,
452                b: e.htod(&b)?,
453                in_f: V_PATCH_IN,
454                out_f: V_HIDDEN,
455            }
456        };
457        let pos = read_f32(&sh, &format!("{p}.pos_embed.weight"))?;
458        assert_eq!(pos.len(), V_POS_GRID * V_POS_GRID * V_HIDDEN);
459        let mut blocks = Vec::with_capacity(V_DEPTH);
460        for il in 0..V_DEPTH {
461            let bp = format!("{p}.blocks.{il}");
462            blocks.push(VisBlock {
463                norm1_w: e.htod(&read_f32(&sh, &format!("{bp}.norm1.weight"))?)?,
464                norm1_b: e.htod(&read_f32(&sh, &format!("{bp}.norm1.bias"))?)?,
465                norm2_w: e.htod(&read_f32(&sh, &format!("{bp}.norm2.weight"))?)?,
466                norm2_b: e.htod(&read_f32(&sh, &format!("{bp}.norm2.bias"))?)?,
467                qkv: load_lin(e, &sh, &format!("{bp}.attn.qkv"), V_HIDDEN, 3 * V_HIDDEN)?,
468                proj: load_lin(e, &sh, &format!("{bp}.attn.proj"), V_HIDDEN, V_HIDDEN)?,
469                fc1: load_lin(e, &sh, &format!("{bp}.mlp.linear_fc1"), V_HIDDEN, V_INTER)?,
470                fc2: load_lin(e, &sh, &format!("{bp}.mlp.linear_fc2"), V_INTER, V_HIDDEN)?,
471            });
472        }
473        let merger_norm_w = e.htod(&read_f32(&sh, &format!("{p}.merger.norm.weight"))?)?;
474        let merger_norm_b = e.htod(&read_f32(&sh, &format!("{p}.merger.norm.bias"))?)?;
475        let merger_fc1 = load_lin(
476            e,
477            &sh,
478            &format!("{p}.merger.linear_fc1"),
479            V_MERGED_IN,
480            V_MERGED_IN,
481        )?;
482        let merger_fc2 = {
483            // Output width is the TRUNK's embedding width (5120 on q38, 2048 on ornith15) —
484            // derived from the shard's merger shape, never assumed. Admission compares the
485            // serving trunk's n_embd against `out_width()`.
486            let w = read_f32(&sh, &format!("{p}.merger.linear_fc2.weight"))?;
487            let b = read_f32(&sh, &format!("{p}.merger.linear_fc2.bias"))?;
488            assert_eq!(w.len() % V_MERGED_IN, 0, "merger.linear_fc2.weight shape");
489            let out_f = w.len() / V_MERGED_IN;
490            assert_eq!(b.len(), out_f, "merger.linear_fc2.bias shape");
491            Lin {
492                w: e.htod(&w)?,
493                b: e.htod(&b)?,
494                in_f: V_MERGED_IN,
495                out_f,
496            }
497        };
498        eprintln!(
499            "[vision] tower loaded from {} ({} blocks, out_width {}, f32-resident)",
500            dir.display(),
501            V_DEPTH,
502            merger_fc2.out_f
503        );
504        Ok(Self {
505            patch,
506            pos,
507            blocks,
508            merger_norm_w,
509            merger_norm_b,
510            merger_fc1,
511            merger_fc2,
512        })
513    }
514
515    /// Embedding width this tower emits per merged token (the merger fc2 out_features,
516    /// i.e. the trunk n_embd of the checkpoint the shard came from).
517    pub fn out_width(&self) -> usize {
518        self.merger_fc2.out_f
519    }
520
521    fn linear_bias(
522        &self,
523        e: &Engine,
524        x: &CudaSlice<f32>,
525        l: &Lin,
526        m: usize,
527    ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
528        let mut y = e.linear(x, &l.w, m, l.in_f, l.out_f)?;
529        // Row-broadcast bias via add_row_inplace (per-row launch; the tower is small and
530        // v1 is correctness-first — the cuBLASLt bias epilogue is the later optimization).
531        for r in 0..m {
532            e.add_row_inplace(&mut y, &l.b, l.out_f, r * l.out_f)?;
533        }
534        Ok(y)
535    }
536
537    /// Bilinear-interpolate the 48x48 learned pos table to [gh, gw] and return host
538    /// [gh*gw, 1152] (added to the patch embeddings).
539    fn pos_for_grid(&self, gh: usize, gw: usize) -> Vec<f32> {
540        let g = V_POS_GRID as f32;
541        let mut out = vec![0f32; gh * gw * V_HIDDEN];
542        for y in 0..gh {
543            for x in 0..gw {
544                // HF fast_pos_embed_interpolate: linspace(0, 47, g) == align_corners=TRUE
545                let sy = if gh > 1 {
546                    y as f32 * (g - 1.0) / (gh as f32 - 1.0)
547                } else {
548                    0.0
549                };
550                let sx = if gw > 1 {
551                    x as f32 * (g - 1.0) / (gw as f32 - 1.0)
552                } else {
553                    0.0
554                };
555                let (y0, x0) = (sy.floor() as usize, sx.floor() as usize);
556                let (y1, x1) = ((y0 + 1).min(V_POS_GRID - 1), (x0 + 1).min(V_POS_GRID - 1));
557                let (fy, fx) = (sy - y0 as f32, sx - x0 as f32);
558                let dst = &mut out[(y * gw + x) * V_HIDDEN..(y * gw + x + 1) * V_HIDDEN];
559                #[allow(clippy::needless_range_loop)]
560                // allow: the explicit index loop keeps the offset arithmetic visible and aligned with the device-side indexing
561                for c in 0..V_HIDDEN {
562                    let p00 = self.pos[(y0 * V_POS_GRID + x0) * V_HIDDEN + c];
563                    let p01 = self.pos[(y0 * V_POS_GRID + x1) * V_HIDDEN + c];
564                    let p10 = self.pos[(y1 * V_POS_GRID + x0) * V_HIDDEN + c];
565                    let p11 = self.pos[(y1 * V_POS_GRID + x1) * V_HIDDEN + c];
566                    dst[c] = p00 * (1.0 - fy) * (1.0 - fx)
567                        + p01 * (1.0 - fy) * fx
568                        + p10 * fy * (1.0 - fx)
569                        + p11 * fy * fx;
570                }
571            }
572        }
573        out
574    }
575
576    /// Forward one image's patches -> [gh*gw/4, 5120] merged embeddings (device).
577    /// `patches` is host [gh*gw, 1536] in the preprocessor's (c, t, ph, pw) flat order.
578    pub fn forward(
579        &self,
580        e: &Engine,
581        patches: &[f32],
582        gh: usize,
583        gw: usize,
584    ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
585        self.forward_seq(e, patches, 1, gh, gw)
586    }
587
588    /// Forward `groups` temporal groups of one video (or a single image at groups=1):
589    /// host patches [groups*gh*gw, 1536], frame-major -> [groups*gh*gw/4, 5120] merged
590    /// embeddings, frame-major. HF cu_seqlens law (vision_utils.get_vision_cu_seqlens,
591    /// merge_temporal=False — the qwen2_vl/qwen3_vl/qwen3_5 convention): EACH temporal
592    /// group is its own attention segment, and pos table / rope / merger are all
593    /// frame-local too — so a video is exactly its groups run through the single-image
594    /// forward, concatenated. (Joint clip attention is the kimi_k25 convention only;
595    /// parity receipt: joint span scored mean_cos 0.92 vs the HF oracle, per-group 1.0.)
596    pub fn forward_seq(
597        &self,
598        e: &Engine,
599        patches: &[f32],
600        groups: usize,
601        gh: usize,
602        gw: usize,
603    ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
604        let n = groups * gh * gw;
605        assert_eq!(patches.len(), n * V_PATCH_IN, "patch buffer shape");
606        if groups > 1 {
607            let frame = gh * gw;
608            let out_per = frame / (V_MERGE * V_MERGE) * self.merger_fc2.out_f;
609            let mut out = e.uninit(groups * out_per)?;
610            for g in 0..groups {
611                let emb = self.forward_one(
612                    e,
613                    &patches[g * frame * V_PATCH_IN..(g + 1) * frame * V_PATCH_IN],
614                    gh,
615                    gw,
616                )?;
617                e.dtod_copy_into(&emb, &mut out, g * out_per)?;
618            }
619            return Ok(out);
620        }
621        self.forward_one(e, patches, gh, gw)
622    }
623
624    /// One attention segment (a single image, or one temporal group of a video).
625    fn forward_one(
626        &self,
627        e: &Engine,
628        patches: &[f32],
629        gh: usize,
630        gw: usize,
631    ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
632        let groups = 1usize;
633        let n = gh * gw;
634        assert_eq!(patches.len(), n * V_PATCH_IN, "patch buffer shape");
635        if n > 12288 {
636            return Err(format!(
637                "vision segment {n} patches exceeds the sdpa shared-memory ceiling (12288); \
638                 lower the pixel budget"
639            )
640            .into());
641        }
642        let xd = e.htod(patches)?;
643        let mut x = self.linear_bias(e, &xd, &self.patch, n)?;
644        // + interpolated pos embed (same table every temporal group)
645        let pos_one = self.pos_for_grid(gh, gw);
646        let mut pos = Vec::with_capacity(n * V_HIDDEN);
647        for _ in 0..groups {
648            pos.extend_from_slice(&pos_one);
649        }
650        let pos_d = e.htod(&pos)?;
651        let mut x2 = e.zeros(n * V_HIDDEN)?;
652        e.add(&x, &pos_d, &mut x2, n * V_HIDDEN)?;
653        x = x2;
654        // dev-only stage dumps for the HF parity bisect (row-major grid order, f32 LE)
655        let dbg = std::env::var("MEMRA_VISION_DEBUG").ok();
656        let dump = |tag: &str, buf: &[f32]| {
657            if let Some(dir) = dbg.as_deref() {
658                let raw: Vec<u8> = buf.iter().flat_map(|v| v.to_le_bytes()).collect();
659                let _ = std::fs::write(format!("{dir}/rust_{tag}.bin"), raw);
660            }
661        };
662        if dbg.is_some() {
663            dump("pre_blocks", &e.dtoh(&x)?);
664        }
665        let scale = 1.0 / (V_HEAD_DIM as f32).sqrt();
666        // 2D vision rope (Qwen3_5VisionRotaryEmbedding, theta 10000): per token (y, x) the
667        // head_dim/2 = 36 rotation angles are [y * inv_freq[0..18], x * inv_freq[0..18]],
668        // GPT-NeoX pairing (d, d+36). Same table for every block/head — precompute cos/sin.
669        let half = V_HEAD_DIM / 2; // 36
670        let quarter = half / 2; // 18
671        let inv_freq: Vec<f32> = (0..quarter)
672            .map(|i| 10000f32.powf(-(i as f32) / quarter as f32))
673            .collect();
674        let mut rope_cos = vec![0f32; n * half];
675        let mut rope_sin = vec![0f32; n * half];
676        for t in 0..n {
677            let f = t % (gh * gw); // frame-local index (rope has no temporal axis here)
678            let (y, x) = (f / gw, f % gw);
679            for d in 0..half {
680                let f = if d < quarter {
681                    y as f32 * inv_freq[d]
682                } else {
683                    x as f32 * inv_freq[d - quarter]
684                };
685                rope_cos[t * half + d] = f.cos();
686                rope_sin[t * half + d] = f.sin();
687            }
688        }
689        for (ib, blk) in self.blocks.iter().enumerate() {
690            // attn: ln1 -> qkv -> sdpa(causal=false) -> proj -> +res
691            let mut h = e.zeros(n * V_HIDDEN)?;
692            e.layer_norm_bias(&x, &blk.norm1_w, &blk.norm1_b, &mut h, V_HIDDEN, n, LN_EPS)?;
693            let qkv = self.linear_bias(e, &h, &blk.qkv, n)?;
694            // sdpa_naive consumes token-major [T, n_head, head_dim] — exactly the qkv GEMM
695            // row layout, so q/k/v are column splits of each row (no permute). Host pass
696            // applies the vision rope to q/k on the way (v untouched).
697            let qkv_h = e.dtoh(&qkv)?;
698            let mut qh = vec![0f32; n * V_HIDDEN];
699            let mut kh = vec![0f32; n * V_HIDDEN];
700            let mut vh = vec![0f32; n * V_HIDDEN];
701            for t in 0..n {
702                let row = &qkv_h[t * 3 * V_HIDDEN..(t + 1) * 3 * V_HIDDEN];
703                let dst = t * V_HIDDEN;
704                vh[dst..dst + V_HIDDEN].copy_from_slice(&row[2 * V_HIDDEN..3 * V_HIDDEN]);
705                for hd in 0..V_HEADS {
706                    let o = hd * V_HEAD_DIM;
707                    // rotate-half pairs (d, d+36), angles shared across heads
708                    for d in 0..half {
709                        let (c, sn) = (rope_cos[t * half + d], rope_sin[t * half + d]);
710                        let (qa, qb) = (row[o + d], row[o + d + half]);
711                        qh[dst + o + d] = qa * c - qb * sn;
712                        qh[dst + o + d + half] = qb * c + qa * sn;
713                        let (ka, kb) = (row[V_HIDDEN + o + d], row[V_HIDDEN + o + d + half]);
714                        kh[dst + o + d] = ka * c - kb * sn;
715                        kh[dst + o + d + half] = kb * c + ka * sn;
716                    }
717                }
718            }
719            let (qd, kd, vd) = (e.htod(&qh)?, e.htod(&kh)?, e.htod(&vh)?);
720            let mut od = e.zeros(n * V_HIDDEN)?;
721            e.sdpa_naive(
722                &qd, &kd, &vd, &mut od, V_HEAD_DIM, V_HEADS, V_HEADS, n, n, scale, false,
723            )?;
724            let attn = self.linear_bias(e, &od, &blk.proj, n)?;
725            let mut xr = e.zeros(n * V_HIDDEN)?;
726            e.add(&x, &attn, &mut xr, n * V_HIDDEN)?;
727            // mlp: ln2 -> fc1 -> gelu_tanh -> fc2 -> +res
728            let mut h2 = e.zeros(n * V_HIDDEN)?;
729            e.layer_norm_bias(
730                &xr,
731                &blk.norm2_w,
732                &blk.norm2_b,
733                &mut h2,
734                V_HIDDEN,
735                n,
736                LN_EPS,
737            )?;
738            let f1 = self.linear_bias(e, &h2, &blk.fc1, n)?;
739            let mut g = e.zeros(n * V_INTER)?;
740            e.gelu_tanh(&f1, &mut g, n * V_INTER)?;
741            let f2 = self.linear_bias(e, &g, &blk.fc2, n)?;
742            let mut xn = e.zeros(n * V_HIDDEN)?;
743            e.add(&xr, &f2, &mut xn, n * V_HIDDEN)?;
744            x = xn;
745            if dbg.is_some() && ib == 0 {
746                dump("blk0", &e.dtoh(&x)?);
747            }
748        }
749        if dbg.is_some() {
750            dump("post_blocks", &e.dtoh(&x)?);
751        }
752        // merger: LN over [n, 1152], then 2x2 spatial concat -> [n/4, 4608] -> fc1 -> gelu -> fc2
753        let mut ln = e.zeros(n * V_HIDDEN)?;
754        e.layer_norm_bias(
755            &x,
756            &self.merger_norm_w,
757            &self.merger_norm_b,
758            &mut ln,
759            V_HIDDEN,
760            n,
761            LN_EPS,
762        )?;
763        let lh = e.dtoh(&ln)?;
764        let (mh, mw) = (gh / V_MERGE, gw / V_MERGE);
765        let nm = groups * mh * mw;
766        let mut merged = vec![0f32; nm * V_MERGED_IN];
767        for g in 0..groups {
768            for my in 0..mh {
769                for mx in 0..mw {
770                    let out_t = (g * mh + my) * mw + mx;
771                    let dst = &mut merged[out_t * V_MERGED_IN..(out_t + 1) * V_MERGED_IN];
772                    for sy in 0..V_MERGE {
773                        for sx in 0..V_MERGE {
774                            let t = g * gh * gw + (my * V_MERGE + sy) * gw + (mx * V_MERGE + sx);
775                            let seg = (sy * V_MERGE + sx) * V_HIDDEN;
776                            dst[seg..seg + V_HIDDEN]
777                                .copy_from_slice(&lh[t * V_HIDDEN..(t + 1) * V_HIDDEN]);
778                        }
779                    }
780                }
781            }
782        }
783        let md = e.htod(&merged)?;
784        let f1 = self.linear_bias(e, &md, &self.merger_fc1, nm)?;
785        let mut g = e.zeros(nm * V_MERGED_IN)?;
786        e.gelu_tanh(&f1, &mut g, nm * V_MERGED_IN)?;
787        self.linear_bias(e, &g, &self.merger_fc2, nm)
788    }
789}
790
791#[cfg(test)]
792mod tests {
793    use super::{EmbedOverlay, OverlayPublish, overlay_publish_resolve};
794    use crate::Engine;
795    use cudarc::driver::CudaContext;
796
797    #[test]
798    fn overlay_publish_arm_matrix_is_pinned() {
799        // Absent and empty mean the DEFAULT, and the default is Auto by decision (FLAGS row):
800        // byte-identical to the pre-lane program wherever the contexts already match, and the
801        // only way a per-stage-stream ppN placement serves an image at all.
802        assert_eq!(overlay_publish_resolve(None), Ok(OverlayPublish::Auto));
803        assert_eq!(overlay_publish_resolve(Some("")), Ok(OverlayPublish::Auto));
804        assert_eq!(
805            overlay_publish_resolve(Some("auto")),
806            Ok(OverlayPublish::Auto)
807        );
808        assert_eq!(overlay_publish_resolve(Some("1")), Ok(OverlayPublish::Auto));
809        assert_eq!(
810            overlay_publish_resolve(Some(" force ")),
811            Ok(OverlayPublish::Force)
812        );
813        assert_eq!(
814            overlay_publish_resolve(Some("0")),
815            Ok(OverlayPublish::Never)
816        );
817        assert_eq!(
818            overlay_publish_resolve(Some("off")),
819            Ok(OverlayPublish::Never)
820        );
821    }
822
823    #[test]
824    fn an_unrecognized_overlay_publish_value_refuses_rather_than_defaulting() {
825        // The failure mode this closes: `MEMRA_VISION_OVERLAY_PUBLISH=yes` silently reading as
826        // the default would make an operator believe a door was thrown that never was.
827        for bad in ["yes", "true", "2", "Force", "no"] {
828            let err = overlay_publish_resolve(Some(bad)).expect_err(bad);
829            assert!(err.contains("unrecognized"), "{err}");
830            assert!(err.contains(bad), "{err}");
831        }
832    }
833    /// THE RESIDENCY REFUSAL, EXECUTED — on one card.
834    ///
835    /// The law's whole point is that it bites when the overlay's rows are in another CUDA
836    /// context, and the serving shape that provokes it needs two devices. But a context is not
837    /// a device: `CudaContext::new_non_primary` gives a genuinely independent context on the
838    /// SAME card (`CudaContext::new` retains the device's PRIMARY context, which is why every
839    /// per-stage Engine on one device shares an address space). So the refusal path can be run
840    /// here rather than only reasoned about — the "loud failures fail quietly" law: execute
841    /// every failure path and assert the outcome.
842    ///
843    /// What this does NOT claim: nothing about whether a PUBLISHED pointer works across two
844    /// contexts. That is the multi-card box battery's job (`research/glm53-vision-ppn-20260901/box/`).
845    ///
846    /// Needs a CUDA device and FAILS (never skips) without one — a skipping test is how a gate
847    /// reports green in perpetuity while running nothing. Not reachable from CI, which runs lib
848    /// suites for the CUDA-FREE crates only; its receipt is the banked rig run.
849    #[test]
850    #[ignore = "needs a CUDA device; run on the rig under flock /tmp/memra-5090.lock"]
851    fn a_foreign_context_overlay_is_refused_not_dereferenced() {
852        let e = Engine::new(0).expect("CUDA engine on device 0");
853        // An independent context on the same card, and rows allocated inside it. No Engine is
854        // built on it deliberately: nothing in this file may make a foreign overlay reachable
855        // from production code, so the fixture goes through the struct's private field.
856        let foreign = CudaContext::new_non_primary(0, 0).expect("non-primary context on device 0");
857        assert_ne!(
858            foreign.cu_ctx(),
859            e.ctx().cu_ctx(),
860            "new_non_primary must NOT hand back the primary context, or this test is vacuous"
861        );
862        let stream = foreign.new_stream().expect("stream in the foreign context");
863        let rows = stream
864            .alloc_zeros::<f32>(8 * 4)
865            .expect("rows in the foreign context");
866
867        // `EmbedOverlay::new` REFUSES this construction outright since memra-next#24 — it takes
868        // residency from `rows.context()` and rejects a disagreement with the engine — so that
869        // is asserted first, and only then is the struct fabricated through the private field to
870        // reach the consumers behind it. Two layers, both executed: a caller cannot build the
871        // mislabelled overlay, and if one ever existed the splice would still refuse it.
872        let refused = match EmbedOverlay::new(&e, rows, vec![(0, 0, 2)]) {
873            Err(err) => err.to_string(),
874            Ok(_) => panic!("new() must refuse rows allocated in another context"),
875        };
876        assert!(refused.contains("refused at construction"), "{refused}");
877
878        let rows = stream
879            .alloc_zeros::<f32>(8 * 4)
880            .expect("rows in the foreign context");
881        let ov = EmbedOverlay {
882            rows: std::sync::Arc::new(rows),
883            spans: vec![(0, 0, 2)],
884            ctx: foreign.clone(),
885        };
886
887        assert!(
888            !ov.resident_in(&e),
889            "an overlay built in another context must not read as resident"
890        );
891        let err = ov
892            .require_resident(&e)
893            .expect_err("the residency law must REFUSE a foreign-context overlay")
894            .to_string();
895        assert!(err.contains("another address space"), "{err}");
896
897        // And the refusal happens BEFORE any copy is attempted: the splice must return the same
898        // named error rather than handing a foreign pointer to a memcpy.
899        let mut embedded = e
900            .zeros(8 * 4)
901            .expect("destination rows on the primary engine");
902        let err = ov
903            .splice_into(&e, &mut embedded, 0, 8, 4)
904            .expect_err("splice_into must refuse a foreign-context overlay");
905        assert!(err.to_string().contains("another address space"), "{err}");
906
907        // AND THE PUBLISH PATH REFUSES ON THE SOURCE SIDE, before it reads a byte. This is the
908        // asymmetry the peer review found: `new_published` used to dtoh `rows` through `tower`
909        // and only check where the bytes LANDED, so a foreign-context source would have been
910        // read unordered (and, under UVA, probably successfully) and then published as if valid.
911        // Force is used so the arm cannot take the zero-copy early return.
912        let rows = stream
913            .alloc_zeros::<f32>(8 * 4)
914            .expect("rows in the foreign context");
915        let refused = match EmbedOverlay::new_published(
916            &e,
917            &e,
918            super::OverlayPublish::Force,
919            rows,
920            vec![(0, 0, 2)],
921        ) {
922            Err(err) => err.to_string(),
923            Ok(_) => panic!("new_published must refuse a foreign-context SOURCE before reading it"),
924        };
925        assert!(
926            refused.contains("publication (source side)"),
927            "the refusal must name the source side, not the landing side: {refused}"
928        );
929
930        // The primary-context twin of the same shape is accepted, so the assertion above is
931        // about RESIDENCY and not about the arguments.
932        let ok = EmbedOverlay::new(&e, e.zeros(8 * 4).unwrap(), vec![(0, 0, 2)])
933            .expect("a same-context overlay constructs");
934        assert!(ok.resident_in(&e));
935        ok.splice_into(&e, &mut embedded, 0, 8, 4)
936            .expect("a same-context overlay splices");
937    }
938}