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}