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;
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
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: 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    /// 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: 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    /// Like [`step`](Self::step), but accumulating gradients sourced from multiple devices.
173    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    /// 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(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    /// Load the optimizer state from an [`OptimizerRecord`].
244    ///
245    /// State tensors are materialized on the default device; no device argument is needed because
246    /// each parameter's state is migrated to that parameter's (gradient's) device on the next
247    /// [`step`](ModuleOptimizer::step) — see the `to_device` call in the step path. The load device
248    /// is therefore irrelevant to correctness.
249    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        // Recover each parameter's rank from its persisted `__rank` scalar (authoritative). Keys
255        // are `"{param_id}.__rank"`, so strip the dotted suffix to recover the id.
256        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            // Fall back to inferring rank from a tensor shape if no `__rank` scalar was present.
280            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            // Skip parameters whose state can't be reconstructed (truncated/foreign record); they
291            // are re-initialized lazily on the next step rather than aborting the load.
292            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    /// Serialize the optimizer state to an in-memory burnpack byte buffer.
310    pub fn into_bytes(&self) -> Result<Bytes, RecordError> {
311        self.to_record().into_bytes()
312    }
313
314    /// Load the optimizer state from an in-memory burnpack byte buffer.
315    pub fn from_bytes(self, bytes: Bytes) -> Result<Self, RecordError> {
316        Ok(self.load_record(OptimizerRecord::from_bytes(bytes)?))
317    }
318
319    /// Save the optimizer state to a burnpack file on disk.
320    #[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    /// Load the optimizer state from a burnpack file on disk.
326    #[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
332/// Wrapper to unify the `remove` method for [GradientsParams] and [MultiGradientsParams].
333pub enum GradAdaptor {
334    /// Wrapper for [`GradientsParams`].
335    Single(GradientsParams),
336
337    /// Wrapper for [`MultiGradientsParams`].
338    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    /// Remove a gradient parameter by ID.
355    ///
356    /// # Returns
357    /// Maybe the (tensor, device) pair.
358    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    /// to_record / load_record must fully preserve a stateful optimizer's internal state so that
547    /// a step on the restored optimizer is numerically identical to one taken on the original.
548    #[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    /// The paths saved in OptimizerRecord enable load_record to route each parameter to the
580    /// correct group optimizer. A step on the restored optimizer must be numerically identical
581    /// to one on the original — for both the group (Adam) and the default (SGD) optimizer.
582    #[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    /// Adding a group after training must clear accumulated state for matching params
631    /// while leaving state for non-matching params untouched.
632    #[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        // Switch layer_a to SGD. Its Adam state must be cleared.
644        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        // Large inputs produce gradients that clearly exceed the clipping threshold.
678        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    /// An OptimizerRecord with empty paths map must load cleanly.
708    /// Parameters default to the default optimizer.
709    #[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        // Simulate a record with empty paths.
719        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}