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, Quant, QuantMode, Quantized, 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 and its scales (and, for affine,
86/// biases), in any [`QuantMode`].
87///
88/// The packed weight, scales and biases are buffers (never trained, also after
89/// `unfreeze`); the bias is a trainable parameter.
90#[derive(Debug, Clone, Module)]
91pub struct QuantizedLinear {
92 /// The packed weight (uint32), `[output_dims, input_dims · bits / 32]`.
93 #[tree(buffer)]
94 pub weight: Array,
95 /// One scale per group of input values, `[output_dims, input_dims / group_size]`
96 /// (uint8 codes for the floating-point modes).
97 #[tree(buffer)]
98 pub scales: Array,
99 /// One bias per group of input values, shaped like `scales`: affine only.
100 #[tree(buffer)]
101 pub biases: Option<Array>,
102 /// The layer's bias, `[output_dims]`.
103 pub bias: Option<Param>,
104 /// How the weight is quantized.
105 #[tree(skip)]
106 pub quant: Quant,
107}
108
109impl QuantizedLinear {
110 /// Quantizes `linear`'s weight with MLX's defaults (affine, groups of 64 values, 4
111 /// bits); the bias is kept.
112 #[track_caller]
113 pub fn from_linear(linear: &Linear) -> Self {
114 Self::from_linear_with(linear, Quant::default())
115 }
116
117 /// Quantizes `linear`'s weight with `quant` (a mode such as `QuantMode::Mxfp4`, or
118 /// `Quant::affine(group_size, bits)`); the bias is kept.
119 #[track_caller]
120 pub fn from_linear_with(linear: &Linear, quant: impl Into<Quant>) -> Self {
121 let q = linear.weight.quantize(quant);
122 Self {
123 weight: q.weight,
124 scales: q.scales,
125 biases: q.biases,
126 bias: linear.bias.clone(),
127 quant: q.quant,
128 }
129 }
130
131 /// The quantized weight.
132 pub fn quantized(&self) -> Quantized {
133 Quantized::new(
134 self.weight.clone(),
135 self.scales.clone(),
136 self.biases.clone(),
137 self.quant,
138 )
139 }
140
141 /// The dequantized weight, `[output_dims, input_dims]`, in `dtype` (default: the
142 /// scales' dtype for affine, bfloat16 for the floating-point modes).
143 #[track_caller]
144 pub fn dequantized_weight(&self, dtype: impl Into<Option<DType>>) -> Array {
145 self.quantized().dequantize(dtype)
146 }
147
148 /// `x Wᵀ + b` over the last axis of `x`.
149 #[track_caller]
150 pub fn forward(&self, x: &Array) -> Array {
151 let y = x.quantized_matmul(&self.quantized(), true);
152 match &self.bias {
153 Some(b) => y.add(b),
154 None => y,
155 }
156 }
157}
158
159/// A lookup table of `dims`-dimensional vectors for integer ids (MLX's `nn.Embedding`).
160#[derive(Debug, Clone, Module)]
161pub struct Embedding {
162 /// The table, `[num_embeddings, dims]`.
163 pub weight: Param,
164}
165
166impl Embedding {
167 /// A table drawn from a normal distribution with standard deviation `1/√dims`, as
168 /// MLX.
169 #[track_caller]
170 pub fn new(num_embeddings: i32, dims: i32) -> Self {
171 let scale = (1.0 / f64::from(dims.max(1))).sqrt();
172 let weight = random::normal([num_embeddings, dims], DType::F32, 0.0, scale, None);
173 Self {
174 weight: Param::new(weight),
175 }
176 }
177
178 /// The rows for the ids in `x`: shape `x.shape + [dims]`.
179 #[track_caller]
180 pub fn forward(&self, x: &Array) -> Array {
181 self.weight.take(x, 0)
182 }
183
184 /// `x Wᵀ`: the table used as an output projection (tied embeddings).
185 #[track_caller]
186 pub fn as_linear(&self, x: &Array) -> Array {
187 x.matmul(&self.weight.t())
188 }
189}
190
191/// An [`Embedding`] with its table quantized (MLX's `nn.QuantizedEmbedding`): rows are
192/// gathered packed and dequantized; the packed table, scales and biases are buffers. In the
193/// floating-point modes the rows come out in bfloat16, as MLX's.
194#[derive(Debug, Clone, Module)]
195pub struct QuantizedEmbedding {
196 /// The packed table (uint32), `[num_embeddings, dims · bits / 32]`.
197 #[tree(buffer)]
198 pub weight: Array,
199 /// One scale per group of values, `[num_embeddings, dims / group_size]`.
200 #[tree(buffer)]
201 pub scales: Array,
202 /// One bias per group of values, shaped like `scales`: affine only.
203 #[tree(buffer)]
204 pub biases: Option<Array>,
205 /// How the table is quantized.
206 #[tree(skip)]
207 pub quant: Quant,
208}
209
210impl QuantizedEmbedding {
211 /// Quantizes `embedding`'s table with MLX's defaults (affine, groups of 64, 4 bits).
212 #[track_caller]
213 pub fn from_embedding(embedding: &Embedding) -> Self {
214 Self::from_embedding_with(embedding, Quant::default())
215 }
216
217 /// Quantizes `embedding`'s table with `quant`.
218 #[track_caller]
219 pub fn from_embedding_with(embedding: &Embedding, quant: impl Into<Quant>) -> Self {
220 let q = embedding.weight.quantize(quant);
221 Self {
222 weight: q.weight,
223 scales: q.scales,
224 biases: q.biases,
225 quant: q.quant,
226 }
227 }
228
229 /// The quantized table.
230 pub fn quantized(&self) -> Quantized {
231 Quantized::new(
232 self.weight.clone(),
233 self.scales.clone(),
234 self.biases.clone(),
235 self.quant,
236 )
237 }
238
239 /// The rows for the ids in `x`, dequantized: shape `x.shape + [dims]`.
240 #[track_caller]
241 pub fn forward(&self, x: &Array) -> Array {
242 let rows = Quantized::new(
243 self.weight.take(x, 0),
244 self.scales.take(x, 0),
245 self.biases.as_ref().map(|b| b.take(x, 0)),
246 self.quant,
247 );
248 rows.dequantize(None)
249 }
250
251 /// `x Wᵀ` with the quantized table (tied embeddings).
252 #[track_caller]
253 pub fn as_linear(&self, x: &Array) -> Array {
254 x.quantized_matmul(&self.quantized(), true)
255 }
256}
257
258/// Root-mean-square normalisation over the last axis with a learned scale (MLX's
259/// `nn.RMSNorm`, the fused `fast::rms_norm`).
260#[derive(Debug, Clone, Module)]
261pub struct RmsNorm {
262 /// The scale, `[dims]`, initially ones.
263 pub weight: Param,
264 /// Added to the mean square (MLX's default `1e-5`).
265 #[tree(skip)]
266 pub eps: f32,
267}
268
269impl RmsNorm {
270 /// A layer over `dims` features with MLX's default `eps` (`1e-5`).
271 #[track_caller]
272 pub fn new(dims: i32) -> Self {
273 Self {
274 weight: Param::new(ops::ones([dims], DType::F32)),
275 eps: 1e-5,
276 }
277 }
278
279 /// Sets `eps`.
280 pub fn eps(mut self, eps: f32) -> Self {
281 self.eps = eps;
282 self
283 }
284
285 /// The normalised `x`.
286 #[track_caller]
287 pub fn forward(&self, x: &Array) -> Array {
288 fast::rms_norm(x, &self.weight, self.eps)
289 }
290}
291
292/// Layer normalisation over the last axis, optionally with a learned scale and bias
293/// (MLX's `nn.LayerNorm`, the fused `fast::layer_norm`).
294#[derive(Debug, Clone, Module)]
295pub struct LayerNorm {
296 /// The scale, `[dims]` (ones), if affine.
297 pub weight: Option<Param>,
298 /// The shift, `[dims]` (zeros), if affine with a bias.
299 pub bias: Option<Param>,
300 /// Added to the variance (MLX's default `1e-5`).
301 #[tree(skip)]
302 pub eps: f32,
303}
304
305impl LayerNorm {
306 /// A layer over `dims` features with MLX's defaults: affine with a bias, `eps = 1e-5`.
307 #[track_caller]
308 pub fn new(dims: i32) -> Self {
309 Self {
310 weight: Some(Param::new(ops::ones([dims], DType::F32))),
311 bias: Some(Param::new(ops::zeros([dims], DType::F32))),
312 eps: 1e-5,
313 }
314 }
315
316 /// Sets `eps`.
317 pub fn eps(mut self, eps: f32) -> Self {
318 self.eps = eps;
319 self
320 }
321
322 /// With (`true`, the default) or without a learned scale and shift (MLX's `affine`).
323 pub fn affine(mut self, affine: bool) -> Self {
324 if !affine {
325 self.weight = None;
326 self.bias = None;
327 }
328 self
329 }
330
331 /// Without the learned shift (MLX's `bias=False`).
332 pub fn without_bias(mut self) -> Self {
333 self.bias = None;
334 self
335 }
336
337 /// The normalised `x`.
338 #[track_caller]
339 pub fn forward(&self, x: &Array) -> Array {
340 fast::layer_norm(
341 x,
342 self.weight.as_ref().map(Param::value),
343 self.bias.as_ref().map(Param::value),
344 self.eps,
345 )
346 }
347}
348
349/// Rotary position embedding of the first `dims` features (MLX's `nn.RoPE`, the fused
350/// `fast::rope`), optionally with a frequency scaling ([`Rope::scaled`]: mlx-lm's
351/// `Llama3RoPE`, `YarnRoPE`, `SuScaledRoPE`, `ProportionalRoPE`). No parameters: the
352/// frequencies are computed from the settings, never loaded or saved.
353#[derive(Debug, Clone, Module)]
354pub struct Rope {
355 /// Rotated features.
356 #[tree(skip)]
357 pub dims: i32,
358 /// Rotate consecutive pairs (`true`) or the two halves (`false`, MLX's default).
359 #[tree(skip)]
360 pub traditional: bool,
361 /// The frequency base (MLX's default 10000).
362 #[tree(skip)]
363 pub base: f32,
364 /// Scale applied to positions.
365 #[tree(skip)]
366 pub scale: f32,
367 /// The frequency scaling.
368 #[tree(skip)]
369 scaling: crate::RopeScaling,
370 /// The scaling's per-pair frequencies (mlx-lm's `_freqs`), if it has them.
371 #[tree(skip)]
372 freqs: Option<Array>,
373}
374
375impl Rope {
376 /// RoPE over `dims` features with MLX's defaults (halves, base 10000, scale 1).
377 pub fn new(dims: i32) -> Self {
378 Self {
379 dims,
380 traditional: false,
381 base: 10000.0,
382 scale: 1.0,
383 scaling: crate::RopeScaling::None,
384 freqs: None,
385 }
386 }
387
388 /// RoPE over `dims` features with `base` and a frequency `scaling`, as mlx-lm's scaled
389 /// RoPE layers (for [`RopeScaling::Proportional`](crate::RopeScaling::Proportional)
390 /// `dims` is the whole head's).
391 ///
392 /// ```
393 /// use nuvec_nn::{Rope, RopeScaling, Yarn};
394 ///
395 /// // gpt-oss: YaRN over 64 features, base 150000, factor 32 over 4096 positions.
396 /// let yarn = Yarn { factor: 32.0, original_max_position_embeddings: 4096.0, ..Yarn::default() };
397 /// let rope = Rope::scaled(64, 150_000.0, &RopeScaling::Yarn(yarn));
398 /// assert!((rope.mscale() - 1.3466).abs() < 1e-4);
399 /// ```
400 pub fn scaled(dims: i32, base: f32, scaling: &crate::RopeScaling) -> Self {
401 Self {
402 dims,
403 traditional: false,
404 base,
405 scale: scaling.position_scale(),
406 scaling: scaling.clone(),
407 freqs: scaling.frequencies(dims, base),
408 }
409 }
410
411 /// The input scale the scaling applies to the rotated features (YaRN, LongRoPE; else
412 /// 1). Callers that rotate queries and keys with [`Rope::rotation`] (one fused kernel
413 /// each) instead multiply the attention scale by `mscale()²`, which is the same up to
414 /// rounding when the rotated features are the whole head. In half precision mlx-lm
415 /// multiplies q and k by the mscale rounded to their dtype, which the fold does not
416 /// reproduce: nuvec-lm multiplies explicitly there and folds only in float32.
417 pub fn mscale(&self) -> f32 {
418 self.scaling.mscale()
419 }
420
421 /// The rotation for the fused ops (`fast::rope`, `fast::norm_rope`), without the input
422 /// scale ([`Rope::mscale`]).
423 pub fn rotation(&self) -> fast::Rotation<'_> {
424 fast::Rotation {
425 dims: self.dims,
426 traditional: self.traditional,
427 base: self.freqs.is_none().then_some(self.base),
428 scale: self.scale,
429 freqs: self.freqs.as_ref(),
430 }
431 }
432
433 /// Rotates consecutive pairs of features (MLX's `traditional=True`).
434 pub fn traditional(mut self) -> Self {
435 self.traditional = true;
436 self
437 }
438
439 /// Sets the frequency base (recomputing a scaling's frequencies).
440 pub fn base(mut self, base: f32) -> Self {
441 self.base = base;
442 self.freqs = self.scaling.frequencies(self.dims, base);
443 self
444 }
445
446 /// Sets the scale applied to positions.
447 pub fn scale(mut self, scale: f32) -> Self {
448 self.scale = scale;
449 self
450 }
451
452 /// `x` (`[..., sequence, features]`) rotated for positions `offset`, `offset + 1`, …,
453 /// its rotated features first multiplied by [`Rope::mscale`] (as mlx-lm).
454 #[track_caller]
455 pub fn forward(&self, x: &Array, offset: i32) -> Array {
456 let m = self.mscale();
457 let x = if m == 1.0 {
458 x.clone()
459 } else if x.shape().last() == Some(&self.dims) {
460 x.multiply(m)
461 } else {
462 let head = (.., ..self.dims);
463 x.clone().at(head).set(x.i(head).multiply(m))
464 };
465 let r = self.rotation();
466 fast::rope(
467 &x,
468 r.dims,
469 r.traditional,
470 r.base,
471 r.scale,
472 &Array::from(offset),
473 r.freqs,
474 )
475 }
476}
477
478/// Randomly zeroes elements with probability `p` during training and scales the rest by
479/// `1/(1 − p)`; the identity in evaluation mode (MLX's `nn.Dropout`). The mask is
480/// `bernoulli(1 − p)`, drawn from the thread's key sequence (bit for bit MLX's for a seed)
481/// or from an explicit key ([`Dropout::forward_with_key`]).
482///
483/// The mode is switched with [`Module::train`] on the whole model, and is part of the
484/// model's structure, so a compiled step retraces when it changes.
485#[derive(Debug, Clone, Module)]
486pub struct Dropout {
487 /// The probability of zeroing an element, `0 ≤ p < 1`.
488 #[tree(skip)]
489 pub p: f32,
490 /// Training (`true`, the default) or evaluation mode.
491 #[tree(mode)]
492 pub training: bool,
493}
494
495impl Dropout {
496 /// Dropout with probability `p` (`0 ≤ p < 1`; another value makes `forward` return a
497 /// failed array), in training mode.
498 pub fn new(p: f32) -> Self {
499 Self { p, training: true }
500 }
501
502 /// `x` with dropout (training mode) or unchanged (evaluation mode).
503 #[track_caller]
504 pub fn forward(&self, x: &Array) -> Array {
505 self.apply(x, None)
506 }
507
508 /// [`Dropout::forward`] with the mask drawn from `key`.
509 #[track_caller]
510 pub fn forward_with_key(&self, x: &Array, key: &Array) -> Array {
511 self.apply(x, Some(key))
512 }
513
514 #[track_caller]
515 fn apply(&self, x: &Array, key: Option<&Array>) -> Array {
516 if !(0.0..1.0).contains(&self.p) {
517 return Array::failure(
518 "dropout",
519 OpErrorKind::InvalidArgument(format!(
520 "the probability must be in [0, 1), got {}",
521 self.p
522 )),
523 );
524 }
525 if !self.training || self.p == 0.0 {
526 return x.clone();
527 }
528 // MLX computes `p_1 = 1 - p` and `1 / p_1` on Python floats: use `p`'s shortest
529 // decimal value as an `f64` (0.1f32 is 0.1), so both round as MLX's.
530 let p: f64 = self.p.to_string().parse().unwrap_or(f64::from(self.p));
531 let keep = 1.0 - p;
532 // MLX: `(mask * x) * (1 / p_1)`.
533 let mask = random::bernoulli(keep, x.shape(), key);
534 mask.multiply(x).multiply(1.0 / keep)
535 }
536}
537
538/// The layer a [`LoraLinear`] adapts.
539#[derive(Debug, Clone, Module)]
540pub enum LoraBase {
541 /// A full-precision layer.
542 Linear(Linear),
543 /// A quantized layer (QLoRA).
544 Quantized(QuantizedLinear),
545}
546
547impl LoraBase {
548 /// `[output_dims, input_dims]` of the adapted layer.
549 fn dims(&self) -> (i32, i32) {
550 match self {
551 Self::Linear(l) => {
552 let s = l.weight.shape();
553 (
554 s.first().copied().unwrap_or(0),
555 s.get(1).copied().unwrap_or(0),
556 )
557 }
558 Self::Quantized(q) => {
559 let s = q.weight.shape();
560 let packed = s.get(1).copied().unwrap_or(0);
561 (
562 s.first().copied().unwrap_or(0),
563 packed * 32 / q.quant.bits.max(1),
564 )
565 }
566 }
567 }
568
569 #[track_caller]
570 fn forward(&self, x: &Array) -> Array {
571 match self {
572 Self::Linear(l) => l.forward(x),
573 Self::Quantized(q) => q.forward(x),
574 }
575 }
576}
577
578/// A frozen linear layer plus a trainable low-rank update (mlx-lm's `LoRALinear`):
579/// `y = base(x) + scale · (dropout(x) A) B`, with `A` of shape `[input, r]` and `B` of shape
580/// `[r, output]` (zeros at first, so the layer starts as its base). Names follow mlx-lm:
581/// `linear.weight`, `lora_a`, `lora_b`.
582#[derive(Debug, Clone, Module)]
583pub struct LoraLinear {
584 /// The adapted layer (frozen).
585 #[tree(rename = "linear")]
586 pub base: LoraBase,
587 /// `[input_dims, r]`.
588 pub lora_a: Param,
589 /// `[r, output_dims]`.
590 pub lora_b: Param,
591 /// The update's scale (mlx-lm's default 20).
592 #[tree(skip)]
593 pub scale: f32,
594 /// Dropout on the update's input (mlx-lm's LoRA dropout; 0 by default).
595 pub dropout: Dropout,
596}
597
598impl LoraLinear {
599 /// Adapts `base` with rank `r` and mlx-lm's default scale 20, as mlx-lm's
600 /// `LoRALinear.from_base` (including its random draws: `A` is uniform in
601 /// `[-1/√input, 1/√input)`). The base is frozen.
602 #[track_caller]
603 pub fn from_base(base: impl Into<LoraBase>, r: i32) -> Self {
604 let mut base = base.into();
605 let (output_dims, input_dims) = base.dims();
606 // mlx-lm builds (and replaces) a fresh `nn.Linear(input, output, bias=False)`
607 // first: draw its weight so the key sequence matches. Nothing is computed for it.
608 let _ = uniform_init(input_dims, &[output_dims, input_dims]);
609 let lora_a = uniform_init(input_dims, &[input_dims, r]);
610 let lora_b = ops::zeros([r, output_dims], DType::F32);
611 // Every parameter of the base is frozen (buffers are never trained).
612 let _ = base.freeze(|_| true);
613 Self {
614 base,
615 lora_a: Param::new(lora_a),
616 lora_b: Param::new(lora_b),
617 scale: 20.0,
618 dropout: Dropout::new(0.0),
619 }
620 }
621
622 /// Sets the update's scale.
623 pub fn scale(mut self, scale: f32) -> Self {
624 self.scale = scale;
625 self
626 }
627
628 /// Drops the update's input with probability `p` in training mode (mlx-lm's LoRA
629 /// dropout; the base sees the input unchanged).
630 pub fn dropout(mut self, p: f32) -> Self {
631 self.dropout = Dropout::new(p);
632 self
633 }
634
635 /// Rank-stabilised scaling (rsLoRA): treating `scale` as `α/r`, the update is scaled
636 /// by `α/√r = scale · √r` instead.
637 pub fn rslora(mut self) -> Self {
638 let r = self.lora_a.shape().get(1).copied().unwrap_or(1).max(1);
639 self.scale *= (r as f32).sqrt();
640 self
641 }
642
643 /// `base(x) + scale · (dropout(x) A) B`, the update cast to `x`'s dtype.
644 #[track_caller]
645 pub fn forward(&self, x: &Array) -> Array {
646 let y = self.base.forward(x);
647 let z = self
648 .dropout
649 .forward(x)
650 .matmul(&self.lora_a)
651 .matmul(&self.lora_b);
652 y.add(z.multiply(f64::from(self.scale)).astype(x.dtype()))
653 }
654
655 /// The base layer with the update merged into its weight (mlx-lm's `fuse` with
656 /// `dequantize`): `W + scale · Bᵀ Aᵀ`. A floating-point quantized base is dequantized to
657 /// the adapter's dtype (not MLX's bfloat16 default), an affine one to its scales' dtype.
658 #[track_caller]
659 pub fn fuse(&self) -> Linear {
660 let (weight, bias) = match &self.base {
661 LoraBase::Linear(l) => (l.weight.value().clone(), l.bias.clone()),
662 LoraBase::Quantized(q) => {
663 let dtype = (q.quant.mode != QuantMode::Affine).then(|| self.lora_a.dtype());
664 (q.dequantized_weight(dtype), q.bias.clone())
665 }
666 };
667 let delta = self
668 .lora_b
669 .t()
670 .multiply(f64::from(self.scale))
671 .matmul(&self.lora_a.t())
672 .astype(weight.dtype());
673 Linear {
674 weight: Param::new(weight.add(&delta)),
675 bias: bias.map(|b| Param::new(b.into_inner())),
676 }
677 }
678}
679
680impl From<Linear> for LoraBase {
681 fn from(l: Linear) -> Self {
682 Self::Linear(l)
683 }
684}
685
686impl From<QuantizedLinear> for LoraBase {
687 fn from(q: QuantizedLinear) -> Self {
688 Self::Quantized(q)
689 }
690}