Skip to main content

rlx_sam3/
vision_encoder_ir.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//! HIR-native SAM3 ViT-L vision trunk (32 blocks, window + global attention).
17//!
18//! Mirrors [`super::vision_encoder::encode_image_native`] but expresses the
19//! 32 transformer blocks as a single HIR graph so the heavy lifting can run
20//! on any backend wired into `rlx-runtime` (Metal, MLX, CUDA, …).
21//!
22//! Patch embed + `ln_pre` stay on the host (cheap, sub-millisecond) and feed
23//! `[1, grid*grid, embed_dim]` tokens into the compiled graph.
24
25use super::config::Sam3VitConfig;
26use super::packed_gguf::packed_linear;
27use super::preprocess::assemble_patch_tokens;
28use super::tensor::layer_norm;
29use super::vision_encoder::{Sam3VisionEncoderWeights, Sam3VisionOutput, Sam3VitBlockWeights};
30use anyhow::{Result, ensure};
31use rlx_flow::CompileProfile;
32use rlx_flow::{GgufPackedLinear, GgufPackedParams};
33use rlx_ir::hir::{HirGraphExt, HirModule, HirMut, HirNodeId};
34use rlx_ir::op::MaskKind;
35use rlx_ir::{DType, Op, Shape};
36use rlx_runtime::{CompiledGraph, Device};
37use std::collections::{HashMap, HashSet};
38
39const ROPE_THETA: f32 = 10_000.0;
40
41/// Build product: HIR module + F32 params + (packed name, U8 blob, dtype) entries.
42pub struct Sam3VisionEncoderHirParts {
43    pub hir: HirModule,
44    pub params: HashMap<String, Vec<f32>>,
45    pub typed_params: Vec<(String, Vec<u8>, DType)>,
46}
47
48/// Compiled ViT-L vision trunk pinned to a device.
49pub struct Sam3CompiledVisionEncoder {
50    pub compiled: CompiledGraph,
51    pub batch: usize,
52    pub grid: usize,
53    pub embed_dim: usize,
54}
55
56impl Sam3CompiledVisionEncoder {
57    pub fn new(
58        weights: &Sam3VisionEncoderWeights,
59        cfg: &Sam3VitConfig,
60        batch: usize,
61        device: Device,
62    ) -> Result<Self> {
63        Self::new_with_profile(weights, cfg, batch, device, &CompileProfile::sam3())
64    }
65
66    pub fn new_with_profile(
67        weights: &Sam3VisionEncoderWeights,
68        cfg: &Sam3VitConfig,
69        batch: usize,
70        device: Device,
71        profile: &CompileProfile,
72    ) -> Result<Self> {
73        Self::new_with_profile_and_gguf(weights, cfg, batch, device, profile, None)
74    }
75
76    pub fn new_with_profile_and_gguf(
77        weights: &Sam3VisionEncoderWeights,
78        cfg: &Sam3VitConfig,
79        batch: usize,
80        device: Device,
81        profile: &CompileProfile,
82        gguf_packed: Option<&GgufPackedParams>,
83    ) -> Result<Self> {
84        let parts = build_vision_encoder_hir(weights, cfg, batch, gguf_packed)?;
85        let mut compiled =
86            rlx_core::flow_bridge::compile_hir_with_profile(device, parts.hir, profile)?;
87        rlx_core::flow_util::attach_built_params(&mut compiled, parts.params, &parts.typed_params);
88        Ok(Self {
89            compiled,
90            batch,
91            grid: cfg.patch_grid(),
92            embed_dim: cfg.embed_dim,
93        })
94    }
95
96    /// Run with already-tokenized input `[batch, grid*grid, embed_dim]` (i.e.
97    /// post patch-embed + `ln_pre`). Output has the same shape.
98    pub fn run_tokens(&mut self, tokens: &[f32]) -> Result<Vec<f32>> {
99        let expected = self.batch * self.grid * self.grid * self.embed_dim;
100        ensure!(
101            tokens.len() == expected,
102            "vision encoder expects {expected} tokens, got {}",
103            tokens.len()
104        );
105        let outputs = self.compiled.run(&[("tokens", tokens)]);
106        outputs
107            .into_iter()
108            .next()
109            .ok_or_else(|| anyhow::anyhow!("vision encoder graph produced no outputs"))
110    }
111}
112
113/// Full end-to-end: preprocess image (CPU) → patch embed + ln_pre (CPU) → 32
114/// transformer blocks (compiled graph) → tokens `[grid*grid, embed_dim]`.
115pub fn encode_image_ir_on_with_profile(
116    weights: &Sam3VisionEncoderWeights,
117    gguf_packed: Option<&GgufPackedParams>,
118    cfg: &Sam3VitConfig,
119    image_nchw: &[f32],
120    device: Device,
121    profile: &CompileProfile,
122) -> Result<Sam3VisionOutput> {
123    let mut compiled = Sam3CompiledVisionEncoder::new_with_profile_and_gguf(
124        weights,
125        cfg,
126        1,
127        device,
128        profile,
129        gguf_packed,
130    )?;
131    let tokens_in = host_preroll(weights, cfg, image_nchw)?;
132    let out = compiled.run_tokens(&tokens_in)?;
133    Ok(Sam3VisionOutput {
134        tokens: out,
135        grid: cfg.patch_grid(),
136        dim: cfg.embed_dim,
137    })
138}
139
140pub fn encode_image_ir_on(
141    weights: &Sam3VisionEncoderWeights,
142    gguf_packed: Option<&GgufPackedParams>,
143    cfg: &Sam3VitConfig,
144    image_nchw: &[f32],
145    device: Device,
146) -> Result<Sam3VisionOutput> {
147    encode_image_ir_on_with_profile(
148        weights,
149        gguf_packed,
150        cfg,
151        image_nchw,
152        device,
153        &CompileProfile::sam3(),
154    )
155}
156
157/// CPU patch embed + ln_pre (lightweight) producing graph input.
158pub fn host_preroll(
159    weights: &Sam3VisionEncoderWeights,
160    cfg: &Sam3VitConfig,
161    image_nchw: &[f32],
162) -> Result<Vec<f32>> {
163    let mut x = assemble_patch_tokens(&weights.pre, image_nchw)?;
164    x = layer_norm(
165        &x,
166        &weights.ln_pre_w,
167        &weights.ln_pre_b,
168        cfg.embed_dim,
169        cfg.layer_norm_eps as f32,
170    )?;
171    Ok(x)
172}
173
174pub fn build_vision_encoder_hir(
175    weights: &Sam3VisionEncoderWeights,
176    cfg: &Sam3VitConfig,
177    batch: usize,
178    gguf_packed: Option<&GgufPackedParams>,
179) -> Result<Sam3VisionEncoderHirParts> {
180    let e = cfg.embed_dim;
181    let grid = cfg.patch_grid();
182    let seq = grid * grid;
183    let nh = cfg.num_heads;
184    let dh = e / nh;
185    ensure!(
186        dh * nh == e,
187        "embed_dim {e} not divisible by num_heads {nh}"
188    );
189    ensure!(dh.is_multiple_of(4), "head_dim must be divisible by 4");
190    let ws = cfg.window_size;
191    ensure!(
192        ws > 0 && grid.is_multiple_of(ws),
193        "vision IR currently assumes window_size>0 and divides grid (got ws={ws}, grid={grid})"
194    );
195    let nw_h = grid / ws;
196    let nw_w = grid / ws;
197    let num_windows = nw_h * nw_w;
198    let win_len = ws * ws;
199    let hidden = (e as f64 * cfg.mlp_ratio) as usize;
200
201    let mut hir = HirModule::new("sam3_vision_encoder");
202    let mut g = HirMut::new(&mut hir);
203    let mut params: HashMap<String, Vec<f32>> = HashMap::new();
204    let mut typed_params: Vec<(String, Vec<u8>, DType)> = Vec::new();
205    let mut gguf_cache: HashMap<String, HirNodeId> = HashMap::new();
206    let f = DType::F32;
207
208    let tokens = g.input("tokens", Shape::new(&[batch, seq, e], f));
209
210    // Two RoPE tables — windowed blocks (24×24, scale=1) and global blocks
211    // (grid×grid, scale=ws/grid). Mirrors `build_rope_freqs` from the CPU path.
212    let scale_global = ws as f32 / grid as f32;
213    let (cos_w_x_v, sin_w_x_v, cos_w_y_v, sin_w_y_v) =
214        rope_quarter_tables(dh, ws, ws, ROPE_THETA, 1.0);
215    let (cos_g_x_v, sin_g_x_v, cos_g_y_v, sin_g_y_v) =
216        rope_quarter_tables(dh, grid, grid, ROPE_THETA, scale_global);
217
218    let quarter = dh / 4;
219    let cos_w_x = param_2d(
220        &mut g,
221        &mut params,
222        "rope.win.cos_x",
223        &cos_w_x_v,
224        win_len,
225        quarter,
226    );
227    let sin_w_x = param_2d(
228        &mut g,
229        &mut params,
230        "rope.win.sin_x",
231        &sin_w_x_v,
232        win_len,
233        quarter,
234    );
235    let cos_w_y = param_2d(
236        &mut g,
237        &mut params,
238        "rope.win.cos_y",
239        &cos_w_y_v,
240        win_len,
241        quarter,
242    );
243    let sin_w_y = param_2d(
244        &mut g,
245        &mut params,
246        "rope.win.sin_y",
247        &sin_w_y_v,
248        win_len,
249        quarter,
250    );
251    let cos_g_x = param_2d(
252        &mut g,
253        &mut params,
254        "rope.glob.cos_x",
255        &cos_g_x_v,
256        seq,
257        quarter,
258    );
259    let sin_g_x = param_2d(
260        &mut g,
261        &mut params,
262        "rope.glob.sin_x",
263        &sin_g_x_v,
264        seq,
265        quarter,
266    );
267    let cos_g_y = param_2d(
268        &mut g,
269        &mut params,
270        "rope.glob.cos_y",
271        &cos_g_y_v,
272        seq,
273        quarter,
274    );
275    let sin_g_y = param_2d(
276        &mut g,
277        &mut params,
278        "rope.glob.sin_y",
279        &sin_g_y_v,
280        seq,
281        quarter,
282    );
283
284    let global_set: HashSet<usize> = cfg.global_att_blocks.iter().copied().collect();
285
286    let mut x = tokens;
287    for (li, block) in weights.blocks.iter().enumerate() {
288        let is_global = global_set.contains(&li);
289        let (cos_x, sin_x, cos_y, sin_y) = if is_global {
290            (cos_g_x, sin_g_x, cos_g_y, sin_g_y)
291        } else {
292            (cos_w_x, sin_w_x, cos_w_y, sin_w_y)
293        };
294        x = emit_block(
295            &mut g,
296            &mut params,
297            &mut typed_params,
298            &mut gguf_cache,
299            gguf_packed,
300            li,
301            block,
302            x,
303            batch,
304            seq,
305            grid,
306            ws,
307            nw_h,
308            nw_w,
309            num_windows,
310            win_len,
311            e,
312            nh,
313            dh,
314            hidden,
315            cfg.layer_norm_eps as f32,
316            is_global,
317            cos_x,
318            sin_x,
319            cos_y,
320            sin_y,
321        )?;
322    }
323    g.set_outputs(vec![x]);
324    Ok(Sam3VisionEncoderHirParts {
325        hir,
326        params,
327        typed_params,
328    })
329}
330
331#[allow(clippy::too_many_arguments)]
332fn emit_block(
333    g: &mut HirMut<'_>,
334    params: &mut HashMap<String, Vec<f32>>,
335    typed_params: &mut Vec<(String, Vec<u8>, DType)>,
336    gguf_cache: &mut HashMap<String, HirNodeId>,
337    gguf_packed: Option<&GgufPackedParams>,
338    li: usize,
339    block: &Sam3VitBlockWeights,
340    x: HirNodeId,
341    batch: usize,
342    seq: usize,
343    grid: usize,
344    ws: usize,
345    nw_h: usize,
346    nw_w: usize,
347    num_windows: usize,
348    win_len: usize,
349    e: usize,
350    nh: usize,
351    dh: usize,
352    hidden: usize,
353    eps: f32,
354    is_global: bool,
355    cos_x: HirNodeId,
356    sin_x: HirNodeId,
357    cos_y: HirNodeId,
358    sin_y: HirNodeId,
359) -> Result<HirNodeId> {
360    let f = DType::F32;
361    let n1w = param_1d(g, params, &format!("b{li}.norm1.w"), &block.norm1_w, e);
362    let n1b = param_1d(g, params, &format!("b{li}.norm1.b"), &block.norm1_b, e);
363    let n1 = g.ln(x, n1w, n1b, eps);
364
365    // QKV projection: [B, seq, e] -> [B, seq, 3e]; then split q/k/v on the
366    // channel axis.
367    let qkv = linear_or_gguf(
368        g,
369        params,
370        typed_params,
371        gguf_cache,
372        gguf_packed,
373        block.qkv_gguf_prefix.as_deref(),
374        &format!("b{li}.qkv"),
375        n1,
376        &block.qkv_w_t,
377        &block.qkv_b,
378        e,
379        3 * e,
380    )?;
381    let q_flat = g.narrow_(qkv, 2, 0, e);
382    let k_flat = g.narrow_(qkv, 2, e, e);
383    let v_flat = g.narrow_(qkv, 2, 2 * e, e);
384
385    let (q_eff, k_eff, v_eff, eff_batch, eff_seq) = if is_global {
386        (q_flat, k_flat, v_flat, batch, seq)
387    } else {
388        let q_w = window_partition(g, q_flat, batch, ws, nw_h, nw_w, e);
389        let k_w = window_partition(g, k_flat, batch, ws, nw_h, nw_w, e);
390        let v_w = window_partition(g, v_flat, batch, ws, nw_h, nw_w, e);
391        (q_w, k_w, v_w, batch * num_windows, win_len)
392    };
393
394    let q_rot = rope_2d_decomposed(
395        g, q_eff, eff_batch, eff_seq, nh, dh, cos_x, sin_x, cos_y, sin_y,
396    );
397    let k_rot = rope_2d_decomposed(
398        g, k_eff, eff_batch, eff_seq, nh, dh, cos_x, sin_x, cos_y, sin_y,
399    );
400
401    let attn = g.attention_kind(
402        q_rot,
403        k_rot,
404        v_eff,
405        nh,
406        dh,
407        MaskKind::None,
408        Shape::new(&[eff_batch, eff_seq, e], f),
409    );
410
411    let attn_full = if is_global {
412        attn
413    } else {
414        window_unpartition(g, attn, batch, grid, ws, nw_h, nw_w, e)
415    };
416
417    let proj = linear_or_gguf(
418        g,
419        params,
420        typed_params,
421        gguf_cache,
422        gguf_packed,
423        block.proj_gguf_prefix.as_deref(),
424        &format!("b{li}.proj"),
425        attn_full,
426        &block.proj_w_t,
427        &block.proj_b,
428        e,
429        e,
430    )?;
431    let x = g.add(x, proj);
432
433    let n2w = param_1d(g, params, &format!("b{li}.norm2.w"), &block.norm2_w, e);
434    let n2b = param_1d(g, params, &format!("b{li}.norm2.b"), &block.norm2_b, e);
435    let n2 = g.ln(x, n2w, n2b, eps);
436    let fc1 = linear_or_gguf(
437        g,
438        params,
439        typed_params,
440        gguf_cache,
441        gguf_packed,
442        block.mlp_fc1_gguf_prefix.as_deref(),
443        &format!("b{li}.mlp1"),
444        n2,
445        &block.mlp_fc1_w_t,
446        &block.mlp_fc1_b,
447        e,
448        hidden,
449    )?;
450    let act = g.gelu_approx(fc1);
451    let fc2 = linear_or_gguf(
452        g,
453        params,
454        typed_params,
455        gguf_cache,
456        gguf_packed,
457        block.mlp_fc2_gguf_prefix.as_deref(),
458        &format!("b{li}.mlp2"),
459        act,
460        &block.mlp_fc2_w_t,
461        &block.mlp_fc2_b,
462        hidden,
463        e,
464    )?;
465    Ok(g.add(x, fc2))
466}
467
468// ---------------------------------------------------------------------------
469// Window partitioning: [B, grid*grid, e] <-> [B*num_windows, win_len, e].
470
471fn window_partition(
472    g: &mut HirMut<'_>,
473    x: HirNodeId,
474    batch: usize,
475    ws: usize,
476    nw_h: usize,
477    nw_w: usize,
478    e: usize,
479) -> HirNodeId {
480    let v = g.reshape_(
481        x,
482        vec![
483            batch as i64,
484            nw_h as i64,
485            ws as i64,
486            nw_w as i64,
487            ws as i64,
488            e as i64,
489        ],
490    );
491    let t = g.transpose_(v, vec![0, 1, 3, 2, 4, 5]);
492    g.reshape_(
493        t,
494        vec![(batch * nw_h * nw_w) as i64, (ws * ws) as i64, e as i64],
495    )
496}
497
498fn window_unpartition(
499    g: &mut HirMut<'_>,
500    x: HirNodeId,
501    batch: usize,
502    grid: usize,
503    ws: usize,
504    nw_h: usize,
505    nw_w: usize,
506    e: usize,
507) -> HirNodeId {
508    let v = g.reshape_(
509        x,
510        vec![
511            batch as i64,
512            nw_h as i64,
513            nw_w as i64,
514            ws as i64,
515            ws as i64,
516            e as i64,
517        ],
518    );
519    let t = g.transpose_(v, vec![0, 1, 3, 2, 4, 5]);
520    g.reshape_(t, vec![batch as i64, (grid * grid) as i64, e as i64])
521}
522
523// ---------------------------------------------------------------------------
524// 2D RoPE — pair-wise rotation matching `super::vision_encoder::rope_apply_inplace`.
525//
526// SAM3's convention is to rotate consecutive value pairs `(v[2k], v[2k+1])`
527// inside each head's `head_dim` slice — the first `head_dim/2` slots get the X
528// rotation, the second `head_dim/2` get the Y rotation. `Op::Rope` does the
529// LLaMA-style **half-split** rotation `(v[i], v[i+rot_half])` instead, so we
530// can't call `g.rope` directly. Implementing the rotation by hand with
531// reshape + mul/add keeps the pairing unambiguous and skips the axis-merging
532// trick (`[B, S, nh, dh] → [B*nh, S, dh]`) that only behaves like a no-op
533// when `S == nh`.
534
535#[allow(clippy::too_many_arguments)]
536fn rope_2d_decomposed(
537    g: &mut HirMut<'_>,
538    x: HirNodeId,
539    batch: usize,
540    seq: usize,
541    nh: usize,
542    dh: usize,
543    cos_x: HirNodeId,
544    sin_x: HirNodeId,
545    cos_y: HirNodeId,
546    sin_y: HirNodeId,
547) -> HirNodeId {
548    let half = dh / 2;
549    let quarter = dh / 4;
550
551    let x4 = g.reshape_(x, vec![batch as i64, seq as i64, nh as i64, dh as i64]);
552    let x_xh = g.narrow_(x4, 3, 0, half);
553    let x_yh = g.narrow_(x4, 3, half, half);
554
555    let xh_rot = pairwise_rope_half(g, x_xh, batch, seq, nh, quarter, cos_x, sin_x);
556    let yh_rot = pairwise_rope_half(g, x_yh, batch, seq, nh, quarter, cos_y, sin_y);
557
558    let cat = g.concat_(vec![xh_rot, yh_rot], 3);
559    g.reshape_(cat, vec![batch as i64, seq as i64, (nh * dh) as i64])
560}
561
562/// Pair-wise complex rotation on `[B, S, nh, 2*quarter]`.
563///
564/// Treats the last dim as `quarter` consecutive `(real, imag)` pairs, rotates
565/// each pair by the `(cos[s, k], sin[s, k])` entry, and returns a tensor of
566/// the same shape. `cos`/`sin` are `[S, quarter]` and broadcast over B/nh.
567#[allow(clippy::too_many_arguments)]
568fn pairwise_rope_half(
569    g: &mut HirMut<'_>,
570    x: HirNodeId, // [B, S, nh, 2*quarter]
571    batch: usize,
572    seq: usize,
573    nh: usize,
574    quarter: usize,
575    cos: HirNodeId, // [S, quarter]
576    sin: HirNodeId, // [S, quarter]
577) -> HirNodeId {
578    // Expose the (real, imag) pair axis.
579    let pairs = g.reshape_(
580        x,
581        vec![batch as i64, seq as i64, nh as i64, quarter as i64, 2],
582    );
583    let x_r5 = g.narrow_(pairs, 4, 0, 1);
584    let x_i5 = g.narrow_(pairs, 4, 1, 1);
585    // Drop the trailing length-1 axis so we can broadcast `cos`/`sin` cleanly.
586    let x_r = g.reshape_(
587        x_r5,
588        vec![batch as i64, seq as i64, nh as i64, quarter as i64],
589    );
590    let x_i = g.reshape_(
591        x_i5,
592        vec![batch as i64, seq as i64, nh as i64, quarter as i64],
593    );
594
595    // [S, quarter] → [1, S, 1, quarter] for broadcasting.
596    let cos_b = g.reshape_(cos, vec![1, seq as i64, 1, quarter as i64]);
597    let sin_b = g.reshape_(sin, vec![1, seq as i64, 1, quarter as i64]);
598
599    let rc = g.mul(x_r, cos_b);
600    let is_ = g.mul(x_i, sin_b);
601    let rs = g.mul(x_r, sin_b);
602    let ic = g.mul(x_i, cos_b);
603    let out_r = g.sub(rc, is_);
604    let out_i = g.add(rs, ic);
605
606    // Re-pair `(out_r, out_i)` into the original `[..., quarter, 2]` layout.
607    let out_r5 = g.reshape_(
608        out_r,
609        vec![batch as i64, seq as i64, nh as i64, quarter as i64, 1],
610    );
611    let out_i5 = g.reshape_(
612        out_i,
613        vec![batch as i64, seq as i64, nh as i64, quarter as i64, 1],
614    );
615    let pairs_out = g.concat_(vec![out_r5, out_i5], 4);
616    g.reshape_(
617        pairs_out,
618        vec![batch as i64, seq as i64, nh as i64, (2 * quarter) as i64],
619    )
620}
621
622// ---------------------------------------------------------------------------
623// Linear with optional GGUF packed fallback. `w_t` is [in_dim, out_dim] —
624// what `FusedMatMulBiasAct` wants.
625
626#[allow(clippy::too_many_arguments)]
627fn linear_or_gguf(
628    g: &mut HirMut<'_>,
629    params: &mut HashMap<String, Vec<f32>>,
630    typed_params: &mut Vec<(String, Vec<u8>, DType)>,
631    gguf_cache: &mut HashMap<String, HirNodeId>,
632    gguf_packed: Option<&GgufPackedParams>,
633    gguf_prefix: Option<&str>,
634    ir_stem: &str,
635    input: HirNodeId,
636    w_t: &[f32],
637    bias: &[f32],
638    in_dim: usize,
639    out_dim: usize,
640) -> Result<HirNodeId> {
641    if let Some(p) = gguf_prefix
642        .and_then(|pref| gguf_packed.map(|gp| (gp, format!("{pref}.weight"))))
643        .and_then(|(gp, key)| packed_linear(gp, &key))
644    {
645        return linear_gguf_bias(
646            g,
647            params,
648            typed_params,
649            gguf_cache,
650            ir_stem,
651            p,
652            input,
653            bias,
654            in_dim,
655            out_dim,
656        );
657    }
658    ensure!(
659        !w_t.is_empty(),
660        "{ir_stem}: missing F32 weight and no GGUF packed entry"
661    );
662    Ok(fused_linear(
663        g, params, ir_stem, input, w_t, bias, in_dim, out_dim,
664    ))
665}
666
667fn fused_linear(
668    g: &mut HirMut<'_>,
669    params: &mut HashMap<String, Vec<f32>>,
670    ir_stem: &str,
671    input: HirNodeId,
672    w_t: &[f32],
673    bias: &[f32],
674    in_dim: usize,
675    out_dim: usize,
676) -> HirNodeId {
677    let f = DType::F32;
678    let w_name = format!("{ir_stem}.w");
679    let b_name = format!("{ir_stem}.b");
680    let w_id = g.param(&w_name, Shape::new(&[in_dim, out_dim], f));
681    params.insert(w_name, w_t.to_vec());
682    let b_id = g.param(&b_name, Shape::new(&[out_dim], f));
683    params.insert(b_name, bias.to_vec());
684    let cur_shape = g.shape(input);
685    let mut out_dims: Vec<usize> = cur_shape.dims().iter().map(|d| d.unwrap_static()).collect();
686    *out_dims.last_mut().unwrap() = out_dim;
687    g.add_node(
688        Op::FusedMatMulBiasAct { activation: None },
689        vec![input, w_id, b_id],
690        Shape::new(&out_dims, f),
691    )
692}
693
694#[allow(clippy::too_many_arguments)]
695fn linear_gguf_bias(
696    g: &mut HirMut<'_>,
697    params: &mut HashMap<String, Vec<f32>>,
698    typed_params: &mut Vec<(String, Vec<u8>, DType)>,
699    gguf_cache: &mut HashMap<String, HirNodeId>,
700    ir_stem: &str,
701    p: &GgufPackedLinear,
702    input: HirNodeId,
703    bias: &[f32],
704    in_dim: usize,
705    out_dim: usize,
706) -> Result<HirNodeId> {
707    ensure!(
708        p.in_dim == in_dim && p.out_dim == out_dim,
709        "{ir_stem}: packed linear shape {}x{} vs {in_dim}x{out_dim}",
710        p.in_dim,
711        p.out_dim
712    );
713    let w_name = format!("{ir_stem}.w");
714    let w_id = if let Some(&id) = gguf_cache.get(&w_name) {
715        id
716    } else {
717        let id = g.param(&w_name, Shape::new(&[p.w_q.len()], DType::U8));
718        typed_params.push((w_name.clone(), p.w_q.clone(), DType::U8));
719        gguf_cache.insert(w_name, id);
720        id
721    };
722    let cur = g.shape(input);
723    let mut dims: Vec<usize> = cur.dims().iter().map(|d| d.unwrap_static()).collect();
724    *dims.last_mut().unwrap() = out_dim;
725    let out_shape = Shape::new(&dims, DType::F32);
726    let mm = g.add_node(
727        Op::DequantMatMul { scheme: p.scheme },
728        vec![input, w_id],
729        out_shape,
730    );
731    Ok(add_f32_bias(g, params, &format!("{ir_stem}.b"), mm, bias))
732}
733
734fn add_f32_bias(
735    g: &mut HirMut<'_>,
736    params: &mut HashMap<String, Vec<f32>>,
737    name: &str,
738    input: HirNodeId,
739    bias: &[f32],
740) -> HirNodeId {
741    if bias.iter().all(|&v| v == 0.0) {
742        return input;
743    }
744    let b_id = g.param(name, Shape::new(&[bias.len()], DType::F32));
745    params.insert(name.to_string(), bias.to_vec());
746    g.add(input, b_id)
747}
748
749fn param_1d(
750    g: &mut HirMut<'_>,
751    params: &mut HashMap<String, Vec<f32>>,
752    name: &str,
753    data: &[f32],
754    n: usize,
755) -> HirNodeId {
756    let id = g.param(name, Shape::new(&[n], DType::F32));
757    params.insert(name.to_string(), data.to_vec());
758    id
759}
760
761fn param_2d(
762    g: &mut HirMut<'_>,
763    params: &mut HashMap<String, Vec<f32>>,
764    name: &str,
765    data: &[f32],
766    rows: usize,
767    cols: usize,
768) -> HirNodeId {
769    let id = g.param(name, Shape::new(&[rows, cols], DType::F32));
770    params.insert(name.to_string(), data.to_vec());
771    id
772}
773
774/// SAM3 2D RoPE quarter tables (cos_x, sin_x, cos_y, sin_y) of shape
775/// `[end_x*end_y, head_dim/4]` each. Matches
776/// [`super::vision_encoder::build_rope_freqs`] but split into the X and Y
777/// halves so each half can be consumed by the scalar `g.rope` op.
778fn rope_quarter_tables(
779    head_dim: usize,
780    end_x: usize,
781    end_y: usize,
782    theta: f32,
783    scale_pos: f32,
784) -> (Vec<f32>, Vec<f32>, Vec<f32>, Vec<f32>) {
785    assert!(head_dim.is_multiple_of(4));
786    let pair_per_axis = head_dim / 4;
787    let mut freqs_per_pair = Vec::with_capacity(pair_per_axis);
788    for k in 0..pair_per_axis {
789        let exp = (4 * k) as f32 / head_dim as f32;
790        freqs_per_pair.push(1.0 / theta.powf(exp));
791    }
792    let seq = end_x * end_y;
793    let q = pair_per_axis;
794    let mut cos_x = vec![0f32; seq * q];
795    let mut sin_x = vec![0f32; seq * q];
796    let mut cos_y = vec![0f32; seq * q];
797    let mut sin_y = vec![0f32; seq * q];
798    for pos in 0..seq {
799        let t_x = (pos % end_x) as f32 * scale_pos;
800        let t_y = (pos / end_x) as f32 * scale_pos;
801        for k in 0..q {
802            let ang_x = t_x * freqs_per_pair[k];
803            let ang_y = t_y * freqs_per_pair[k];
804            cos_x[pos * q + k] = ang_x.cos();
805            sin_x[pos * q + k] = ang_x.sin();
806            cos_y[pos * q + k] = ang_y.cos();
807            sin_y[pos * q + k] = ang_y.sin();
808        }
809    }
810    (cos_x, sin_x, cos_y, sin_y)
811}