Skip to main content

entrenar/lora/layer/
core.rs

1//! LoRA (Low-Rank Adaptation) layer implementation
2//!
3//! LoRA enables parameter-efficient fine-tuning by adding trainable low-rank
4//! decomposition matrices to frozen pretrained weights.
5//!
6//! For a frozen weight matrix W ∈ ℝ^(d_out × d_in), LoRA adds:
7//! ΔW = B @ A where A ∈ ℝ^(r × d_in) and B ∈ ℝ^(d_out × r)
8//!
9//! Forward pass: y = (W + α·B·A) @ x = W@x + α·(B@(A@x))
10//! where α is a scaling factor (typically alpha/r)
11//!
12//! Dropout placement (PMAT-879): matching HF PEFT `lora.Linear.forward`, dropout
13//! is applied to the INPUT `x` *before* the down-projection `A`:
14//!
15//! `y = W@x + scale · B(A(dropout(x)))`
16//!
17//! Dropout is active only in training mode; in eval mode (the default) it is the
18//! identity, so inference output is unchanged. Inverted dropout scales the
19//! surviving activations by `1/(1-p)` so the expected value is preserved.
20
21use crate::autograd::matmul;
22use crate::autograd::ops::{add, scale};
23use crate::Tensor;
24use std::cell::Cell;
25
26/// LoRA scaling mode (ENT-LoRA-004)
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub enum LoRAScaling {
29    /// Standard: scale = alpha / rank
30    Standard,
31    /// rsLoRA: scale = alpha / sqrt(rank) — rank-stable, default for rank > 16
32    RsLoRA,
33}
34
35impl LoRAScaling {
36    /// Compute the scaling factor
37    ///
38    /// # Panics
39    /// Panics if rank is zero
40    pub fn compute(self, alpha: f32, rank: usize) -> f32 {
41        assert!(rank > 0, "LoRA rank must be > 0");
42        match self {
43            Self::Standard => alpha / rank as f32,
44            Self::RsLoRA => alpha / (rank as f32).sqrt(),
45        }
46    }
47}
48
49/// LoRA layer: adds trainable low-rank adaptation to a frozen base weight
50#[derive(Clone)]
51pub struct LoRALayer {
52    /// Frozen base weight matrix stored as 1D [d_out * d_in]
53    base_weight: Tensor,
54    /// LoRA matrix A stored as 1D [r * d_in] - downprojection
55    lora_a: Tensor,
56    /// LoRA matrix B stored as 1D [d_out * r] - upprojection
57    lora_b: Tensor,
58    /// Output dimension
59    d_out: usize,
60    /// Input dimension
61    d_in: usize,
62    /// LoRA rank
63    rank: usize,
64    /// Scaling factor (alpha/rank)
65    scale: f32,
66    /// Whether the adapter is merged into base_weight
67    merged: bool,
68    /// LoRA dropout probability applied to the input `x` in the LoRA branch
69    /// (PMAT-879). `0.0` disables dropout (identity). Active only in training mode.
70    dropout: f32,
71    /// Training mode flag. `false` (eval) is the default so inference output is
72    /// deterministic and dropout-free, matching PEFT (`nn.Dropout` is identity in eval).
73    training: bool,
74    /// Base seed for the deterministic dropout RNG (PMAT-879).
75    dropout_seed: u64,
76    /// Per-forward counter that advances the dropout RNG so successive training
77    /// steps draw fresh, but reproducible, masks. Interior mutability keeps
78    /// `forward(&self)` (shared borrow) while remaining deterministic for a seed.
79    dropout_step: Cell<u64>,
80}
81
82impl LoRALayer {
83    /// Create a new LoRA layer
84    ///
85    /// # Arguments
86    /// * `base_weight` - Frozen pretrained weight [d_out * d_in]
87    /// * `d_out` - Output dimension
88    /// * `d_in` - Input dimension
89    /// * `rank` - LoRA rank (typically 4, 8, 16, 32, or 64)
90    /// * `alpha` - LoRA scaling parameter (often same as rank)
91    ///
92    /// # Returns
93    /// LoRA layer with randomly initialized A (Gaussian) and zero-initialized B
94    pub fn new(base_weight: Tensor, d_out: usize, d_in: usize, rank: usize, alpha: f32) -> Self {
95        assert!(rank > 0, "LoRA rank must be > 0");
96        assert_eq!(base_weight.len(), d_out * d_in, "Base weight size must match d_out * d_in");
97
98        // Initialize A with small Gaussian noise, B with zeros (standard LoRA init)
99        // This ensures that initially ΔW = B·A = 0
100        let lora_a_data: Vec<f32> = (0..rank * d_in)
101            .map(|i| {
102                // Simple deterministic "random" init for reproducibility in tests
103                let x = (i as f32 * 0.1).sin();
104                x * 0.01 // Small values
105            })
106            .collect();
107        let lora_a = Tensor::from_vec(lora_a_data, true);
108
109        let lora_b = Tensor::zeros(d_out * rank, true);
110
111        let scale = alpha / rank as f32;
112
113        Self {
114            base_weight,
115            lora_a,
116            lora_b,
117            d_out,
118            d_in,
119            rank,
120            scale,
121            merged: false,
122            dropout: 0.0,
123            training: false,
124            dropout_seed: 0,
125            dropout_step: Cell::new(0),
126        }
127    }
128
129    /// Create a new LoRA layer with explicit scaling mode (ENT-LoRA-004)
130    ///
131    /// Use `LoRAScaling::RsLoRA` for rank-stable training (recommended for rank > 16).
132    pub fn new_with_scaling(
133        base_weight: Tensor,
134        d_out: usize,
135        d_in: usize,
136        rank: usize,
137        alpha: f32,
138        scaling: LoRAScaling,
139    ) -> Self {
140        let mut layer = Self::new(base_weight, d_out, d_in, rank, alpha);
141        layer.scale = scaling.compute(alpha, rank);
142        layer
143    }
144
145    /// Override the LoRA scaling factor.
146    ///
147    /// Used when restoring a serialized adapter whose `scale` was produced by a
148    /// non-Standard mode (e.g. rsLoRA, where `scale = alpha / sqrt(rank)` rather than
149    /// the `alpha / rank` that [`LoRALayer::new`] recomputes). The adapter stores the
150    /// resulting scale *value*, not the scaling mode, so restoration sets it directly.
151    #[must_use]
152    pub fn with_scale(mut self, scale: f32) -> Self {
153        self.scale = scale;
154        self
155    }
156
157    /// Set the LoRA dropout probability (PMAT-879).
158    ///
159    /// Matches HF PEFT `lora_dropout`: dropout is applied to the input `x` before
160    /// the down-projection `A`. `p` is clamped to `[0.0, 1.0)`; `0.0` disables
161    /// dropout. Dropout is only active in training mode (see [`LoRALayer::train`]).
162    #[must_use]
163    pub fn with_dropout(mut self, p: f32) -> Self {
164        // Clamp to [0, 1): p == 1.0 would zero everything and divide by zero in
165        // the inverted-dropout scale, which is never a valid configuration.
166        self.dropout = p.clamp(0.0, 0.999_999);
167        self
168    }
169
170    /// Set the deterministic dropout RNG seed (PMAT-879).
171    ///
172    /// With a fixed seed, dropout masks are fully reproducible, which makes the
173    /// training-mode forward path testable.
174    #[must_use]
175    pub fn with_dropout_seed(mut self, seed: u64) -> Self {
176        self.dropout_seed = seed;
177        self
178    }
179
180    /// Switch the layer to training mode. Dropout is active when `dropout > 0.0`.
181    pub fn train(&mut self) {
182        self.training = true;
183    }
184
185    /// Switch the layer to evaluation mode (the default). Dropout is the identity,
186    /// so inference output is unchanged — matching PEFT `nn.Dropout` in eval.
187    pub fn eval(&mut self) {
188        self.training = false;
189    }
190
191    /// Set training/eval mode explicitly.
192    pub fn set_training(&mut self, training: bool) {
193        self.training = training;
194    }
195
196    /// Whether the layer is in training mode.
197    pub fn is_training(&self) -> bool {
198        self.training
199    }
200
201    /// LoRA dropout probability.
202    pub fn dropout(&self) -> f32 {
203        self.dropout
204    }
205
206    /// Apply inverted dropout to the LoRA-branch input (PMAT-879).
207    ///
208    /// Returns `x` unchanged when not in training mode or when `p == 0.0` (the
209    /// identity), exactly mirroring PEFT's `nn.Dropout`/`nn.Identity` placement.
210    /// Otherwise each element is independently zeroed with probability `p` and the
211    /// survivors are scaled by `1/(1-p)` so the expectation is preserved.
212    ///
213    /// Uses a deterministic RNG seeded from `(dropout_seed, dropout_step)` so the
214    /// mask is reproducible for a given seed while advancing per forward call.
215    fn apply_input_dropout(&self, x: &Tensor) -> Tensor {
216        if !self.training || self.dropout <= 0.0 {
217            return x.clone();
218        }
219
220        use rand::rngs::StdRng;
221        use rand::{Rng, SeedableRng};
222
223        let step = self.dropout_step.get();
224        self.dropout_step.set(step.wrapping_add(1));
225
226        // Mix seed and step so each forward draws a fresh, reproducible mask.
227        let mixed = self.dropout_seed ^ step.wrapping_mul(0x9E37_79B9_7F4A_7C15);
228        let mut rng = StdRng::seed_from_u64(mixed);
229
230        let keep = 1.0 - self.dropout;
231        let inv_keep = 1.0 / keep;
232
233        let dropped: Vec<f32> = x
234            .data()
235            .iter()
236            .map(|&v| if rng.random::<f32>() < self.dropout { 0.0 } else { v * inv_keep })
237            .collect();
238
239        Tensor::from_vec(dropped, x.requires_grad())
240    }
241
242    /// Forward pass: y = W@x + scale * (B @ (A @ dropout(x)))
243    ///
244    /// # Arguments
245    /// * `x` - Input tensor `[d_in]`
246    ///
247    /// # Returns
248    /// Output tensor `[d_out]`
249    pub fn forward(&self, x: &Tensor) -> Tensor {
250        assert_eq!(x.len(), self.d_in, "Input size must match d_in");
251
252        // Base forward: W @ x [d_out, d_in] @ [d_in, 1] -> [d_out, 1]
253        let base_output = matmul(&self.base_weight, x, self.d_out, self.d_in, 1);
254
255        if self.merged {
256            // If merged, W already includes LoRA adaptation
257            base_output
258        } else {
259            // LoRA forward: scale * (B @ (A @ dropout(x)))
260            // PEFT placement: dropout is applied to the INPUT x before A.
261            // In eval mode (default) or with dropout == 0.0 this is the identity.
262            let dropped_x = self.apply_input_dropout(x);
263
264            // Step 1: A @ dropout(x) [r, d_in] @ [d_in, 1] -> [r, 1]
265            let lora_out_a = matmul(&self.lora_a, &dropped_x, self.rank, self.d_in, 1);
266
267            // Step 2: B @ (A @ x) [d_out, r] @ [r, 1] -> [d_out, 1]
268            let lora_out_b = matmul(&self.lora_b, &lora_out_a, self.d_out, self.rank, 1);
269
270            // Step 3: scale * LoRA output.
271            //
272            // PMAT-931: route the scale through the autograd-aware `scale` op
273            // instead of rebuilding the tensor with `Tensor::new(.., false)`,
274            // which SEVERS the backward edge to lora_a/lora_b (the same
275            // graph-severing class as the PMAT-921/922 sweep). Without this the
276            // adapter receives no gradient and LoRA fine-tuning silently fails
277            // to train.
278            let scaled_lora = scale(&lora_out_b, self.scale);
279
280            // Step 4: base + LoRA, again through the autograd-aware `add` op so
281            // the result keeps a live backward op reaching both the frozen base
282            // matmul (no-grad, dropped) AND the trainable LoRA branch.
283            add(&base_output, &scaled_lora)
284        }
285    }
286
287    /// Merge LoRA weights into base weight: W' = W + scale * (B @ A)
288    ///
289    /// After merging, forward pass only uses W' (more efficient).
290    /// This is typically done for inference.
291    pub fn merge(&mut self) {
292        if self.merged {
293            return; // Already merged
294        }
295
296        // Compute B @ A [d_out, r] @ [r, d_in] -> [d_out, d_in]
297        let ba = matmul(&self.lora_b, &self.lora_a, self.d_out, self.rank, self.d_in);
298
299        // Scale and add to base weight: W' = W + scale * B @ A
300        for (i, val) in self.base_weight.data_mut().iter_mut().enumerate() {
301            *val += self.scale * ba.data()[i];
302        }
303
304        self.merged = true;
305    }
306
307    /// Unmerge LoRA weights from base weight: W = W' - scale * (B @ A)
308    ///
309    /// Reverses the merge operation. Useful for continuing training or
310    /// switching adapters.
311    pub fn unmerge(&mut self) {
312        if !self.merged {
313            return; // Not merged
314        }
315
316        // Compute B @ A
317        let ba = matmul(&self.lora_b, &self.lora_a, self.d_out, self.rank, self.d_in);
318
319        // Subtract from base weight: W = W' - scale * B @ A
320        for (i, val) in self.base_weight.data_mut().iter_mut().enumerate() {
321            *val -= self.scale * ba.data()[i];
322        }
323
324        self.merged = false;
325    }
326
327    /// Get reference to base weight matrix
328    pub fn base_weight(&self) -> &Tensor {
329        &self.base_weight
330    }
331
332    /// Get reference to LoRA A matrix
333    pub fn lora_a(&self) -> &Tensor {
334        &self.lora_a
335    }
336
337    /// Get mutable reference to LoRA A matrix
338    pub fn lora_a_mut(&mut self) -> &mut Tensor {
339        &mut self.lora_a
340    }
341
342    /// Get reference to LoRA B matrix
343    pub fn lora_b(&self) -> &Tensor {
344        &self.lora_b
345    }
346
347    /// Get mutable reference to LoRA B matrix
348    pub fn lora_b_mut(&mut self) -> &mut Tensor {
349        &mut self.lora_b
350    }
351
352    /// Get trainable parameters (A and B)
353    pub fn trainable_params(&mut self) -> Vec<&mut Tensor> {
354        vec![&mut self.lora_a, &mut self.lora_b]
355    }
356
357    /// Check if LoRA is merged
358    pub fn is_merged(&self) -> bool {
359        self.merged
360    }
361
362    /// Get rank
363    pub fn rank(&self) -> usize {
364        self.rank
365    }
366
367    /// Get scale factor
368    pub fn scale(&self) -> f32 {
369        self.scale
370    }
371
372    /// Get output dimension
373    pub fn d_out(&self) -> usize {
374        self.d_out
375    }
376
377    /// Get input dimension
378    pub fn d_in(&self) -> usize {
379        self.d_in
380    }
381}