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///
14/// Module-owned control flags are left unchanged. Apply [`Module::freeze`](crate::module::Module::freeze)
15/// before LoRA if dropout, batch normalization running statistics and other training behavior in
16/// the base module should also be disabled.
17///
18/// # Training state
19///
20/// [`Module::unfreeze`](crate::module::Module::unfreeze) and `set_require_grad(true)` deliberately
21/// enable every dense parameter, including LoRA bases, and therefore switch to full fine-tuning.
22/// To enable everything except the LoRA bases, retain a [`ParamGroup`] containing their exact
23/// paths or IDs and call
24/// `unfreeze_group(ParamGroup::all().exclude(lora_base_group))`.
25///
26/// ```
27/// use burn_core::module::{Lora, Module, ParamGroup};
28///
29/// # fn configure<M: Module>(model: M) -> M {
30/// let lora_bases = ParamGroup::from_paths(vec![
31///     "encoder.weight",
32///     "decoder.weight",
33/// ]);
34///
35/// model
36///     .apply_lora(Lora::new(2, 4.0).set_param_group(lora_bases.clone()))
37///     .unfreeze_group(ParamGroup::all().exclude(lora_bases))
38/// # }
39/// ```
40#[derive(Debug, Clone)]
41pub struct Lora {
42    /// Rank of the low-rank decomposition. Must be greater than zero.
43    pub rank: usize,
44    /// Scaling numerator; the adapter contribution is scaled by `alpha / rank`.
45    pub alpha: f64,
46    /// Standard deviation used to initialize the `A` factor. Defaults to `1 / rank`.
47    pub init_std: Option<f64>,
48    /// Precision the factors are built at. Defaults to the base weight's own
49    /// float dtype; a *quantized* base has none, so a model that computes in
50    /// half precision over a packed base must say so here — the factors meet
51    /// its activations and gradients in elementwise ops that do not promote.
52    pub dtype: Option<FloatDType>,
53    /// The parameter group on which to apply the LoRA.
54    pub param_group: ParamGroup,
55}
56
57impl Lora {
58    /// Create a new LoRA reparameterizer with the given rank and alpha.
59    ///
60    /// # Panics
61    ///
62    /// Panics if `rank` is zero.
63    pub fn new(rank: usize, alpha: f64) -> Self {
64        Self::assert_valid_rank(rank);
65        Self {
66            rank,
67            alpha,
68            init_std: None,
69            dtype: None,
70            param_group: ParamGroup::all(),
71        }
72    }
73
74    fn assert_valid_rank(rank: usize) {
75        assert!(rank > 0, "LoRA rank must be greater than 0, got {rank}");
76    }
77
78    /// Set the parameter group on which to apply LoRA adapters.
79    pub fn set_param_group(mut self, group: ParamGroup) -> Self {
80        self.param_group = group;
81        self
82    }
83
84    /// Set the precision the adapter factors are built at.
85    pub fn set_dtype(mut self, dtype: FloatDType) -> Self {
86        self.dtype = Some(dtype);
87        self
88    }
89}
90
91impl Reparameterizer for Lora {
92    type Reparam = LoraAdapter;
93
94    fn reparameterize<const D: usize>(
95        &mut self,
96        path: &str,
97        param: Param<Tensor<D>>,
98    ) -> (Param<Tensor<D>>, Option<Self::Reparam>) {
99        Self::assert_valid_rank(self.rank);
100
101        // LoRA only adapts 2-D weight matrices. Every other base parameter is frozen too, so that
102        // only the adapter factors are trained (the canonical LoRA fine-tuning contract).
103        if D != 2 {
104            return (param.set_require_grad(false), None);
105        }
106
107        let rank = self.rank;
108        let id = param.id;
109        let tensor = param.base();
110        let device = tensor.device();
111        let dims = tensor.dims();
112        let (d_in, d_out) = (dims[0], dims[1]);
113        // The factors compose with the base in `base + scale * (a @ b)`, so they
114        // are built at the base's precision: a half-precision checkpoint would
115        // otherwise get f32 adapters and fail that op on any backend that does
116        // not promote silently. A *quantized* base is dequantized before it
117        // composes, so its packed dtype says nothing about theirs — there the
118        // configured `dtype` is the only source, and without one they stay at
119        // the default.
120        let base_dtype = tensor.dtype();
121        let dtype = self
122            .dtype
123            .or_else(|| base_dtype.is_float().then(|| FloatDType::from(base_dtype)));
124
125        // Freeze the base weight; only the adapter factors will be trained.
126        let base = param.set_require_grad(false);
127
128        if self.param_group.matches(&id, Some(path)) {
129            // Standard LoRA init: A ~ N(0, std) and B = 0, so the initial delta (and the model output)
130            // is unchanged when the adapter is first attached.
131            //
132            // Built under a persistent-allocation window, as `Param::from_data` places its
133            // value: the factors are parameters, alive as long as the module they attach to,
134            // and this is the code that knows it. A caller sizing or capping the dynamic
135            // pools around a prepared model must not find its trainable weights living there.
136            let std = self.init_std.unwrap_or(1.0 / rank as f64);
137            let (a, b) = device.memory_persistent_allocations((), |_| {
138                let a = Tensor::<2>::random([d_in, rank], Distribution::Normal(0.0, std), &device);
139                let b = Tensor::<2>::zeros([rank, d_out], &device);
140                match dtype {
141                    Some(dtype) => (a.cast(dtype), b.cast(dtype)),
142                    None => (a, b),
143                }
144            });
145
146            let adapter = LoraAdapter {
147                a: Param::from_tensor(a),
148                b: Param::from_tensor(b),
149                scale: self.alpha / rank as f64,
150            };
151
152            return (base, Some(adapter));
153        }
154
155        (base, None)
156    }
157}
158
159/// A [`Reparameterizer`] implementing QLoRA: it quantizes the (frozen) base weights and
160/// attaches full-precision trainable LoRA adapters to 2-D weights.
161///
162/// It is applied via [`Module::apply_qlora`](crate::module::Module::apply_qlora).
163///
164/// The quantized base is kept at rest in its low-bit representation; the adapter contribution is
165/// added on top during the forward pass (the base is dequantized on the fly when composed).
166/// Module-owned control flags are left unchanged, as with [`Lora`].
167///
168/// # Memory limitations
169///
170/// QLoRA reduces stored base-weight memory and trains only the adapter factors, but still
171/// composes a dense effective weight as `dequantize(base) + scale * (a @ b)`. For a linear
172/// projection whose input requires gradients, autodiff retains that dense weight for backward
173/// and computes a full weight-shaped gradient before propagating into the factors. Peak training
174/// memory can therefore be substantially higher than the packed weights and adapters alone suggest.
175/// Fusion can avoid a separate dequantized base allocation, but does not eliminate all of these
176/// dense intermediates.
177///
178/// [`Module::valid`](crate::module::Module::valid) preserves the packed base and adapters.
179/// Explicitly calling [`Module::materialize`](crate::module::Module::materialize) merges them
180/// into dense weights; requantization must be requested separately.
181pub struct QLora {
182    lora: Lora,
183    quantizer: Quantizer,
184}
185
186impl QLora {
187    /// Create a new QLoRA reparameterizer from LoRA settings and a quantizer.
188    pub fn new(lora: Lora, quantizer: Quantizer) -> Self {
189        Self { lora, quantizer }
190    }
191}
192
193impl Reparameterizer for QLora {
194    type Reparam = LoraAdapter;
195
196    fn reparameterize<const D: usize>(
197        &mut self,
198        path: &str,
199        param: Param<Tensor<D>>,
200    ) -> (Param<Tensor<D>>, Option<Self::Reparam>) {
201        // Quantize the frozen base weight first, then attach a trainable adapter to 2-D weights.
202        let param = self.quantizer.map_float_at_path(param, path);
203        self.lora.reparameterize(path, param)
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use crate as burn;
211    use crate::module::{Flag, Module, ParamId};
212    use crate::test_device;
213    use crate::test_utils::SimpleLinear;
214    use burn_tensor::Tolerance;
215
216    fn lora_model(in_features: usize, out_features: usize) -> (SimpleLinear, super::Lora) {
217        let device = test_device();
218        let lora = Lora::new(2, 4.0);
219        let model = SimpleLinear::new(in_features, out_features, &device).apply_lora(lora.clone());
220        (model, lora)
221    }
222
223    #[derive(Module, Debug)]
224    struct FlaggedWeight {
225        weight: Param<Tensor<2>>,
226        training: Param<Flag>,
227    }
228
229    fn flagged_weight() -> FlaggedWeight {
230        let device = test_device();
231        FlaggedWeight {
232            weight: Param::from_tensor(Tensor::random([4, 4], Distribution::Default, &device)),
233            training: Param::from_bool(true),
234        }
235    }
236
237    #[test]
238    fn lora_preserves_module_control_flags() {
239        let model = flagged_weight().apply_lora(Lora::new(2, 4.0));
240
241        assert!(model.training.is_enabled());
242        assert!(model.weight.adapter().is_some());
243    }
244
245    #[test]
246    fn qlora_preserves_module_control_flags() {
247        use burn_tensor::quantization::{Calibration, QuantValue};
248
249        let device = test_device();
250        let scheme = device
251            .settings()
252            .quantization
253            .scheme
254            .with_value(QuantValue::Q8S);
255        let quantizer = Quantizer::new(Calibration::MinMax, scheme);
256        let model = flagged_weight().apply_qlora(QLora::new(Lora::new(2, 4.0), quantizer));
257
258        assert!(model.training.is_enabled());
259        assert!(model.weight.adapter().is_some());
260    }
261
262    #[test]
263    fn materialize_lora_matches_base_plus_delta() {
264        let device = test_device();
265        let (model, config) = lora_model(4, 6);
266
267        let weight = &model.weight;
268        let adapter = weight.adapter().expect("adapter should be attached");
269
270        // The effective value must equal base + scale * (a @ b).
271        let expected = weight.base() + adapter.delta();
272        weight
273            .val()
274            .into_data()
275            .assert_approx_eq::<f32>(&expected.into_data(), Tolerance::default());
276
277        // Scale is alpha / rank.
278        assert_eq!(adapter.scale, config.alpha / config.rank as f64);
279        let _ = device;
280    }
281
282    #[test]
283    fn lora_freezes_base_and_trains_adapter() {
284        let (model, _) = lora_model(4, 6);
285        let weight = &model.weight;
286        let adapter = weight.adapter().expect("adapter should be attached");
287
288        // Distinct parameter ids for base / a / b.
289        let ids = [weight.id, adapter.a.id, adapter.b.id];
290        assert_eq!(
291            ids.iter()
292                .collect::<alloc::collections::BTreeSet<&ParamId>>()
293                .len(),
294            3
295        );
296
297        // num_params includes the adapter factors:
298        // weight [6,4]=24, bias [6]=6, a [6,2]=12, b [2,4]=8.
299        assert_eq!(model.num_params(), 24 + 6 + 12 + 8);
300    }
301
302    #[test]
303    fn lora_b_is_zero_initialized_so_initial_delta_is_zero() {
304        let (model, _) = lora_model(4, 6);
305        let weight = &model.weight;
306        // B = 0 => delta = 0 => effective weight equals the (frozen) base at init.
307        weight
308            .val()
309            .into_data()
310            .assert_approx_eq::<f32>(&weight.base().into_data(), Tolerance::default());
311    }
312
313    #[test]
314    fn lora_record_roundtrip_preserves_base_and_adapter() {
315        let (model, config) = lora_model(4, 6);
316
317        // A freshly-prepared model has different random base/A and zero B.
318        let device = test_device();
319        let target = SimpleLinear::new(4, 6, &device).apply_lora(config);
320
321        let record = model.clone().into_record();
322        let loaded = target.load_record(record);
323
324        // Base, A and B must all be restored from the record (paths weight / weight.lora.a / .b).
325        loaded
326            .weight
327            .base()
328            .into_data()
329            .assert_eq(&model.weight.base().into_data(), true);
330        loaded
331            .weight
332            .adapter()
333            .unwrap()
334            .a
335            .val()
336            .into_data()
337            .assert_eq(&model.weight.adapter().unwrap().a.val().into_data(), true);
338        loaded
339            .weight
340            .val()
341            .into_data()
342            .assert_eq(&model.weight.val().into_data(), true);
343    }
344
345    #[cfg(feature = "autodiff")]
346    #[test]
347    fn lora_backward_grads_adapter_only() {
348        let device = test_device().autodiff();
349        let lora = Lora::new(2, 4.0);
350        let model = SimpleLinear::new(4, 6, &device).apply_lora(lora);
351
352        // Forward through the composed weight and backpropagate.
353        let loss = model.weight.val().sum();
354        let grads = loss.backward();
355
356        let adapter = model.weight.adapter().unwrap();
357        // Adapter factors receive gradients; the frozen base does not.
358        assert!(adapter.a.val().grad(&grads).is_some());
359        assert!(adapter.b.val().grad(&grads).is_some());
360        assert!(model.weight.base().grad(&grads).is_none());
361    }
362
363    #[cfg(feature = "autodiff")]
364    #[test]
365    fn global_gradient_activation_enables_lora_base_weights() {
366        let device = test_device().autodiff();
367        let model = SimpleLinear::new(4, 6, &device).apply_lora(Lora::new(2, 4.0));
368
369        let activated = [
370            model.clone().unfreeze(),
371            model.clone().set_require_grad(true),
372            model.set_require_grad_group(ParamGroup::from_path("weight"), true),
373        ];
374
375        for model in activated {
376            assert!(model.weight.base().is_require_grad());
377
378            let grads = model.weight.val().sum().backward();
379            assert!(model.weight.base().grad(&grads).is_some());
380        }
381    }
382
383    #[cfg(feature = "autodiff")]
384    #[test]
385    fn group_can_unfreeze_everything_except_lora_base_weights() {
386        let device = test_device().autodiff();
387        let lora_bases = ParamGroup::from_path("weight");
388        let model = SimpleLinear::new(4, 6, &device)
389            .apply_lora(Lora::new(2, 4.0).set_param_group(lora_bases.clone()))
390            .unfreeze_group(ParamGroup::all().exclude(lora_bases));
391
392        let adapter = model.weight.adapter().unwrap();
393        assert!(!model.weight.base().is_require_grad());
394        assert!(model.bias.as_ref().unwrap().is_require_grad());
395        assert!(adapter.a.is_require_grad());
396        assert!(adapter.b.is_require_grad());
397
398        let grads = model.weight.val().sum().backward();
399        assert!(model.weight.base().grad(&grads).is_none());
400        assert!(adapter.a.val().grad(&grads).is_some());
401        assert!(adapter.b.val().grad(&grads).is_some());
402    }
403
404    #[test]
405    fn qlora_quantizes_base_and_attaches_adapter() {
406        use crate::module::Quantizer;
407        use burn_tensor::quantization::{Calibration, QuantValue};
408
409        let device = test_device();
410        let scheme = device
411            .settings()
412            .quantization
413            .scheme
414            .with_value(QuantValue::Q8S);
415        let quantizer = Quantizer::new(Calibration::MinMax, scheme);
416
417        let original = SimpleLinear::new(8, 8, &device).weight.val();
418
419        let qlora = QLora::new(Lora::new(2, 4.0), quantizer);
420        let model = SimpleLinear::new(8, 8, &device).apply_qlora(qlora);
421
422        let weight = &model.weight;
423        assert!(weight.adapter().is_some());
424
425        // The composed value (dequant(base) + delta) has the right shape and is finite. With B = 0
426        // the initial delta is zero, so it is just the dequantized base.
427        let composed = weight.val();
428        assert_eq!(composed.dims(), [8, 8]);
429        assert_eq!(composed.into_data().shape(), original.into_data().shape());
430    }
431
432    #[cfg(feature = "autodiff")]
433    #[test]
434    fn qlora_backward_grads_adapter_only() {
435        use burn_tensor::quantization::{Calibration, QuantValue};
436
437        let device = test_device().autodiff();
438        let scheme = device
439            .settings()
440            .quantization
441            .scheme
442            .with_value(QuantValue::Q8S);
443        let quantizer = Quantizer::new(Calibration::MinMax, scheme);
444        let model =
445            SimpleLinear::new(8, 8, &device).apply_qlora(QLora::new(Lora::new(2, 4.0), quantizer));
446
447        let grads = model.weight.val().sum().backward();
448        let adapter = model.weight.adapter().unwrap();
449
450        assert!(model.weight.base().grad(&grads).is_none());
451        assert!(adapter.a.val().grad(&grads).is_some());
452        assert!(adapter.b.val().grad(&grads).is_some());
453    }
454
455    #[test]
456    fn qlora_composes_packed_base_at_the_configured_factor_dtype() {
457        use crate::module::Quantizer;
458        use burn_tensor::DType;
459        use burn_tensor::quantization::{Calibration, QuantValue};
460
461        let device = test_device();
462        let scheme = device
463            .settings()
464            .quantization
465            .scheme
466            .with_value(QuantValue::Q8S);
467        let quantizer = Quantizer::new(Calibration::MinMax, scheme);
468
469        // A packed base carries no float dtype, so the configured one is the
470        // only source the factors have.
471        let qlora = QLora::new(Lora::new(2, 4.0).set_dtype(FloatDType::F16), quantizer);
472        let model = SimpleLinear::new(8, 8, &device).apply_qlora(qlora);
473
474        let weight = &model.weight;
475        let adapter = weight.adapter().expect("adapter should be attached");
476        assert_eq!(adapter.a.val().dtype(), DType::F16);
477        assert_eq!(adapter.b.val().dtype(), DType::F16);
478
479        // Mixed QFloat/Float addition dequantizes the packed base directly to
480        // the factors' dtype.
481        assert_eq!(weight.val().dtype(), DType::F16);
482    }
483
484    #[test]
485    fn param_group_restricts_adapter_to_matching_parameters() {
486        use crate as burn;
487
488        #[derive(Module, Debug)]
489        struct TwoWeights {
490            a: Param<Tensor<2>>,
491            b: Param<Tensor<2>>,
492        }
493
494        let device = test_device();
495        let model = TwoWeights {
496            a: Param::from_tensor(Tensor::random(
497                [4, 4],
498                burn_tensor::Distribution::Default,
499                &device,
500            )),
501            b: Param::from_tensor(Tensor::random(
502                [4, 4],
503                burn_tensor::Distribution::Default,
504                &device,
505            )),
506        };
507
508        let group = ParamGroup::from_predicate("a");
509        let lora = Lora::new(2, 4.0).set_param_group(group);
510        let model = model.apply_lora(lora);
511
512        // Only the parameter whose path matches the group gets an adapter attached.
513        assert!(
514            model.a.adapter().is_some(),
515            "parameter in the group should get a LoRA adapter"
516        );
517        assert!(
518            model.b.adapter().is_none(),
519            "parameter outside the group should not get a LoRA adapter"
520        );
521
522        // Every 2-D weight is frozen regardless of group membership.
523        assert!(!model.a.base().is_require_grad());
524        assert!(!model.b.val().is_require_grad());
525    }
526
527    #[cfg(feature = "autodiff")]
528    #[test]
529    fn lora_valid_preserves_adapter_and_train_restores_only_factors() {
530        let device = test_device().autodiff();
531        let lora = Lora::new(2, 4.0);
532        let model = SimpleLinear::new(4, 6, &device).apply_lora(lora);
533
534        let inference = model.valid();
535        let adapter = inference.weight.adapter().unwrap();
536        assert!(!inference.weight.base().is_autodiff());
537        assert!(!adapter.a.val().is_autodiff());
538        assert!(!adapter.b.val().is_autodiff());
539        assert_eq!(adapter.a.id, model.weight.adapter().unwrap().a.id);
540        assert_eq!(adapter.b.id, model.weight.adapter().unwrap().b.id);
541        inference.weight.val().into_data().assert_approx_eq::<f32>(
542            &model.weight.val().inner().into_data(),
543            Tolerance::default(),
544        );
545
546        let restored = inference.valid().train();
547        assert!(!restored.weight.base().is_require_grad());
548        assert!(!restored.bias.as_ref().unwrap().is_require_grad());
549        let adapter = restored.weight.adapter().unwrap();
550        let grads = restored.weight.val().sum().backward();
551        assert!(restored.weight.base().grad(&grads).is_none());
552        assert!(adapter.a.val().grad(&grads).is_some());
553        assert!(adapter.b.val().grad(&grads).is_some());
554
555        let merged = model.valid().materialize();
556        assert!(merged.weight.adapter().is_none());
557        assert!(!merged.weight.val().is_autodiff());
558        merged.weight.val().into_data().assert_approx_eq::<f32>(
559            &model.weight.val().inner().into_data(),
560            Tolerance::default(),
561        );
562        assert!(!merged.train().weight.base().is_require_grad());
563    }
564
565    #[test]
566    #[should_panic(expected = "LoRA rank must be greater than 0")]
567    fn lora_new_rejects_rank_zero() {
568        let _ = Lora::new(0, 4.0);
569    }
570
571    #[test]
572    #[should_panic(expected = "LoRA rank must be greater than 0")]
573    fn lora_rejects_rank_zero_set_after_construction() {
574        let device = test_device();
575        let mut lora = Lora::new(2, 4.0);
576        lora.rank = 0;
577        let _ = SimpleLinear::new(4, 6, &device).apply_lora(lora);
578    }
579
580    #[test]
581    #[should_panic(expected = "LoRA rank must be greater than 0")]
582    fn qlora_rejects_rank_zero_set_after_construction() {
583        use crate::module::Quantizer;
584        use burn_tensor::quantization::{Calibration, QuantValue};
585
586        let device = test_device();
587        let scheme = device
588            .settings()
589            .quantization
590            .scheme
591            .with_value(QuantValue::Q8S);
592        let quantizer = Quantizer::new(Calibration::MinMax, scheme);
593
594        let mut lora = Lora::new(2, 4.0);
595        lora.rank = 0;
596        let _ = SimpleLinear::new(8, 8, &device).apply_qlora(QLora::new(lora, quantizer));
597    }
598}