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
4use nuvec::{
5    Array, DType, Result, add, dequantize, fast, matmul, multiply, quantize, quantized_matmul,
6    random, take,
7};
8
9use crate::{Module, Parameters};
10
11/// An affine layer `y = x Wᵀ + b`, with `W` of shape `[output, input]` and `b` of shape
12/// `[output]` (MLX's `nn.Linear`).
13#[derive(Debug, Clone)]
14pub struct Linear {
15    /// The weight, `[output_dims, input_dims]`.
16    pub weight: Array,
17    /// The bias, `[output_dims]`.
18    pub bias: Option<Array>,
19}
20
21impl Linear {
22    /// A layer initialised as MLX's `nn.Linear`: weight, then bias, drawn uniformly from
23    /// `[-1/√input, 1/√input)`.
24    pub fn new(input_dims: usize, output_dims: usize, bias: bool) -> Result<Self> {
25        let scale = (1.0 / input_dims as f64).sqrt();
26        let draw = |shape: &[usize]| random::uniform(-scale, scale, shape, DType::F32, None);
27        let weight = draw(&[output_dims, input_dims])?;
28        let bias = if bias {
29            Some(draw(&[output_dims])?)
30        } else {
31            None
32        };
33        Ok(Self { weight, bias })
34    }
35
36    /// `x Wᵀ + b` over the last axis of `x`; with a bias it is one fused `addmm`, as in
37    /// MLX.
38    pub fn forward(&self, x: &Array) -> Result<Array> {
39        match &self.bias {
40            Some(b) => nuvec::addmm(b, x, &self.weight.t()?, 1.0, 1.0),
41            None => matmul(x, &self.weight.t()?),
42        }
43    }
44}
45
46impl Module for Linear {
47    fn visit(&mut self, p: &mut Parameters<'_>) {
48        p.param("weight", &mut self.weight);
49        p.optional("bias", &mut self.bias);
50    }
51}
52
53/// A [`Linear`] layer with its weight quantized (MLX's `nn.QuantizedLinear`): `x Wᵀ + b`
54/// computed by `quantized_matmul` from the packed weight, its scales and biases.
55///
56/// The packed weight, scales and biases are frozen; the bias stays trainable, as in MLX.
57#[derive(Debug, Clone)]
58pub struct QuantizedLinear {
59    /// The packed weight (uint32), `[output_dims, input_dims · bits / 32]`.
60    pub weight: Array,
61    /// One scale per group of input values, `[output_dims, input_dims / group_size]`.
62    pub scales: Array,
63    /// One bias per group of input values, shaped like `scales`.
64    pub biases: Array,
65    /// The layer's bias, `[output_dims]`.
66    pub bias: Option<Array>,
67    /// Input values per scale and bias.
68    pub group_size: usize,
69    /// Bits per weight.
70    pub bits: u32,
71}
72
73impl QuantizedLinear {
74    /// Quantizes `linear`'s weight in groups of `group_size` (32, 64 or 128) input values
75    /// with `bits` (1–6 or 8) bits each; the bias is kept. MLX's defaults are 64 and 4.
76    pub fn from_linear(linear: &Linear, group_size: usize, bits: u32) -> Result<Self> {
77        let (weight, scales, biases) = quantize(&linear.weight, group_size, bits)?;
78        Ok(Self {
79            weight,
80            scales,
81            biases,
82            bias: linear.bias.clone(),
83            group_size,
84            bits,
85        })
86    }
87
88    /// The dequantized weight, `[output_dims, input_dims]`.
89    pub fn dequantized_weight(&self) -> Result<Array> {
90        dequantize(
91            &self.weight,
92            &self.scales,
93            &self.biases,
94            self.group_size,
95            self.bits,
96        )
97    }
98
99    /// `x Wᵀ + b` over the last axis of `x`.
100    pub fn forward(&self, x: &Array) -> Result<Array> {
101        let y = quantized_matmul(
102            x,
103            &self.weight,
104            &self.scales,
105            &self.biases,
106            true,
107            self.group_size,
108            self.bits,
109        )?;
110        match &self.bias {
111            Some(b) => add(&y, b),
112            None => Ok(y),
113        }
114    }
115}
116
117impl Module for QuantizedLinear {
118    fn visit(&mut self, p: &mut Parameters<'_>) {
119        p.frozen("weight", &mut self.weight);
120        p.frozen("scales", &mut self.scales);
121        p.frozen("biases", &mut self.biases);
122        p.optional("bias", &mut self.bias);
123    }
124}
125
126/// A lookup table of `dims`-dimensional vectors for integer ids (MLX's `nn.Embedding`).
127#[derive(Debug, Clone)]
128pub struct Embedding {
129    /// The table, `[num_embeddings, dims]`.
130    pub weight: Array,
131}
132
133impl Embedding {
134    /// A table drawn from a normal distribution with standard deviation `1/√dims`, as
135    /// MLX.
136    pub fn new(num_embeddings: usize, dims: usize) -> Result<Self> {
137        let scale = (1.0 / dims as f64).sqrt();
138        let weight = random::normal(&[num_embeddings, dims], DType::F32, None, Some(scale), None)?;
139        Ok(Self { weight })
140    }
141
142    /// The rows for the ids in `x`: shape `x.shape + [dims]`.
143    pub fn forward(&self, x: &Array) -> Result<Array> {
144        take(&self.weight, x, Some(0))
145    }
146
147    /// `x Wᵀ`: the table used as an output projection (tied embeddings).
148    pub fn as_linear(&self, x: &Array) -> Result<Array> {
149        matmul(x, &self.weight.t()?)
150    }
151}
152
153impl Module for Embedding {
154    fn visit(&mut self, p: &mut Parameters<'_>) {
155        p.param("weight", &mut self.weight);
156    }
157}
158
159/// An [`Embedding`] with its table quantized (MLX's `nn.QuantizedEmbedding`): rows are
160/// gathered packed and dequantized; the packed table, scales and biases are frozen.
161#[derive(Debug, Clone)]
162pub struct QuantizedEmbedding {
163    /// The packed table (uint32), `[num_embeddings, dims · bits / 32]`.
164    pub weight: Array,
165    /// One scale per group of values, `[num_embeddings, dims / group_size]`.
166    pub scales: Array,
167    /// One bias per group of values, shaped like `scales`.
168    pub biases: Array,
169    /// Values per scale and bias.
170    pub group_size: usize,
171    /// Bits per value.
172    pub bits: u32,
173}
174
175impl QuantizedEmbedding {
176    /// Quantizes `embedding`'s table in groups of `group_size` values with `bits` bits.
177    pub fn from_embedding(embedding: &Embedding, group_size: usize, bits: u32) -> Result<Self> {
178        let (weight, scales, biases) = quantize(&embedding.weight, group_size, bits)?;
179        Ok(Self {
180            weight,
181            scales,
182            biases,
183            group_size,
184            bits,
185        })
186    }
187
188    /// The rows for the ids in `x`, dequantized: shape `x.shape + [dims]`.
189    pub fn forward(&self, x: &Array) -> Result<Array> {
190        dequantize(
191            &take(&self.weight, x, Some(0))?,
192            &take(&self.scales, x, Some(0))?,
193            &take(&self.biases, x, Some(0))?,
194            self.group_size,
195            self.bits,
196        )
197    }
198
199    /// `x Wᵀ` with the quantized table (tied embeddings).
200    pub fn as_linear(&self, x: &Array) -> Result<Array> {
201        quantized_matmul(
202            x,
203            &self.weight,
204            &self.scales,
205            &self.biases,
206            true,
207            self.group_size,
208            self.bits,
209        )
210    }
211}
212
213impl Module for QuantizedEmbedding {
214    fn visit(&mut self, p: &mut Parameters<'_>) {
215        p.frozen("weight", &mut self.weight);
216        p.frozen("scales", &mut self.scales);
217        p.frozen("biases", &mut self.biases);
218    }
219}
220
221/// Root-mean-square normalisation over the last axis with a learned scale (MLX's
222/// `nn.RMSNorm`, the fused `fast::rms_norm`).
223#[derive(Debug, Clone)]
224pub struct RmsNorm {
225    /// The scale, `[dims]`, initially ones.
226    pub weight: Array,
227    /// Added to the mean square.
228    pub eps: f32,
229}
230
231impl RmsNorm {
232    /// A layer over `dims` features (MLX's default `eps` is `1e-5`).
233    pub fn new(dims: usize, eps: f32) -> Result<Self> {
234        Ok(Self {
235            weight: nuvec::ones(&[dims], DType::F32)?,
236            eps,
237        })
238    }
239
240    /// The normalised `x`.
241    pub fn forward(&self, x: &Array) -> Result<Array> {
242        fast::rms_norm(x, Some(&self.weight), self.eps)
243    }
244}
245
246impl Module for RmsNorm {
247    fn visit(&mut self, p: &mut Parameters<'_>) {
248        p.param("weight", &mut self.weight);
249    }
250}
251
252/// Layer normalisation over the last axis, optionally with a learned scale and bias
253/// (MLX's `nn.LayerNorm`, the fused `fast::layer_norm`).
254#[derive(Debug, Clone)]
255pub struct LayerNorm {
256    /// The scale, `[dims]` (ones), if affine.
257    pub weight: Option<Array>,
258    /// The shift, `[dims]` (zeros), if affine with a bias.
259    pub bias: Option<Array>,
260    /// Added to the variance.
261    pub eps: f32,
262}
263
264impl LayerNorm {
265    /// A layer over `dims` features: `affine` adds a scale, and `bias` (with `affine`) a
266    /// shift. MLX's defaults: `eps = 1e-5`, affine with a bias.
267    pub fn new(dims: usize, eps: f32, affine: bool, bias: bool) -> Result<Self> {
268        let weight = affine
269            .then(|| nuvec::ones(&[dims], DType::F32))
270            .transpose()?;
271        let bias = (affine && bias)
272            .then(|| nuvec::zeros(&[dims], DType::F32))
273            .transpose()?;
274        Ok(Self { weight, bias, eps })
275    }
276
277    /// The normalised `x`.
278    pub fn forward(&self, x: &Array) -> Result<Array> {
279        fast::layer_norm(x, self.weight.as_ref(), self.bias.as_ref(), self.eps)
280    }
281}
282
283impl Module for LayerNorm {
284    fn visit(&mut self, p: &mut Parameters<'_>) {
285        p.optional("weight", &mut self.weight);
286        p.optional("bias", &mut self.bias);
287    }
288}
289
290/// Rotary position embedding of the first `dims` features (MLX's `nn.RoPE`, the fused
291/// `fast::rope`). No parameters.
292#[derive(Debug, Clone)]
293pub struct Rope {
294    /// Rotated features.
295    pub dims: usize,
296    /// Rotate consecutive pairs (`true`) or the two halves (`false`, MLX's default).
297    pub traditional: bool,
298    /// The frequency base (MLX's default 10000).
299    pub base: f32,
300    /// Scale applied to positions.
301    pub scale: f32,
302}
303
304impl Rope {
305    /// RoPE over `dims` features with MLX's defaults (halves, base 10000, scale 1).
306    pub fn new(dims: usize) -> Self {
307        Self {
308            dims,
309            traditional: false,
310            base: 10000.0,
311            scale: 1.0,
312        }
313    }
314
315    /// `x` (`[..., sequence, features]`) rotated for positions `offset`, `offset + 1`, ….
316    pub fn forward(&self, x: &Array, offset: usize) -> Result<Array> {
317        fast::rope(
318            x,
319            self.dims,
320            self.traditional,
321            Some(self.base),
322            self.scale,
323            &Array::scalar(offset as i32),
324            None,
325        )
326    }
327}
328
329impl Module for Rope {
330    fn visit(&mut self, _: &mut Parameters<'_>) {}
331}
332
333/// The layer a [`LoraLinear`] adapts.
334#[derive(Debug, Clone)]
335pub enum LoraBase {
336    /// A full-precision layer.
337    Linear(Linear),
338    /// A quantized layer (QLoRA).
339    Quantized(QuantizedLinear),
340}
341
342/// A frozen linear layer plus a trainable low-rank update (mlx-lm's `LoRALinear`):
343/// `y = base(x) + scale · (x A) B`, with `A` of shape `[input, r]` and `B` of shape
344/// `[r, output]` (zeros at first, so the layer starts as its base).
345#[derive(Debug, Clone)]
346pub struct LoraLinear {
347    /// The adapted layer (frozen).
348    pub base: LoraBase,
349    /// `[input_dims, r]`.
350    pub lora_a: Array,
351    /// `[r, output_dims]`.
352    pub lora_b: Array,
353    /// The update's scale (mlx-lm's default 20).
354    pub scale: f32,
355}
356
357impl LoraLinear {
358    /// Adapts `base` with rank `r`, as mlx-lm's `LoRALinear.from_base` (including its
359    /// random draws: `A` is uniform in `[-1/√input, 1/√input)`).
360    pub fn from_base(base: LoraBase, r: usize, scale: f32) -> Result<Self> {
361        let (output_dims, input_dims) = match &base {
362            LoraBase::Linear(l) => (l.weight.shape()[0], l.weight.shape()[1]),
363            LoraBase::Quantized(q) => (
364                q.weight.shape()[0],
365                q.weight.shape()[1] * 32 / q.bits as usize,
366            ),
367        };
368        // mlx-lm builds (and replaces) a fresh linear layer first: draw its weight so the
369        // key sequence matches. Nothing is computed for it.
370        Linear::new(input_dims, output_dims, false)?;
371        let bound = 1.0 / (input_dims as f64).sqrt();
372        let lora_a = random::uniform(-bound, bound, &[input_dims, r], DType::F32, None)?;
373        let lora_b = nuvec::zeros(&[r, output_dims], DType::F32)?;
374        Ok(Self {
375            base,
376            lora_a,
377            lora_b,
378            scale,
379        })
380    }
381
382    /// `base(x) + scale · (x A) B`, the update cast to `x`'s dtype.
383    pub fn forward(&self, x: &Array) -> Result<Array> {
384        let y = match &self.base {
385            LoraBase::Linear(l) => l.forward(x)?,
386            LoraBase::Quantized(q) => q.forward(x)?,
387        };
388        let z = matmul(&matmul(x, &self.lora_a)?, &self.lora_b)?;
389        add(&y, multiply(z, f64::from(self.scale))?.astype(x.dtype())?)
390    }
391
392    /// The base layer with the update merged into its weight (mlx-lm's `fuse` with
393    /// `dequantize`): `W + scale · Bᵀ Aᵀ`.
394    pub fn fuse(&self) -> Result<Linear> {
395        let (weight, bias) = match &self.base {
396            LoraBase::Linear(l) => (l.weight.clone(), l.bias.clone()),
397            LoraBase::Quantized(q) => (q.dequantized_weight()?, q.bias.clone()),
398        };
399        let delta = matmul(
400            &multiply(f64::from(self.scale), self.lora_b.t()?)?,
401            &self.lora_a.t()?,
402        )?
403        .astype(weight.dtype())?;
404        Ok(Linear {
405            weight: add(&weight, &delta)?,
406            bias,
407        })
408    }
409}
410
411impl Module for LoraLinear {
412    fn visit(&mut self, p: &mut Parameters<'_>) {
413        // mlx-lm's names: `linear.*` for the base, frozen.
414        let base = &mut self.base;
415        p.freeze(|p| match base {
416            LoraBase::Linear(l) => p.module("linear", l),
417            LoraBase::Quantized(q) => p.module("linear", q),
418        });
419        p.param("lora_a", &mut self.lora_a);
420        p.param("lora_b", &mut self.lora_b);
421    }
422}