Skip to main content

burn_core/module/
lora.rs

1use burn_tensor::{Distribution, FloatDType, Tensor};
2
3use crate::module::{LoraAdapter, Param, ParamGroup, Quantizer, Reparameterizer};
4
5/// A [`Reparameterizer`] that attaches LoRA adapters to 2-D weight parameters.
6///
7/// It is applied via [`Module::apply_lora`](crate::module::Module::apply_lora).
8///
9/// All existing floating-point parameters are frozen. Matching rank-2 parameters receive
10/// trainable LoRA [adapter](LoraAdapter)s; other parameters remain frozen without adapters. No
11/// model or layer code needs to change—the same `Linear` (and any other module) keeps working, now
12/// producing `base + scale * (a @ b)` for adapted weights.
13#[derive(Debug, Clone)]
14pub struct Lora {
15    /// Rank of the low-rank decomposition.
16    pub rank: usize,
17    /// Scaling numerator; the adapter contribution is scaled by `alpha / rank`.
18    pub alpha: f64,
19    /// Standard deviation used to initialize the `A` factor. Defaults to `1 / rank`.
20    pub init_std: Option<f64>,
21    /// The parameter group on which to apply the LoRA.
22    pub param_group: ParamGroup,
23}
24
25impl Lora {
26    /// Create a new LoRA reparameterizer with the given rank and alpha.
27    pub fn new(rank: usize, alpha: f64) -> Self {
28        Self {
29            rank,
30            alpha,
31            init_std: None,
32            param_group: ParamGroup::all(),
33        }
34    }
35
36    /// Set the parameter group on which to apply LoRA adapters.
37    pub fn set_param_group(mut self, group: ParamGroup) -> Self {
38        self.param_group = group;
39        self
40    }
41}
42
43impl Reparameterizer for Lora {
44    type Reparam = LoraAdapter;
45
46    fn reparameterize<const D: usize>(
47        &mut self,
48        path: &str,
49        param: Param<Tensor<D>>,
50    ) -> (Param<Tensor<D>>, Option<Self::Reparam>) {
51        // LoRA only adapts 2-D weight matrices. Every other base parameter is frozen too, so that
52        // only the adapter factors are trained (the canonical LoRA fine-tuning contract).
53        if D != 2 {
54            let (id, tensor, mapper) = param.consume();
55            return (
56                Param::from_mapped_value(id, tensor.set_require_grad(false), mapper),
57                None,
58            );
59        }
60
61        let rank = self.rank;
62        let (id, tensor, mapper) = param.consume();
63        let device = tensor.device();
64        let dims = tensor.dims();
65        let (d_in, d_out) = (dims[0], dims[1]);
66        // The factors compose with the base in `base + scale * (a @ b)`, so they
67        // are built at the base's precision: a half-precision checkpoint would
68        // otherwise get f32 adapters and fail that op on any backend that does
69        // not promote silently. A *quantized* base is dequantized before it
70        // composes, so its packed dtype says nothing about theirs — they stay at
71        // the default there.
72        let base_dtype = tensor.dtype();
73        let dtype = base_dtype.is_float().then(|| FloatDType::from(base_dtype));
74
75        // Freeze the base weight; only the adapter factors will be trained.
76        let base = Param::from_mapped_value(id, tensor.set_require_grad(false), mapper);
77
78        if self.param_group.matches(&id, Some(path)) {
79            // Standard LoRA init: A ~ N(0, std) and B = 0, so the initial delta (and the model output)
80            // is unchanged when the adapter is first attached.
81            let std = self.init_std.unwrap_or(1.0 / rank as f64);
82            let a = Tensor::<2>::random([d_in, rank], Distribution::Normal(0.0, std), &device);
83            let b = Tensor::<2>::zeros([rank, d_out], &device);
84            let (a, b) = match dtype {
85                Some(dtype) => (a.cast(dtype), b.cast(dtype)),
86                None => (a, b),
87            };
88
89            let adapter = LoraAdapter {
90                a: Param::from_tensor(a),
91                b: Param::from_tensor(b),
92                scale: self.alpha / rank as f64,
93            };
94
95            return (base, Some(adapter));
96        }
97
98        (base, None)
99    }
100}
101
102/// A [`Reparameterizer`] implementing QLoRA: it quantizes the (frozen) base weights and
103/// attaches full-precision trainable LoRA adapters to 2-D weights.
104///
105/// It is applied via [`Module::apply_qlora`](crate::module::Module::apply_qlora).
106///
107/// The quantized base is kept at rest in its low-bit representation; the adapter contribution is
108/// added on top during the forward pass (the base is dequantized on the fly when composed).
109pub struct QLora {
110    lora: Lora,
111    quantizer: Quantizer,
112}
113
114impl QLora {
115    /// Create a new QLoRA reparameterizer from LoRA settings and a quantizer.
116    pub fn new(lora: Lora, quantizer: Quantizer) -> Self {
117        Self { lora, quantizer }
118    }
119}
120
121impl Reparameterizer for QLora {
122    type Reparam = LoraAdapter;
123
124    fn reparameterize<const D: usize>(
125        &mut self,
126        path: &str,
127        param: Param<Tensor<D>>,
128    ) -> (Param<Tensor<D>>, Option<Self::Reparam>) {
129        // Quantize the frozen base weight first, then attach a trainable adapter to 2-D weights.
130        let param = self.quantizer.map_float_at_path(param, path);
131        self.lora.reparameterize(path, param)
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    #[cfg(feature = "autodiff")]
139    use crate::module::AutodiffModule;
140    use crate::module::{Module, ParamId};
141    use crate::test_device;
142    use crate::test_utils::SimpleLinear;
143    use burn_tensor::Tolerance;
144
145    fn lora_model(in_features: usize, out_features: usize) -> (SimpleLinear, super::Lora) {
146        let device = test_device();
147        let lora = Lora::new(2, 4.0);
148        let model = SimpleLinear::new(in_features, out_features, &device).apply_lora(lora.clone());
149        (model, lora)
150    }
151
152    #[test]
153    fn materialize_lora_matches_base_plus_delta() {
154        let device = test_device();
155        let (model, config) = lora_model(4, 6);
156
157        let weight = &model.weight;
158        let adapter = weight.adapter().expect("adapter should be attached");
159
160        // The effective value must equal base + scale * (a @ b).
161        let expected = weight.base() + adapter.delta();
162        weight
163            .val()
164            .into_data()
165            .assert_approx_eq::<f32>(&expected.into_data(), Tolerance::default());
166
167        // Scale is alpha / rank.
168        assert_eq!(adapter.scale, config.alpha / config.rank as f64);
169        let _ = device;
170    }
171
172    #[test]
173    fn lora_freezes_base_and_trains_adapter() {
174        let (model, _) = lora_model(4, 6);
175        let weight = &model.weight;
176        let adapter = weight.adapter().expect("adapter should be attached");
177
178        // Distinct parameter ids for base / a / b.
179        let ids = [weight.id, adapter.a.id, adapter.b.id];
180        assert_eq!(
181            ids.iter()
182                .collect::<alloc::collections::BTreeSet<&ParamId>>()
183                .len(),
184            3
185        );
186
187        // num_params includes the adapter factors:
188        // weight [6,4]=24, bias [6]=6, a [6,2]=12, b [2,4]=8.
189        assert_eq!(model.num_params(), 24 + 6 + 12 + 8);
190    }
191
192    #[test]
193    fn lora_b_is_zero_initialized_so_initial_delta_is_zero() {
194        let (model, _) = lora_model(4, 6);
195        let weight = &model.weight;
196        // B = 0 => delta = 0 => effective weight equals the (frozen) base at init.
197        weight
198            .val()
199            .into_data()
200            .assert_approx_eq::<f32>(&weight.base().into_data(), Tolerance::default());
201    }
202
203    #[test]
204    fn lora_record_roundtrip_preserves_base_and_adapter() {
205        let (model, config) = lora_model(4, 6);
206
207        // A freshly-prepared model has different random base/A and zero B.
208        let device = test_device();
209        let target = SimpleLinear::new(4, 6, &device).apply_lora(config);
210
211        let record = model.clone().into_record();
212        let loaded = target.load_record(record);
213
214        // Base, A and B must all be restored from the record (paths weight / weight.lora.a / .b).
215        loaded
216            .weight
217            .base()
218            .into_data()
219            .assert_eq(&model.weight.base().into_data(), true);
220        loaded
221            .weight
222            .adapter()
223            .unwrap()
224            .a
225            .val()
226            .into_data()
227            .assert_eq(&model.weight.adapter().unwrap().a.val().into_data(), true);
228        loaded
229            .weight
230            .val()
231            .into_data()
232            .assert_eq(&model.weight.val().into_data(), true);
233    }
234
235    #[cfg(feature = "autodiff")]
236    #[test]
237    fn lora_backward_grads_adapter_only() {
238        let device = test_device().autodiff();
239        let lora = Lora::new(2, 4.0);
240        let model = SimpleLinear::new(4, 6, &device).apply_lora(lora);
241
242        // Forward through the composed weight and backpropagate.
243        let loss = model.weight.val().sum();
244        let grads = loss.backward();
245
246        let adapter = model.weight.adapter().unwrap();
247        // Adapter factors receive gradients; the frozen base does not.
248        assert!(adapter.a.val().grad(&grads).is_some());
249        assert!(adapter.b.val().grad(&grads).is_some());
250        assert!(model.weight.base().grad(&grads).is_none());
251    }
252
253    #[test]
254    fn qlora_quantizes_base_and_attaches_adapter() {
255        use crate::module::Quantizer;
256        use burn_tensor::quantization::{Calibration, QuantLevel, QuantParam, QuantValue};
257
258        let device = test_device();
259        let scheme = device
260            .settings()
261            .quantization
262            .scheme
263            .with_value(QuantValue::Q8S)
264            .with_level(QuantLevel::Tensor)
265            .with_param(QuantParam::F32);
266        let quantizer = Quantizer::new(Calibration::MinMax, scheme);
267
268        let original = SimpleLinear::new(8, 8, &device).weight.val();
269
270        let qlora = QLora::new(Lora::new(2, 4.0), quantizer);
271        let model = SimpleLinear::new(8, 8, &device).apply_qlora(qlora);
272
273        let weight = &model.weight;
274        assert!(weight.adapter().is_some());
275
276        // The composed value (dequant(base) + delta) has the right shape and is finite. With B = 0
277        // the initial delta is zero, so it is just the dequantized base.
278        let composed = weight.val();
279        assert_eq!(composed.dims(), [8, 8]);
280        assert_eq!(composed.into_data().shape, original.into_data().shape);
281    }
282
283    #[test]
284    fn param_group_restricts_adapter_to_matching_parameters() {
285        use crate as burn;
286
287        #[derive(Module, Debug)]
288        struct TwoWeights {
289            a: Param<Tensor<2>>,
290            b: Param<Tensor<2>>,
291        }
292
293        let device = test_device();
294        let model = TwoWeights {
295            a: Param::from_tensor(Tensor::random(
296                [4, 4],
297                burn_tensor::Distribution::Default,
298                &device,
299            )),
300            b: Param::from_tensor(Tensor::random(
301                [4, 4],
302                burn_tensor::Distribution::Default,
303                &device,
304            )),
305        };
306
307        let group = ParamGroup::from_predicate("a");
308        let lora = Lora::new(2, 4.0).set_param_group(group);
309        let model = model.apply_lora(lora);
310
311        // Only the parameter whose path matches the group gets an adapter attached.
312        assert!(
313            model.a.adapter().is_some(),
314            "parameter in the group should get a LoRA adapter"
315        );
316        assert!(
317            model.b.adapter().is_none(),
318            "parameter outside the group should not get a LoRA adapter"
319        );
320
321        // Every 2-D weight is frozen regardless of group membership.
322        assert!(!model.a.base().is_require_grad());
323        assert!(!model.b.val().is_require_grad());
324    }
325
326    #[cfg(feature = "autodiff")]
327    #[test]
328    fn lora_valid_folds_adapter_for_inference() {
329        let device = test_device().autodiff();
330        let lora = Lora::new(2, 4.0);
331        let model = SimpleLinear::new(4, 6, &device).apply_lora(lora);
332
333        let inference = model.valid();
334        // The inference parameter has no adapter (folded) and equals the composed training weight.
335        assert!(inference.weight.adapter().is_none());
336        inference.weight.val().into_data().assert_approx_eq::<f32>(
337            &model.weight.val().inner().into_data(),
338            Tolerance::default(),
339        );
340    }
341}