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 {
60 Self {
61 rank,
62 alpha,
63 init_std: None,
64 dtype: None,
65 param_group: ParamGroup::all(),
66 }
67 }
68
69 pub fn set_param_group(mut self, group: ParamGroup) -> Self {
71 self.param_group = group;
72 self
73 }
74
75 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 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 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 let base = param.set_require_grad(false);
116
117 if self.param_group.matches(&id, Some(path)) {
118 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
148pub struct QLora {
171 lora: Lora,
172 quantizer: Quantizer,
173}
174
175impl QLora {
176 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 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 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 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 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 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 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 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 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 let loss = model.weight.val().sum();
343 let grads = loss.backward();
344
345 let adapter = model.weight.adapter().unwrap();
346 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 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 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 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 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 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}