Skip to main content

combs_models/
smolvlm.rs

1//! Idefics3 / SmolVLM architecture: SigLIP vision encoder + pixel-shuffle
2//! connector + Llama-family text decoder (SmolLM2), on the same
3//! [`GenerativeModel`] contract. The text stack is the shared Llama trunk
4//! (weights under `model.text_model.*`); the vision tower is stateless and
5//! runs once per image inside `embed_multimodal`, whose output replaces the
6//! `<image>` token spans in the embedded prompt.
7//!
8//! Weight names (HF safetensors):
9//! `model.vision_model.embeddings.{patch_embedding.weight,bias}`,
10//! `model.vision_model.embeddings.position_embedding.weight`,
11//! `model.vision_model.encoder.layers.{i}.{layer_norm1,self_attn,layer_norm2,mlp}.*`,
12//! `model.vision_model.post_layernorm.{weight,bias}`,
13//! `model.connector.modality_projection.proj.weight`,
14//! `model.text_model.*` (Llama layout), `lm_head.weight`.
15
16use std::ops::Range;
17
18use burn::tensor::{Device, Int, Tensor, TensorData, activation::softmax, backend::Backend};
19use combs_formats::{ModelMetadata, ModelSource, VisionConfig};
20
21use crate::kv::{CacheConfig, KVCache};
22use crate::llama::{LlamaModel, linear, load_tensor};
23use crate::matmul::safe_matmul;
24use crate::norm::layer_norm;
25use crate::precision::{to_f32, to_float};
26use crate::traits::GenerativeModel;
27use crate::{ModelError, Result};
28
29/// One SigLIP encoder layer's weights (all projections carry biases).
30struct SiglipLayer<B: Backend> {
31    ln1_w: Tensor<B, 1>,
32    ln1_b: Tensor<B, 1>,
33    q_w: Tensor<B, 2>,
34    q_b: Tensor<B, 1>,
35    k_w: Tensor<B, 2>,
36    k_b: Tensor<B, 1>,
37    v_w: Tensor<B, 2>,
38    v_b: Tensor<B, 1>,
39    o_w: Tensor<B, 2>,
40    o_b: Tensor<B, 1>,
41    ln2_w: Tensor<B, 1>,
42    ln2_b: Tensor<B, 1>,
43    fc1_w: Tensor<B, 2>,
44    fc1_b: Tensor<B, 1>,
45    fc2_w: Tensor<B, 2>,
46    fc2_b: Tensor<B, 1>,
47}
48
49/// SigLIP vision transformer (fixed square input, full self-attention).
50struct SiglipEncoder<B: Backend> {
51    cfg: VisionConfig,
52    /// Patch-embed conv weight flattened to `[hidden, channels*patch²]`.
53    patch_w: Tensor<B, 2>,
54    patch_b: Tensor<B, 1>,
55    /// Learned absolute position embeddings `[num_patches, hidden]`.
56    pos_embed: Tensor<B, 2>,
57    layers: Vec<SiglipLayer<B>>,
58    post_ln_w: Tensor<B, 1>,
59    post_ln_b: Tensor<B, 1>,
60    scale: f64,
61}
62
63/// GELU (tanh approximation), SigLIP's `gelu_pytorch_tanh`.
64fn gelu_tanh<B: Backend, const D: usize>(x: Tensor<B, D>) -> Tensor<B, D> {
65    let inner = (x.clone() + x.clone().powf_scalar(3.0).mul_scalar(0.044715))
66        .mul_scalar((2.0f64 / std::f64::consts::PI).sqrt());
67    // 0.5 * x * (1 + tanh(inner))
68    x * inner.tanh().add_scalar(1.0).mul_scalar(0.5)
69}
70
71impl<B: Backend> SiglipEncoder<B> {
72    fn load(source: &dyn ModelSource, device: &Device<B>, cfg: &VisionConfig) -> Result<Self> {
73        let p = "model.vision_model";
74        // Conv weight [hidden, channels, patch, patch] -> [hidden, channels*patch²].
75        let conv: Tensor<B, 4> = load_tensor(
76            source,
77            device,
78            &format!("{p}.embeddings.patch_embedding.weight"),
79        )?;
80        let patch_w = conv.reshape([
81            cfg.hidden_size,
82            3 * cfg.patch_size * cfg.patch_size,
83        ]);
84        let patch_b = load_tensor(source, device, &format!("{p}.embeddings.patch_embedding.bias"))?;
85        let pos_embed: Tensor<B, 2> = load_tensor(
86            source,
87            device,
88            &format!("{p}.embeddings.position_embedding.weight"),
89        )?;
90
91        let mut layers = Vec::with_capacity(cfg.num_hidden_layers);
92        for i in 0..cfg.num_hidden_layers {
93            let lp = format!("{p}.encoder.layers.{i}");
94            layers.push(SiglipLayer {
95                ln1_w: load_tensor(source, device, &format!("{lp}.layer_norm1.weight"))?,
96                ln1_b: load_tensor(source, device, &format!("{lp}.layer_norm1.bias"))?,
97                q_w: load_tensor(source, device, &format!("{lp}.self_attn.q_proj.weight"))?,
98                q_b: load_tensor(source, device, &format!("{lp}.self_attn.q_proj.bias"))?,
99                k_w: load_tensor(source, device, &format!("{lp}.self_attn.k_proj.weight"))?,
100                k_b: load_tensor(source, device, &format!("{lp}.self_attn.k_proj.bias"))?,
101                v_w: load_tensor(source, device, &format!("{lp}.self_attn.v_proj.weight"))?,
102                v_b: load_tensor(source, device, &format!("{lp}.self_attn.v_proj.bias"))?,
103                o_w: load_tensor(source, device, &format!("{lp}.self_attn.out_proj.weight"))?,
104                o_b: load_tensor(source, device, &format!("{lp}.self_attn.out_proj.bias"))?,
105                ln2_w: load_tensor(source, device, &format!("{lp}.layer_norm2.weight"))?,
106                ln2_b: load_tensor(source, device, &format!("{lp}.layer_norm2.bias"))?,
107                fc1_w: load_tensor(source, device, &format!("{lp}.mlp.fc1.weight"))?,
108                fc1_b: load_tensor(source, device, &format!("{lp}.mlp.fc1.bias"))?,
109                fc2_w: load_tensor(source, device, &format!("{lp}.mlp.fc2.weight"))?,
110                fc2_b: load_tensor(source, device, &format!("{lp}.mlp.fc2.bias"))?,
111            });
112        }
113
114        Ok(SiglipEncoder {
115            scale: 1.0 / (cfg.head_dim() as f64).sqrt(),
116            cfg: cfg.clone(),
117            patch_w,
118            patch_b,
119            pos_embed,
120            layers,
121            post_ln_w: load_tensor(source, device, &format!("{p}.post_layernorm.weight"))?,
122            post_ln_b: load_tensor(source, device, &format!("{p}.post_layernorm.bias"))?,
123        })
124    }
125
126    /// Full (non-causal) self-attention over the patch sequence.
127    fn attention(&self, layer: &SiglipLayer<B>, x: Tensor<B, 3>) -> Tensor<B, 3> {
128        let cfg = &self.cfg;
129        let [batch, seq, _] = x.dims();
130        let heads = cfg.num_attention_heads;
131        let head_dim = cfg.head_dim();
132
133        let q = linear(x.clone(), &layer.q_w, Some(&layer.q_b))
134            .reshape([batch, seq, heads, head_dim])
135            .swap_dims(1, 2);
136        let k = linear(x.clone(), &layer.k_w, Some(&layer.k_b))
137            .reshape([batch, seq, heads, head_dim])
138            .swap_dims(1, 2);
139        let v = linear(x, &layer.v_w, Some(&layer.v_b))
140            .reshape([batch, seq, heads, head_dim])
141            .swap_dims(1, 2);
142
143        // K dims hit the broken wgpu/Metal matmul region (>=512) — safe_matmul.
144        // Scores + softmax in f32 for f16 stability (no-op in f32 builds).
145        let out_dtype = q.dtype();
146        let (q, k, v) = (to_f32(q), to_f32(k), to_f32(v));
147        let scores = safe_matmul(q, k.transpose()).mul_scalar(self.scale);
148        let ctx = to_float(safe_matmul(softmax(scores, 3), v), out_dtype);
149        let ctx = ctx.swap_dims(1, 2).reshape([batch, seq, heads * head_dim]);
150        linear(ctx, &layer.o_w, Some(&layer.o_b))
151    }
152
153    /// `[1, channels, image, image] -> [1, num_patches, hidden]`.
154    fn forward(&self, pixels: Tensor<B, 4>) -> Tensor<B, 3> {
155        let cfg = &self.cfg;
156        let p = cfg.patch_size;
157        let [_, c, h, w] = pixels.dims();
158        let (gh, gw) = (h / p, w / p);
159        debug_assert_eq!(c, 3);
160        debug_assert_eq!(h % p + w % p, 0);
161
162        // Unfold into patches (equivalent to the stride-p conv): reshape to
163        // [c, gh, p, gw, p] -> [gh, gw, c, p, p] -> [gh*gw, c*p*p].
164        let patches = pixels
165            .reshape([c, gh, p, gw, p])
166            .swap_dims(0, 1)
167            .swap_dims(1, 3)
168            .swap_dims(2, 3)
169            .reshape([gh * gw, c * p * p])
170            .unsqueeze_dim::<3>(0);
171        let mut x = linear(patches, &self.patch_w, Some(&self.patch_b));
172
173        // Fixed square input: positional ids are exactly 0..num_patches.
174        let np = gh * gw;
175        x = x + self
176            .pos_embed
177            .clone()
178            .narrow(0, 0, np)
179            .reshape([1, np, cfg.hidden_size]);
180
181        for layer in &self.layers {
182            let h = layer_norm(
183                x.clone(),
184                layer.ln1_w.clone(),
185                layer.ln1_b.clone(),
186                cfg.layer_norm_eps,
187            );
188            x = x + self.attention(layer, h);
189            let h = layer_norm(
190                x.clone(),
191                layer.ln2_w.clone(),
192                layer.ln2_b.clone(),
193                cfg.layer_norm_eps,
194            );
195            let mlp = linear(
196                gelu_tanh(linear(h, &layer.fc1_w, Some(&layer.fc1_b))),
197                &layer.fc2_w,
198                Some(&layer.fc2_b),
199            );
200            x = x + mlp;
201        }
202
203        layer_norm(x, self.post_ln_w.clone(), self.post_ln_b.clone(), cfg.layer_norm_eps)
204    }
205}
206
207/// Idefics3 connector: pixel-shuffle (space-to-depth, HF ordering) followed
208/// by a bias-free linear projection into the text hidden size.
209struct Connector<B: Backend> {
210    scale: usize,
211    proj: Tensor<B, 2>, // [text_hidden, vision_hidden * scale²]
212}
213
214impl<B: Backend> Connector<B> {
215    /// `[1, patches, vision_hidden] -> [1, patches/s², vision_hidden*s²]`
216    /// (matches HF `Idefics3Connector.pixel_shuffle` channel ordering:
217    /// channel = ((row_in_group * s) + col_in_group) * C + c).
218    fn pixel_shuffle(&self, x: Tensor<B, 3>) -> Tensor<B, 3> {
219        let s = self.scale;
220        let [b, seq, c] = x.dims();
221        let side = (seq as f64).sqrt() as usize;
222        assert_eq!(side * side, seq, "patch grid must be square");
223        x.reshape([b, side, side, c])
224            .reshape([b, side, side / s, c * s])
225            .swap_dims(1, 2) // [b, side/s, side, c*s]
226            .reshape([b, side / s, side / s, c * s * s])
227            .swap_dims(1, 2) // [b, side/s, side/s, c*s²]
228            .reshape([b, seq / (s * s), c * s * s])
229    }
230
231    fn forward(&self, x: Tensor<B, 3>) -> Tensor<B, 3> {
232        linear(self.pixel_shuffle(x), &self.proj, None)
233    }
234}
235
236/// SmolVLM (Idefics3): SigLIP + connector + Llama-family text decoder.
237pub struct SmolVlmModel<B: Backend> {
238    metadata: ModelMetadata,
239    vision_cfg: VisionConfig,
240    vision: SiglipEncoder<B>,
241    connector: Connector<B>,
242    text: LlamaModel<B>,
243}
244
245impl<B: Backend> SmolVlmModel<B> {
246    /// Runs one image through the vision tower + connector:
247    /// `[1, 3, H, W] -> [1, image_seq_len, text_hidden]`.
248    fn image_features(&self, pixels: Tensor<B, 4>) -> Tensor<B, 3> {
249        self.connector.forward(self.vision.forward(pixels))
250    }
251}
252
253impl<B: Backend> GenerativeModel<B> for SmolVlmModel<B> {
254    fn metadata(&self) -> &ModelMetadata {
255        &self.metadata
256    }
257
258    fn load(source: &dyn ModelSource, device: &Device<B>) -> Result<Self> {
259        let metadata = source.metadata().clone();
260        let vision_cfg = metadata
261            .vision
262            .clone()
263            .ok_or_else(|| ModelError::MissingTensor("vision_config".to_string()))?;
264        let vision = SiglipEncoder::load(source, device, &vision_cfg)?;
265        let proj: Tensor<B, 2> = load_tensor(
266            source,
267            device,
268            "model.connector.modality_projection.proj.weight",
269        )?;
270        LlamaModel::<B>::expect_shape(
271            "model.connector.modality_projection.proj.weight",
272            &proj.dims(),
273            &[
274                metadata.hidden_size,
275                vision_cfg.hidden_size * vision_cfg.scale_factor * vision_cfg.scale_factor,
276            ],
277        )?;
278        let text = LlamaModel::<B>::load_with_prefix(source, device, "model.text_model")?;
279        Ok(SmolVlmModel {
280            connector: Connector {
281                scale: vision_cfg.scale_factor,
282                proj,
283            },
284            metadata,
285            vision_cfg,
286            vision,
287            text,
288        })
289    }
290
291    fn create_kv_cache(&self, config: &CacheConfig) -> Box<dyn KVCache<B>> {
292        self.text.create_kv_cache(config)
293    }
294
295    fn embed(&self, tokens: Tensor<B, 2, Int>) -> Tensor<B, 3> {
296        self.text.embed(tokens)
297    }
298
299    fn embed_multimodal(
300        &self,
301        tokens: Tensor<B, 2, Int>,
302        images: &[Tensor<B, 4>],
303    ) -> Result<Tensor<B, 3>> {
304        if images.is_empty() {
305            return Ok(self.text.embed(tokens));
306        }
307        let [_, seq] = tokens.dims();
308        let ids: Vec<i64> = tokens
309            .clone()
310            .into_data()
311            .convert::<i64>()
312            .to_vec()
313            .map_err(|e| ModelError::BadShape {
314                tensor: "tokens".to_string(),
315                expected: vec![1, seq],
316                got: vec![],
317            })
318            .unwrap_or_default();
319        if ids.len() != seq {
320            return Err(ModelError::BadShape {
321                tensor: "tokens".to_string(),
322                expected: vec![1, seq],
323                got: vec![ids.len()],
324            });
325        }
326
327        // Consecutive spans of the image token; one span per image, in order.
328        let image_id = self.vision_cfg.image_token_id as i64;
329        let span_len = self.vision_cfg.image_seq_len();
330        let mut spans: Vec<(usize, usize)> = Vec::new();
331        let mut i = 0;
332        while i < seq {
333            if ids[i] == image_id {
334                let start = i;
335                while i < seq && ids[i] == image_id {
336                    i += 1;
337                }
338                spans.push((start, i));
339            } else {
340                i += 1;
341            }
342        }
343        if spans.len() != images.len() {
344            return Err(ModelError::UnsupportedMedia(format!(
345                "found {} image-token span(s) of len {span_len} but {} image(s) were provided",
346                spans.len(),
347                images.len()
348            )));
349        }
350
351        let base = self.text.embed(tokens);
352        let mut pieces: Vec<Tensor<B, 3>> = Vec::new();
353        let mut cursor = 0;
354        for (idx, (start, end)) in spans.iter().enumerate() {
355            if end - start != span_len {
356                return Err(ModelError::UnsupportedMedia(format!(
357                    "image-token span has length {}, expected {span_len}",
358                    end - start
359                )));
360            }
361            if *start > cursor {
362                pieces.push(base.clone().narrow(1, cursor, start - cursor));
363            }
364            pieces.push(self.image_features(images[idx].clone()));
365            cursor = *end;
366        }
367        if cursor < seq {
368            pieces.push(base.narrow(1, cursor, seq - cursor));
369        }
370        Ok(Tensor::cat(pieces, 1))
371    }
372
373    fn prefill(
374        &mut self,
375        input: Tensor<B, 3>,
376        cache: &mut dyn KVCache<B>,
377        pos: Range<u32>,
378    ) -> Tensor<B, 2> {
379        let [_, seq, _] = input.dims();
380        assert_eq!(
381            seq,
382            (pos.end - pos.start) as usize,
383            "prefill pos range must match the input sequence length"
384        );
385        let hidden = self.text.forward_hidden(input, cache, pos.start as usize);
386        self.text.last_logits(hidden)
387    }
388
389    fn decode(&mut self, input: Tensor<B, 3>, cache: &mut dyn KVCache<B>) -> Tensor<B, 2> {
390        let pos = cache.seq_len();
391        let hidden = self.text.forward_hidden(input, cache, pos);
392        self.text.last_logits(hidden)
393    }
394
395    fn prefill_hidden(
396        &mut self,
397        input: Tensor<B, 3>,
398        cache: &mut dyn KVCache<B>,
399        pos: Range<u32>,
400    ) -> Result<Tensor<B, 3>> {
401        self.text.prefill_hidden(input, cache, pos)
402    }
403
404    fn supports_hidden_states(&self) -> bool {
405        true
406    }
407
408    fn prefill_all_logits(
409        &mut self,
410        input: Tensor<B, 3>,
411        cache: &mut dyn KVCache<B>,
412        pos: Range<u32>,
413    ) -> Result<Tensor<B, 3>> {
414        self.text.prefill_all_logits(input, cache, pos)
415    }
416}
417
418/// Builds the Idefics3 prompt expansion for one image:
419/// `<fake_token_around_image><global-img><image>×image_seq_len<fake_token_around_image>`.
420/// (`image_seq_len` = 64 for SmolVLM-256M.) The returned string is meant to
421/// replace each `<image>` placeholder in the chat text.
422pub fn image_prompt_expansion(image_seq_len: usize) -> String {
423    let mut s = String::with_capacity(image_seq_len * 8 + 64);
424    s.push_str("<fake_token_around_image><global-img>");
425    for _ in 0..image_seq_len {
426        s.push_str("<image>");
427    }
428    s.push_str("<fake_token_around_image>");
429    s
430}
431
432/// Reads token data back to host ids (helper for tests).
433#[allow(dead_code)]
434fn token_ids<B: Backend>(tokens: Tensor<B, 2, Int>) -> Vec<i64> {
435    tokens
436        .into_data()
437        .convert::<i64>()
438        .to_vec()
439        .unwrap_or_default()
440}
441
442/// Builds a `[1, 3, H, W]` pixel tensor from planar CHW f32 data (used by the
443/// runtime to hand media to `embed_multimodal`).
444pub fn pixels_to_tensor<B: Backend>(
445    data: Vec<f32>,
446    shape: [usize; 4],
447    device: &Device<B>,
448) -> Tensor<B, 4> {
449    Tensor::from_data(TensorData::new(data, shape), device)
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455    type TestBackend = burn::backend::NdArray<f32>;
456
457    #[test]
458    fn pixel_shuffle_matches_hf_ordering() {
459        // seq=16 (4x4 grid), s=2, C=1: channel groups must follow
460        // ((row_in_group * s) + col_in_group) * C + c ordering.
461        let device = Default::default();
462        let data: Vec<f32> = (0..16).map(|v| v as f32).collect();
463        let x = Tensor::<TestBackend, 3>::from_data(TensorData::new(data, [1, 16, 1]), &device);
464        let conn = Connector::<TestBackend> {
465            scale: 2,
466            proj: Tensor::eye(4, &device),
467        };
468        let out = conn.pixel_shuffle(x);
469        let got: Vec<f32> = out.into_data().to_vec().unwrap();
470        // Grid (row-major ids): 0 1 2 3 / 4 5 6 7 / 8 9 10 11 / 12 13 14 15
471        // Token 0 (top-left 2x2 group): rows 0-1, cols 0-1 → [0,1,4,5]
472        // Token 1: rows 0-1, cols 2-3 → [2,3,6,7]
473        // Token 2: rows 2-3, cols 0-1 → [8,9,12,13]
474        // Token 3: rows 2-3, cols 2-3 → [10,11,14,15]
475        let expected: Vec<f32> = vec![
476            0.0, 1.0, 4.0, 5.0, //
477            2.0, 3.0, 6.0, 7.0, //
478            8.0, 9.0, 12.0, 13.0, //
479            10.0, 11.0, 14.0, 15.0,
480        ];
481        assert_eq!(got, expected);
482    }
483
484    #[test]
485    fn image_prompt_expansion_shape() {
486        let s = image_prompt_expansion(64);
487        assert!(s.starts_with("<fake_token_around_image><global-img>"));
488        assert!(s.ends_with("<fake_token_around_image>"));
489        assert_eq!(s.matches("<image>").count(), 64);
490    }
491
492    #[test]
493    fn gelu_tanh_reference() {
494        let device = Default::default();
495        let x = Tensor::<TestBackend, 1>::from_data(TensorData::new(vec![0.0f32, 1.0, -1.0], [3]), &device);
496        let y: Vec<f32> = gelu_tanh(x).into_data().to_vec().unwrap();
497        // gelu(0)=0, gelu(1)≈0.8412, gelu(-1)≈-0.1588 (tanh approx).
498        assert!(y[0].abs() < 1e-5);
499        assert!((y[1] - 0.8412).abs() < 1e-3, "gelu(1) = {}", y[1]);
500        assert!((y[2] + 0.1588).abs() < 1e-3, "gelu(-1) = {}", y[2]);
501    }
502}