Skip to main content

nuvec_nn/
layers.rs

1//! Layers, as in MLX's `mlx.nn` (and mlx-lm's LoRA layer). Initialisation draws from this
2//! thread's key sequence in MLX's order, so the same seed gives MLX's values.
3//!
4//! Constructors take no `bool` and return no `Result`: they build lazy arrays, and an
5//! invalid size gives failed parameters whose error surfaces at the first boundary. Every
6//! `forward` returns an [`Array`] and is `#[track_caller]`: an invalid input gives a failed
7//! array whose error (`check()`, `eval()`, …) reports the line that called `forward`.
8
9use std::cell::RefCell;
10
11use nuvec::{Array, DType, OpErrorKind, Param, fast, ops, random};
12
13use crate::Module;
14
15thread_local! {
16    /// The key sequence before and after the bias draw of the last `Linear::new` on this
17    /// thread, and the bias it drew: `without_bias` rewinds the sequence when nothing was
18    /// drawn since, so `Linear::new(i, o).without_bias()` draws exactly MLX's
19    /// `nn.Linear(i, o, bias=False)`.
20    static BIAS_DRAW: RefCell<Option<(Array, Array, usize)>> = const { RefCell::new(None) };
21}
22
23/// A uniform draw in `[-1/√input, 1/√input)` (MLX's `nn.Linear` initialisation).
24#[track_caller]
25fn uniform_init(input: i32, shape: &[i32]) -> Array {
26    let scale = (1.0 / f64::from(input.max(1))).sqrt();
27    random::uniform(-scale, scale, shape, DType::F32, None)
28}
29
30/// An affine layer `y = x Wᵀ + b`, with `W` of shape `[output, input]` and `b` of shape
31/// `[output]` (MLX's `nn.Linear`).
32#[derive(Debug, Clone, Module)]
33pub struct Linear {
34    /// The weight, `[output_dims, input_dims]`.
35    pub weight: Param,
36    /// The bias, `[output_dims]`.
37    pub bias: Option<Param>,
38}
39
40impl Linear {
41    /// A layer with a bias, initialised as MLX's `nn.Linear`: weight, then bias, drawn
42    /// uniformly from `[-1/√input, 1/√input)`.
43    #[track_caller]
44    pub fn new(input_dims: i32, output_dims: i32) -> Self {
45        let weight = uniform_init(input_dims, &[output_dims, input_dims]);
46        let before = random::sequence_key();
47        let bias = uniform_init(input_dims, &[output_dims]);
48        let after = random::sequence_key();
49        BIAS_DRAW.set(Some((before, after, nuvec::graph::id(&bias))));
50        Self {
51            weight: Param::new(weight),
52            bias: Some(Param::new(bias)),
53        }
54    }
55
56    /// The layer without its bias. Right after [`Linear::new`] it also undoes the bias's
57    /// draw from the key sequence, so the initialisation equals MLX's
58    /// `nn.Linear(i, o, bias=False)`.
59    pub fn without_bias(mut self) -> Self {
60        if let Some(bias) = self.bias.take() {
61            BIAS_DRAW.with_borrow_mut(|draw| {
62                if let Some((before, after, id)) = draw.take()
63                    && id == nuvec::graph::id(&bias)
64                    && nuvec::graph::id(&random::sequence_key()) == nuvec::graph::id(&after)
65                {
66                    random::set_sequence_key(before);
67                }
68            });
69        }
70        self
71    }
72
73    /// `x Wᵀ + b` over the last axis of `x`; with a bias it is one fused `addmm`, as in
74    /// MLX.
75    #[track_caller]
76    pub fn forward(&self, x: &Array) -> Array {
77        match &self.bias {
78            Some(b) => b.addmm(x, &self.weight.t(), 1.0, 1.0),
79            None => x.matmul(&self.weight.t()),
80        }
81    }
82}
83
84/// A [`Linear`] layer with its weight quantized (MLX's `nn.QuantizedLinear`): `x Wᵀ + b`
85/// computed by `quantized_matmul` from the packed weight, its scales and biases.
86///
87/// The packed weight, scales and biases are buffers (never trained, also after
88/// `unfreeze`); the bias is a trainable parameter.
89#[derive(Debug, Clone, Module)]
90pub struct QuantizedLinear {
91    /// The packed weight (uint32), `[output_dims, input_dims · bits / 32]`.
92    #[tree(buffer)]
93    pub weight: Array,
94    /// One scale per group of input values, `[output_dims, input_dims / group_size]`.
95    #[tree(buffer)]
96    pub scales: Array,
97    /// One bias per group of input values, shaped like `scales`.
98    #[tree(buffer)]
99    pub biases: Array,
100    /// The layer's bias, `[output_dims]`.
101    pub bias: Option<Param>,
102    /// Input values per scale and bias.
103    #[tree(skip)]
104    pub group_size: i32,
105    /// Bits per weight.
106    #[tree(skip)]
107    pub bits: i32,
108}
109
110impl QuantizedLinear {
111    /// Quantizes `linear`'s weight with MLX's defaults (groups of 64 values, 4 bits); the
112    /// bias is kept.
113    #[track_caller]
114    pub fn from_linear(linear: &Linear) -> Self {
115        Self::from_linear_with(linear, 64, 4)
116    }
117
118    /// Quantizes `linear`'s weight in groups of `group_size` (32, 64 or 128) input values
119    /// with `bits` (1–6 or 8) bits each; the bias is kept.
120    #[track_caller]
121    pub fn from_linear_with(linear: &Linear, group_size: i32, bits: i32) -> Self {
122        let (weight, scales, biases) = linear.weight.quantize(group_size, bits);
123        Self {
124            weight,
125            scales,
126            biases,
127            bias: linear.bias.clone(),
128            group_size,
129            bits,
130        }
131    }
132
133    /// The dequantized weight, `[output_dims, input_dims]`.
134    #[track_caller]
135    pub fn dequantized_weight(&self) -> Array {
136        self.weight
137            .dequantize(&self.scales, &self.biases, self.group_size, self.bits)
138    }
139
140    /// `x Wᵀ + b` over the last axis of `x`.
141    #[track_caller]
142    pub fn forward(&self, x: &Array) -> Array {
143        let y = x.quantized_matmul(
144            &self.weight,
145            &self.scales,
146            &self.biases,
147            true,
148            self.group_size,
149            self.bits,
150        );
151        match &self.bias {
152            Some(b) => y.add(b),
153            None => y,
154        }
155    }
156}
157
158/// A lookup table of `dims`-dimensional vectors for integer ids (MLX's `nn.Embedding`).
159#[derive(Debug, Clone, Module)]
160pub struct Embedding {
161    /// The table, `[num_embeddings, dims]`.
162    pub weight: Param,
163}
164
165impl Embedding {
166    /// A table drawn from a normal distribution with standard deviation `1/√dims`, as
167    /// MLX.
168    #[track_caller]
169    pub fn new(num_embeddings: i32, dims: i32) -> Self {
170        let scale = (1.0 / f64::from(dims.max(1))).sqrt();
171        let weight = random::normal([num_embeddings, dims], DType::F32, 0.0, scale, None);
172        Self {
173            weight: Param::new(weight),
174        }
175    }
176
177    /// The rows for the ids in `x`: shape `x.shape + [dims]`.
178    #[track_caller]
179    pub fn forward(&self, x: &Array) -> Array {
180        self.weight.take(x, 0)
181    }
182
183    /// `x Wᵀ`: the table used as an output projection (tied embeddings).
184    #[track_caller]
185    pub fn as_linear(&self, x: &Array) -> Array {
186        x.matmul(&self.weight.t())
187    }
188}
189
190/// An [`Embedding`] with its table quantized (MLX's `nn.QuantizedEmbedding`): rows are
191/// gathered packed and dequantized; the packed table, scales and biases are buffers.
192#[derive(Debug, Clone, Module)]
193pub struct QuantizedEmbedding {
194    /// The packed table (uint32), `[num_embeddings, dims · bits / 32]`.
195    #[tree(buffer)]
196    pub weight: Array,
197    /// One scale per group of values, `[num_embeddings, dims / group_size]`.
198    #[tree(buffer)]
199    pub scales: Array,
200    /// One bias per group of values, shaped like `scales`.
201    #[tree(buffer)]
202    pub biases: Array,
203    /// Values per scale and bias.
204    #[tree(skip)]
205    pub group_size: i32,
206    /// Bits per value.
207    #[tree(skip)]
208    pub bits: i32,
209}
210
211impl QuantizedEmbedding {
212    /// Quantizes `embedding`'s table with MLX's defaults (groups of 64, 4 bits).
213    #[track_caller]
214    pub fn from_embedding(embedding: &Embedding) -> Self {
215        Self::from_embedding_with(embedding, 64, 4)
216    }
217
218    /// Quantizes `embedding`'s table in groups of `group_size` values with `bits` bits.
219    #[track_caller]
220    pub fn from_embedding_with(embedding: &Embedding, group_size: i32, bits: i32) -> Self {
221        let (weight, scales, biases) = embedding.weight.quantize(group_size, bits);
222        Self {
223            weight,
224            scales,
225            biases,
226            group_size,
227            bits,
228        }
229    }
230
231    /// The rows for the ids in `x`, dequantized: shape `x.shape + [dims]`.
232    #[track_caller]
233    pub fn forward(&self, x: &Array) -> Array {
234        self.weight.take(x, 0).dequantize(
235            &self.scales.take(x, 0),
236            &self.biases.take(x, 0),
237            self.group_size,
238            self.bits,
239        )
240    }
241
242    /// `x Wᵀ` with the quantized table (tied embeddings).
243    #[track_caller]
244    pub fn as_linear(&self, x: &Array) -> Array {
245        x.quantized_matmul(
246            &self.weight,
247            &self.scales,
248            &self.biases,
249            true,
250            self.group_size,
251            self.bits,
252        )
253    }
254}
255
256/// Root-mean-square normalisation over the last axis with a learned scale (MLX's
257/// `nn.RMSNorm`, the fused `fast::rms_norm`).
258#[derive(Debug, Clone, Module)]
259pub struct RmsNorm {
260    /// The scale, `[dims]`, initially ones.
261    pub weight: Param,
262    /// Added to the mean square (MLX's default `1e-5`).
263    #[tree(skip)]
264    pub eps: f32,
265}
266
267impl RmsNorm {
268    /// A layer over `dims` features with MLX's default `eps` (`1e-5`).
269    #[track_caller]
270    pub fn new(dims: i32) -> Self {
271        Self {
272            weight: Param::new(ops::ones([dims], DType::F32)),
273            eps: 1e-5,
274        }
275    }
276
277    /// Sets `eps`.
278    pub fn eps(mut self, eps: f32) -> Self {
279        self.eps = eps;
280        self
281    }
282
283    /// The normalised `x`.
284    #[track_caller]
285    pub fn forward(&self, x: &Array) -> Array {
286        fast::rms_norm(x, &self.weight, self.eps)
287    }
288}
289
290/// Layer normalisation over the last axis, optionally with a learned scale and bias
291/// (MLX's `nn.LayerNorm`, the fused `fast::layer_norm`).
292#[derive(Debug, Clone, Module)]
293pub struct LayerNorm {
294    /// The scale, `[dims]` (ones), if affine.
295    pub weight: Option<Param>,
296    /// The shift, `[dims]` (zeros), if affine with a bias.
297    pub bias: Option<Param>,
298    /// Added to the variance (MLX's default `1e-5`).
299    #[tree(skip)]
300    pub eps: f32,
301}
302
303impl LayerNorm {
304    /// A layer over `dims` features with MLX's defaults: affine with a bias, `eps = 1e-5`.
305    #[track_caller]
306    pub fn new(dims: i32) -> Self {
307        Self {
308            weight: Some(Param::new(ops::ones([dims], DType::F32))),
309            bias: Some(Param::new(ops::zeros([dims], DType::F32))),
310            eps: 1e-5,
311        }
312    }
313
314    /// Sets `eps`.
315    pub fn eps(mut self, eps: f32) -> Self {
316        self.eps = eps;
317        self
318    }
319
320    /// With (`true`, the default) or without a learned scale and shift (MLX's `affine`).
321    pub fn affine(mut self, affine: bool) -> Self {
322        if !affine {
323            self.weight = None;
324            self.bias = None;
325        }
326        self
327    }
328
329    /// Without the learned shift (MLX's `bias=False`).
330    pub fn without_bias(mut self) -> Self {
331        self.bias = None;
332        self
333    }
334
335    /// The normalised `x`.
336    #[track_caller]
337    pub fn forward(&self, x: &Array) -> Array {
338        fast::layer_norm(
339            x,
340            self.weight.as_ref().map(Param::value),
341            self.bias.as_ref().map(Param::value),
342            self.eps,
343        )
344    }
345}
346
347/// Rotary position embedding of the first `dims` features (MLX's `nn.RoPE`, the fused
348/// `fast::rope`). No parameters.
349#[derive(Debug, Clone, Module)]
350pub struct Rope {
351    /// Rotated features.
352    #[tree(skip)]
353    pub dims: i32,
354    /// Rotate consecutive pairs (`true`) or the two halves (`false`, MLX's default).
355    #[tree(skip)]
356    pub traditional: bool,
357    /// The frequency base (MLX's default 10000).
358    #[tree(skip)]
359    pub base: f32,
360    /// Scale applied to positions.
361    #[tree(skip)]
362    pub scale: f32,
363}
364
365impl Rope {
366    /// RoPE over `dims` features with MLX's defaults (halves, base 10000, scale 1).
367    pub fn new(dims: i32) -> Self {
368        Self {
369            dims,
370            traditional: false,
371            base: 10000.0,
372            scale: 1.0,
373        }
374    }
375
376    /// Rotates consecutive pairs of features (MLX's `traditional=True`).
377    pub fn traditional(mut self) -> Self {
378        self.traditional = true;
379        self
380    }
381
382    /// Sets the frequency base.
383    pub fn base(mut self, base: f32) -> Self {
384        self.base = base;
385        self
386    }
387
388    /// Sets the scale applied to positions.
389    pub fn scale(mut self, scale: f32) -> Self {
390        self.scale = scale;
391        self
392    }
393
394    /// `x` (`[..., sequence, features]`) rotated for positions `offset`, `offset + 1`, ….
395    #[track_caller]
396    pub fn forward(&self, x: &Array, offset: i32) -> Array {
397        fast::rope(
398            x,
399            self.dims,
400            self.traditional,
401            Some(self.base),
402            self.scale,
403            &Array::from(offset),
404            None,
405        )
406    }
407}
408
409/// Randomly zeroes elements with probability `p` during training and scales the rest by
410/// `1/(1 − p)`; the identity in evaluation mode (MLX's `nn.Dropout`). The mask is
411/// `bernoulli(1 − p)`, drawn from the thread's key sequence (bit for bit MLX's for a seed)
412/// or from an explicit key ([`Dropout::forward_with_key`]).
413///
414/// The mode is switched with [`Module::train`] on the whole model, and is part of the
415/// model's structure, so a compiled step retraces when it changes.
416#[derive(Debug, Clone, Module)]
417pub struct Dropout {
418    /// The probability of zeroing an element, `0 ≤ p < 1`.
419    #[tree(skip)]
420    pub p: f32,
421    /// Training (`true`, the default) or evaluation mode.
422    #[tree(mode)]
423    pub training: bool,
424}
425
426impl Dropout {
427    /// Dropout with probability `p` (`0 ≤ p < 1`; another value makes `forward` return a
428    /// failed array), in training mode.
429    pub fn new(p: f32) -> Self {
430        Self { p, training: true }
431    }
432
433    /// `x` with dropout (training mode) or unchanged (evaluation mode).
434    #[track_caller]
435    pub fn forward(&self, x: &Array) -> Array {
436        self.apply(x, None)
437    }
438
439    /// [`Dropout::forward`] with the mask drawn from `key`.
440    #[track_caller]
441    pub fn forward_with_key(&self, x: &Array, key: &Array) -> Array {
442        self.apply(x, Some(key))
443    }
444
445    #[track_caller]
446    fn apply(&self, x: &Array, key: Option<&Array>) -> Array {
447        if !(0.0..1.0).contains(&self.p) {
448            return Array::failure(
449                "dropout",
450                OpErrorKind::InvalidArgument(format!(
451                    "the probability must be in [0, 1), got {}",
452                    self.p
453                )),
454            );
455        }
456        if !self.training || self.p == 0.0 {
457            return x.clone();
458        }
459        // MLX computes `p_1 = 1 - p` and `1 / p_1` on Python floats: use `p`'s shortest
460        // decimal value as an `f64` (0.1f32 is 0.1), so both round as MLX's.
461        let p: f64 = self.p.to_string().parse().unwrap_or(f64::from(self.p));
462        let keep = 1.0 - p;
463        // MLX: `(mask * x) * (1 / p_1)`.
464        let mask = random::bernoulli(keep, x.shape(), key);
465        mask.multiply(x).multiply(1.0 / keep)
466    }
467}
468
469/// The layer a [`LoraLinear`] adapts.
470#[derive(Debug, Clone, Module)]
471pub enum LoraBase {
472    /// A full-precision layer.
473    Linear(Linear),
474    /// A quantized layer (QLoRA).
475    Quantized(QuantizedLinear),
476}
477
478impl LoraBase {
479    /// `[output_dims, input_dims]` of the adapted layer.
480    fn dims(&self) -> (i32, i32) {
481        match self {
482            Self::Linear(l) => {
483                let s = l.weight.shape();
484                (
485                    s.first().copied().unwrap_or(0),
486                    s.get(1).copied().unwrap_or(0),
487                )
488            }
489            Self::Quantized(q) => {
490                let s = q.weight.shape();
491                let packed = s.get(1).copied().unwrap_or(0);
492                (s.first().copied().unwrap_or(0), packed * 32 / q.bits.max(1))
493            }
494        }
495    }
496
497    #[track_caller]
498    fn forward(&self, x: &Array) -> Array {
499        match self {
500            Self::Linear(l) => l.forward(x),
501            Self::Quantized(q) => q.forward(x),
502        }
503    }
504}
505
506/// A frozen linear layer plus a trainable low-rank update (mlx-lm's `LoRALinear`):
507/// `y = base(x) + scale · (dropout(x) A) B`, with `A` of shape `[input, r]` and `B` of shape
508/// `[r, output]` (zeros at first, so the layer starts as its base). Names follow mlx-lm:
509/// `linear.weight`, `lora_a`, `lora_b`.
510#[derive(Debug, Clone, Module)]
511pub struct LoraLinear {
512    /// The adapted layer (frozen).
513    #[tree(rename = "linear")]
514    pub base: LoraBase,
515    /// `[input_dims, r]`.
516    pub lora_a: Param,
517    /// `[r, output_dims]`.
518    pub lora_b: Param,
519    /// The update's scale (mlx-lm's default 20).
520    #[tree(skip)]
521    pub scale: f32,
522    /// Dropout on the update's input (mlx-lm's LoRA dropout; 0 by default).
523    pub dropout: Dropout,
524}
525
526impl LoraLinear {
527    /// Adapts `base` with rank `r` and mlx-lm's default scale 20, as mlx-lm's
528    /// `LoRALinear.from_base` (including its random draws: `A` is uniform in
529    /// `[-1/√input, 1/√input)`). The base is frozen.
530    #[track_caller]
531    pub fn from_base(base: impl Into<LoraBase>, r: i32) -> Self {
532        let mut base = base.into();
533        let (output_dims, input_dims) = base.dims();
534        // mlx-lm builds (and replaces) a fresh `nn.Linear(input, output, bias=False)`
535        // first: draw its weight so the key sequence matches. Nothing is computed for it.
536        let _ = uniform_init(input_dims, &[output_dims, input_dims]);
537        let lora_a = uniform_init(input_dims, &[input_dims, r]);
538        let lora_b = ops::zeros([r, output_dims], DType::F32);
539        // Every parameter of the base is frozen (buffers are never trained).
540        let _ = base.freeze(|_| true);
541        Self {
542            base,
543            lora_a: Param::new(lora_a),
544            lora_b: Param::new(lora_b),
545            scale: 20.0,
546            dropout: Dropout::new(0.0),
547        }
548    }
549
550    /// Sets the update's scale.
551    pub fn scale(mut self, scale: f32) -> Self {
552        self.scale = scale;
553        self
554    }
555
556    /// Drops the update's input with probability `p` in training mode (mlx-lm's LoRA
557    /// dropout; the base sees the input unchanged).
558    pub fn dropout(mut self, p: f32) -> Self {
559        self.dropout = Dropout::new(p);
560        self
561    }
562
563    /// Rank-stabilised scaling (rsLoRA): treating `scale` as `α/r`, the update is scaled
564    /// by `α/√r = scale · √r` instead.
565    pub fn rslora(mut self) -> Self {
566        let r = self.lora_a.shape().get(1).copied().unwrap_or(1).max(1);
567        self.scale *= (r as f32).sqrt();
568        self
569    }
570
571    /// `base(x) + scale · (dropout(x) A) B`, the update cast to `x`'s dtype.
572    #[track_caller]
573    pub fn forward(&self, x: &Array) -> Array {
574        let y = self.base.forward(x);
575        let z = self
576            .dropout
577            .forward(x)
578            .matmul(&self.lora_a)
579            .matmul(&self.lora_b);
580        y.add(z.multiply(f64::from(self.scale)).astype(x.dtype()))
581    }
582
583    /// The base layer with the update merged into its weight (mlx-lm's `fuse` with
584    /// `dequantize`): `W + scale · Bᵀ Aᵀ`.
585    #[track_caller]
586    pub fn fuse(&self) -> Linear {
587        let (weight, bias) = match &self.base {
588            LoraBase::Linear(l) => (l.weight.value().clone(), l.bias.clone()),
589            LoraBase::Quantized(q) => (q.dequantized_weight(), q.bias.clone()),
590        };
591        let delta = self
592            .lora_b
593            .t()
594            .multiply(f64::from(self.scale))
595            .matmul(&self.lora_a.t())
596            .astype(weight.dtype());
597        Linear {
598            weight: Param::new(weight.add(&delta)),
599            bias: bias.map(|b| Param::new(b.into_inner())),
600        }
601    }
602}
603
604impl From<Linear> for LoraBase {
605    fn from(l: Linear) -> Self {
606        Self::Linear(l)
607    }
608}
609
610impl From<QuantizedLinear> for LoraBase {
611    fn from(q: QuantizedLinear) -> Self {
612        Self::Quantized(q)
613    }
614}