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