1use burn_core as burn;
2use burn_core::module::ParamGroup;
3
4use super::Optimizer;
5use crate::lr_scheduler::module_lr_scheduler::ModuleLearningRate;
6use crate::{
7 DynOptimizer, DynState, MultiGradientsParams, OptimizerRecord, StateSink, StateSource,
8 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::{AutodiffModule, ModuleMapper, Param, ParamId};
16use burn::store::RecordError;
17use burn::tensor::{Bytes, Device, Tensor, TensorData};
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: AutodiffModule>(
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: AutodiffModule>(
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: AutodiffModule>(
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(burn_pack::Tensor::new(
224 name,
225 data.dtype,
226 data.shape,
227 Some(id.val()),
228 data.bytes,
229 ));
230 }
231 for (name, value) in sink.scalars {
232 scalars.insert(name, value);
233 }
234 }
235
236 OptimizerRecord {
237 tensors,
238 scalars,
239 paths,
240 }
241 }
242
243 pub fn load_record(mut self, record: OptimizerRecord) -> Self {
250 let device = Device::default();
251 let mut ranks: BTreeMap<u64, usize> = BTreeMap::new();
252 let mut paths: BTreeMap<u64, String> = BTreeMap::new();
253
254 let suffix = alloc::format!(".{RANK_KEY}");
257 for (name, value) in record.scalars.iter() {
258 if let Some(id_str) = name.strip_suffix(&suffix)
259 && let (Ok(id), Ok(rank)) = (id_str.parse::<u64>(), usize::try_from(*value))
260 {
261 ranks.insert(id, rank);
262 }
263 }
264
265 for (name, path) in record.paths.iter() {
266 if let Ok(id) = name.parse::<u64>() {
267 paths.insert(id, path.to_string());
268 }
269 }
270
271 let mut source = StateSource::new(record.scalars);
272
273 for tensor in record.tensors {
274 let id = tensor
275 .param_id
276 .expect("Optimizer record tensors should carry a parameter id.");
277 let name = tensor.name;
278 let data = TensorData::from_bytes(tensor.bytes, tensor.shape, tensor.dtype);
279 ranks.entry(id).or_insert(data.shape.len());
281 source.insert_tensor(name, data);
282 }
283
284 let mut states = HashMap::new();
285 for (id, rank) in ranks {
286 let prefix = id.to_string();
287 let path = paths.get(&id);
288 let (optim, grad_clipping) =
289 self.optim_from_param(id.into(), path.map(|path| path.as_str()));
290 if let Some(state) = optim.state_unflatten(rank, &prefix, &mut source, &device) {
293 states.insert(
294 ParamId::from(id),
295 OptimizationContext {
296 optim: optim.clone(),
297 path: path.cloned(),
298 state,
299 grad_clipping,
300 },
301 );
302 }
303 }
304
305 self.param_context = states;
306 self
307 }
308
309 pub fn into_bytes(&self) -> Result<Bytes, RecordError> {
311 self.to_record().into_bytes()
312 }
313
314 pub fn from_bytes(self, bytes: Bytes) -> Result<Self, RecordError> {
316 Ok(self.load_record(OptimizerRecord::from_bytes(bytes)?))
317 }
318
319 #[cfg(feature = "std")]
321 pub fn save<P: AsRef<std::path::Path>>(&self, path: P) -> Result<(), RecordError> {
322 self.to_record().save(path)
323 }
324
325 #[cfg(feature = "std")]
327 pub fn load<P: AsRef<std::path::Path>>(self, path: P) -> Result<Self, RecordError> {
328 Ok(self.load_record(OptimizerRecord::load(path)?))
329 }
330}
331
332pub enum GradAdaptor {
334 Single(GradientsParams),
336
337 Multi(MultiGradientsParams),
339}
340
341impl From<GradientsParams> for GradAdaptor {
342 fn from(grads: GradientsParams) -> Self {
343 Self::Single(grads)
344 }
345}
346
347impl From<MultiGradientsParams> for GradAdaptor {
348 fn from(grads: MultiGradientsParams) -> Self {
349 Self::Multi(grads)
350 }
351}
352
353impl GradAdaptor {
354 pub fn remove<const D: usize>(&mut self, id: ParamId) -> Option<(Tensor<D>, Device)> {
359 match self {
360 GradAdaptor::Single(grads) => grads.remove(id).map(|t| {
361 let device = t.device();
362 (t, device)
363 }),
364 GradAdaptor::Multi(grads) => grads.remove(id),
365 }
366 }
367}
368
369struct ModuleOptimizerMapper<'a> {
370 path: Vec<String>,
371 optimizer_groups: Vec<&'a OptimizerGroup>,
372 states: &'a mut HashMap<ParamId, OptimizationContext>,
373 grads: &'a mut GradAdaptor,
374 lr_module: ModuleLearningRate,
375}
376
377impl<'a> ModuleOptimizerMapper<'a> {
378 pub(crate) fn new(
379 optimizer_groups: Vec<&'a OptimizerGroup>,
380 states: &'a mut HashMap<ParamId, OptimizationContext>,
381 grads: &'a mut GradAdaptor,
382 lr_module: ModuleLearningRate,
383 ) -> Self {
384 Self {
385 path: vec![],
386 optimizer_groups,
387 states,
388 grads,
389 lr_module,
390 }
391 }
392
393 fn optimizer_from_param(
394 &self,
395 id: ParamId,
396 path: Option<&str>,
397 ) -> (Arc<dyn DynOptimizer>, Option<GradientClipping>) {
398 self.optimizer_groups
399 .iter()
400 .filter_map(|val| {
401 val.group
402 .matches(&id, path)
403 .then_some((val.optim.clone(), val.grad_clipping.clone()))
404 })
405 .next_back()
406 .expect("Should match at least one parameter group.")
407 }
408}
409
410impl ModuleMapper for ModuleOptimizerMapper<'_> {
411 fn enter_module(&mut self, name: &str, _container_type: &str) {
412 self.path.push(name.to_string());
413 }
414
415 fn exit_module(&mut self, _name: &str, _container_type: &str) {
416 self.path.pop();
417 }
418
419 fn map_float<const D: usize>(&mut self, param: Param<Tensor<D>>) -> Param<Tensor<D>> {
420 let (id, tensor, mapper) = param.consume();
421 let grad = self.grads.remove(id);
422
423 let tensor = if let Some((grad, device)) = grad {
424 let is_require_grad = tensor.is_require_grad();
425 #[cfg(feature = "std")]
426 let is_distributed = tensor.is_distributed();
427
428 let entry = self.states.remove_entry(&id);
429 let key = entry.as_ref().map(|(k, _)| *k);
430 let tensor = if tensor.device() != device {
431 tensor.to_device(&device)
432 } else {
433 tensor
434 };
435
436 let path = self.path.join(".");
437 let (optim, grad_clipping, existing_dyn_state) = match entry.map(|(_, s)| s) {
438 Some(OptimizationContext {
439 optim,
440 grad_clipping,
441 state,
442 ..
443 }) => (optim, grad_clipping, Some(state)),
444 None => {
445 let (optim, grad_clipping) =
446 self.optimizer_from_param(id, Some(path.as_str())).clone();
447 (optim, grad_clipping, None)
448 }
449 };
450
451 debug_assert_eq!(
452 grad.device(),
453 device,
454 "The gradient is on the provided device"
455 );
456 let clipped_grad: Tensor<D> = if let Some(g_clipping) = grad_clipping.as_ref() {
457 g_clipping.clip_gradient(grad)
458 } else {
459 grad
460 };
461
462 debug_assert_eq!(
463 tensor.device(),
464 device,
465 "Tensor and gradients are on the same device."
466 );
467
468 let lr = self.lr_module.lr_from_param(id, Some(path.as_str()));
469 let (tensor, state) = optim.step_dyn(
470 D,
471 lr,
472 tensor.inner().into_bridge(),
473 clipped_grad.into_bridge(),
474 existing_dyn_state.map(|s| optim.to_device_dyn(s, &device)),
475 );
476
477 if let Some(state) = state {
478 self.states.insert(
479 key.unwrap_or(id),
480 OptimizationContext {
481 optim,
482 path: Some(path),
483 state,
484 grad_clipping,
485 },
486 );
487 }
488
489 let mut tensor = Tensor::from_inner(Tensor::from_bridge(tensor));
490
491 if is_require_grad {
492 tensor = tensor.require_grad();
493 }
494 #[cfg(feature = "std")]
495 if is_distributed {
496 tensor = tensor.set_distributed(id)
497 }
498
499 tensor
500 } else {
501 tensor
502 };
503
504 Param::from_mapped_value(id, tensor, mapper)
505 }
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511 use crate::{
512 AdamConfig, GradientsParams, SgdConfig,
513 lr_scheduler::module_lr_scheduler::ModuleLearningRate,
514 };
515 use burn::module::ParamGroup;
516 use burn::tensor::{Distribution, Tensor, Tolerance};
517 use burn_derive::Module;
518 use burn_nn::{Linear, LinearConfig};
519
520 #[derive(Module, Debug)]
521 struct TwoLayerModel {
522 layer_a: Linear,
523 layer_b: Linear,
524 }
525
526 fn make_model(device: &Device) -> TwoLayerModel {
527 TwoLayerModel {
528 layer_a: LinearConfig::new(4, 4).init(device),
529 layer_b: LinearConfig::new(4, 4).init(device),
530 }
531 }
532
533 fn make_grads(model: &TwoLayerModel, x: Tensor<2>) -> GradientsParams {
534 let out = model.layer_a.forward(x.clone()) + model.layer_b.forward(x);
535 GradientsParams::from_grads(out.mean().backward(), model)
536 }
537
538 fn lr() -> ModuleLearningRate {
539 ModuleLearningRate::from(0.01_f64)
540 }
541
542 fn sgd() -> ModuleOptimizer {
543 ModuleOptimizer::from(SgdConfig::new().init())
544 }
545
546 #[test]
549 fn default_optimizer_state_survives_round_trip() {
550 let device = Device::default().autodiff();
551 let mut model = make_model(&device);
552 let mut optim: ModuleOptimizer = AdamConfig::new().init();
553
554 for _ in 0..3 {
555 let x = Tensor::<2>::random([2, 4], Distribution::Default, &device);
556 model = optim.step(lr(), model.clone(), make_grads(&model, x));
557 }
558
559 let record = optim.to_record();
560 let mut reloaded: ModuleOptimizer = AdamConfig::new().init().load_record(record);
561
562 let x = Tensor::<2>::random([2, 4], Distribution::Default, &device);
563 let grads_a = make_grads(&model, x.clone());
564 let grads_b = make_grads(&model, x);
565 let from_orig = optim.step(lr(), model.clone(), grads_a);
566 let from_reload = reloaded.step(lr(), model, grads_b);
567
568 from_orig
569 .layer_a
570 .weight
571 .val()
572 .into_data()
573 .assert_approx_eq::<f32>(
574 &from_reload.layer_a.weight.val().into_data(),
575 Tolerance::absolute(1e-6),
576 );
577 }
578
579 #[test]
583 fn group_optimizer_routes_correctly_after_record_round_trip() {
584 let device = Device::default().autodiff();
585 let mut model = make_model(&device);
586
587 let make_optim = || {
588 sgd().with_group(
589 ParamGroup::from_predicate("layer_a"),
590 AdamConfig::new().build(),
591 None,
592 )
593 };
594 let mut optim = make_optim();
595
596 for _ in 0..3 {
597 let x = Tensor::<2>::random([2, 4], Distribution::Default, &device);
598 model = optim.step(lr(), model.clone(), make_grads(&model, x));
599 }
600
601 let record = optim.to_record();
602 let mut reloaded = make_optim().load_record(record);
603
604 let x = Tensor::<2>::random([2, 4], Distribution::Default, &device);
605 let grads_a = make_grads(&model, x.clone());
606 let grads_b = make_grads(&model, x);
607 let from_orig = optim.step(lr(), model.clone(), grads_a);
608 let from_reload = reloaded.step(lr(), model, grads_b);
609
610 from_orig
611 .layer_a
612 .weight
613 .val()
614 .into_data()
615 .assert_approx_eq::<f32>(
616 &from_reload.layer_a.weight.val().into_data(),
617 Tolerance::absolute(1e-6),
618 );
619 from_orig
620 .layer_b
621 .weight
622 .val()
623 .into_data()
624 .assert_approx_eq::<f32>(
625 &from_reload.layer_b.weight.val().into_data(),
626 Tolerance::absolute(1e-6),
627 );
628 }
629
630 #[test]
633 fn with_group_clears_state_for_matching_params() {
634 let device = Device::default().autodiff();
635 let mut model = make_model(&device);
636 let mut optim: ModuleOptimizer = AdamConfig::new().init();
637
638 for _ in 0..2 {
639 let x = Tensor::<2>::random([2, 4], Distribution::Default, &device);
640 model = optim.step(lr(), model.clone(), make_grads(&model, x));
641 }
642
643 optim = optim.with_group(
645 ParamGroup::from_predicate("layer_a"),
646 SgdConfig::new().build(),
647 None,
648 );
649
650 let x = Tensor::<2>::random([2, 4], Distribution::Default, &device);
651 _ = optim.step(lr(), model.clone(), make_grads(&model, x));
652
653 let record = optim.to_record();
654
655 let time_key_count = record.scalars.keys().filter(|k| k.contains("time")).count();
656 assert_eq!(
657 time_key_count, 2,
658 "only layer_b params should carry Adam's time scalar after group switch"
659 );
660 }
661
662 #[test]
663 fn group_grad_clipping_applies_only_to_matching_params() {
664 let device = Device::default().autodiff();
665 let mut model = make_model(&device);
666
667 let lr_value = 0.01;
668 let threshold = 0.01_f32;
669 let bound = (lr_value * threshold as f64) as f32;
670
671 let mut optim = sgd().with_group(
672 ParamGroup::from_predicate("layer_a"),
673 SgdConfig::new().build(),
674 Some(GradientClipping::Value(threshold)),
675 );
676
677 let x = Tensor::<2>::random([2, 4], Distribution::Uniform(100.0, 200.0), &device);
679 let weight_a_before = model.layer_a.weight.val();
680 let weight_b_before = model.layer_b.weight.val();
681
682 model = optim.step(
683 ModuleLearningRate::from(lr_value),
684 model.clone(),
685 make_grads(&model, x),
686 );
687
688 let diff_a = weight_a_before - model.layer_a.weight.val();
689 let diff_b = weight_b_before - model.layer_b.weight.val();
690
691 let eps = 1e-6_f32;
692 for value in diff_a.into_data().iter::<f32>() {
693 assert!(
694 value.abs() <= bound + eps,
695 "layer_a's group grad_clipping should keep every update within lr * threshold"
696 );
697 }
698 assert!(
699 diff_b
700 .into_data()
701 .iter::<f32>()
702 .any(|value| value.abs() > bound + eps),
703 "layer_b uses the default optimizer and should not be clipped"
704 );
705 }
706
707 #[test]
710 fn record_without_paths_loads_without_panic() {
711 let device = Device::default().autodiff();
712 let mut model = make_model(&device);
713 let mut optim: ModuleOptimizer = AdamConfig::new().init();
714
715 let x = Tensor::<2>::random([2, 4], Distribution::Default, &device);
716 model = optim.step(lr(), model.clone(), make_grads(&model, x));
717
718 let mut record = optim.to_record();
720 record.paths.clear();
721
722 let mut optim_loaded: ModuleOptimizer = AdamConfig::new().init().load_record(record);
723
724 let x = Tensor::<2>::random([2, 4], Distribution::Default, &device);
725 let grads_a = make_grads(&model, x.clone());
726 let grads_b = make_grads(&model, x);
727 let from_orig = optim.step(lr(), model.clone(), grads_a);
728 let from_reload = optim_loaded.step(lr(), model, grads_b);
729
730 from_orig
731 .layer_a
732 .weight
733 .val()
734 .into_data()
735 .assert_approx_eq::<f32>(
736 &from_reload.layer_a.weight.val().into_data(),
737 Tolerance::absolute(1e-6),
738 );
739 from_orig
740 .layer_b
741 .weight
742 .val()
743 .into_data()
744 .assert_approx_eq::<f32>(
745 &from_reload.layer_b.weight.val().into_data(),
746 Tolerance::absolute(1e-6),
747 );
748 }
749}