1use burn_tensor::{Distribution, FloatDType, Tensor};
2
3use crate::module::{LoraAdapter, Param, ParamGroup, Quantizer, Reparameterizer};
4
5#[derive(Debug, Clone)]
41pub struct Lora {
42 pub rank: usize,
44 pub alpha: f64,
46 pub init_std: Option<f64>,
48 pub dtype: Option<FloatDType>,
53 pub param_group: ParamGroup,
55}
56
57impl Lora {
58 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 pub fn set_param_group(mut self, group: ParamGroup) -> Self {
80 self.param_group = group;
81 self
82 }
83
84 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 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 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 let base = param.set_require_grad(false);
127
128 if self.param_group.matches(&id, Some(path)) {
129 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
159pub struct QLora {
182 lora: Lora,
183 quantizer: Quantizer,
184}
185
186impl QLora {
187 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 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 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 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 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 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 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 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 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 let loss = model.weight.val().sum();
354 let grads = loss.backward();
355
356 let adapter = model.weight.adapter().unwrap();
357 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 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 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 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 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 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}