Skip to main content

combs_models/
llama.rs

1//! Llama-family architecture (Llama, SmolLM2, …) on the [`GenerativeModel`]
2//! contract.
3//!
4//! Expects HuggingFace weight names:
5//! `model.embed_tokens.weight`,
6//! `model.layers.{i}.self_attn.{q,k,v,o}_proj.weight`,
7//! `model.layers.{i}.mlp.{gate,up,down}_proj.weight`,
8//! `model.layers.{i}.{input,post_attention}_layernorm.weight`,
9//! `model.norm.weight`, `lm_head.weight` (optional when tied).
10//!
11//! Weights are hand-rolled matmuls (`y = x @ W^T`) rather than burn `nn`
12//! modules so they can be streamed straight from a [`ModelSource`] without
13//! record files. The family has no biases; the loader tolerates optional
14//! `*_proj.bias` tensors for related checkpoints that carry them.
15
16use std::ops::Range;
17
18use burn::tensor::{Device, Int, Tensor, backend::Backend};
19use combs_formats::{ModelMetadata, ModelSource};
20
21use crate::kv::{CacheConfig, CacheKind, ContiguousKVCache, KVCache, PagedKVCache};
22use crate::matmul::safe_matmul;
23use crate::norm::rms_norm;
24use crate::rope::RotaryEmbedding;
25use crate::traits::GenerativeModel;
26use crate::{ModelError, Result};
27
28/// One decoder layer's weights. All projections are `[out, in]` (HF layout).
29struct LlamaLayer<B: Backend> {
30    q: Tensor<B, 2>,
31    k: Tensor<B, 2>,
32    v: Tensor<B, 2>,
33    o: Tensor<B, 2>,
34    q_bias: Option<Tensor<B, 1>>,
35    k_bias: Option<Tensor<B, 1>>,
36    v_bias: Option<Tensor<B, 1>>,
37    o_bias: Option<Tensor<B, 1>>,
38    gate: Tensor<B, 2>,
39    up: Tensor<B, 2>,
40    down: Tensor<B, 2>,
41    input_norm: Tensor<B, 1>,
42    post_norm: Tensor<B, 1>,
43}
44
45/// Llama-family causal LM.
46pub struct LlamaModel<B: Backend> {
47    metadata: ModelMetadata,
48    embed: Tensor<B, 2>, // [vocab, hidden]
49    lm_head: Option<Tensor<B, 2>>, // None => tied to `embed`
50    final_norm: Tensor<B, 1>,
51    layers: Vec<LlamaLayer<B>>,
52    rotary: RotaryEmbedding<B>,
53    /// 1 / sqrt(head_dim)
54    scale: f64,
55}
56
57/// `y = x @ W^T (+ b)` for `[batch, seq, in] @ [out, in]`.
58pub(crate) fn linear<B: Backend>(
59    x: Tensor<B, 3>,
60    w: &Tensor<B, 2>,
61    bias: Option<&Tensor<B, 1>>,
62) -> Tensor<B, 3> {
63    // matmul is same-rank only, so the weight is batch-unsqueezed.
64    // `safe_matmul`: at seq >= 512 with in >= 512 this shape enters the
65    // broken wgpu/Metal matmul region (see combs_models::matmul docs).
66    let out = safe_matmul(x, w.clone().transpose().unsqueeze_dim::<3>(0));
67    match bias {
68        Some(b) => {
69            let [batch, seq, dim] = out.dims();
70            out + b.clone().reshape([1, 1, dim]).expand([batch, seq, dim])
71        }
72        None => out,
73    }
74}
75
76fn load_weight<B: Backend, const D: usize>(
77    source: &dyn ModelSource,
78    device: &Device<B>,
79    name: &str,
80) -> Result<Tensor<B, D>> {
81    source
82        .open_tensor(name)
83        .map_err(|e| match e {
84            combs_formats::FormatError::TensorNotFound(_) => {
85                ModelError::MissingTensor(name.to_string())
86            }
87            other => ModelError::Format(other),
88        })?
89        .load_to_tensor::<B, D>(device)
90        .map_err(ModelError::Format)
91}
92
93pub(crate) fn load_tensor<B: Backend, const D: usize>(
94    source: &dyn ModelSource,
95    device: &Device<B>,
96    name: &str,
97) -> Result<Tensor<B, D>> {
98    load_weight(source, device, name)
99}
100
101fn load_optional_bias<B: Backend>(
102    source: &dyn ModelSource,
103    device: &Device<B>,
104    name: &str,
105) -> Result<Option<Tensor<B, 1>>> {
106    match source.open_tensor(name) {
107        Ok(reader) => Ok(Some(
108            reader.load_to_tensor::<B, 1>(device).map_err(ModelError::Format)?,
109        )),
110        Err(combs_formats::FormatError::TensorNotFound(_)) => Ok(None),
111        Err(e) => Err(ModelError::Format(e)),
112    }
113}
114
115impl<B: Backend> LlamaModel<B> {
116    pub(crate) fn expect_shape(name: &str, got: &[usize], expected: &[usize]) -> Result<()> {
117        if got == expected {
118            Ok(())
119        } else {
120            Err(ModelError::BadShape {
121                tensor: name.to_string(),
122                expected: expected.to_vec(),
123                got: got.to_vec(),
124            })
125        }
126    }
127
128    /// Shared trunk for prefill and decode: embeddings in, final-normed
129    /// hidden states out. `pos` is the absolute position of the first input
130    /// token.
131    pub(crate) fn forward_hidden(
132        &self,
133        mut x: Tensor<B, 3>,
134        cache: &mut dyn KVCache<B>,
135        pos: usize,
136    ) -> Tensor<B, 3> {
137        let m = &self.metadata;
138        let [_, seq, _] = x.dims();
139
140        for (layer_idx, layer) in self.layers.iter().enumerate() {
141            // --- attention block ------------------------------------------------
142            let h = rms_norm(x.clone(), layer.input_norm.clone(), m.rms_norm_eps);
143            let q = linear(h.clone(), &layer.q, layer.q_bias.as_ref());
144            let k = linear(h.clone(), &layer.k, layer.k_bias.as_ref());
145            let v = linear(h, &layer.v, layer.v_bias.as_ref());
146
147            let q = q
148                .reshape([1, seq, m.num_attention_heads, m.head_dim])
149                .swap_dims(1, 2);
150            let k = k
151                .reshape([1, seq, m.num_key_value_heads, m.head_dim])
152                .swap_dims(1, 2);
153            let v = v
154                .reshape([1, seq, m.num_key_value_heads, m.head_dim])
155                .swap_dims(1, 2);
156
157            let q = self.rotary.apply(q, pos);
158            let k = self.rotary.apply(k, pos);
159
160            // The cache owns K/V layout, GQA expansion and causal masking.
161            let ctx = cache.attention(layer_idx, q, k, v, pos, self.scale);
162            let ctx = ctx
163                .swap_dims(1, 2)
164                .reshape([1, seq, m.num_attention_heads * m.head_dim]);
165            let attn_out = linear(ctx, &layer.o, layer.o_bias.as_ref());
166            x = x + attn_out;
167
168            // --- MLP block (SwiGLU) ---------------------------------------------
169            let h = rms_norm(x.clone(), layer.post_norm.clone(), m.rms_norm_eps);
170            let gated = burn::tensor::activation::silu(linear(h.clone(), &layer.gate, None))
171                * linear(h.clone(), &layer.up, None);
172            let mlp_out = linear(gated, &layer.down, None);
173            x = x + mlp_out;
174        }
175
176        rms_norm(x, self.final_norm.clone(), m.rms_norm_eps)
177    }
178
179    /// Logits of the last sequence position: `[1, hidden] -> [1, vocab]`.
180    pub(crate) fn last_logits(&self, hidden: Tensor<B, 3>) -> Tensor<B, 2> {
181        let [_, seq, hidden_size] = hidden.dims();
182        let last = hidden.narrow(1, seq - 1, 1).reshape([1, hidden_size]);
183        let w = self.lm_head.as_ref().unwrap_or(&self.embed);
184        safe_matmul(last, w.clone().transpose())
185    }
186}
187
188impl<B: Backend> GenerativeModel<B> for LlamaModel<B> {
189    fn metadata(&self) -> &ModelMetadata {
190        &self.metadata
191    }
192
193    fn load(source: &dyn ModelSource, device: &Device<B>) -> Result<Self> {
194        Self::load_with_prefix(source, device, "model")
195    }
196
197    fn create_kv_cache(&self, config: &CacheConfig) -> Box<dyn KVCache<B>> {
198        match config.kind {
199            CacheKind::Contiguous => {
200                Box::new(ContiguousKVCache::<B>::new(self.metadata.num_hidden_layers))
201            }
202            CacheKind::Paged => Box::new(PagedKVCache::<B>::new(
203                self.metadata.num_hidden_layers,
204                *config,
205            )),
206        }
207    }
208
209    fn embed(&self, tokens: Tensor<B, 2, Int>) -> Tensor<B, 3> {
210        let [batch, seq] = tokens.dims();
211        let flat = tokens.reshape([batch * seq]);
212        self.embed
213            .clone()
214            .select(0, flat)
215            .reshape([batch, seq, self.metadata.hidden_size])
216    }
217
218    fn prefill(
219        &mut self,
220        input: Tensor<B, 3>,
221        cache: &mut dyn KVCache<B>,
222        pos: Range<u32>,
223    ) -> Tensor<B, 2> {
224        let [_, seq, _] = input.dims();
225        assert_eq!(
226            seq,
227            (pos.end - pos.start) as usize,
228            "prefill pos range must match the input sequence length"
229        );
230        let hidden = self.forward_hidden(input, cache, pos.start as usize);
231        self.last_logits(hidden)
232    }
233
234    fn decode(&mut self, input: Tensor<B, 3>, cache: &mut dyn KVCache<B>) -> Tensor<B, 2> {
235        let pos = cache.seq_len();
236        let hidden = self.forward_hidden(input, cache, pos);
237        self.last_logits(hidden)
238    }
239}
240
241impl<B: Backend> LlamaModel<B> {
242    /// Loads the text stack with weight names under `prefix` (e.g. `"model"`
243    /// for plain Llama, `"model.text_model"` for Idefics3/SmolVLM).
244    /// `lm_head.weight` always stays top-level.
245    pub(crate) fn load_with_prefix(
246        source: &dyn ModelSource,
247        device: &Device<B>,
248        prefix: &str,
249    ) -> Result<Self> {
250        let m = source.metadata().clone();
251
252        let embed: Tensor<B, 2> =
253            load_weight(source, device, &format!("{prefix}.embed_tokens.weight"))?;
254        Self::expect_shape(
255            "embed_tokens.weight",
256            &embed.dims(),
257            &[m.vocab_size, m.hidden_size],
258        )?;
259
260        let lm_head = if m.tie_word_embeddings {
261            None
262        } else {
263            let w: Tensor<B, 2> = load_weight(source, device, "lm_head.weight")?;
264            Self::expect_shape("lm_head.weight", &w.dims(), &[m.vocab_size, m.hidden_size])?;
265            Some(w)
266        };
267
268        let final_norm: Tensor<B, 1> =
269            load_weight(source, device, &format!("{prefix}.norm.weight"))?;
270
271        let mut layers = Vec::with_capacity(m.num_hidden_layers);
272        for i in 0..m.num_hidden_layers {
273            let p = format!("{prefix}.layers.{i}");
274            let q: Tensor<B, 2> =
275                load_weight(source, device, &format!("{p}.self_attn.q_proj.weight"))?;
276            let k: Tensor<B, 2> =
277                load_weight(source, device, &format!("{p}.self_attn.k_proj.weight"))?;
278            let v: Tensor<B, 2> =
279                load_weight(source, device, &format!("{p}.self_attn.v_proj.weight"))?;
280            let o: Tensor<B, 2> =
281                load_weight(source, device, &format!("{p}.self_attn.o_proj.weight"))?;
282            Self::expect_shape(
283                &format!("{p}.self_attn.q_proj.weight"),
284                &q.dims(),
285                &[m.num_attention_heads * m.head_dim, m.hidden_size],
286            )?;
287            Self::expect_shape(
288                &format!("{p}.self_attn.k_proj.weight"),
289                &k.dims(),
290                &[m.num_key_value_heads * m.head_dim, m.hidden_size],
291            )?;
292
293            // Biases are absent in this model family but tolerated for
294            // related checkpoints (e.g. some Qwen releases).
295            let bias = |proj: &str| -> Result<Option<Tensor<B, 1>>> {
296                if m.attention_bias || proj.starts_with("mlp") {
297                    load_optional_bias(
298                        source,
299                        device,
300                        &format!("{p}.{proj}.bias"),
301                    )
302                } else {
303                    Ok(None)
304                }
305            };
306
307            layers.push(LlamaLayer {
308                q,
309                k,
310                v,
311                o,
312                q_bias: bias("self_attn.q_proj")?,
313                k_bias: bias("self_attn.k_proj")?,
314                v_bias: bias("self_attn.v_proj")?,
315                o_bias: bias("self_attn.o_proj")?,
316                gate: load_weight(source, device, &format!("{p}.mlp.gate_proj.weight"))?,
317                up: load_weight(source, device, &format!("{p}.mlp.up_proj.weight"))?,
318                down: load_weight(source, device, &format!("{p}.mlp.down_proj.weight"))?,
319                input_norm: load_weight(source, device, &format!("{p}.input_layernorm.weight"))?,
320                post_norm: load_weight(
321                    source,
322                    device,
323                    &format!("{p}.post_attention_layernorm.weight"),
324                )?,
325            });
326        }
327
328        let rotary =
329            RotaryEmbedding::new(m.head_dim, m.rope_theta, m.max_position_embeddings, device);
330
331        Ok(LlamaModel {
332            scale: 1.0 / (m.head_dim as f64).sqrt(),
333            metadata: m,
334            embed,
335            lm_head,
336            final_norm,
337            layers,
338            rotary,
339        })
340    }
341}