1use burn_core as burn;
2use burn_core::module::ParamGroup;
3
4use super::{Optimizer, ParameterContext};
5use crate::lr_scheduler::module_lr_scheduler::ModuleLearningRate;
6use crate::{
7 DynOptimizer, DynState, MultiGradientsParams, OptimizerRecord, RecordTensor, StateSink,
8 StateSource, grad_clipping::GradientClipping, optim::GradientsParams, optim::state::join_path,
9};
10
11use alloc::collections::BTreeMap;
12use alloc::string::ToString;
13use alloc::sync::Arc;
14use alloc::vec::Vec;
15use burn::module::{Module, ModuleMapper, Param, ParamId};
16use burn::store::RecordError;
17use burn::tensor::{Bytes, Device, Tensor};
18use hashbrown::HashMap;
19
20const RANK_KEY: &str = "__rank";
25
26#[derive(Clone)]
27struct OptimizerGroup {
28 group: ParamGroup,
29 optim: Arc<dyn DynOptimizer>,
30 grad_clipping: Option<GradientClipping>,
31}
32
33impl OptimizerGroup {
34 pub(crate) fn set_gradient_clipping(&mut self, gradient_clipping: GradientClipping) {
35 self.grad_clipping = Some(gradient_clipping)
36 }
37}
38
39#[derive(Clone)]
41struct OptimizationContext {
42 optim: Arc<dyn DynOptimizer>,
43 grad_clipping: Option<GradientClipping>,
44 path: Option<String>,
45 state: DynState,
46}
47
48#[derive(Clone)]
59pub struct ModuleOptimizer {
60 optimizers: Vec<OptimizerGroup>,
61 param_context: HashMap<ParamId, OptimizationContext>,
62}
63
64impl<O> From<O> for ModuleOptimizer
65where
66 O: Optimizer,
67{
68 fn from(optim: O) -> Self {
69 Self {
70 param_context: HashMap::new(),
71 optimizers: vec![OptimizerGroup {
72 group: ParamGroup::all(),
73 optim: Arc::new(optim),
74 grad_clipping: None,
75 }],
76 }
77 }
78}
79
80impl ModuleOptimizer {
81 pub fn has_gradient_clipping(&self) -> bool {
84 self.optimizers.iter().any(|g| g.grad_clipping.is_some())
85 }
86
87 pub fn grad_clipping(&self) -> Option<&GradientClipping> {
90 self.optimizers
91 .first()
92 .expect("Should have at least one optimizer")
93 .grad_clipping
94 .as_ref()
95 }
96
97 pub fn with_grad_clipping(mut self, gradient_clipping: GradientClipping) -> Self {
108 self.optimizers
109 .first_mut()
110 .expect("Should have at least one optimizer")
111 .set_gradient_clipping(gradient_clipping);
112 self
113 }
114
115 fn step_common<M: Module>(
116 &mut self,
117 lr_policy: ModuleLearningRate,
118 module: M,
119 mut grads: GradAdaptor,
120 ) -> M {
121 module.map(&mut ModuleOptimizerMapper::new(
122 self.optimizers.iter().collect(),
123 &mut self.param_context,
124 &mut grads,
125 lr_policy,
126 ))
127 }
128
129 pub fn with_group<O>(
142 mut self,
143 group: ParamGroup,
144 optim: O,
145 grad_clipping: Option<GradientClipping>,
146 ) -> Self
147 where
148 O: DynOptimizer + 'static,
149 {
150 self.optimizers.push(OptimizerGroup {
151 group: group.clone(),
152 optim: Arc::new(optim),
153 grad_clipping,
154 });
155 self.param_context
156 .retain(|id, param_state| !group.matches(id, param_state.path.as_deref()));
157 self
158 }
159}
160
161impl ModuleOptimizer {
162 pub fn step<M: Module>(
164 &mut self,
165 lr_module: impl Into<ModuleLearningRate>,
166 module: M,
167 grads: GradientsParams,
168 ) -> M {
169 self.step_common(lr_module.into(), module, grads.into())
170 }
171
172 pub fn step_multi<M: Module>(
174 &mut self,
175 lr_module: impl Into<ModuleLearningRate>,
176 module: M,
177 grads: MultiGradientsParams,
178 ) -> M {
179 self.step_common(lr_module.into(), module, grads.into())
180 }
181
182 fn optim_from_param(
183 &self,
184 id: ParamId,
185 path: Option<&str>,
186 ) -> (&'_ Arc<dyn DynOptimizer>, Option<GradientClipping>) {
187 self.optimizers
188 .iter()
189 .filter_map(|val| {
190 val.group
191 .matches(&id, path)
192 .then_some((&val.optim, val.grad_clipping.clone()))
193 })
194 .next_back()
195 .expect("Should match at least one parameter group.")
196 }
197
198 pub fn to_record(&self) -> OptimizerRecord {
200 let mut tensors = Vec::new();
201 let mut scalars = BTreeMap::new();
202 let mut paths = BTreeMap::new();
203
204 for (id, param_state) in self.param_context.iter() {
205 let prefix = id.val().to_string();
206 let mut sink = StateSink::default();
207 param_state
208 .optim
209 .state_flatten(&prefix, ¶m_state.state, &mut sink);
210
211 scalars.insert(
214 join_path(&prefix, RANK_KEY),
215 burn_pack::Scalar::from(param_state.state.rank()),
216 );
217 if let Some(path) = ¶m_state.path {
219 paths.insert(prefix, path.clone());
220 }
221
222 for (name, data) in sink.tensors {
223 tensors.push(RecordTensor {
224 name,
225 param_id: Some(id.val()),
226 data,
227 });
228 }
229 for (name, value) in sink.scalars {
230 scalars.insert(name, value);
231 }
232 }
233
234 OptimizerRecord {
235 tensors,
236 scalars,
237 paths,
238 }
239 }
240
241 pub fn load_record(mut self, record: OptimizerRecord) -> Self {
248 let device = Device::default();
249 let mut ranks: BTreeMap<u64, usize> = BTreeMap::new();
250 let mut paths: BTreeMap<u64, String> = BTreeMap::new();
251
252 let suffix = alloc::format!(".{RANK_KEY}");
255 for (name, value) in record.scalars.iter() {
256 if let Some(id_str) = name.strip_suffix(&suffix)
257 && let (Ok(id), Ok(rank)) = (id_str.parse::<u64>(), usize::try_from(*value))
258 {
259 ranks.insert(id, rank);
260 }
261 }
262
263 for (name, path) in record.paths.iter() {
264 if let Ok(id) = name.parse::<u64>() {
265 paths.insert(id, path.to_string());
266 }
267 }
268
269 let mut source = StateSource::new(record.scalars);
270
271 for tensor in record.tensors {
272 let RecordTensor {
273 name,
274 param_id,
275 data,
276 } = tensor;
277 let id = param_id.expect("Optimizer record tensors should carry a parameter id.");
278 ranks.entry(id).or_insert(data.shape.len());
280 source.insert_tensor(name, data);
281 }
282
283 let mut states = HashMap::new();
284 for (id, rank) in ranks {
285 let prefix = id.to_string();
286 let path = paths.get(&id);
287 let (optim, grad_clipping) =
288 self.optim_from_param(id.into(), path.map(|path| path.as_str()));
289 if let Some(state) = optim.state_unflatten(rank, &prefix, &mut source, &device) {
292 states.insert(
293 ParamId::from(id),
294 OptimizationContext {
295 optim: optim.clone(),
296 path: path.cloned(),
297 state,
298 grad_clipping,
299 },
300 );
301 }
302 }
303
304 self.param_context = states;
305 self
306 }
307
308 pub fn into_bytes(&self) -> Result<Bytes, RecordError> {
310 self.to_record().into_bytes()
311 }
312
313 pub fn from_bytes(self, bytes: Bytes) -> Result<Self, RecordError> {
315 Ok(self.load_record(OptimizerRecord::from_bytes(bytes)?))
316 }
317
318 #[cfg(feature = "std")]
320 pub fn save<P: AsRef<std::path::Path>>(&self, path: P) -> Result<(), RecordError> {
321 self.to_record().save(path)
322 }
323
324 #[cfg(feature = "std")]
326 pub fn load<P: AsRef<std::path::Path>>(self, path: P) -> Result<Self, RecordError> {
327 Ok(self.load_record(OptimizerRecord::load(path)?))
328 }
329}
330
331pub enum GradAdaptor {
333 Single(GradientsParams),
335
336 Multi(MultiGradientsParams),
338}
339
340impl From<GradientsParams> for GradAdaptor {
341 fn from(grads: GradientsParams) -> Self {
342 Self::Single(grads)
343 }
344}
345
346impl From<MultiGradientsParams> for GradAdaptor {
347 fn from(grads: MultiGradientsParams) -> Self {
348 Self::Multi(grads)
349 }
350}
351
352impl GradAdaptor {
353 pub fn remove<const D: usize>(&mut self, id: ParamId) -> Option<(Tensor<D>, Device)> {
358 match self {
359 GradAdaptor::Single(grads) => grads.remove(id).map(|t| {
360 let device = t.device();
361 (t, device)
362 }),
363 GradAdaptor::Multi(grads) => grads.remove(id),
364 }
365 }
366}
367
368struct ModuleOptimizerMapper<'a> {
369 path: Vec<String>,
370 optimizer_groups: Vec<&'a OptimizerGroup>,
371 states: &'a mut HashMap<ParamId, OptimizationContext>,
372 grads: &'a mut GradAdaptor,
373 lr_module: ModuleLearningRate,
374}
375
376impl<'a> ModuleOptimizerMapper<'a> {
377 pub(crate) fn new(
378 optimizer_groups: Vec<&'a OptimizerGroup>,
379 states: &'a mut HashMap<ParamId, OptimizationContext>,
380 grads: &'a mut GradAdaptor,
381 lr_module: ModuleLearningRate,
382 ) -> Self {
383 Self {
384 path: vec![],
385 optimizer_groups,
386 states,
387 grads,
388 lr_module,
389 }
390 }
391
392 fn optimizer_from_param(
393 &self,
394 id: ParamId,
395 path: Option<&str>,
396 ) -> (Arc<dyn DynOptimizer>, Option<GradientClipping>) {
397 self.optimizer_groups
398 .iter()
399 .filter_map(|val| {
400 val.group
401 .matches(&id, path)
402 .then_some((val.optim.clone(), val.grad_clipping.clone()))
403 })
404 .next_back()
405 .expect("Should match at least one parameter group.")
406 }
407}
408
409impl ModuleMapper for ModuleOptimizerMapper<'_> {
410 fn enter_module(&mut self, name: &str, _container_type: &str) {
411 self.path.push(name.to_string());
412 }
413
414 fn exit_module(&mut self, _name: &str, _container_type: &str) {
415 self.path.pop();
416 }
417
418 fn map_float<const D: usize>(&mut self, param: Param<Tensor<D>>) -> Param<Tensor<D>> {
419 let (id, tensor, mapper) = param.consume();
420 let grad = self.grads.remove(id);
421
422 let tensor = if let Some((grad, device)) = grad {
423 let context = ParameterContext::capture(&tensor);
424
425 let entry = self.states.remove_entry(&id);
426 let key = entry.as_ref().map(|(k, _)| *k);
427 let tensor = if tensor.device() != device {
428 tensor.to_device(&device)
429 } else {
430 tensor
431 };
432
433 let path = self.path.join(".");
434 let (optim, grad_clipping, existing_dyn_state) = match entry.map(|(_, s)| s) {
435 Some(OptimizationContext {
436 optim,
437 grad_clipping,
438 state,
439 ..
440 }) => (optim, grad_clipping, Some(state)),
441 None => {
442 let (optim, grad_clipping) =
443 self.optimizer_from_param(id, Some(path.as_str())).clone();
444 (optim, grad_clipping, None)
445 }
446 };
447
448 debug_assert_eq!(
449 grad.device(),
450 device,
451 "The gradient is on the provided device"
452 );
453 let clipped_grad: Tensor<D> = if let Some(g_clipping) = grad_clipping.as_ref() {
454 g_clipping.clip_gradient(grad)
455 } else {
456 grad
457 };
458
459 debug_assert_eq!(
460 tensor.device(),
461 device,
462 "Tensor and gradients are on the same device."
463 );
464
465 let lr = self.lr_module.lr_from_param(id, Some(path.as_str()));
466 let (tensor, state) = optim.step_dyn(
467 D,
468 lr,
469 tensor.inner().into_bridge(),
470 clipped_grad.into_bridge(),
471 existing_dyn_state.map(|s| optim.to_device_dyn(s, &device)),
472 );
473
474 if let Some(state) = state {
475 self.states.insert(
476 key.unwrap_or(id),
477 OptimizationContext {
478 optim,
479 path: Some(path),
480 state,
481 grad_clipping,
482 },
483 );
484 }
485
486 context.restore(Tensor::from_bridge(tensor), id)
487 } else {
488 tensor
489 };
490
491 Param::from_mapped_value(id, tensor, mapper)
492 }
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498 use crate::{
499 AdamConfig, GradientsParams, SgdConfig,
500 lr_scheduler::module_lr_scheduler::ModuleLearningRate,
501 };
502 use burn::module::ParamGroup;
503 use burn::tensor::{Distribution, Tensor, Tolerance};
504 use burn_derive::Module;
505 use burn_nn::{Linear, LinearConfig};
506
507 #[derive(Module, Debug)]
508 struct TwoLayerModel {
509 layer_a: Linear,
510 layer_b: Linear,
511 }
512
513 fn make_model(device: &Device) -> TwoLayerModel {
514 TwoLayerModel {
515 layer_a: LinearConfig::new(4, 4).init(device),
516 layer_b: LinearConfig::new(4, 4).init(device),
517 }
518 }
519
520 fn make_grads(model: &TwoLayerModel, x: Tensor<2>) -> GradientsParams {
521 let out = model.layer_a.forward(x.clone()) + model.layer_b.forward(x);
522 GradientsParams::from_grads(out.mean().backward(), model)
523 }
524
525 fn lr() -> ModuleLearningRate {
526 ModuleLearningRate::from(0.01_f64)
527 }
528
529 #[test]
534 fn step_keeps_the_gradient_checkpointing_strategy() {
535 let device = Device::default().autodiff().gradient_checkpointing();
536 let model = make_model(&device);
537 let mut optim = sgd();
538
539 let x = Tensor::<2>::random([2, 4], Distribution::Default, &device);
540 let model = optim.step(lr(), model.clone(), make_grads(&model, x.clone()));
541
542 assert_eq!(
543 model.layer_a.weight.val().gradient_checkpointing_strategy(),
544 device.gradient_checkpointing_strategy()
545 );
546 let _ = optim.step(lr(), model.clone(), make_grads(&model, x));
549 }
550
551 fn sgd() -> ModuleOptimizer {
552 SgdConfig::new().init()
553 }
554
555 #[test]
558 fn default_optimizer_state_survives_round_trip() {
559 let device = Device::default().autodiff();
560 let mut model = make_model(&device);
561 let mut optim: ModuleOptimizer = AdamConfig::new().init();
562
563 for _ in 0..3 {
564 let x = Tensor::<2>::random([2, 4], Distribution::Default, &device);
565 model = optim.step(lr(), model.clone(), make_grads(&model, x));
566 }
567
568 let record = optim.to_record();
569 let mut reloaded: ModuleOptimizer = AdamConfig::new().init().load_record(record);
570
571 let x = Tensor::<2>::random([2, 4], Distribution::Default, &device);
572 let grads_a = make_grads(&model, x.clone());
573 let grads_b = make_grads(&model, x);
574 let from_orig = optim.step(lr(), model.clone(), grads_a);
575 let from_reload = reloaded.step(lr(), model, grads_b);
576
577 from_orig
578 .layer_a
579 .weight
580 .val()
581 .into_data()
582 .assert_approx_eq::<f32>(
583 &from_reload.layer_a.weight.val().into_data(),
584 Tolerance::absolute(1e-6),
585 );
586 }
587
588 #[test]
592 fn group_optimizer_routes_correctly_after_record_round_trip() {
593 let device = Device::default().autodiff();
594 let mut model = make_model(&device);
595
596 let make_optim = || {
597 sgd().with_group(
598 ParamGroup::from_predicate("layer_a"),
599 AdamConfig::new().build(),
600 None,
601 )
602 };
603 let mut optim = make_optim();
604
605 for _ in 0..3 {
606 let x = Tensor::<2>::random([2, 4], Distribution::Default, &device);
607 model = optim.step(lr(), model.clone(), make_grads(&model, x));
608 }
609
610 let record = optim.to_record();
611 let mut reloaded = make_optim().load_record(record);
612
613 let x = Tensor::<2>::random([2, 4], Distribution::Default, &device);
614 let grads_a = make_grads(&model, x.clone());
615 let grads_b = make_grads(&model, x);
616 let from_orig = optim.step(lr(), model.clone(), grads_a);
617 let from_reload = reloaded.step(lr(), model, grads_b);
618
619 from_orig
620 .layer_a
621 .weight
622 .val()
623 .into_data()
624 .assert_approx_eq::<f32>(
625 &from_reload.layer_a.weight.val().into_data(),
626 Tolerance::absolute(1e-6),
627 );
628 from_orig
629 .layer_b
630 .weight
631 .val()
632 .into_data()
633 .assert_approx_eq::<f32>(
634 &from_reload.layer_b.weight.val().into_data(),
635 Tolerance::absolute(1e-6),
636 );
637 }
638
639 #[test]
642 fn with_group_clears_state_for_matching_params() {
643 let device = Device::default().autodiff();
644 let mut model = make_model(&device);
645 let mut optim: ModuleOptimizer = AdamConfig::new().init();
646
647 for _ in 0..2 {
648 let x = Tensor::<2>::random([2, 4], Distribution::Default, &device);
649 model = optim.step(lr(), model.clone(), make_grads(&model, x));
650 }
651
652 optim = optim.with_group(
654 ParamGroup::from_predicate("layer_a"),
655 SgdConfig::new().build(),
656 None,
657 );
658
659 let x = Tensor::<2>::random([2, 4], Distribution::Default, &device);
660 _ = optim.step(lr(), model.clone(), make_grads(&model, x));
661
662 let record = optim.to_record();
663
664 let time_key_count = record.scalars.keys().filter(|k| k.contains("time")).count();
665 assert_eq!(
666 time_key_count, 2,
667 "only layer_b params should carry Adam's time scalar after group switch"
668 );
669 }
670
671 #[test]
672 fn group_grad_clipping_applies_only_to_matching_params() {
673 let device = Device::default().autodiff();
674 let mut model = make_model(&device);
675
676 let lr_value = 0.01;
677 let threshold = 0.01_f32;
678 let bound = (lr_value * threshold as f64) as f32;
679
680 let mut optim = sgd().with_group(
681 ParamGroup::from_predicate("layer_a"),
682 SgdConfig::new().build(),
683 Some(GradientClipping::Value(threshold)),
684 );
685
686 let x = Tensor::<2>::random([2, 4], Distribution::Uniform(100.0, 200.0), &device);
688 let weight_a_before = model.layer_a.weight.val();
689 let weight_b_before = model.layer_b.weight.val();
690
691 model = optim.step(
692 ModuleLearningRate::from(lr_value),
693 model.clone(),
694 make_grads(&model, x),
695 );
696
697 let diff_a = weight_a_before - model.layer_a.weight.val();
698 let diff_b = weight_b_before - model.layer_b.weight.val();
699
700 let eps = 1e-6_f32;
701 for value in diff_a.into_data().iter::<f32>() {
702 assert!(
703 value.abs() <= bound + eps,
704 "layer_a's group grad_clipping should keep every update within lr * threshold"
705 );
706 }
707 assert!(
708 diff_b
709 .into_data()
710 .iter::<f32>()
711 .any(|value| value.abs() > bound + eps),
712 "layer_b uses the default optimizer and should not be clipped"
713 );
714 }
715
716 #[test]
719 fn record_without_paths_loads_without_panic() {
720 let device = Device::default().autodiff();
721 let mut model = make_model(&device);
722 let mut optim: ModuleOptimizer = AdamConfig::new().init();
723
724 let x = Tensor::<2>::random([2, 4], Distribution::Default, &device);
725 model = optim.step(lr(), model.clone(), make_grads(&model, x));
726
727 let mut record = optim.to_record();
729 record.paths.clear();
730
731 let mut optim_loaded: ModuleOptimizer = AdamConfig::new().init().load_record(record);
732
733 let x = Tensor::<2>::random([2, 4], Distribution::Default, &device);
734 let grads_a = make_grads(&model, x.clone());
735 let grads_b = make_grads(&model, x);
736 let from_orig = optim.step(lr(), model.clone(), grads_a);
737 let from_reload = optim_loaded.step(lr(), model, grads_b);
738
739 from_orig
740 .layer_a
741 .weight
742 .val()
743 .into_data()
744 .assert_approx_eq::<f32>(
745 &from_reload.layer_a.weight.val().into_data(),
746 Tolerance::absolute(1e-6),
747 );
748 from_orig
749 .layer_b
750 .weight
751 .val()
752 .into_data()
753 .assert_approx_eq::<f32>(
754 &from_reload.layer_b.weight.val().into_data(),
755 Tolerance::absolute(1e-6),
756 );
757 }
758}