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 {
157 lora: Lora,
158 quantizer: Quantizer,
159}
160
161impl QLora {
162 pub fn new(lora: Lora, quantizer: Quantizer) -> Self {
164 Self { lora, quantizer }
165 }
166}
167
168impl Reparameterizer for QLora {
169 type Reparam = LoraAdapter;
170
171 fn reparameterize<const D: usize>(
172 &mut self,
173 path: &str,
174 param: Param<Tensor<D>>,
175 ) -> (Param<Tensor<D>>, Option<Self::Reparam>) {
176 let param = self.quantizer.map_float_at_path(param, path);
178 self.lora.reparameterize(path, param)
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185 use crate as burn;
186 use crate::module::{Flag, Module, ParamId};
187 use crate::test_device;
188 use crate::test_utils::SimpleLinear;
189 use burn_tensor::Tolerance;
190
191 fn lora_model(in_features: usize, out_features: usize) -> (SimpleLinear, super::Lora) {
192 let device = test_device();
193 let lora = Lora::new(2, 4.0);
194 let model = SimpleLinear::new(in_features, out_features, &device).apply_lora(lora.clone());
195 (model, lora)
196 }
197
198 #[derive(Module, Debug)]
199 struct FlaggedWeight {
200 weight: Param<Tensor<2>>,
201 training: Param<Flag>,
202 }
203
204 fn flagged_weight() -> FlaggedWeight {
205 let device = test_device();
206 FlaggedWeight {
207 weight: Param::from_tensor(Tensor::random([4, 4], Distribution::Default, &device)),
208 training: Param::from_bool(true),
209 }
210 }
211
212 #[test]
213 fn lora_preserves_module_control_flags() {
214 let model = flagged_weight().apply_lora(Lora::new(2, 4.0));
215
216 assert!(model.training.is_enabled());
217 assert!(model.weight.adapter().is_some());
218 }
219
220 #[test]
221 fn qlora_preserves_module_control_flags() {
222 use burn_tensor::quantization::{Calibration, QuantValue};
223
224 let device = test_device();
225 let scheme = device
226 .settings()
227 .quantization
228 .scheme
229 .with_value(QuantValue::Q8S);
230 let quantizer = Quantizer::new(Calibration::MinMax, scheme);
231 let model = flagged_weight().apply_qlora(QLora::new(Lora::new(2, 4.0), quantizer));
232
233 assert!(model.training.is_enabled());
234 assert!(model.weight.adapter().is_some());
235 }
236
237 #[test]
238 fn materialize_lora_matches_base_plus_delta() {
239 let device = test_device();
240 let (model, config) = lora_model(4, 6);
241
242 let weight = &model.weight;
243 let adapter = weight.adapter().expect("adapter should be attached");
244
245 let expected = weight.base() + adapter.delta();
247 weight
248 .val()
249 .into_data()
250 .assert_approx_eq::<f32>(&expected.into_data(), Tolerance::default());
251
252 assert_eq!(adapter.scale, config.alpha / config.rank as f64);
254 let _ = device;
255 }
256
257 #[test]
258 fn lora_freezes_base_and_trains_adapter() {
259 let (model, _) = lora_model(4, 6);
260 let weight = &model.weight;
261 let adapter = weight.adapter().expect("adapter should be attached");
262
263 let ids = [weight.id, adapter.a.id, adapter.b.id];
265 assert_eq!(
266 ids.iter()
267 .collect::<alloc::collections::BTreeSet<&ParamId>>()
268 .len(),
269 3
270 );
271
272 assert_eq!(model.num_params(), 24 + 6 + 12 + 8);
275 }
276
277 #[test]
278 fn lora_b_is_zero_initialized_so_initial_delta_is_zero() {
279 let (model, _) = lora_model(4, 6);
280 let weight = &model.weight;
281 weight
283 .val()
284 .into_data()
285 .assert_approx_eq::<f32>(&weight.base().into_data(), Tolerance::default());
286 }
287
288 #[test]
289 fn lora_record_roundtrip_preserves_base_and_adapter() {
290 let (model, config) = lora_model(4, 6);
291
292 let device = test_device();
294 let target = SimpleLinear::new(4, 6, &device).apply_lora(config);
295
296 let record = model.clone().into_record();
297 let loaded = target.load_record(record);
298
299 loaded
301 .weight
302 .base()
303 .into_data()
304 .assert_eq(&model.weight.base().into_data(), true);
305 loaded
306 .weight
307 .adapter()
308 .unwrap()
309 .a
310 .val()
311 .into_data()
312 .assert_eq(&model.weight.adapter().unwrap().a.val().into_data(), true);
313 loaded
314 .weight
315 .val()
316 .into_data()
317 .assert_eq(&model.weight.val().into_data(), true);
318 }
319
320 #[cfg(feature = "autodiff")]
321 #[test]
322 fn lora_backward_grads_adapter_only() {
323 let device = test_device().autodiff();
324 let lora = Lora::new(2, 4.0);
325 let model = SimpleLinear::new(4, 6, &device).apply_lora(lora);
326
327 let loss = model.weight.val().sum();
329 let grads = loss.backward();
330
331 let adapter = model.weight.adapter().unwrap();
332 assert!(adapter.a.val().grad(&grads).is_some());
334 assert!(adapter.b.val().grad(&grads).is_some());
335 assert!(model.weight.base().grad(&grads).is_none());
336 }
337
338 #[cfg(feature = "autodiff")]
339 #[test]
340 fn global_gradient_activation_enables_lora_base_weights() {
341 let device = test_device().autodiff();
342 let model = SimpleLinear::new(4, 6, &device).apply_lora(Lora::new(2, 4.0));
343
344 let activated = [
345 model.clone().unfreeze(),
346 model.clone().set_require_grad(true),
347 model.set_require_grad_group(ParamGroup::from_path("weight"), true),
348 ];
349
350 for model in activated {
351 assert!(model.weight.base().is_require_grad());
352
353 let grads = model.weight.val().sum().backward();
354 assert!(model.weight.base().grad(&grads).is_some());
355 }
356 }
357
358 #[cfg(feature = "autodiff")]
359 #[test]
360 fn group_can_unfreeze_everything_except_lora_base_weights() {
361 let device = test_device().autodiff();
362 let lora_bases = ParamGroup::from_path("weight");
363 let model = SimpleLinear::new(4, 6, &device)
364 .apply_lora(Lora::new(2, 4.0).set_param_group(lora_bases.clone()))
365 .unfreeze_group(ParamGroup::all().exclude(lora_bases));
366
367 let adapter = model.weight.adapter().unwrap();
368 assert!(!model.weight.base().is_require_grad());
369 assert!(model.bias.as_ref().unwrap().is_require_grad());
370 assert!(adapter.a.is_require_grad());
371 assert!(adapter.b.is_require_grad());
372
373 let grads = model.weight.val().sum().backward();
374 assert!(model.weight.base().grad(&grads).is_none());
375 assert!(adapter.a.val().grad(&grads).is_some());
376 assert!(adapter.b.val().grad(&grads).is_some());
377 }
378
379 #[test]
380 fn qlora_quantizes_base_and_attaches_adapter() {
381 use crate::module::Quantizer;
382 use burn_tensor::quantization::{Calibration, QuantValue};
383
384 let device = test_device();
385 let scheme = device
386 .settings()
387 .quantization
388 .scheme
389 .with_value(QuantValue::Q8S);
390 let quantizer = Quantizer::new(Calibration::MinMax, scheme);
391
392 let original = SimpleLinear::new(8, 8, &device).weight.val();
393
394 let qlora = QLora::new(Lora::new(2, 4.0), quantizer);
395 let model = SimpleLinear::new(8, 8, &device).apply_qlora(qlora);
396
397 let weight = &model.weight;
398 assert!(weight.adapter().is_some());
399
400 let composed = weight.val();
403 assert_eq!(composed.dims(), [8, 8]);
404 assert_eq!(composed.into_data().shape, original.into_data().shape);
405 }
406
407 #[cfg(feature = "autodiff")]
408 #[test]
409 fn qlora_backward_grads_adapter_only() {
410 use burn_tensor::quantization::{Calibration, QuantValue};
411
412 let device = test_device().autodiff();
413 let scheme = device
414 .settings()
415 .quantization
416 .scheme
417 .with_value(QuantValue::Q8S);
418 let quantizer = Quantizer::new(Calibration::MinMax, scheme);
419 let model =
420 SimpleLinear::new(8, 8, &device).apply_qlora(QLora::new(Lora::new(2, 4.0), quantizer));
421
422 let grads = model.weight.val().sum().backward();
423 let adapter = model.weight.adapter().unwrap();
424
425 assert!(model.weight.base().grad(&grads).is_none());
426 assert!(adapter.a.val().grad(&grads).is_some());
427 assert!(adapter.b.val().grad(&grads).is_some());
428 }
429
430 #[test]
431 fn qlora_composes_packed_base_at_the_configured_factor_dtype() {
432 use crate::module::Quantizer;
433 use burn_tensor::DType;
434 use burn_tensor::quantization::{Calibration, QuantValue};
435
436 let device = test_device();
437 let scheme = device
438 .settings()
439 .quantization
440 .scheme
441 .with_value(QuantValue::Q8S);
442 let quantizer = Quantizer::new(Calibration::MinMax, scheme);
443
444 let qlora = QLora::new(Lora::new(2, 4.0).set_dtype(FloatDType::F16), quantizer);
447 let model = SimpleLinear::new(8, 8, &device).apply_qlora(qlora);
448
449 let weight = &model.weight;
450 let adapter = weight.adapter().expect("adapter should be attached");
451 assert_eq!(adapter.a.val().dtype(), DType::F16);
452 assert_eq!(adapter.b.val().dtype(), DType::F16);
453
454 assert_eq!(weight.val().dtype(), DType::F16);
457 }
458
459 #[test]
460 fn param_group_restricts_adapter_to_matching_parameters() {
461 use crate as burn;
462
463 #[derive(Module, Debug)]
464 struct TwoWeights {
465 a: Param<Tensor<2>>,
466 b: Param<Tensor<2>>,
467 }
468
469 let device = test_device();
470 let model = TwoWeights {
471 a: Param::from_tensor(Tensor::random(
472 [4, 4],
473 burn_tensor::Distribution::Default,
474 &device,
475 )),
476 b: Param::from_tensor(Tensor::random(
477 [4, 4],
478 burn_tensor::Distribution::Default,
479 &device,
480 )),
481 };
482
483 let group = ParamGroup::from_predicate("a");
484 let lora = Lora::new(2, 4.0).set_param_group(group);
485 let model = model.apply_lora(lora);
486
487 assert!(
489 model.a.adapter().is_some(),
490 "parameter in the group should get a LoRA adapter"
491 );
492 assert!(
493 model.b.adapter().is_none(),
494 "parameter outside the group should not get a LoRA adapter"
495 );
496
497 assert!(!model.a.base().is_require_grad());
499 assert!(!model.b.val().is_require_grad());
500 }
501
502 #[cfg(feature = "autodiff")]
503 #[test]
504 fn lora_valid_folds_adapter_for_inference() {
505 let device = test_device().autodiff();
506 let lora = Lora::new(2, 4.0);
507 let model = SimpleLinear::new(4, 6, &device).apply_lora(lora);
508
509 let inference = model.valid();
510 assert!(inference.weight.adapter().is_none());
512 inference.weight.val().into_data().assert_approx_eq::<f32>(
513 &model.weight.val().inner().into_data(),
514 Tolerance::default(),
515 );
516 }
517}