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