Skip to main content

rlx_sam/
image_encoder.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! SAM v1 ViT image encoder HIR builder.
17//!
18//! Mirrors `candle-transformers/src/models/segment_anything/image_encoder.rs`.
19//! Decomposes attention into primitives (rlx-ir's `attention_` op is a
20//! black box and can't host the inline rel-pos add SAM uses).
21//!
22//! Two attention modes:
23//!   - **Global** (window_size == 0): full S = hw·hw attention. Used by
24//!     blocks listed in `global_attn_indexes`.
25//!   - **Windowed** (window_size > 0): pad spatial dims to a multiple
26//!     of `window_size` via concat-with-zeros, reshape into
27//!     `[B·nW, ws, ws, C]`, attention within each window, reverse the
28//!     reshape, narrow off the padding.
29//!
30//! The neck (Conv2d 1×1 + LN2d + Conv2d 3×3 + LN2d → `[B, 256, hw, hw]`)
31//! is appended to the encoder HIR via [`rlx_core::vision_ops_ir`].
32
33use super::config::{SAM_EMBED_HW, SamEncoderConfig};
34use super::preprocess::{SamPreprocessWeights, extract_preprocess_weights};
35use anyhow::{Result, anyhow, ensure};
36use rlx_core::vision_ops_ir::{bhwc_to_nchw, conv2d_no_bias, layer_norm2d_nchw};
37use rlx_core::weight_map::WeightMap;
38use rlx_ir::HirGraphExt;
39use rlx_ir::hir::{HirModule, HirMut, HirNodeId};
40use rlx_ir::*;
41use std::collections::HashMap;
42
43struct SamBuilder {
44    hir: HirModule,
45    params: HashMap<String, Vec<f32>>,
46}
47
48impl SamBuilder {
49    fn new(name: &str) -> Self {
50        Self {
51            hir: HirModule::new(name),
52            params: HashMap::new(),
53        }
54    }
55
56    fn m(&mut self) -> HirMut<'_> {
57        HirMut::new(&mut self.hir)
58    }
59}
60
61#[allow(dead_code)]
62fn lower_hir(hir: HirModule) -> Result<Graph> {
63    Graph::from_hir(hir).map_err(|e| anyhow!("{e}"))
64}
65
66/// Build the SAM ViT image-encoder HIR (body + neck).
67///
68/// Input: `"hidden"` shape `[1, hw·hw, embed_dim]` — patch tokens from
69/// [`crate::sam::preprocess::assemble_patch_tokens`].
70///
71/// Output: `[1, out_chans, hw, hw]` NCHW image embeddings.
72pub fn build_sam_encoder_hir(
73    cfg: &SamEncoderConfig,
74    weights: &mut WeightMap,
75) -> Result<(HirModule, HashMap<String, Vec<f32>>, SamPreprocessWeights)> {
76    let mut b = SamBuilder::new("sam_image_encoder");
77    let f = DType::F32;
78
79    // Host-side preprocess weights (patch projection + abs pos embed).
80    // Drain these *before* iterating blocks so the keys are gone when
81    // we later assert the WeightMap is empty.
82    let preprocess = extract_preprocess_weights(weights, cfg)?;
83
84    let e = cfg.embed_dim;
85    let nh = cfg.num_heads;
86    let dh = cfg.head_dim();
87    let scale = 1.0 / (dh as f32).sqrt();
88    let eps = cfg.layer_norm_eps as f32;
89    let hw = SAM_EMBED_HW;
90    let s = hw * hw; // 64·64 = 4096
91
92    // Input: pre-assembled patch tokens [1, 4096, E].
93    let hidden_input = b.m().input("hidden", Shape::new(&[1, s, e], f));
94
95    let mut x = hidden_input;
96    for layer_idx in 0..cfg.depth {
97        let lp = format!("image_encoder.blocks.{layer_idx}");
98        let is_global = cfg.global_attn_indexes.contains(&layer_idx);
99        let ws = if is_global { 0 } else { cfg.window_size };
100
101        // ── Pre-LN1 ──
102        let n1_g = load_p(&mut b, weights, &format!("{lp}.norm1.weight"), false)?;
103        let n1_b = load_p(&mut b, weights, &format!("{lp}.norm1.bias"), false)?;
104        let normed = b.m().ln(x, n1_g, n1_b, eps);
105
106        // ── Attention (windowed or global) ──
107        let attn_out = if ws == 0 {
108            attention_global(
109                &mut b,
110                weights,
111                &lp,
112                normed,
113                e,
114                nh,
115                dh,
116                scale,
117                hw,
118                cfg.use_rel_pos,
119                cfg.qkv_bias,
120            )?
121        } else {
122            attention_windowed(
123                &mut b,
124                weights,
125                &lp,
126                normed,
127                e,
128                nh,
129                dh,
130                scale,
131                hw,
132                ws,
133                cfg.use_rel_pos,
134                cfg.qkv_bias,
135            )?
136        };
137
138        // Residual
139        x = b.m().add(x, attn_out);
140
141        // ── Pre-LN2 + MLP (4× expansion, plain GELU) ──
142        let n2_g = load_p(&mut b, weights, &format!("{lp}.norm2.weight"), false)?;
143        let n2_b = load_p(&mut b, weights, &format!("{lp}.norm2.bias"), false)?;
144        let normed2 = b.m().ln(x, n2_g, n2_b, eps);
145
146        let fc1_w = load_p(&mut b, weights, &format!("{lp}.mlp.lin1.weight"), true)?;
147        let fc1_b = load_p(&mut b, weights, &format!("{lp}.mlp.lin1.bias"), false)?;
148        let fc2_w = load_p(&mut b, weights, &format!("{lp}.mlp.lin2.weight"), true)?;
149        let fc2_b = load_p(&mut b, weights, &format!("{lp}.mlp.lin2.bias"), false)?;
150
151        let up_mm = b.m().mm(normed2, fc1_w);
152        let up = b.m().add(up_mm, fc1_b);
153        // candle's `Activation::Gelu` dispatches to `Tensor::gelu_erf()`
154        // — the exact erf form — for SAM's MlpBlock. Use the matching
155        // erf kernel here.
156        let act = b.m().gelu(up);
157        let down_mm = b.m().mm(act, fc2_w);
158        let ffn = b.m().add(down_mm, fc2_b);
159
160        x = b.m().add(x, ffn);
161    }
162
163    // ── Neck: BHWC → NCHW, 1×1 conv, LN2d, 3×3 conv, LN2d ──
164    // Meta's `segment_anything/modeling/image_encoder.py` uses
165    // `bias=False` on both neck Conv2ds, so the official safetensors
166    // (e.g. `sam_vit_b_01ec64`) only contain `neck.{0,2}.weight` — no
167    // biases. We mirror that here with `conv2d_no_bias`.
168    let oc = cfg.out_chans;
169    let nchw = bhwc_to_nchw(&mut b.m(), x, 1, hw, hw, e);
170    let c1_w = load_p(&mut b, weights, "image_encoder.neck.0.weight", false)?;
171    let feat = conv2d_no_bias(&mut b.m(), nchw, c1_w, 1, oc, 1, 1, [1, 1], [0, 0], hw, hw);
172    let ln1_g = load_p(&mut b, weights, "image_encoder.neck.1.weight", false)?;
173    let ln1_b = load_p(&mut b, weights, "image_encoder.neck.1.bias", false)?;
174    let feat = layer_norm2d_nchw(&mut b.m(), feat, ln1_g, ln1_b, eps);
175    let c2_w = load_p(&mut b, weights, "image_encoder.neck.2.weight", false)?;
176    let feat = conv2d_no_bias(&mut b.m(), feat, c2_w, 1, oc, 3, 3, [1, 1], [1, 1], hw, hw);
177    let ln2_g = load_p(&mut b, weights, "image_encoder.neck.3.weight", false)?;
178    let ln2_b = load_p(&mut b, weights, "image_encoder.neck.3.bias", false)?;
179    let out = layer_norm2d_nchw(&mut b.m(), feat, ln2_g, ln2_b, eps);
180
181    b.hir.set_outputs(vec![out]);
182
183    Ok((b.hir, b.params, preprocess))
184}
185
186/// Lowered graph wrapper for legacy callers (via [`super::flow::SamEncoderFlow`]).
187pub fn build_sam_encoder_graph(
188    cfg: &SamEncoderConfig,
189    weights: &mut WeightMap,
190) -> Result<(Graph, HashMap<String, Vec<f32>>, SamPreprocessWeights)> {
191    let built = super::flow::build_sam_encoder_built(cfg, weights)?;
192    let (graph, params) = rlx_core::flow_util::graph_from_built(built.model)?;
193    Ok((graph, params, built.preprocess))
194}
195
196/// Global-attention block: full self-attention over all `hw·hw` tokens.
197#[allow(clippy::too_many_arguments)]
198fn attention_global(
199    sb: &mut SamBuilder,
200    w: &mut WeightMap,
201    lp: &str,
202    x: HirNodeId, // [1, S, E]
203    e: usize,
204    nh: usize,
205    dh: usize,
206    scale: f32,
207    hw: usize,
208    use_rel_pos: bool,
209    qkv_bias: bool,
210) -> Result<HirNodeId> {
211    let s = hw * hw;
212    decomposed_attention(
213        sb,
214        w,
215        lp,
216        x,
217        e,
218        nh,
219        dh,
220        scale,
221        hw,
222        hw,
223        s,
224        1,
225        use_rel_pos,
226        qkv_bias,
227    )
228}
229
230/// Windowed-attention block: pad → partition into `nW = (hw_p/ws)²`
231/// windows → attention within each window → reverse partition → crop.
232#[allow(clippy::too_many_arguments)]
233fn attention_windowed(
234    sb: &mut SamBuilder,
235    w: &mut WeightMap,
236    lp: &str,
237    x: HirNodeId, // [1, S, E] flat (= [1, hw, hw, E] BHWC, flattened)
238    e: usize,
239    nh: usize,
240    dh: usize,
241    scale: f32,
242    hw: usize,
243    ws: usize,
244    use_rel_pos: bool,
245    qkv_bias: bool,
246) -> Result<HirNodeId> {
247    // Restore spatial: [1, S, E] → [1, hw, hw, E]
248    let bhwc = sb.m().reshape_(x, vec![1, hw as i64, hw as i64, e as i64]);
249
250    let pad = (ws - hw % ws) % ws;
251    let hw_p = hw + pad;
252    let n_win_per_side = hw_p / ws;
253    let n_win = n_win_per_side * n_win_per_side;
254
255    // Pad with concat-zeros along axes 1, 2.
256    let padded = if pad > 0 {
257        let z_h = pad_zero_param(sb, &format!("{lp}.attn._pad_h"), &[1, pad, hw, e]);
258        let p1 = sb.m().concat_(vec![bhwc, z_h], 1); // [1, hw_p, hw, E]
259        let z_w = pad_zero_param(sb, &format!("{lp}.attn._pad_w"), &[1, hw_p, pad, e]);
260        sb.m().concat_(vec![p1, z_w], 2) // [1, hw_p, hw_p, E]
261    } else {
262        bhwc
263    };
264
265    // [1, hw_p, hw_p, E] → [1, nw, ws, nw, ws, E] → transpose(2,3)
266    //   → [1, nw, nw, ws, ws, E] → reshape [nw², ws, ws, E]
267    let reshaped = sb.m().reshape_(
268        padded,
269        vec![
270            1,
271            n_win_per_side as i64,
272            ws as i64,
273            n_win_per_side as i64,
274            ws as i64,
275            e as i64,
276        ],
277    );
278    let transposed = sb.m().transpose_(reshaped, vec![0, 1, 3, 2, 4, 5]);
279    let windowed = sb.m().reshape_(
280        transposed,
281        vec![n_win as i64, ws as i64, ws as i64, e as i64],
282    );
283    // Flatten spatial for the attention: [nw², ws², E]
284    let win_flat = sb
285        .m()
286        .reshape_(windowed, vec![n_win as i64, (ws * ws) as i64, e as i64]);
287
288    // Run decomposed attention. Window has spatial dims (ws, ws);
289    // sequence length S = ws·ws; batch dim = n_win.
290    let attn_out = decomposed_attention(
291        sb,
292        w,
293        lp,
294        win_flat,
295        e,
296        nh,
297        dh,
298        scale,
299        ws,
300        ws,
301        ws * ws,
302        n_win,
303        use_rel_pos,
304        qkv_bias,
305    )?;
306    // attn_out: [nw², ws·ws, E]
307
308    // Reverse: [nw², ws², E] → [nw², ws, ws, E] → [1, nw, nw, ws, ws, E]
309    //   → transpose(2,3) → [1, nw, ws, nw, ws, E] → [1, hw_p, hw_p, E]
310    let un = sb
311        .m()
312        .reshape_(attn_out, vec![n_win as i64, ws as i64, ws as i64, e as i64]);
313    let un = sb.m().reshape_(
314        un,
315        vec![
316            1,
317            n_win_per_side as i64,
318            n_win_per_side as i64,
319            ws as i64,
320            ws as i64,
321            e as i64,
322        ],
323    );
324    let un = sb.m().transpose_(un, vec![0, 1, 3, 2, 4, 5]);
325    let un = sb
326        .m()
327        .reshape_(un, vec![1, hw_p as i64, hw_p as i64, e as i64]);
328    // Crop off the padding
329    let un = if pad > 0 {
330        let cropped_h = sb.m().narrow_(un, 1, 0, hw);
331        sb.m().narrow_(cropped_h, 2, 0, hw)
332    } else {
333        un
334    };
335    // Flatten back to [1, S, E]
336    Ok(sb.m().reshape_(un, vec![1, (hw * hw) as i64, e as i64]))
337}
338
339/// Decomposed multi-head attention with optional decomposed rel_pos.
340/// Input `[B, S, E]`; output `[B, S, E]`.
341///
342/// `h, w` are the spatial dims of the attention window (S = h·w).
343/// For windowed attention `B = n_win`, `h = w = ws`. For global,
344/// `B = 1`, `h = w = hw`.
345#[allow(clippy::too_many_arguments)]
346fn decomposed_attention(
347    sb: &mut SamBuilder,
348    w: &mut WeightMap,
349    lp: &str,
350    x: HirNodeId, // [B, S, E]
351    e: usize,
352    nh: usize,
353    dh: usize,
354    scale: f32,
355    h: usize,
356    w_dim: usize,
357    s: usize, // = h * w_dim
358    batch: usize,
359    use_rel_pos: bool,
360    qkv_bias: bool,
361) -> Result<HirNodeId> {
362    // 1) QKV projection. Bias param is loaded *before* the mm so its
363    //    HirNodeId is lower — `FuseMatMulBiasAct` walks nodes in topo
364    //    order and assumes the bias has been copied into the new id
365    //    map before the matmul is rewritten.
366    let qkv_w_node = load_p(sb, w, &format!("{lp}.attn.qkv.weight"), true)?;
367    let qkv_b_node = if qkv_bias {
368        Some(load_p(sb, w, &format!("{lp}.attn.qkv.bias"), false)?)
369    } else {
370        None
371    };
372    let qkv_mm = sb.m().mm(x, qkv_w_node); // [B, S, 3E]
373    let qkv = if let Some(b) = qkv_b_node {
374        sb.m().add(qkv_mm, b)
375    } else {
376        qkv_mm
377    };
378
379    // 2) Reshape & permute to [3, B·nh, S, dh].
380    //    [B, S, 3E] → [B, S, 3, nh, dh] → permute(2,0,3,1,4) → [3, B, nh, S, dh]
381    //    → reshape [3, B·nh, S, dh].
382    let qkv5 = sb
383        .m()
384        .reshape_(qkv, vec![batch as i64, s as i64, 3, nh as i64, dh as i64]);
385    let qkv_perm = sb.m().transpose_(qkv5, vec![2, 0, 3, 1, 4]); // [3, B, nh, S, dh]
386    let qkv_flat = sb
387        .m()
388        .reshape_(qkv_perm, vec![3, (batch * nh) as i64, s as i64, dh as i64]);
389    let q = sb.m().narrow_(qkv_flat, 0, 0, 1);
390    let q = sb
391        .m()
392        .reshape_(q, vec![(batch * nh) as i64, s as i64, dh as i64]);
393    let k = sb.m().narrow_(qkv_flat, 0, 1, 1);
394    let k = sb
395        .m()
396        .reshape_(k, vec![(batch * nh) as i64, s as i64, dh as i64]);
397    let v = sb.m().narrow_(qkv_flat, 0, 2, 1);
398    let v = sb
399        .m()
400        .reshape_(v, vec![(batch * nh) as i64, s as i64, dh as i64]);
401
402    // 3) attn = (q * scale) @ k.T   shape [B·nh, S, S]
403    let scale_node = scalar_param(sb, &format!("{lp}.attn._scale"), scale);
404    let q_scaled = sb.m().mul(q, scale_node);
405    let k_t = sb.m().transpose_(k, vec![0, 2, 1]); // [B·nh, dh, S]
406    let scores = sb.m().mm(q_scaled, k_t); // [B·nh, S, S]
407
408    // 4) Optionally add decomposed rel_pos.
409    let scores = if use_rel_pos {
410        // rel_pos_h: [2h-1, dh]  rel_pos_w: [2w-1, dh]
411        // We pre-resolve get_rel_pos() host-side into r_h: [h, h, dh] and
412        // r_w: [w, w, dh] indexed buffers (cheap, ≤ 27×27×64 elements).
413        let (mut r_h_data, mut r_w_data) = extract_rel_pos(w, lp, h, w_dim, dh)?;
414        // Bisect helpers:
415        //   RLX_SAM_DEBUG_ZERO_RELPOS=1  zero both r_h and r_w
416        //   RLX_SAM_DEBUG_ZERO_RELH=1    zero only r_h (keep rel_w)
417        //   RLX_SAM_DEBUG_ZERO_RELW=1    zero only r_w (keep rel_h)
418        if rlx_ir::env::flag("RLX_SAM_DEBUG_ZERO_RELPOS") {
419            r_h_data.iter_mut().for_each(|v| *v = 0.0);
420            r_w_data.iter_mut().for_each(|v| *v = 0.0);
421        }
422        if rlx_ir::env::flag("RLX_SAM_DEBUG_ZERO_RELH") {
423            r_h_data.iter_mut().for_each(|v| *v = 0.0);
424        }
425        if rlx_ir::env::flag("RLX_SAM_DEBUG_ZERO_RELW") {
426            r_w_data.iter_mut().for_each(|v| *v = 0.0);
427        }
428        let r_h_node = const_param(
429            sb,
430            &format!("{lp}.attn._rel_h_indexed"),
431            &[h, h, dh],
432            r_h_data,
433        );
434        let r_w_node = const_param(
435            sb,
436            &format!("{lp}.attn._rel_w_indexed"),
437            &[w_dim, w_dim, dh],
438            r_w_data,
439        );
440        add_decomposed_rel_pos(sb, scores, q, r_h_node, r_w_node, batch, nh, h, w_dim, dh)?
441    } else {
442        scores
443    };
444
445    // 5) softmax over last axis
446    let attn_w = sb.m().sm(scores, -1);
447
448    // 6) attn @ V → [B·nh, S, dh]
449    let attn_v = sb.m().mm(attn_w, v);
450
451    // 7) Reverse the head split: [B·nh, S, dh] → [B, nh, S, dh] → [B, S, nh, dh] → [B, S, E]
452    let reshaped = sb
453        .m()
454        .reshape_(attn_v, vec![batch as i64, nh as i64, s as i64, dh as i64]);
455    let perm = sb.m().transpose_(reshaped, vec![0, 2, 1, 3]); // [B, S, nh, dh]
456    let merged = sb
457        .m()
458        .reshape_(perm, vec![batch as i64, s as i64, e as i64]);
459
460    // 8) Output projection (always biased).
461    let proj_w = load_p(sb, w, &format!("{lp}.attn.proj.weight"), true)?;
462    let proj_b = load_p(sb, w, &format!("{lp}.attn.proj.bias"), false)?;
463    let proj_mm = sb.m().mm(merged, proj_w);
464    Ok(sb.m().add(proj_mm, proj_b))
465}
466
467/// Add decomposed relative positional bias to attention scores.
468///
469/// Math (per the SAM paper, candle's `add_decomposed_rel_pos`):
470///   r_q = q.reshape(B·nh, h, w, dh)
471///   rel_h[bhw,c] = sum_c r_q[bhw,c] · r_h_indexed[hq, hk, c]    → [B·nh, h, w, h]
472///   rel_w[bhw,c] = sum_c r_q[bhw,c] · r_w_indexed[wq, wk, c]    → [B·nh, h, w, w]
473///   scores += rel_h.unsqueeze(4) + rel_w.unsqueeze(3)           → [B·nh, h, w, h, w]
474///   scores.reshape(B·nh, h·w, h·w)
475#[allow(clippy::too_many_arguments)]
476fn add_decomposed_rel_pos(
477    sb: &mut SamBuilder,
478    scores: HirNodeId, // [B·nh, S, S]
479    q: HirNodeId,      // [B·nh, S, dh]
480    r_h: HirNodeId,    // [h, h, dh]  (pre-indexed)
481    r_w: HirNodeId,    // [w, w, dh]
482    batch: usize,
483    nh: usize,
484    h: usize,
485    w: usize,
486    dh: usize,
487) -> Result<HirNodeId> {
488    let bh = batch * nh;
489    // r_q: [bh, h, w, dh]
490    let r_q = sb
491        .m()
492        .reshape_(q, vec![bh as i64, h as i64, w as i64, dh as i64]);
493
494    // rel_h: "bhwc, hkc -> bhwk".
495    // Unrolled-per-h_q: rlx-cpu's batched 3-D matmul gives subtly wrong
496    // results in this exact shape regime, so we lower the einsum to
497    // `h` independent 2-D matmuls (one per h_q index) and `sb.m().concat_`
498    // them back. Each per-h_q matmul is `[bh, w, dh] @ [dh, h_k]`,
499    // which uses the well-tested flat sgemm path (rhs has no batch
500    // dim, only the lhs does — that's the case the Sgemm flatten
501    // trick was designed for).
502    let mut rel_h_slices: Vec<HirNodeId> = Vec::with_capacity(h);
503    for h_q in 0..h {
504        // r_q at h_q: narrow axis 1, then squeeze.
505        let rq_slice = sb.m().narrow_(r_q, 1, h_q, 1); // [bh, 1, w, dh]
506        let rq_slice = sb
507            .m()
508            .reshape_(rq_slice, vec![bh as i64, w as i64, dh as i64]);
509        // r_h at h_q: narrow axis 0, then squeeze + transpose to [dh, h].
510        let rh_slice = sb.m().narrow_(r_h, 0, h_q, 1); // [1, h, dh]
511        let rh_slice = sb.m().reshape_(rh_slice, vec![h as i64, dh as i64]); // [h_k, dh]
512        let rh_t = sb.m().transpose_(rh_slice, vec![1, 0]); // [dh, h_k]
513        let mm = sb.m().mm(rq_slice, rh_t); // [bh, w, h_k]
514        // Add a leading length-1 axis so we can concat into [bh, h, w, h_k].
515        let mm5 = sb.m().reshape_(mm, vec![bh as i64, 1, w as i64, h as i64]);
516        rel_h_slices.push(mm5);
517    }
518    let rel_h_4d = sb.m().concat_(rel_h_slices, 1); // [bh, h, w, h]
519
520    // rel_w: same idea, w_q as the unrolled axis.
521    let mut rel_w_slices: Vec<HirNodeId> = Vec::with_capacity(w);
522    for w_q in 0..w {
523        let rq_slice = sb.m().narrow_(r_q, 2, w_q, 1); // [bh, h, 1, dh]
524        let rq_slice = sb
525            .m()
526            .reshape_(rq_slice, vec![bh as i64, h as i64, dh as i64]);
527        let rw_slice = sb.m().narrow_(r_w, 0, w_q, 1); // [1, w, dh]
528        let rw_slice = sb.m().reshape_(rw_slice, vec![w as i64, dh as i64]); // [w_k, dh]
529        let rw_t = sb.m().transpose_(rw_slice, vec![1, 0]); // [dh, w_k]
530        let mm = sb.m().mm(rq_slice, rw_t); // [bh, h, w_k]
531        let mm5 = sb.m().reshape_(mm, vec![bh as i64, h as i64, 1, w as i64]);
532        rel_w_slices.push(mm5);
533    }
534    let rel_w_4d = sb.m().concat_(rel_w_slices, 2); // [bh, h, w, w]
535
536    // Broadcast-add into the [bh, h, w, h, w] view of scores.
537    //
538    // History: rlx-cpu's BiasAdd misroute for mid-shape singletons is
539    // now fixed (`is_trailing_bias_broadcast`), so CPU uses simple
540    // unsqueeze+add. The rlx-metal BinaryBroadcast MSL kernel exists
541    // but produces wrong results on the SAM rel_pos pattern (suspect:
542    // setBytes alignment of inline `constant uint*` for ranks > 4 —
543    // needs focused debugging). Until then, materialise both rel
544    // tensors to the full output shape via `concat`-tile so the add
545    // is a same-shape op and works on every backend.
546    let scores_5d = sb.m().reshape_(
547        scores,
548        vec![bh as i64, h as i64, w as i64, h as i64, w as i64],
549    );
550    let rel_h_5d = sb
551        .m()
552        .reshape_(rel_h_4d, vec![bh as i64, h as i64, w as i64, h as i64, 1]);
553    let rel_h_tiled = {
554        let mut copies = Vec::with_capacity(w);
555        for _ in 0..w {
556            copies.push(rel_h_5d);
557        }
558        sb.m().concat_(copies, 4) // [bh, h, w, h, w]
559    };
560    let rel_w_5d = sb
561        .m()
562        .reshape_(rel_w_4d, vec![bh as i64, h as i64, w as i64, 1, w as i64]);
563    let rel_w_tiled = {
564        let mut copies = Vec::with_capacity(h);
565        for _ in 0..h {
566            copies.push(rel_w_5d);
567        }
568        sb.m().concat_(copies, 3) // [bh, h, w, h, w]
569    };
570    let s1 = sb.m().add(scores_5d, rel_h_tiled);
571    let s2 = sb.m().add(s1, rel_w_tiled);
572    Ok(sb
573        .m()
574        .reshape_(s2, vec![bh as i64, (h * w) as i64, (h * w) as i64]))
575}
576
577/// Resolve candle's `get_rel_pos()` host-side into per-axis bias
578/// tables of shape `[q_size, k_size, dh]` (here q_size == k_size).
579///
580/// Stored `rel_pos_h` has shape `[2·max(q,k)-1, dh]`; we gather along
581/// axis 0 using `relative_coords[i,j] = i - j + (k-1)` (since q==k,
582/// scale factors collapse to 1).
583fn extract_rel_pos(
584    weights: &mut WeightMap,
585    lp: &str,
586    h: usize,
587    w: usize,
588    dh: usize,
589) -> Result<(Vec<f32>, Vec<f32>)> {
590    let (rel_h_raw, rh_shape) = weights.take(&format!("{lp}.attn.rel_pos_h"))?;
591    let (rel_w_raw, rw_shape) = weights.take(&format!("{lp}.attn.rel_pos_w"))?;
592    ensure!(
593        rh_shape == vec![2 * h - 1, dh],
594        "{lp}.attn.rel_pos_h expected [{}, {dh}], got {rh_shape:?}",
595        2 * h - 1
596    );
597    ensure!(
598        rw_shape == vec![2 * w - 1, dh],
599        "{lp}.attn.rel_pos_w expected [{}, {dh}], got {rw_shape:?}",
600        2 * w - 1
601    );
602
603    let mut r_h = vec![0f32; h * h * dh];
604    for q in 0..h {
605        for k in 0..h {
606            let idx = (q as isize - k as isize + (h as isize - 1)) as usize;
607            let src = &rel_h_raw[idx * dh..(idx + 1) * dh];
608            let dst = &mut r_h[(q * h + k) * dh..(q * h + k + 1) * dh];
609            dst.copy_from_slice(src);
610        }
611    }
612    let mut r_w = vec![0f32; w * w * dh];
613    for q in 0..w {
614        for k in 0..w {
615            let idx = (q as isize - k as isize + (w as isize - 1)) as usize;
616            let src = &rel_w_raw[idx * dh..(idx + 1) * dh];
617            let dst = &mut r_w[(q * w + k) * dh..(q * w + k + 1) * dh];
618            dst.copy_from_slice(src);
619        }
620    }
621    Ok((r_h, r_w))
622}
623
624// ─── Neck (Conv2d 1×1 + LN2d + Conv2d 3×3 + LN2d) host-side ────────
625
626/// Weights for the four neck layers, kept on the host because rlx-ir
627/// doesn't have f32 forward Conv2d (and 3×3 padding=1 doesn't reduce
628/// to matmul).
629pub struct NeckWeights {
630    pub conv1_w: Vec<f32>, // [out_chans, embed_dim] (1×1 conv = per-channel linear)
631    pub ln1_g: Vec<f32>,   // [out_chans]
632    pub ln1_b: Vec<f32>,
633    pub conv2_w: Vec<f32>, // [out_chans, out_chans, 3, 3]
634    pub ln2_g: Vec<f32>,
635    pub ln2_b: Vec<f32>,
636    pub embed_dim: usize,
637    pub out_chans: usize,
638    pub eps: f32,
639}
640
641#[allow(dead_code)]
642fn extract_neck_weights(weights: &mut WeightMap, cfg: &SamEncoderConfig) -> Result<NeckWeights> {
643    let (conv1_w_raw, c1_shape) = weights.take("image_encoder.neck.0.weight")?;
644    ensure!(
645        c1_shape == vec![cfg.out_chans, cfg.embed_dim, 1, 1],
646        "neck.0.weight expected [{}, {}, 1, 1], got {c1_shape:?}",
647        cfg.out_chans,
648        cfg.embed_dim
649    );
650    let conv1_w = conv1_w_raw; // [out_chans, embed_dim] after flattening last two singleton dims
651    let (ln1_g, _) = weights.take("image_encoder.neck.1.weight")?;
652    let (ln1_b, _) = weights.take("image_encoder.neck.1.bias")?;
653    let (conv2_w, c2_shape) = weights.take("image_encoder.neck.2.weight")?;
654    ensure!(
655        c2_shape == vec![cfg.out_chans, cfg.out_chans, 3, 3],
656        "neck.2.weight expected [{}, {}, 3, 3], got {c2_shape:?}",
657        cfg.out_chans,
658        cfg.out_chans
659    );
660    let (ln2_g, _) = weights.take("image_encoder.neck.3.weight")?;
661    let (ln2_b, _) = weights.take("image_encoder.neck.3.bias")?;
662    Ok(NeckWeights {
663        conv1_w,
664        ln1_g,
665        ln1_b,
666        conv2_w,
667        ln2_g,
668        ln2_b,
669        embed_dim: cfg.embed_dim,
670        out_chans: cfg.out_chans,
671        eps: cfg.layer_norm_eps as f32,
672    })
673}
674
675/// Run the encoder neck on the host. `body_out` is the encoder body's
676/// output reshaped to `[hw·hw, embed_dim]` (BHWC flattened). Returns
677/// `[out_chans, hw, hw]` NCHW image embeddings.
678pub fn apply_neck_host(neck: &NeckWeights, body_out: &[f32], hw: usize) -> Vec<f32> {
679    let e = neck.embed_dim;
680    let oc = neck.out_chans;
681    let eps = neck.eps;
682
683    // 1) Conv 1×1: per-pixel linear projection from embed_dim → out_chans.
684    //    body_out is BHWC; treat as [hw·hw, embed_dim] and matmul by
685    //    conv1_w.T (i.e. `out[s, oc] = sum_e body_out[s, e] * conv1_w[oc, e]`).
686    let s = hw * hw;
687    let mut feat = vec![0f32; s * oc]; // BHWC: [hw·hw, oc]
688    for si in 0..s {
689        for oi in 0..oc {
690            let mut acc = 0f32;
691            for ei in 0..e {
692                acc += body_out[si * e + ei] * neck.conv1_w[oi * e + ei];
693            }
694            feat[si * oc + oi] = acc;
695        }
696    }
697
698    // 2) LN2d: normalize over channel dim (per spatial position).
699    layernorm2d_inplace(&mut feat, s, oc, &neck.ln1_g, &neck.ln1_b, eps);
700
701    // 3) Conv 3×3 padding=1, stride=1. We compute it in NCHW. The input
702    //    is currently BHWC = [hw·hw, oc]; convert to NCHW = [oc, hw, hw].
703    let mut nchw = vec![0f32; oc * hw * hw];
704    for y in 0..hw {
705        for x in 0..hw {
706            for c in 0..oc {
707                nchw[c * hw * hw + y * hw + x] = feat[(y * hw + x) * oc + c];
708            }
709        }
710    }
711    let conv2_out = conv2d_3x3_pad1(&nchw, oc, oc, hw, hw, &neck.conv2_w);
712
713    // 4) LN2d again. Convert back to BHWC for the LN, then back to NCHW.
714    let mut bhwc = vec![0f32; s * oc];
715    for c in 0..oc {
716        for y in 0..hw {
717            for x in 0..hw {
718                bhwc[(y * hw + x) * oc + c] = conv2_out[c * hw * hw + y * hw + x];
719            }
720        }
721    }
722    layernorm2d_inplace(&mut bhwc, s, oc, &neck.ln2_g, &neck.ln2_b, eps);
723
724    let mut out_nchw = vec![0f32; oc * hw * hw];
725    for y in 0..hw {
726        for x in 0..hw {
727            for c in 0..oc {
728                out_nchw[c * hw * hw + y * hw + x] = bhwc[(y * hw + x) * oc + c];
729            }
730        }
731    }
732    out_nchw
733}
734
735/// LN over channel dim of BHWC `[S, C]` (matches candle's LayerNorm2d).
736fn layernorm2d_inplace(data: &mut [f32], s: usize, c: usize, g: &[f32], b: &[f32], eps: f32) {
737    for si in 0..s {
738        let row = &mut data[si * c..(si + 1) * c];
739        let mean: f32 = row.iter().sum::<f32>() / c as f32;
740        let var: f32 = row.iter().map(|v| (v - mean) * (v - mean)).sum::<f32>() / c as f32;
741        let inv = 1.0 / (var + eps).sqrt();
742        for k in 0..c {
743            row[k] = (row[k] - mean) * inv * g[k] + b[k];
744        }
745    }
746}
747
748/// 3×3 Conv2d with stride=1, padding=1, no bias. NCHW in, NCHW out.
749/// Reference implementation — not vectorized, fine for the SAM neck
750/// (1 call per inference, 64×64×256).
751fn conv2d_3x3_pad1(
752    input: &[f32],
753    in_c: usize,
754    out_c: usize,
755    h: usize,
756    w: usize,
757    weight: &[f32], // [out_c, in_c, 3, 3]
758) -> Vec<f32> {
759    let mut out = vec![0f32; out_c * h * w];
760    for oc in 0..out_c {
761        for y in 0..h {
762            for x in 0..w {
763                let mut acc = 0f32;
764                for ic in 0..in_c {
765                    for ky in 0..3 {
766                        let iy = y as isize + ky as isize - 1;
767                        if iy < 0 || iy >= h as isize {
768                            continue;
769                        }
770                        for kx in 0..3 {
771                            let ix = x as isize + kx as isize - 1;
772                            if ix < 0 || ix >= w as isize {
773                                continue;
774                            }
775                            let v = input[ic * h * w + iy as usize * w + ix as usize];
776                            let wi = ((oc * in_c + ic) * 3 + ky) * 3 + kx;
777                            acc += v * weight[wi];
778                        }
779                    }
780                }
781                out[oc * h * w + y * w + x] = acc;
782            }
783        }
784    }
785    out
786}
787
788// ─── Small builder helpers ─────────────────────────────────────────
789
790fn load_p(
791    sb: &mut SamBuilder,
792    weights: &mut WeightMap,
793    key: &str,
794    transpose: bool,
795) -> Result<HirNodeId> {
796    let (data, shape) = if transpose {
797        weights
798            .take_transposed(key)
799            .map_err(|e| anyhow!("transpose-load `{key}`: {e}"))?
800    } else {
801        weights
802            .take(key)
803            .map_err(|e| anyhow!("load `{key}`: {e}"))?
804    };
805    let name = key.to_string();
806    let id = sb.m().param(&name, Shape::new(&shape, DType::F32));
807    sb.params.insert(name, data);
808    Ok(id)
809}
810
811#[allow(dead_code)]
812fn scalar_param(sb: &mut SamBuilder, name: &str, value: f32) -> HirNodeId {
813    let id = sb.m().param(name, Shape::new(&[1], DType::F32));
814    sb.params.insert(name.to_string(), vec![value]);
815    id
816}
817
818fn const_param(sb: &mut SamBuilder, name: &str, shape: &[usize], data: Vec<f32>) -> HirNodeId {
819    let id = sb.m().param(name, Shape::new(shape, DType::F32));
820    sb.params.insert(name.to_string(), data);
821    id
822}
823
824fn pad_zero_param(sb: &mut SamBuilder, name: &str, shape: &[usize]) -> HirNodeId {
825    let n: usize = shape.iter().product();
826    let id = sb.m().param(name, Shape::new(shape, DType::F32));
827    sb.params.insert(name.to_string(), vec![0f32; n]);
828    id
829}