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}