Skip to main content

memra_engine/
vision_step.rs

1//! Vision tower for StepFun Step-3.7-Flash (arch step35), lane/step37-vision 2026-08-30.
2//!
3//! step37 is its OWN semantic program. Every law here was derived from the pinned
4//! artifact (HF stepfun-ai/Step-3.7-Flash-NVFP4 @ 4275532f): config.json
5//! `vision_config` (model_type perception_encoder) plus the vendor reference code the
6//! checkpoint ships (vision_encoder.py / processing_step3.py / modeling_step3p7.py)
7//! and the shard-header tensor census. Full census + plan:
8//! research/step37-vision-20260830/CENSUS.md. Nothing is inherited from the qwen3_5
9//! or gemma4 towers by analogy; the census decided this is a third program:
10//!
11//! - ViT: 47 blocks, hidden 1536, 16 heads (head_dim 96), mlp 8960, patch 14,
12//!   CLIP lineage: LayerNorm WITH biases (eps 1e-5), fused in_proj qkv (+bias),
13//!   out_proj (+bias), quick_gelu MLP (x*sigmoid(1.702x); NOT gelu_tanh, NOT GEGLU),
14//!   and LayerScale gammas (ls_1/ls_2) on both residual branches, which neither
15//!   shipped tower has. ln_pre only (use_ln_post false, use_cls_token false).
16//! - Positions enter twice: a learned 52x52 absolute table added to the patch
17//!   embeddings (other grids: bilinear interpolation, align_corners=FALSE — the
18//!   qwen3_5 table interpolates align_corners=TRUE, flagged so nobody unifies them),
19//!   and a per-layer 2D rope over the FULL head_dim 96: first 48 dims rotate by the
20//!   COLUMN, last 48 by the ROW, theta 10000, INTERLEAVED pairing ((2i, 2i+1) share
21//!   one angle — GPT-J style; qwen and gemma both pair NeoX-style (d, d+half/2)).
22//! - Attention: sdpa scale 1/sqrt(96), non-causal, one image or one 504-crop per
23//!   segment (the reference batches tiles on the batch dim; attention never crosses
24//!   tiles). No video path exists for this family.
25//! - Head: NOT a merger. [n,1536] reshapes to [1536,g,g], runs two OVERLAPPING 3x3
26//!   stride-2 pad-1 convs (1536->3072->6144, biases), row-major flatten, then
27//!   vit_large_projector 6144->4096 (no bias). 52-grid -> 169 rows, 36-grid -> 81.
28//! - Preprocessing (processing_step3.py): CLIP mean/std, every ViT input a SQUARE
29//!   bilinear resize (728 main view, 504 crop tiles); ImagePatcher tiling for large /
30//!   extreme-aspect images (window law in `determine_window_size`). The vendor
31//!   Compose normalizes BEFORE resizing; per-channel affine commutes with the linear
32//!   resample, memra resizes first (parity arbitrated by the fixed-pixel oracle).
33//! - Token layout per image (ids from the tokenizer, hardcoded nowhere): crops FIRST
34//!   (<patch_start> + 81 pads + <patch_end>, <patch_newline> per full tile row except
35//!   a trailing one), then <im_start> + 169 pads + <im_end>. Embedding rows replace
36//!   pad positions in order; delimiters keep their text embeddings. Image spans are
37//!   CAUSAL in the LM (standard create_causal_mask in the reference — unlike gemma4's
38//!   bidirectional islands), so the existing overlay prime path is the correct one.
39//!
40//! v1 posture matches the shipped towers: correctness-first — f32 GEMMs
41//! (Engine::linear + add_row_inplace bias), sdpa_naive, host-side rope / LayerScale /
42//! quick_gelu / im2col; parity gate before any serving path (bin/step_vision_oracle).
43
44use crate::Engine;
45use cudarc::driver::CudaSlice;
46use memra_gguf::dequant::bf16_to_f32;
47use memra_gguf::safetensors::StModel;
48use std::path::Path;
49
50pub const SV_HIDDEN: usize = 1536;
51pub const SV_HEADS: usize = 16;
52pub const SV_HEAD_DIM: usize = SV_HIDDEN / SV_HEADS; // 96
53pub const SV_INTER: usize = 8960; // int(1536 * 5.8333...)
54pub const SV_DEPTH: usize = 47;
55pub const SV_PATCH: usize = 14;
56pub const SV_POS_GRID: usize = 52; // 728 / 14, the learned-table grid
57pub const SV_PATCH_IN: usize = 3 * SV_PATCH * SV_PATCH; // 588
58/// Main-view edge (px) and its patch grid; crop-tile edge and grid.
59pub const SV_IMAGE_SIZE: usize = 728;
60pub const SV_TILE_SIZE: usize = 504;
61pub const SV_GRID_MAIN: usize = SV_IMAGE_SIZE / SV_PATCH; // 52
62pub const SV_GRID_TILE: usize = SV_TILE_SIZE / SV_PATCH; // 36
63/// Trunk rows per view after the two stride-2 downsamplers (52->26->13, 36->18->9).
64pub const SV_MAIN_ROWS: usize = 169;
65pub const SV_TILE_ROWS: usize = 81;
66/// ImagePatcher long-side cap before tiling (MAX_IMAGE_SIZE in processing_step3.py).
67pub const SV_MAX_IMAGE_SIZE: usize = 3024;
68const LN_EPS: f32 = 1e-5;
69const ROPE_THETA: f32 = 10000.0;
70/// CLIP normalization (processing_step3.py Step3VisionProcessor).
71const MEAN: [f32; 3] = [0.481_454_66, 0.457_827_5, 0.408_210_73];
72const STD: [f32; 3] = [0.268_629_54, 0.261_302_6, 0.275_777_1];
73
74struct Lin {
75    w: CudaSlice<f32>,
76    b: Option<CudaSlice<f32>>,
77    in_f: usize,
78    out_f: usize,
79}
80
81struct SBlock {
82    ln1_w: CudaSlice<f32>,
83    ln1_b: CudaSlice<f32>,
84    ln2_w: CudaSlice<f32>,
85    ln2_b: CudaSlice<f32>,
86    ls1: Vec<f32>,
87    ls2: Vec<f32>,
88    qkv: Lin,
89    proj: Lin,
90    fc: Lin,
91    cproj: Lin,
92}
93
94/// 3x3 stride-2 pad-1 conv as im2col + GEMM: weight rows are the PyTorch
95/// [C_out, C_in, 3, 3] flatten, i.e. (c, ky, kx) inner order.
96struct Conv3x3s2 {
97    w: CudaSlice<f32>, // [C_out, C_in*9]
98    b: CudaSlice<f32>,
99    c_in: usize,
100    c_out: usize,
101}
102
103pub struct StepVisionTower {
104    patch: Lin, // conv1 14x14 stride-14 (no bias) as Linear 588 -> 1536
105    /// Host copy of the learned pos table [2704, 1536].
106    pos: Vec<f32>,
107    ln_pre_w: CudaSlice<f32>,
108    ln_pre_b: CudaSlice<f32>,
109    blocks: Vec<SBlock>,
110    down1: Conv3x3s2, // 1536 -> 3072
111    down2: Conv3x3s2, // 3072 -> 6144
112    proj: Lin,        // vit_large_projector 6144 -> 4096, no bias
113}
114
115fn read_f32(m: &StModel, name: &str) -> Result<Vec<f32>, Box<dyn std::error::Error>> {
116    let (info, raw) = m
117        .raw(name)
118        .ok_or_else(|| format!("step vision tensor missing: {name}"))?;
119    match info.dtype.as_str() {
120        "BF16" => Ok(raw
121            .chunks_exact(2)
122            .map(|c| bf16_to_f32(u16::from_le_bytes([c[0], c[1]])))
123            .collect()),
124        "F32" => Ok(raw
125            .chunks_exact(4)
126            .map(|c| f32::from_le_bytes(c.try_into().unwrap()))
127            .collect()),
128        other => Err(format!("step vision tensor {name}: unsupported dtype {other}").into()),
129    }
130}
131
132fn load_lin(
133    e: &Engine,
134    m: &StModel,
135    stem: &str,
136    in_f: usize,
137    out_f: usize,
138    bias: bool,
139) -> Result<Lin, Box<dyn std::error::Error>> {
140    let w = read_f32(m, &format!("{stem}.weight"))?;
141    assert_eq!(w.len(), in_f * out_f, "{stem}.weight shape");
142    let b = if bias {
143        let b = read_f32(m, &format!("{stem}.bias"))?;
144        assert_eq!(b.len(), out_f, "{stem}.bias shape");
145        Some(e.htod(&b)?)
146    } else {
147        None
148    };
149    Ok(Lin {
150        w: e.htod(&w)?,
151        b,
152        in_f,
153        out_f,
154    })
155}
156
157// ---------------- preprocessing (processing_step3.py, pinned rev) ----------------
158
159/// `determine_window_size`: the crop-tiling decision. 0 = no tiles.
160fn window_size(long: usize, short: usize) -> usize {
161    if long <= SV_IMAGE_SIZE {
162        if long as f64 / short as f64 > 1.5 {
163            short
164        } else {
165            0
166        }
167    } else if long as f64 / short as f64 > 4.0 {
168        short.min(SV_TILE_SIZE)
169    } else {
170        SV_TILE_SIZE
171    }
172}
173
174/// `get_image_size_for_padding`: tiny extreme-aspect images pad to a black square.
175fn pad_rule(w: usize, h: usize) -> (usize, usize) {
176    let ratio = w as f64 / h as f64;
177    if w.min(h) < 32 && !(0.25..=4.0).contains(&ratio) {
178        let s = w.max(h);
179        (s, s)
180    } else {
181        (w, h)
182    }
183}
184
185/// `get_image_size_for_preprocess`: cap the long side at 3024 (int() truncation).
186fn cap_rule(w: usize, h: usize) -> (usize, usize) {
187    if w.max(h) > SV_MAX_IMAGE_SIZE {
188        let s = SV_MAX_IMAGE_SIZE as f64 / w.max(h) as f64;
189        ((w as f64 * s) as usize, (h as f64 * s) as usize)
190    } else {
191        (w, h)
192    }
193}
194
195/// `get_image_size_for_crop`: snap a side to whole windows with the 0.2 overflow rule.
196fn crop_snap(side: usize, win: usize) -> usize {
197    let ratio = side as f64 / win as f64;
198    if ratio < 1.0 {
199        return side;
200    }
201    let whole = side / win;
202    let n = if ratio - whole as f64 > 0.2 {
203        whole + 1
204    } else {
205        whole
206    };
207    win * n
208}
209
210/// Tiling plan for an image of (w, h): tile count, tiles per row (x_num), and the
211/// per-tile newline mask (vendor law: a newline after each full tile row, except a
212/// trailing one on the final tile). Derivable from HEADER dims alone, so the pad run
213/// and the request's token price are known before any canvas expands.
214pub struct StepImagePlan {
215    pub n_tiles: usize,
216    pub newline_mask: Vec<bool>,
217}
218
219impl StepImagePlan {
220    /// Trunk embedding rows this image occupies (pads only, not delimiters).
221    pub fn n_rows(&self) -> usize {
222        self.n_tiles * SV_TILE_ROWS + SV_MAIN_ROWS
223    }
224    /// Prompt TOKENS the placeholder expansion renders (pads + delimiters + newlines).
225    pub fn n_prompt_tokens(&self) -> usize {
226        let newlines = self.newline_mask.iter().filter(|&&b| b).count();
227        self.n_tiles * (SV_TILE_ROWS + 2) + newlines + SV_MAIN_ROWS + 2
228    }
229}
230
231fn plan_for_dims(w0: usize, h0: usize) -> StepImagePlan {
232    let (w, h) = pad_rule(w0, h0);
233    let (w, h) = cap_rule(w, h);
234    let win = window_size(w.max(h), w.min(h));
235    if win == 0 {
236        return StepImagePlan {
237            n_tiles: 0,
238            newline_mask: Vec::new(),
239        };
240    }
241    let (cw, ch) = (crop_snap(w, win), crop_snap(h, win));
242    // slide_window with size == step: whole non-overlapping tiles (cw, ch are snapped
243    // to multiples of win when >= win; a side < win yields one column/row).
244    let x_num = (cw / win).max(1);
245    let y_num = (ch / win).max(1);
246    let n = x_num * y_num;
247    let mut mask = vec![false; n];
248    let mut newlines: Vec<usize> = (0..n).filter(|i| (i + 1) % x_num == 0).collect();
249    if newlines.last() == Some(&(n - 1)) {
250        newlines.pop(); // the vendor pops a trailing row-final newline
251    }
252    for i in newlines {
253        mask[i] = true;
254    }
255    StepImagePlan {
256        n_tiles: n,
257        newline_mask: mask,
258    }
259}
260
261/// PRE-DECODE admission (hermes decode-bomb law, same as the qwen/gemma planners):
262/// header dims -> decode-budget check -> tiling plan. No canvas expands here.
263pub fn step_plan_image(bytes: &[u8]) -> Result<StepImagePlan, String> {
264    let (w, h) = crate::vision_pre::image_header_dims(bytes)?;
265    if w.saturating_mul(h) > crate::vision_pre::IMG_MAX_DECODE_PIXELS {
266        return Err(format!(
267            "image {w}x{h} exceeds the decode budget ({} px) — refused before decode",
268            crate::vision_pre::IMG_MAX_DECODE_PIXELS
269        ));
270    }
271    if w < 2 || h < 2 {
272        return Err(format!("image too small: {w}x{h}"));
273    }
274    Ok(plan_for_dims(w, h))
275}
276
277/// One preprocessed step37 image: the 728 main view plus its 504 crop tiles, each as
278/// patch rows the tower consumes directly. Carried from the HTTP layer to the GPU
279/// worker; the patch buffers drop after the tower forward.
280pub struct StepVisionUnit {
281    /// [52*52, 588] rows, (c, ky, kx) inner order.
282    pub main: Vec<f32>,
283    /// Each [36*36, 588]; slide-window order (row-major over the tile grid).
284    pub tiles: Vec<Vec<f32>>,
285    pub newline_mask: Vec<bool>,
286}
287
288impl StepVisionUnit {
289    pub fn n_rows(&self) -> usize {
290        self.tiles.len() * SV_TILE_ROWS + SV_MAIN_ROWS
291    }
292}
293
294/// Normalize + patchify one square RGB view into [g*g, 588] rows.
295fn patchify(img: &image::RgbImage, g: usize) -> Vec<f32> {
296    let mut rows = vec![0f32; g * g * SV_PATCH_IN];
297    for py in 0..g {
298        for px in 0..g {
299            let dst = &mut rows[(py * g + px) * SV_PATCH_IN..(py * g + px + 1) * SV_PATCH_IN];
300            for c in 0..3 {
301                for ky in 0..SV_PATCH {
302                    for kx in 0..SV_PATCH {
303                        let p =
304                            img.get_pixel((px * SV_PATCH + kx) as u32, (py * SV_PATCH + ky) as u32);
305                        dst[(c * SV_PATCH + ky) * SV_PATCH + kx] =
306                            ((p[c] as f32) / 255.0 - MEAN[c]) / STD[c];
307                    }
308                }
309            }
310        }
311    }
312    rows
313}
314
315/// Decode + preprocess one image: bytes -> main view + crop tiles per the vendor
316/// pipeline. Admission runs FIRST (header-only); the decoder is capped to the admitted
317/// dimensions. The returned unit's tile count MUST match the header plan — the caller
318/// refuses on drift (pad runs are already rendered from the plan).
319pub fn step_prep_image(bytes: &[u8]) -> Result<StepVisionUnit, Box<dyn std::error::Error>> {
320    step_plan_image(bytes)?;
321    let (hw, hh) = crate::vision_pre::image_header_dims(bytes)?;
322    let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format()?;
323    let mut limits = image::Limits::default();
324    limits.max_image_width = Some(hw as u32);
325    limits.max_image_height = Some(hh as u32);
326    reader.limits(limits);
327    let mut img = reader.decode()?.to_rgb8();
328    let (w0, h0) = (img.width() as usize, img.height() as usize);
329    // 1. tiny extreme-aspect pad-to-square (paste at (0,0), black fill)
330    let (pw, ph) = pad_rule(w0, h0);
331    if (pw, ph) != (w0, h0) {
332        let mut padded = image::RgbImage::new(pw as u32, ph as u32);
333        image::imageops::replace(&mut padded, &img, 0, 0);
334        img = padded;
335    }
336    // 2. long-side cap at 3024 (aspect kept; bilinear, like the vendor's PIL resize)
337    let (cw, ch) = cap_rule(img.width() as usize, img.height() as usize);
338    if (cw, ch) != (img.width() as usize, img.height() as usize) {
339        img = image::imageops::resize(
340            &img,
341            cw as u32,
342            ch as u32,
343            image::imageops::FilterType::Triangle,
344        );
345    }
346    let (w, h) = (img.width() as usize, img.height() as usize);
347    // 3. main view: square 728 resize of the (padded/capped) image
348    let main_img = image::imageops::resize(
349        &img,
350        SV_IMAGE_SIZE as u32,
351        SV_IMAGE_SIZE as u32,
352        image::imageops::FilterType::Triangle,
353    );
354    let main = patchify(&main_img, SV_GRID_MAIN);
355    // 4. crop tiles
356    let win = window_size(w.max(h), w.min(h));
357    let (mut tiles, mut newline_mask) = (Vec::new(), Vec::new());
358    if win > 0 {
359        let (sw, sh) = (crop_snap(w, win), crop_snap(h, win));
360        let snapped = if (sw, sh) != (w, h) {
361            image::imageops::resize(
362                &img,
363                sw as u32,
364                sh as u32,
365                image::imageops::FilterType::Triangle,
366            )
367        } else {
368            img
369        };
370        let x_num = (sw / win).max(1);
371        let y_num = (sh / win).max(1);
372        let n = x_num * y_num;
373        for ty in 0..y_num {
374            for tx in 0..x_num {
375                let crop = image::imageops::crop_imm(
376                    &snapped,
377                    (tx * win) as u32,
378                    (ty * win) as u32,
379                    win as u32,
380                    win as u32,
381                )
382                .to_image();
383                let tile = image::imageops::resize(
384                    &crop,
385                    SV_TILE_SIZE as u32,
386                    SV_TILE_SIZE as u32,
387                    image::imageops::FilterType::Triangle,
388                );
389                tiles.push(patchify(&tile, SV_GRID_TILE));
390            }
391        }
392        let mut newlines: Vec<usize> = (0..n).filter(|i| (i + 1) % x_num == 0).collect();
393        if newlines.last() == Some(&(n - 1)) {
394            newlines.pop();
395        }
396        newline_mask = vec![false; n];
397        for i in newlines {
398            newline_mask[i] = true;
399        }
400    }
401    Ok(StepVisionUnit {
402        main,
403        tiles,
404        newline_mask,
405    })
406}
407
408// ---------------- tower ----------------
409
410impl StepVisionTower {
411    /// Load the tower from the serving artifact's own directory (the vision tensors
412    /// live unquantized, BF16, inside the NVFP4 checkpoint: `model.vision_model.*` +
413    /// `model.vit_large_projector.weight`, routed by model.safetensors.index.json).
414    /// Refuses any directory whose tensors do not census as this exact program.
415    pub fn load(e: &Engine, dir: &Path) -> Result<Self, Box<dyn std::error::Error>> {
416        let m = StModel::open(dir)?;
417        let p = "model.vision_model";
418        let patch = {
419            // conv1 [1536, 3, 14, 14] stride 14, no bias == Linear over (c, ky, kx)
420            // 588-float patch rows (the patchify order above).
421            let w = read_f32(&m, &format!("{p}.conv1.weight"))?;
422            assert_eq!(w.len(), SV_HIDDEN * SV_PATCH_IN, "conv1.weight shape");
423            Lin {
424                w: e.htod(&w)?,
425                b: None,
426                in_f: SV_PATCH_IN,
427                out_f: SV_HIDDEN,
428            }
429        };
430        let pos = read_f32(&m, &format!("{p}.positional_embedding"))?;
431        assert_eq!(
432            pos.len(),
433            SV_POS_GRID * SV_POS_GRID * SV_HIDDEN,
434            "positional_embedding shape"
435        );
436        let ln_pre_w = e.htod(&read_f32(&m, &format!("{p}.ln_pre.weight"))?)?;
437        let ln_pre_b = e.htod(&read_f32(&m, &format!("{p}.ln_pre.bias"))?)?;
438        let mut blocks = Vec::with_capacity(SV_DEPTH);
439        for il in 0..SV_DEPTH {
440            let bp = format!("{p}.transformer.resblocks.{il}");
441            let ls1 = read_f32(&m, &format!("{bp}.ls_1.gamma"))?;
442            let ls2 = read_f32(&m, &format!("{bp}.ls_2.gamma"))?;
443            assert_eq!(ls1.len(), SV_HIDDEN, "ls_1.gamma shape");
444            assert_eq!(ls2.len(), SV_HIDDEN, "ls_2.gamma shape");
445            blocks.push(SBlock {
446                ln1_w: e.htod(&read_f32(&m, &format!("{bp}.ln_1.weight"))?)?,
447                ln1_b: e.htod(&read_f32(&m, &format!("{bp}.ln_1.bias"))?)?,
448                ln2_w: e.htod(&read_f32(&m, &format!("{bp}.ln_2.weight"))?)?,
449                ln2_b: e.htod(&read_f32(&m, &format!("{bp}.ln_2.bias"))?)?,
450                ls1,
451                ls2,
452                qkv: {
453                    // fused in_proj: weight [4608, 1536] + bias [4608], chunk order q,k,v
454                    let w = read_f32(&m, &format!("{bp}.attn.in_proj_weight"))?;
455                    let b = read_f32(&m, &format!("{bp}.attn.in_proj_bias"))?;
456                    assert_eq!(w.len(), 3 * SV_HIDDEN * SV_HIDDEN, "in_proj_weight shape");
457                    assert_eq!(b.len(), 3 * SV_HIDDEN, "in_proj_bias shape");
458                    Lin {
459                        w: e.htod(&w)?,
460                        b: Some(e.htod(&b)?),
461                        in_f: SV_HIDDEN,
462                        out_f: 3 * SV_HIDDEN,
463                    }
464                },
465                proj: load_lin(
466                    e,
467                    &m,
468                    &format!("{bp}.attn.out_proj"),
469                    SV_HIDDEN,
470                    SV_HIDDEN,
471                    true,
472                )?,
473                fc: load_lin(e, &m, &format!("{bp}.mlp.c_fc"), SV_HIDDEN, SV_INTER, true)?,
474                cproj: load_lin(
475                    e,
476                    &m,
477                    &format!("{bp}.mlp.c_proj"),
478                    SV_INTER,
479                    SV_HIDDEN,
480                    true,
481                )?,
482            });
483        }
484        let load_conv = |stem: &str,
485                         c_in: usize,
486                         c_out: usize|
487         -> Result<Conv3x3s2, Box<dyn std::error::Error>> {
488            let w = read_f32(&m, &format!("{stem}.weight"))?;
489            let b = read_f32(&m, &format!("{stem}.bias"))?;
490            assert_eq!(w.len(), c_out * c_in * 9, "{stem}.weight shape");
491            assert_eq!(b.len(), c_out, "{stem}.bias shape");
492            Ok(Conv3x3s2 {
493                w: e.htod(&w)?,
494                b: e.htod(&b)?,
495                c_in,
496                c_out,
497            })
498        };
499        let down1 = load_conv(&format!("{p}.vit_downsampler1"), SV_HIDDEN, 2 * SV_HIDDEN)?;
500        let down2 = load_conv(
501            &format!("{p}.vit_downsampler2"),
502            2 * SV_HIDDEN,
503            4 * SV_HIDDEN,
504        )?;
505        let proj = {
506            // vit_large_projector [n_embd, 6144], projector_bias false. Output width is
507            // the TRUNK's n_embd, derived from the tensor, never assumed — admission
508            // compares the serving trunk's n_embd against `out_width()`.
509            let w = read_f32(&m, "model.vit_large_projector.weight")?;
510            assert_eq!(w.len() % (4 * SV_HIDDEN), 0, "vit_large_projector shape");
511            let out_f = w.len() / (4 * SV_HIDDEN);
512            Lin {
513                w: e.htod(&w)?,
514                b: None,
515                in_f: 4 * SV_HIDDEN,
516                out_f,
517            }
518        };
519        eprintln!(
520            "[step-vision] tower loaded from {} ({SV_DEPTH} blocks, out_width {}, f32-resident)",
521            dir.display(),
522            proj.out_f
523        );
524        Ok(Self {
525            patch,
526            pos,
527            ln_pre_w,
528            ln_pre_b,
529            blocks,
530            down1,
531            down2,
532            proj,
533        })
534    }
535
536    /// Embedding width this tower emits per row (the projector out_features == the
537    /// trunk n_embd of the checkpoint it loaded from; 4096 on Step-3.7-Flash).
538    pub fn out_width(&self) -> usize {
539        self.proj.out_f
540    }
541
542    fn linear_bias(
543        &self,
544        e: &Engine,
545        x: &CudaSlice<f32>,
546        l: &Lin,
547        m: usize,
548    ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
549        let mut y = e.linear(x, &l.w, m, l.in_f, l.out_f)?;
550        if let Some(b) = &l.b {
551            for r in 0..m {
552                e.add_row_inplace(&mut y, b, l.out_f, r * l.out_f)?;
553            }
554        }
555        Ok(y)
556    }
557
558    /// Bilinear-interpolate the 52x52 learned pos table to [g, g] on host,
559    /// align_corners=FALSE (torch F.interpolate default — the vendor's
560    /// sample_abs_posemb; NOT the qwen table's align_corners=true law).
561    fn pos_for_grid(&self, g: usize) -> Vec<f32> {
562        if g == SV_POS_GRID {
563            return self.pos.clone();
564        }
565        let scale = SV_POS_GRID as f32 / g as f32;
566        let mut out = vec![0f32; g * g * SV_HIDDEN];
567        for y in 0..g {
568            for x in 0..g {
569                let sy = ((y as f32 + 0.5) * scale - 0.5).clamp(0.0, (SV_POS_GRID - 1) as f32);
570                let sx = ((x as f32 + 0.5) * scale - 0.5).clamp(0.0, (SV_POS_GRID - 1) as f32);
571                let (y0, x0) = (sy.floor() as usize, sx.floor() as usize);
572                let (y1, x1) = ((y0 + 1).min(SV_POS_GRID - 1), (x0 + 1).min(SV_POS_GRID - 1));
573                let (fy, fx) = (sy - y0 as f32, sx - x0 as f32);
574                let dst = &mut out[(y * g + x) * SV_HIDDEN..(y * g + x + 1) * SV_HIDDEN];
575                #[allow(clippy::needless_range_loop)]
576                // allow: the explicit channel index keeps the four-corner offset arithmetic
577                // visible and aligned across the four `self.pos` reads and the `dst` write
578                for c in 0..SV_HIDDEN {
579                    let p00 = self.pos[(y0 * SV_POS_GRID + x0) * SV_HIDDEN + c];
580                    let p01 = self.pos[(y0 * SV_POS_GRID + x1) * SV_HIDDEN + c];
581                    let p10 = self.pos[(y1 * SV_POS_GRID + x0) * SV_HIDDEN + c];
582                    let p11 = self.pos[(y1 * SV_POS_GRID + x1) * SV_HIDDEN + c];
583                    dst[c] = p00 * (1.0 - fy) * (1.0 - fx)
584                        + p01 * (1.0 - fy) * fx
585                        + p10 * fy * (1.0 - fx)
586                        + p11 * fy * fx;
587                }
588            }
589        }
590        out
591    }
592
593    /// im2col for a 3x3 stride-2 pad-1 conv over a [c_in, g, g] feature map held as
594    /// token-major [g*g, c_in] rows: emits [og*og, c_in*9] rows in (c, ky, kx) inner
595    /// order (the PyTorch conv-weight flatten), og = floor((g - 1) / 2) + 1.
596    fn im2col(x: &[f32], g: usize, c_in: usize) -> (Vec<f32>, usize) {
597        let og = (g - 1) / 2 + 1;
598        let mut out = vec![0f32; og * og * c_in * 9];
599        for oy in 0..og {
600            for ox in 0..og {
601                let dst = &mut out[(oy * og + ox) * c_in * 9..(oy * og + ox + 1) * c_in * 9];
602                for ky in 0..3usize {
603                    for kx in 0..3usize {
604                        let iy = (2 * oy + ky) as isize - 1;
605                        let ix = (2 * ox + kx) as isize - 1;
606                        if iy < 0 || ix < 0 || iy >= g as isize || ix >= g as isize {
607                            continue; // zero padding
608                        }
609                        let src = &x[((iy as usize) * g + ix as usize) * c_in..];
610                        for c in 0..c_in {
611                            dst[c * 9 + ky * 3 + kx] = src[c];
612                        }
613                    }
614                }
615            }
616        }
617        (out, og)
618    }
619
620    /// Forward ONE view (the 728 main image at g=52, or one 504 crop tile at g=36):
621    /// host patch rows [g*g, 588] -> device [rows, out_width] projector output,
622    /// rows = (g/4 rounded per the two stride-2 convs)^2 (169 or 81).
623    pub fn forward(
624        &self,
625        e: &Engine,
626        patches: &[f32],
627        g: usize,
628    ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
629        let n = g * g;
630        assert_eq!(patches.len(), n * SV_PATCH_IN, "patch buffer shape");
631        if n > 12288 {
632            return Err(format!(
633                "step vision segment {n} patches exceeds the sdpa shared-memory ceiling (12288)"
634            )
635            .into());
636        }
637        let dbg = std::env::var("MEMRA_VISION_DEBUG").ok();
638        let dump = |tag: &str, buf: &[f32]| {
639            if let Some(dir) = dbg.as_deref() {
640                let raw: Vec<u8> = buf.iter().flat_map(|v| v.to_le_bytes()).collect();
641                let _ = std::fs::write(format!("{dir}/rust_{tag}.bin"), raw);
642            }
643        };
644        // patch embed (no bias) + learned abs posemb + ln_pre
645        let xd = e.htod(patches)?;
646        let embedded = self.linear_bias(e, &xd, &self.patch, n)?;
647        let pos = self.pos_for_grid(g);
648        let pos_d = e.htod(&pos)?;
649        let mut summed = e.zeros(n * SV_HIDDEN)?;
650        e.add(&embedded, &pos_d, &mut summed, n * SV_HIDDEN)?;
651        let mut x = e.zeros(n * SV_HIDDEN)?;
652        e.layer_norm_bias(
653            &summed,
654            &self.ln_pre_w,
655            &self.ln_pre_b,
656            &mut x,
657            SV_HIDDEN,
658            n,
659            LN_EPS,
660        )?;
661        if dbg.is_some() {
662            dump("pre_blocks", &e.dtoh(&x)?);
663        }
664        // 2D rope tables (EncoderRope2D): dim = head_dim = 96; inv_freq[i] =
665        // theta^(-2i/48), i in 0..24; per token (row, col) the FIRST 48 dims carry
666        // col-angles, the LAST 48 row-angles, each angle repeated for the INTERLEAVED
667        // pair (2i, 2i+1) inside its half. Same table every block/head.
668        let half = SV_HEAD_DIM / 2; // 48
669        let quarter = half / 2; // 24 distinct angles per half
670        let inv_freq: Vec<f32> = (0..quarter)
671            .map(|i| ROPE_THETA.powf(-2.0 * (i as f32) / half as f32))
672            .collect();
673        let mut cos_t = vec![0f32; n * half]; // [token, 48]: 24 col-angles then 24 row-angles
674        let mut sin_t = vec![0f32; n * half];
675        for t in 0..n {
676            let (row, col) = (t / g, t % g);
677            for i in 0..quarter {
678                let ac = col as f32 * inv_freq[i];
679                let ar = row as f32 * inv_freq[i];
680                cos_t[t * half + i] = ac.cos();
681                sin_t[t * half + i] = ac.sin();
682                cos_t[t * half + quarter + i] = ar.cos();
683                sin_t[t * half + quarter + i] = ar.sin();
684            }
685        }
686        let scale = 1.0 / (SV_HEAD_DIM as f32).sqrt();
687        for (ib, blk) in self.blocks.iter().enumerate() {
688            // attn: ln1 -> fused qkv -> rope2d(q,k) -> sdpa(non-causal, 1/sqrt(96))
689            //       -> out_proj -> ls_1 -> +res
690            let mut h = e.zeros(n * SV_HIDDEN)?;
691            e.layer_norm_bias(&x, &blk.ln1_w, &blk.ln1_b, &mut h, SV_HIDDEN, n, LN_EPS)?;
692            let qkv = self.linear_bias(e, &h, &blk.qkv, n)?;
693            let qkv_h = e.dtoh(&qkv)?;
694            let mut qh = vec![0f32; n * SV_HIDDEN];
695            let mut kh = vec![0f32; n * SV_HIDDEN];
696            let mut vh = vec![0f32; n * SV_HIDDEN];
697            for t in 0..n {
698                let row = &qkv_h[t * 3 * SV_HIDDEN..(t + 1) * 3 * SV_HIDDEN];
699                let dst = t * SV_HIDDEN;
700                vh[dst..dst + SV_HIDDEN].copy_from_slice(&row[2 * SV_HIDDEN..3 * SV_HIDDEN]);
701                for hd in 0..SV_HEADS {
702                    let o = hd * SV_HEAD_DIM;
703                    // interleaved pairs (2i, 2i+1) per half; angle index i within the
704                    // half's 24-entry table (col-half at 0, row-half at +quarter... the
705                    // cos_t row is [col 0..24, row 0..24], halves at dim 0..48 / 48..96)
706                    for hf in 0..2usize {
707                        for i in 0..quarter {
708                            let (c, s) = (
709                                cos_t[t * half + hf * quarter + i],
710                                sin_t[t * half + hf * quarter + i],
711                            );
712                            let d = hf * half + 2 * i;
713                            let (qa, qb) = (row[o + d], row[o + d + 1]);
714                            qh[dst + o + d] = qa * c - qb * s;
715                            qh[dst + o + d + 1] = qb * c + qa * s;
716                            let (ka, kb) = (row[SV_HIDDEN + o + d], row[SV_HIDDEN + o + d + 1]);
717                            kh[dst + o + d] = ka * c - kb * s;
718                            kh[dst + o + d + 1] = kb * c + ka * s;
719                        }
720                    }
721                }
722            }
723            let (qd, kd, vd) = (e.htod(&qh)?, e.htod(&kh)?, e.htod(&vh)?);
724            let mut od = e.zeros(n * SV_HIDDEN)?;
725            e.sdpa_naive(
726                &qd,
727                &kd,
728                &vd,
729                &mut od,
730                SV_HEAD_DIM,
731                SV_HEADS,
732                SV_HEADS,
733                n,
734                n,
735                scale,
736                false,
737            )?;
738            let attn = self.linear_bias(e, &od, &blk.proj, n)?;
739            // LayerScale then residual (host: per-channel gamma multiply)
740            let mut ah = e.dtoh(&attn)?;
741            for t in 0..n {
742                for c in 0..SV_HIDDEN {
743                    ah[t * SV_HIDDEN + c] *= blk.ls1[c];
744                }
745            }
746            let ad = e.htod(&ah)?;
747            let mut xr = e.zeros(n * SV_HIDDEN)?;
748            e.add(&x, &ad, &mut xr, n * SV_HIDDEN)?;
749            // mlp: ln2 -> c_fc -> quick_gelu -> c_proj -> ls_2 -> +res
750            let mut h2 = e.zeros(n * SV_HIDDEN)?;
751            e.layer_norm_bias(&xr, &blk.ln2_w, &blk.ln2_b, &mut h2, SV_HIDDEN, n, LN_EPS)?;
752            let f1 = self.linear_bias(e, &h2, &blk.fc, n)?;
753            let mut fh = e.dtoh(&f1)?;
754            for v in fh.iter_mut() {
755                // quick_gelu(x) = x * sigmoid(1.702 x) — NOT the tanh approximation.
756                *v = *v / (1.0 + (-1.702 * *v).exp());
757            }
758            let fd = e.htod(&fh)?;
759            let f2 = self.linear_bias(e, &fd, &blk.cproj, n)?;
760            let mut mh = e.dtoh(&f2)?;
761            for t in 0..n {
762                for c in 0..SV_HIDDEN {
763                    mh[t * SV_HIDDEN + c] *= blk.ls2[c];
764                }
765            }
766            let md = e.htod(&mh)?;
767            let mut xn = e.zeros(n * SV_HIDDEN)?;
768            e.add(&xr, &md, &mut xn, n * SV_HIDDEN)?;
769            x = xn;
770            if dbg.is_some() && ib == 0 {
771                dump("blk0", &e.dtoh(&x)?);
772            }
773        }
774        // NO ln_post (vision_config.use_ln_post = false)
775        if dbg.is_some() {
776            dump("post_blocks", &e.dtoh(&x)?);
777        }
778        // head: [n, 1536] as [1536, g, g] -> downsampler1 -> downsampler2 (im2col +
779        // GEMM each) -> [og*og, 6144] rows -> vit_large_projector
780        let xh = e.dtoh(&x)?;
781        let (col1, g1) = Self::im2col(&xh, g, SV_HIDDEN);
782        let c1 = e.htod(&col1)?;
783        let mut y1 = e.linear(
784            &c1,
785            &self.down1.w,
786            g1 * g1,
787            self.down1.c_in * 9,
788            self.down1.c_out,
789        )?;
790        for r in 0..g1 * g1 {
791            e.add_row_inplace(
792                &mut y1,
793                &self.down1.b,
794                self.down1.c_out,
795                r * self.down1.c_out,
796            )?;
797        }
798        let y1h = e.dtoh(&y1)?;
799        let (col2, g2) = Self::im2col(&y1h, g1, self.down2.c_in);
800        let c2 = e.htod(&col2)?;
801        let mut y2 = e.linear(
802            &c2,
803            &self.down2.w,
804            g2 * g2,
805            self.down2.c_in * 9,
806            self.down2.c_out,
807        )?;
808        for r in 0..g2 * g2 {
809            e.add_row_inplace(
810                &mut y2,
811                &self.down2.b,
812                self.down2.c_out,
813                r * self.down2.c_out,
814            )?;
815        }
816        if dbg.is_some() {
817            dump("downsampled", &e.dtoh(&y2)?);
818        }
819        let out = self.linear_bias(e, &y2, &self.proj, g2 * g2)?;
820        if dbg.is_some() {
821            dump("projected", &e.dtoh(&out)?);
822        }
823        Ok(out)
824    }
825
826    /// Forward one whole unit (tiles first, then the main view — the vendor merge
827    /// order) into `rows` at `row_off * out_width`. Returns rows written.
828    pub fn forward_unit(
829        &self,
830        e: &Engine,
831        unit: &StepVisionUnit,
832        rows: &mut CudaSlice<f32>,
833        row_off: usize,
834    ) -> Result<usize, Box<dyn std::error::Error>> {
835        let w = self.out_width();
836        let mut off = row_off;
837        for tile in &unit.tiles {
838            let emb = self.forward(e, tile, SV_GRID_TILE)?;
839            e.dtod_copy_into(&emb, rows, off * w)?;
840            off += SV_TILE_ROWS;
841        }
842        let emb = self.forward(e, &unit.main, SV_GRID_MAIN)?;
843        e.dtod_copy_into(&emb, rows, off * w)?;
844        off += SV_MAIN_ROWS;
845        Ok(off - row_off)
846    }
847}
848
849#[cfg(test)]
850mod tests {
851    use super::*;
852
853    /// The tiling plan law, pinned to processing_step3.py arithmetic by hand-checked
854    /// cells (each row worked from the vendor code at the pinned rev).
855    #[test]
856    fn tiling_plan_cells() {
857        // small square-ish: no tiles (window 0), 169 + 2 prompt tokens
858        let p = plan_for_dims(600, 400);
859        assert_eq!((p.n_tiles, p.n_prompt_tokens()), (0, 171));
860        let p = plan_for_dims(728, 728);
861        assert_eq!(p.n_tiles, 0);
862        // small extreme-aspect (long <= 728, ratio > 1.5): window = short
863        // 700x300: snap 700 -> 2.33 ratio -> decimal 0.33 > 0.2 -> 3 windows = 900;
864        // 300 -> 1 window; tiles 3x1, newline after id 2 popped (trailing) -> 0 newlines
865        let p = plan_for_dims(700, 300);
866        assert_eq!(p.n_tiles, 3);
867        assert_eq!(p.newline_mask, vec![false, false, false]);
868        // 1600x900: window 504 (long > 728, ratio 1.78 <= 4); 1600/504 = 3.17 ->
869        // 3 cols (0.17 <= 0.2); 900/504 = 1.79 -> 2 rows (0.79 > 0.2);
870        // 6 tiles, newlines after ids 2 and 5, 5 popped -> mask true only at 2
871        let p = plan_for_dims(1600, 900);
872        assert_eq!(p.n_tiles, 6);
873        assert_eq!(
874            p.newline_mask,
875            vec![false, false, true, false, false, false]
876        );
877        // prompt tokens: 6*(81+2) + 1 newline + 169 + 2 = 670
878        assert_eq!(p.n_prompt_tokens(), 670);
879        // tiny extreme-aspect pads to square then window 0
880        let p = plan_for_dims(200, 20);
881        assert_eq!(p.n_tiles, 0);
882        // huge: capped to 3024 first; 4000x1000 -> 3024x756, ratio 4.0 (NOT > 4) ->
883        // window 504; 3024/504 = 6 cols; 756/504 = 1.5 -> decimal 0.5 > 0.2 -> 2 rows
884        let p = plan_for_dims(4000, 1000);
885        assert_eq!(p.n_tiles, 12);
886        assert_eq!(p.n_rows(), 12 * 81 + 169);
887    }
888
889    /// im2col output geometry: 52 -> 26 -> 13 and 36 -> 18 -> 9 (the 169/81 law).
890    #[test]
891    fn downsampler_geometry() {
892        let x = vec![0f32; 52 * 52 * 4];
893        let (_, og) = StepVisionTower::im2col(&x, 52, 4);
894        assert_eq!(og, 26);
895        let x = vec![0f32; 26 * 26 * 4];
896        let (_, og) = StepVisionTower::im2col(&x, 26, 4);
897        assert_eq!(og, 13);
898        let x = vec![0f32; 36 * 36 * 4];
899        let (_, og) = StepVisionTower::im2col(&x, 36, 4);
900        assert_eq!(og, 18);
901        let x = vec![0f32; 18 * 18 * 4];
902        let (_, og) = StepVisionTower::im2col(&x, 18, 4);
903        assert_eq!(og, 9);
904    }
905
906    /// im2col values: a 3x3 map with c_in 1, identity-checkable by hand.
907    #[test]
908    fn im2col_values() {
909        // map [1,3,3] = [[1,2,3],[4,5,6],[7,8,9]]; og = 2; output position (0,0)
910        // covers input rows -1..2 x -1..2 (zero pad): window [[0,0,0],[0,1,2],[0,4,5]]
911        let x: Vec<f32> = (1..=9).map(|v| v as f32).collect();
912        let (col, og) = StepVisionTower::im2col(&x, 3, 1);
913        assert_eq!(og, 2);
914        assert_eq!(&col[0..9], &[0., 0., 0., 0., 1., 2., 0., 4., 5.]);
915        // position (1,1): rows 1..4 x 1..4 -> [[5,6,0],[8,9,0],[0,0,0]]
916        assert_eq!(&col[27..36], &[5., 6., 0., 8., 9., 0., 0., 0., 0.]);
917    }
918
919    fn png_bytes(w: u32, h: u32) -> Vec<u8> {
920        let img = image::RgbImage::from_fn(w, h, |x, y| {
921            image::Rgb([(x % 251) as u8, (y % 241) as u8, ((x + y) % 253) as u8])
922        });
923        let mut buf = std::io::Cursor::new(Vec::new());
924        img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
925        buf.into_inner()
926    }
927
928    /// The decoded prep must land on the header plan exactly (pad runs render from the
929    /// plan; decode_pending_vision refuses on drift, so drift here is a shipped bug).
930    #[test]
931    fn prep_matches_plan() {
932        for (w, h) in [(64u32, 64u32), (1600, 900), (700, 300), (900, 3000)] {
933            let bytes = png_bytes(w, h);
934            let plan = step_plan_image(&bytes).unwrap();
935            let prep = step_prep_image(&bytes).unwrap();
936            assert_eq!(prep.tiles.len(), plan.n_tiles, "{w}x{h} tile count");
937            assert_eq!(prep.newline_mask, plan.newline_mask, "{w}x{h} newline mask");
938            assert_eq!(prep.main.len(), SV_GRID_MAIN * SV_GRID_MAIN * SV_PATCH_IN);
939            for t in &prep.tiles {
940                assert_eq!(t.len(), SV_GRID_TILE * SV_GRID_TILE * SV_PATCH_IN);
941            }
942            assert_eq!(prep.n_rows(), plan.n_rows());
943        }
944    }
945
946    /// Patch rows carry the CLIP normalization: a flat mid-gray image lands every
947    /// channel plane on its exact normalized constant.
948    #[test]
949    fn patchify_normalization() {
950        let img = image::RgbImage::from_pixel(
951            SV_IMAGE_SIZE as u32,
952            SV_IMAGE_SIZE as u32,
953            image::Rgb([128, 128, 128]),
954        );
955        let rows = patchify(&img, SV_GRID_MAIN);
956        let want: Vec<f32> = (0..3).map(|c| (128.0 / 255.0 - MEAN[c]) / STD[c]).collect();
957        let r0 = &rows[..SV_PATCH_IN];
958        for c in 0..3 {
959            for i in 0..SV_PATCH * SV_PATCH {
960                assert!((r0[c * SV_PATCH * SV_PATCH + i] - want[c]).abs() < 1e-6);
961            }
962        }
963    }
964}