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, 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}