Skip to main content

ferrotensor_graph/
lib.rs

1//! Model building blocks on top of `ferrotensor-autograd`: linear layers with
2//! LoRA, RMSNorm, RoPE grouped-query attention, SwiGLU MLPs, Llama and LFM2
3//! decoders, and the fused attention layers in [`flash_attention`].
4
5use ferrotensor_autograd::Tensor;
6use ferrotensor_backend::{ComputeEngine, UnifiedTensor, WgpuContext};
7use std::collections::HashMap;
8use std::sync::Arc;
9
10pub mod flash_attention;
11/// LFM2 (Liquid foundation model) blocks.
12pub mod lfm;
13pub mod model_builder;
14
15pub use flash_attention::{
16    AttentionMask, FlashAttention, FlashAttentionGrads, GroupedQueryAttention,
17    KVCache as FlashKVCache, MultiQueryAttention, SlidingWindowAttention,
18};
19pub use lfm::Lfm2Model;
20
21// --- LoRA Adapter ---
22/// Low-rank adapter added to a [`Linear`]: `x @ a @ b * alpha / rank`.
23#[derive(Debug, Clone)]
24pub struct LoRAAdapter {
25    /// Down projection `[in, rank]`.
26    pub a: Tensor, // [In, Rank]
27    /// Up projection `[rank, out]` (zero-initialised, so training starts
28    /// from the base model).
29    pub b: Tensor, // [Rank, Out]
30    /// Scale numerator; the update is scaled by `alpha / rank`.
31    pub alpha: f32,
32    /// Adapter rank.
33    pub rank: usize,
34}
35
36impl LoRAAdapter {
37    /// An adapter for an `in_dim -> out_dim` projection. `a` is drawn
38    /// uniformly from `±1/sqrt(in_dim)` (Kaiming-uniform, as in PEFT) with a
39    /// deterministic generator seeded from the shape, and `b` is zero.
40    ///
41    /// A constant `a` would make every rank component identical, so the
42    /// adapter could never learn more than a rank-1 update.
43    pub fn new(
44        ctx: Arc<WgpuContext>,
45        in_dim: usize,
46        out_dim: usize,
47        rank: usize,
48        alpha: f32,
49    ) -> Self {
50        Self::new_seeded(ctx, in_dim, out_dim, rank, alpha, 0)
51    }
52
53    /// [`new`](Self::new) with an extra `seed`, so adapters of equal shape
54    /// (e.g. every layer's `q_proj`) start from different matrices.
55    pub fn new_seeded(
56        ctx: Arc<WgpuContext>,
57        in_dim: usize,
58        out_dim: usize,
59        rank: usize,
60        alpha: f32,
61        seed: u64,
62    ) -> Self {
63        let bound = 1.0 / (in_dim.max(1) as f32).sqrt();
64        let mut state = seed
65            .wrapping_mul(0x9E37_79B9_7F4A_7C15)
66            .wrapping_add((in_dim as u64) << 32 | (out_dim as u64) << 8 | rank as u64)
67            | 1;
68        let a_data: Vec<f32> = (0..in_dim * rank)
69            .map(|_| {
70                // xorshift64*
71                state ^= state >> 12;
72                state ^= state << 25;
73                state ^= state >> 27;
74                let bits = state.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 40;
75                let unit = bits as f32 / (1u64 << 24) as f32;
76                (unit * 2.0 - 1.0) * bound
77            })
78            .collect();
79        Self {
80            a: Tensor::new(
81                ctx.clone(),
82                UnifiedTensor::new(&ctx, &a_data, &[in_dim, rank]),
83            ),
84            b: Tensor::new(ctx.clone(), UnifiedTensor::zeros(&ctx, &[rank, out_dim])),
85            alpha,
86            rank,
87        }
88    }
89
90    /// The adapter tensors `[a, b]`.
91    pub fn params(&self) -> Vec<Tensor> {
92        vec![self.a.clone(), self.b.clone()]
93    }
94}
95
96/// Bias-free linear layer `y = x @ weight` with an optional LoRA adapter.
97#[derive(Debug, Clone)]
98pub struct Linear {
99    /// Weight `[in, out]` (already transposed from the PyTorch `[out, in]`).
100    pub weight: Tensor,
101    /// Optional low-rank adapter added to the output.
102    pub lora: Option<LoRAAdapter>,
103}
104
105impl Linear {
106    fn project_2d(&self, engine: &ComputeEngine, x: &Tensor) -> Tensor {
107        let mut out = Tensor::matmul(x, &self.weight, engine);
108
109        if let Some(lora) = &self.lora {
110            let temp = Tensor::matmul(x, &lora.a, engine);
111            let lora_out = Tensor::matmul(&temp, &lora.b, engine);
112            let scale = lora.alpha / lora.rank as f32;
113            let scale_tensor = Tensor::new(
114                x.ctx.clone(),
115                UnifiedTensor::new(
116                    &x.ctx,
117                    &vec![scale; lora_out.data.shape.iter().product()],
118                    &lora_out.data.shape,
119                ),
120            );
121            let scaled_lora = Tensor::mul(&lora_out, &scale_tensor, engine);
122            out = Tensor::add(&out, &scaled_lora, engine);
123        }
124
125        out
126    }
127
128    /// A layer without LoRA.
129    pub fn new(weight: Tensor) -> Self {
130        Self { weight, lora: None }
131    }
132
133    /// A layer with a LoRA adapter.
134    pub fn with_lora(weight: Tensor, lora: LoRAAdapter) -> Self {
135        Self {
136            weight,
137            lora: Some(lora),
138        }
139    }
140
141    /// Projects `[tokens, in]` or `[batch, seq, in]` to the same leading
142    /// shape with `out` features.
143    pub fn forward(&self, engine: &ComputeEngine, x: &Tensor) -> Tensor {
144        match x.data.shape.as_slice() {
145            [_, _] => self.project_2d(engine, x),
146            [batch, seq, _hidden] => {
147                let flat = Tensor::reshape_2d(x);
148                let projected = self.project_2d(engine, &flat);
149                Tensor::unflatten_last(&projected, *batch, *seq)
150            }
151            shape => panic!("Linear::forward expects 2D or 3D tensor, got {shape:?}"),
152        }
153    }
154
155    /// Trainable tensors (weights, then any LoRA adapters).
156    pub fn params(&self) -> Vec<Tensor> {
157        let mut p = vec![self.weight.clone()];
158        if let Some(lora) = &self.lora {
159            p.extend(lora.params());
160        }
161        p
162    }
163
164    /// Parameters keyed by Hugging Face-style names under `prefix`.
165    pub fn named_params(&self, prefix: &str) -> HashMap<String, Tensor> {
166        let mut map = HashMap::new();
167        map.insert(format!("{}.weight", prefix), self.weight.clone());
168        if let Some(lora) = &self.lora {
169            map.insert(format!("{}.lora_A.weight", prefix), lora.a.clone());
170            map.insert(format!("{}.lora_B.weight", prefix), lora.b.clone());
171        }
172        map
173    }
174
175    /// Only the LoRA adapter tensors (what LoRA fine-tuning updates).
176    pub fn lora_params(&self) -> Vec<Tensor> {
177        if let Some(lora) = &self.lora {
178            lora.params()
179        } else {
180            vec![]
181        }
182    }
183}
184
185/// Token embedding table.
186pub struct Embedding {
187    /// Embedding table `[vocab, hidden]`.
188    pub weight: Tensor,
189}
190
191impl Embedding {
192    /// Wraps an embedding table.
193    pub fn new(weight: Tensor) -> Self {
194        Self { weight }
195    }
196
197    /// Looks up `input_ids` and returns `[1, seq, hidden]`. The lookup runs
198    /// on the CPU (the table is read back from the GPU).
199    pub fn forward(&self, ctx: &WgpuContext, _engine: &ComputeEngine, input_ids: &[u32]) -> Tensor {
200        let hidden_dim = self.weight.data.shape[1];
201        let seq_len = input_ids.len();
202        let mut out_data = Vec::with_capacity(seq_len * hidden_dim);
203        let w_data = pollster::block_on(self.weight.data.to_vec(ctx));
204        for &id in input_ids {
205            let start = (id as usize) * hidden_dim;
206            let end = start + hidden_dim;
207            out_data.extend_from_slice(&w_data[start..end]);
208        }
209        Tensor::new(
210            self.weight.ctx.clone(),
211            UnifiedTensor::new(ctx, &out_data, &[1, seq_len, hidden_dim]),
212        )
213    }
214
215    /// Trainable tensors (weights, then any LoRA adapters).
216    pub fn params(&self) -> Vec<Tensor> {
217        vec![self.weight.clone()]
218    }
219
220    /// Parameters keyed by Hugging Face-style names under `prefix`.
221    pub fn named_params(&self, prefix: &str) -> HashMap<String, Tensor> {
222        let mut map = HashMap::new();
223        map.insert(format!("{}.weight", prefix), self.weight.clone());
224        map
225    }
226}
227
228/// Root-mean-square layer norm over the last dimension.
229pub struct RMSNorm {
230    /// Per-feature scale `[hidden]`.
231    pub weight: Tensor,
232    /// Epsilon added to the mean square.
233    pub eps: f32,
234}
235
236impl RMSNorm {
237    /// Wraps a scale vector and epsilon.
238    pub fn new(weight: Tensor, eps: f32) -> Self {
239        Self { weight, eps }
240    }
241
242    /// Normalises `input` over its last dimension.
243    pub fn forward(&self, engine: &ComputeEngine, input: &Tensor) -> Tensor {
244        Tensor::rms_norm(input, &self.weight, self.eps, engine)
245    }
246
247    /// Trainable tensors (weights, then any LoRA adapters).
248    pub fn params(&self) -> Vec<Tensor> {
249        vec![self.weight.clone()]
250    }
251
252    /// Parameters keyed by Hugging Face-style names under `prefix`.
253    pub fn named_params(&self, prefix: &str) -> HashMap<String, Tensor> {
254        let mut map = HashMap::new();
255        map.insert(format!("{}.weight", prefix), self.weight.clone());
256        map
257    }
258}
259
260/// Causal grouped-query self-attention with RoPE (Llama style).
261pub struct Attention {
262    /// Query projection `[hidden, n_heads * head_dim]`.
263    pub q_proj: Linear,
264    /// Key projection `[hidden, num_kv_heads * head_dim]`.
265    pub k_proj: Linear,
266    /// Value projection `[hidden, num_kv_heads * head_dim]`.
267    pub v_proj: Linear,
268    /// Output projection `[n_heads * head_dim, hidden]`.
269    pub o_proj: Linear,
270    /// Number of query heads.
271    pub n_heads: usize,
272    /// Dimension of each head (at most 128).
273    pub head_dim: usize,
274    /// Number of key/value heads; divides `n_heads`.
275    pub num_kv_heads: usize,
276    /// Attention dropout probability.
277    pub dropout_prob: f32,
278    /// RoPE base frequency (`rope_theta` in Hugging Face configs).
279    pub rope_theta: f32,
280}
281
282impl Attention {
283    /// Attends over `x` (`[batch, seq, hidden]`) whose first token is at
284    /// position `pos`. With `cache`, the new keys/values are written at `pos`
285    /// and the queries attend to the whole cache up to their own position.
286    pub fn forward(
287        &self,
288        engine: &ComputeEngine,
289        x: &Tensor,
290        pos: usize,
291        cache: Option<&KVCacheLayer>,
292        mask: Option<&UnifiedTensor>,
293    ) -> Tensor {
294        let q = self.q_proj.forward(engine, x);
295        let k = self.k_proj.forward(engine, x);
296        let v = self.v_proj.forward(engine, x);
297
298        let q = Tensor::rope_theta(&q, self.head_dim, pos, self.rope_theta, engine);
299        let k = Tensor::rope_theta(&k, self.head_dim, pos, self.rope_theta, engine);
300
301        // Reshape for GQA [batch, seq, n_heads, head_dim]
302        let batch = q.data.shape[0];
303        let seq_len = q.data.shape[1];
304
305        let Some(c) = cache else {
306            // Differentiable path (training and full-sequence evaluation).
307            let q_4d = Tensor::reshape(&q, &[batch, seq_len, self.n_heads, self.head_dim]);
308            let k_4d = Tensor::reshape(&k, &[batch, seq_len, self.num_kv_heads, self.head_dim]);
309            let v_4d = Tensor::reshape(&v, &[batch, seq_len, self.num_kv_heads, self.head_dim]);
310            let mask = mask.map(|m| Tensor::frozen(x.ctx.clone(), m.clone()));
311            let out = Tensor::flash_attention(
312                &q_4d,
313                &k_4d,
314                &v_4d,
315                mask.as_ref(),
316                true,
317                self.dropout_prob,
318                0,
319                engine,
320            );
321            let out = Tensor::reshape(&out, &[batch, seq_len, self.n_heads * self.head_dim]);
322            return self.o_proj.forward(engine, &out);
323        };
324
325        // Incremental decoding against a KV cache (inference only, no autograd).
326        let q_4d = q
327            .data
328            .reshaped(&[batch, seq_len, self.n_heads, self.head_dim]);
329        let gqa = GroupedQueryAttention::new(
330            self.n_heads * self.head_dim,
331            self.n_heads,
332            self.num_kv_heads,
333            true, // causal
334            self.dropout_prob,
335        );
336
337        // Write the new keys/values at `pos` (cache is `[1, max_seq, kv * head_dim]`).
338        let offset = pos * self.num_kv_heads * self.head_dim * 4;
339        engine.copy_tensor(&x.ctx, &k.data, &c.key, offset);
340        engine.copy_tensor(&x.ctx, &v.data, &c.value, offset);
341
342        let cache_shape = &c.key.shape;
343        let kv_shape = [
344            cache_shape[0],
345            cache_shape[1],
346            self.num_kv_heads,
347            self.head_dim,
348        ];
349        let c_k_4d = c.key.reshaped(&kv_shape);
350        let c_v_4d = c.value.reshaped(&kv_shape);
351
352        // Queries sit at positions pos..pos + seq_len; causal masking
353        // hides the cache slots that have not been written yet.
354        let attn_out_4d = gqa.forward_at(&q_4d, &c_k_4d, &c_v_4d, mask, pos, &x.ctx, engine);
355        let attn_out = attn_out_4d.reshaped(&[batch, seq_len, self.n_heads * self.head_dim]);
356        let attn_tensor = Tensor::new(x.ctx.clone(), attn_out);
357        self.o_proj.forward(engine, &attn_tensor)
358    }
359
360    /// Trainable tensors (weights, then any LoRA adapters).
361    pub fn params(&self) -> Vec<Tensor> {
362        let mut p = Vec::new();
363        p.extend(self.q_proj.params());
364        p.extend(self.k_proj.params());
365        p.extend(self.v_proj.params());
366        p.extend(self.o_proj.params());
367        p
368    }
369
370    /// Parameters keyed by Hugging Face-style names under `prefix`.
371    pub fn named_params(&self, prefix: &str) -> HashMap<String, Tensor> {
372        let mut map = HashMap::new();
373        map.extend(self.q_proj.named_params(&format!("{}.q_proj", prefix)));
374        map.extend(self.k_proj.named_params(&format!("{}.k_proj", prefix)));
375        map.extend(self.v_proj.named_params(&format!("{}.v_proj", prefix)));
376        map.extend(self.o_proj.named_params(&format!("{}.o_proj", prefix)));
377        map
378    }
379
380    /// Only the LoRA adapter tensors (what LoRA fine-tuning updates).
381    pub fn lora_params(&self) -> Vec<Tensor> {
382        let mut p = Vec::new();
383        p.extend(self.q_proj.lora_params());
384        p.extend(self.k_proj.lora_params());
385        p.extend(self.v_proj.lora_params());
386        p.extend(self.o_proj.lora_params());
387        p
388    }
389}
390
391/// SwiGLU feed-forward: `down(silu(gate(x)) * up(x))`.
392pub struct MLP {
393    /// Gate projection (passed through SiLU).
394    pub gate_proj: Linear,
395    /// Up projection.
396    pub up_proj: Linear,
397    /// Down projection back to the hidden size.
398    pub down_proj: Linear,
399}
400
401impl MLP {
402    /// Applies the feed-forward to `[tokens, hidden]` or `[batch, seq, hidden]`.
403    pub fn forward(&self, engine: &ComputeEngine, input: &Tensor) -> Tensor {
404        let up = self.up_proj.forward(engine, input);
405        let gate = self.gate_proj.forward(engine, input);
406        // silu_gate_fused(a, b) = silu(a) * b; SwiGLU applies SiLU to the gate.
407        let fused = Tensor::silu_gate_fused(&gate, &up, engine);
408        self.down_proj.forward(engine, &fused)
409    }
410
411    /// Trainable tensors (weights, then any LoRA adapters).
412    pub fn params(&self) -> Vec<Tensor> {
413        let mut p = Vec::new();
414        p.extend(self.gate_proj.params());
415        p.extend(self.up_proj.params());
416        p.extend(self.down_proj.params());
417        p
418    }
419
420    /// Parameters keyed by Hugging Face-style names under `prefix`.
421    pub fn named_params(&self, prefix: &str) -> HashMap<String, Tensor> {
422        let mut map = HashMap::new();
423        map.extend(
424            self.gate_proj
425                .named_params(&format!("{}.gate_proj", prefix)),
426        );
427        map.extend(self.up_proj.named_params(&format!("{}.up_proj", prefix)));
428        map.extend(
429            self.down_proj
430                .named_params(&format!("{}.down_proj", prefix)),
431        );
432        map
433    }
434
435    /// Only the LoRA adapter tensors (what LoRA fine-tuning updates).
436    pub fn lora_params(&self) -> Vec<Tensor> {
437        let mut p = Vec::new();
438        p.extend(self.gate_proj.lora_params());
439        p.extend(self.up_proj.lora_params());
440        p.extend(self.down_proj.lora_params());
441        p
442    }
443}
444
445/// Pre-norm Llama decoder block: attention and MLP with residuals.
446pub struct LlamaBlock {
447    /// Norm before attention (`input_layernorm`).
448    pub norm1: RMSNorm,
449    /// Self-attention.
450    pub attn: Attention,
451    /// Norm before the MLP (`post_attention_layernorm`).
452    pub norm2: RMSNorm,
453    /// Feed-forward.
454    pub mlp: MLP,
455}
456
457impl LlamaBlock {
458    /// Runs the block on `input` (`[batch, seq, hidden]`); see
459    /// [`Attention::forward`] for `pos`, `cache` and `mask`.
460    pub fn forward(
461        &self,
462        engine: &ComputeEngine,
463        input: &Tensor,
464        pos: usize,
465        cache: Option<&KVCacheLayer>,
466        mask: Option<&UnifiedTensor>,
467    ) -> Tensor {
468        let normalized1 = self.norm1.forward(engine, input);
469        let attn_out = self.attn.forward(engine, &normalized1, pos, cache, mask);
470        let h = Tensor::add(input, &attn_out, engine);
471        let normalized2 = self.norm2.forward(engine, &h);
472        let mlp_out = self.mlp.forward(engine, &normalized2);
473        Tensor::add(&h, &mlp_out, engine)
474    }
475
476    /// Trainable tensors (weights, then any LoRA adapters).
477    pub fn params(&self) -> Vec<Tensor> {
478        let mut p = Vec::new();
479        p.extend(self.norm1.params());
480        p.extend(self.attn.params());
481        p.extend(self.norm2.params());
482        p.extend(self.mlp.params());
483        p
484    }
485
486    /// Parameters keyed by Hugging Face-style names under `prefix`.
487    pub fn named_params(&self, prefix: &str) -> HashMap<String, Tensor> {
488        let mut map = HashMap::new();
489        map.extend(
490            self.norm1
491                .named_params(&format!("{}.input_layernorm", prefix)),
492        );
493        map.extend(self.attn.named_params(&format!("{}.self_attn", prefix)));
494        map.extend(
495            self.norm2
496                .named_params(&format!("{}.post_attention_layernorm", prefix)),
497        );
498        map.extend(self.mlp.named_params(&format!("{}.mlp", prefix)));
499        map
500    }
501
502    /// Only the LoRA adapter tensors (what LoRA fine-tuning updates).
503    pub fn lora_params(&self) -> Vec<Tensor> {
504        let mut p = Vec::new();
505        p.extend(self.attn.lora_params());
506        p.extend(self.mlp.lora_params());
507        p
508    }
509}
510
511/// A Llama-style decoder: embedding, blocks, final norm and LM head.
512pub struct LlamaModel {
513    /// Token embeddings.
514    pub embed_tokens: Embedding,
515    /// Decoder blocks.
516    pub layers: Vec<LlamaBlock>,
517    /// Final norm.
518    pub norm: RMSNorm,
519    /// Projection to vocabulary logits.
520    pub lm_head: Linear,
521}
522
523impl LlamaModel {
524    /// Logits `[1, seq, vocab]` for `input_ids`, the first of which is at
525    /// `start_pos`.
526    pub fn forward(
527        &self,
528        ctx: &WgpuContext,
529        engine: &ComputeEngine,
530        input_ids: &[u32],
531        start_pos: usize,
532        cache: Option<&mut KVCache>,
533        mask: Option<&UnifiedTensor>,
534    ) -> Tensor {
535        let mut x = self.embed_tokens.forward(ctx, engine, input_ids);
536        for (i, layer) in self.layers.iter().enumerate() {
537            let layer_cache = cache.as_ref().map(|c| &c.layers[i]);
538            x = layer.forward(engine, &x, start_pos, layer_cache, mask);
539        }
540        let x_norm = self.norm.forward(engine, &x);
541        self.lm_head.forward(engine, &x_norm)
542    }
543
544    /// Trainable tensors (weights, then any LoRA adapters).
545    pub fn params(&self) -> Vec<Tensor> {
546        let mut p = Vec::new();
547        p.extend(self.embed_tokens.params());
548        for layer in &self.layers {
549            p.extend(layer.params());
550        }
551        p.extend(self.norm.params());
552        p.extend(self.lm_head.params());
553        p
554    }
555
556    /// Parameters keyed by Hugging Face-style names under `prefix`.
557    pub fn named_params(&self, prefix: &str) -> HashMap<String, Tensor> {
558        let mut map = HashMap::new();
559        map.extend(
560            self.embed_tokens
561                .named_params(&format!("{}.embed_tokens", prefix)),
562        );
563        for (i, layer) in self.layers.iter().enumerate() {
564            map.extend(layer.named_params(&format!("{}.layers.{}", prefix, i)));
565        }
566        map.extend(self.norm.named_params(&format!("{}.norm", prefix)));
567        map.extend(self.lm_head.named_params("lm_head"));
568        map
569    }
570
571    /// Only the LoRA adapter tensors (what LoRA fine-tuning updates).
572    pub fn lora_params(&self) -> Vec<Tensor> {
573        let mut p = Vec::new();
574        for layer in &self.layers {
575            p.extend(layer.lora_params());
576        }
577        p.extend(self.lm_head.lora_params());
578        p
579    }
580}
581
582/// Cached keys and values of one layer for incremental decoding.
583pub struct KVCacheLayer {
584    /// Keys `[batch, max_seq, num_kv_heads * head_dim]`.
585    pub key: UnifiedTensor,
586    /// Values, same shape as `key`.
587    pub value: UnifiedTensor,
588}
589/// Per-layer KV caches of a model.
590pub struct KVCache {
591    /// One cache per decoder layer.
592    pub layers: Vec<KVCacheLayer>,
593}
594
595/// Helper to create a padding mask for Flash Attention V2 (additive mask)
596///
597/// Returns a tensor of shape [batch_size, 1, 1, max_seq_len] (or broadcastable).
598/// - 0.0 for valid positions
599/// - -1e9 for padded positions
600pub fn create_padding_mask(
601    ctx: &WgpuContext,
602    lengths: &[usize],
603    max_seq_len: usize,
604) -> UnifiedTensor {
605    let batch_size = lengths.len();
606    let mut mask_data = vec![0.0f32; batch_size * max_seq_len];
607
608    for (b, &len) in lengths.iter().enumerate() {
609        for s in 0..max_seq_len {
610            if s >= len {
611                mask_data[b * max_seq_len + s] = -1e9;
612            }
613        }
614    }
615
616    UnifiedTensor::new(ctx, &mask_data, &[batch_size, 1, 1, max_seq_len])
617}