Skip to main content

burn_optim/optim/module/
module_optimizer.rs

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
20/// Scalar key (per parameter) under which the parameter's state rank is persisted.
21///
22/// Reserved: a custom [`Optimizer::State`](crate::Optimizer::State) must not have a top-level
23/// scalar field named `__rank`, as it would collide with this key in the record.
24const 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/// Keep a reference to the optimizer to avoid matching every step.
40#[derive(Clone)]
41struct OptimizationContext {
42    optim: Arc<dyn DynOptimizer>,
43    grad_clipping: Option<GradientClipping>,
44    path: Option<String>,
45    state: DynState,
46}
47
48/// Optimizes a whole module by applying a per-parameter [`Optimizer`] to each of its parameters.
49///
50/// It is non-generic over the module and optimizer: any `O: Optimizer` is type-erased behind a
51/// dynamic optimizer, and per-parameter states are kept as type-erased states keyed by
52/// [`ParamId`](burn::module::ParamId). Build one with `optimizer.into()` or
53/// `OptimizerConfig::init()`.
54///
55/// It is possible to use different optimizers for different parameters. To do so, use the
56/// [ModuleOptimizer::with_group] function to add an optimizer for all parameters matching the
57/// provided group.
58#[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    /// Check if the optimizer has gradient clipping.
82    /// If there are multiple optimizers, checks if any group has gradient clipping.
83    pub fn has_gradient_clipping(&self) -> bool {
84        self.optimizers.iter().any(|g| g.grad_clipping.is_some())
85    }
86
87    /// Access the gradient clipping.
88    /// If there are multiple optimizers, returns the first optimizer's [GradientClipping].
89    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    /// Sets the gradient clipping.
98    /// If there are multiple optimizers, assigns it to the first one.
99    ///
100    /// # Arguments
101    ///
102    /// * `gradient_clipping` - The gradient clipping.
103    ///
104    /// # Returns
105    ///
106    /// The optimizer.
107    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    /// Adds an optimizer specific to a parameter group.
130    ///
131    /// Parameters matching this group will be optimized using the provided optimizer
132    /// and gradient clipping configuration.
133    ///
134    /// ### Matching Rules
135    /// * **Precedence:** If a parameter matches multiple groups, the *last* group added takes precedence.
136    /// * **Fallback:** The first optimizer added must match all parameters to act as a global fallback.
137    ///
138    /// ### Side Effects
139    /// * **State Reset:** Adding a new group will reset any existing optimizer states for parameters
140    ///   that match the new group.
141    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    /// Update the `module` parameters with the given `gradients`, advancing the optimizer state.
163    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    /// Like [`step`](Self::step), but accumulating gradients sourced from multiple devices.
173    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    /// Decompose the optimizer state into a serializable [`OptimizerRecord`].
199    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, &param_state.state, &mut sink);
210
211            // Persist the parameter rank explicitly so the state can be reconstructed even when it
212            // carries no tensors, and without inferring the rank from tensor shapes.
213            scalars.insert(
214                join_path(&prefix, RANK_KEY),
215                burn_pack::Scalar::from(param_state.state.rank()),
216            );
217            // Save parameter path to be able to match to the right group when loading.
218            if let Some(path) = &param_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    /// Load the optimizer state from an [`OptimizerRecord`].
242    ///
243    /// State tensors are materialized on the default device; no device argument is needed because
244    /// each parameter's state is migrated to that parameter's (gradient's) device on the next
245    /// [`step`](ModuleOptimizer::step) — see the `to_device` call in the step path. The load device
246    /// is therefore irrelevant to correctness.
247    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        // Recover each parameter's rank from its persisted `__rank` scalar (authoritative). Keys
253        // are `"{param_id}.__rank"`, so strip the dotted suffix to recover the id.
254        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            // Fall back to inferring rank from a tensor shape if no `__rank` scalar was present.
279            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            // Skip parameters whose state can't be reconstructed (truncated/foreign record); they
290            // are re-initialized lazily on the next step rather than aborting the load.
291            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    /// Serialize the optimizer state to an in-memory burnpack byte buffer.
309    pub fn into_bytes(&self) -> Result<Bytes, RecordError> {
310        self.to_record().into_bytes()
311    }
312
313    /// Load the optimizer state from an in-memory burnpack byte buffer.
314    pub fn from_bytes(self, bytes: Bytes) -> Result<Self, RecordError> {
315        Ok(self.load_record(OptimizerRecord::from_bytes(bytes)?))
316    }
317
318    /// Save the optimizer state to a burnpack file on disk.
319    #[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    /// Load the optimizer state from a burnpack file on disk.
325    #[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
331/// Wrapper to unify the `remove` method for [GradientsParams] and [MultiGradientsParams].
332pub enum GradAdaptor {
333    /// Wrapper for [`GradientsParams`].
334    Single(GradientsParams),
335
336    /// Wrapper for [`MultiGradientsParams`].
337    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    /// Remove a gradient parameter by ID.
354    ///
355    /// # Returns
356    /// Maybe the (tensor, device) pair.
357    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    /// A parameter trains under the checkpointing strategy of the device it
530    /// was created on. The update leaves the tape and comes back, and it must
531    /// come back with that strategy, or the next step's operations merge
532    /// parameters of two strategies and refuse.
533    #[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        // Panics without the fix: the updated parameter and the input meet
547        // under two strategies.
548        let _ = optim.step(lr(), model.clone(), make_grads(&model, x));
549    }
550
551    fn sgd() -> ModuleOptimizer {
552        SgdConfig::new().init()
553    }
554
555    /// to_record / load_record must fully preserve a stateful optimizer's internal state so that
556    /// a step on the restored optimizer is numerically identical to one taken on the original.
557    #[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    /// The paths saved in OptimizerRecord enable load_record to route each parameter to the
589    /// correct group optimizer. A step on the restored optimizer must be numerically identical
590    /// to one on the original — for both the group (Adam) and the default (SGD) optimizer.
591    #[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    /// Adding a group after training must clear accumulated state for matching params
640    /// while leaving state for non-matching params untouched.
641    #[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        // Switch layer_a to SGD. Its Adam state must be cleared.
653        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        // Large inputs produce gradients that clearly exceed the clipping threshold.
687        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    /// An OptimizerRecord with empty paths map must load cleanly.
717    /// Parameters default to the default optimizer.
718    #[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        // Simulate a record with empty paths.
728        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}