Skip to main content

burn_optim/optim/
state.rs

1//! Decomposition of a state struct into named tensors and scalars for the burnpack format.
2//!
3//! Used for both optimizer state — keyed per-[`ParamId`](burn_core::module::ParamId) rather than
4//! per module path — and learning-rate scheduler state. Each state is usually a small struct
5//! holding a few tensors plus scalar bookkeeping (e.g. a step counter). [`RecordState`] flattens
6//! such a struct into a flat list of named tensors plus a few typed [scalars](burn_pack::Scalar),
7//! and reconstructs it on load.
8//!
9//! Implementations are generated with `#[derive(RecordState)]`; the derive supports the field
10//! shapes `Tensor<D>`, `Option<Tensor<D>>`, `Vec<Tensor<D>>`, scalars, optional scalars, nested
11//! states and optional nested states. Scalar fields rely on `burn_pack`'s [`From`]/[`TryFrom`]
12//! conversions to [`Scalar`](burn_pack::Scalar).
13
14use alloc::collections::BTreeMap;
15use alloc::format;
16use alloc::string::String;
17use alloc::vec::Vec;
18
19use burn_core::tensor::{Device, TensorData};
20use burn_pack::Scalar;
21
22pub use burn_derive::RecordState;
23
24/// Join a `prefix` and a `leaf` into a dot-separated path (`"prefix.leaf"`).
25///
26/// An empty prefix yields the leaf unchanged, so a top-level call can pass `""`.
27pub fn join_path(prefix: &str, leaf: &str) -> String {
28    if prefix.is_empty() {
29        return String::from(leaf);
30    }
31    let mut path = String::with_capacity(prefix.len() + 1 + leaf.len());
32    path.push_str(prefix);
33    path.push('.');
34    path.push_str(leaf);
35    path
36}
37
38/// Join a `prefix` and a numeric `index` into a dot-separated path (`"prefix.3"`).
39///
40/// Used by the derive to name the elements of a `Vec<Tensor>` field.
41pub fn join_index(prefix: &str, index: usize) -> String {
42    format!("{prefix}.{index}")
43}
44
45/// Accumulates the named tensors and scalars produced while flattening a [`RecordState`].
46#[derive(Default, Debug)]
47pub struct StateSink {
48    /// The collected `(name, data)` tensor leaves.
49    pub tensors: Vec<(String, TensorData)>,
50    /// The collected `(name, value)` scalar leaves.
51    pub scalars: Vec<(String, Scalar)>,
52}
53
54impl StateSink {
55    /// Record a tensor leaf named `{prefix}.{leaf}`.
56    pub fn push_tensor(&mut self, prefix: &str, leaf: &str, data: TensorData) {
57        self.tensors.push((join_path(prefix, leaf), data));
58    }
59
60    /// Record a scalar leaf named `{prefix}.{leaf}`.
61    pub fn push_scalar(&mut self, prefix: &str, leaf: &str, value: Scalar) {
62        self.scalars.push((join_path(prefix, leaf), value));
63    }
64}
65
66/// Provides the named tensors and scalars consumed while reconstructing an [`RecordState`].
67///
68/// Tensors are taken (removed) by name so the same source can feed several parameters in turn;
69/// scalars are looked up by name and left in place.
70#[derive(Default, Debug)]
71pub struct StateSource {
72    tensors: BTreeMap<String, TensorData>,
73    scalars: BTreeMap<String, Scalar>,
74}
75
76impl StateSource {
77    /// Create a source from an existing scalar map (e.g. the burnpack scalars).
78    pub fn new(scalars: BTreeMap<String, Scalar>) -> Self {
79        Self {
80            tensors: BTreeMap::new(),
81            scalars,
82        }
83    }
84
85    /// Register a tensor under its full `name`.
86    pub fn insert_tensor(&mut self, name: String, data: TensorData) {
87        self.tensors.insert(name, data);
88    }
89
90    /// Take the tensor named `{prefix}.{leaf}`, if present.
91    pub fn take_tensor(&mut self, prefix: &str, leaf: &str) -> Option<TensorData> {
92        self.tensors.remove(&join_path(prefix, leaf))
93    }
94
95    /// Read the scalar named `{prefix}.{leaf}`, if present.
96    pub fn take_scalar(&mut self, prefix: &str, leaf: &str) -> Option<Scalar> {
97        self.scalars.get(&join_path(prefix, leaf)).copied()
98    }
99
100    /// Whether any tensor or scalar leaf was recorded under `prefix` (a key beginning with
101    /// `"{prefix}."`).
102    ///
103    /// Used by the derive to tell an absent optional nested state (nothing recorded) apart from a
104    /// present one whose leaves all happen to be optional.
105    pub fn has_under(&self, prefix: &str) -> bool {
106        let pat = join_path(prefix, "");
107        self.tensors.keys().any(|k| k.starts_with(&pat))
108            || self.scalars.keys().any(|k| k.starts_with(&pat))
109    }
110}
111
112/// A type that can be flattened into named tensors and scalars and rebuilt from them.
113///
114/// Generated with `#[derive(RecordState)]`. The `prefix` threads the parameter identity (and any
115/// nested field path) through the recursion; leaves are named `{prefix}.{field}`.
116pub trait RecordState: Sized + Send + Sync + 'static {
117    /// Flatten `self` into `out`, naming every leaf under `prefix`.
118    fn state_flatten(&self, prefix: &str, out: &mut StateSink);
119
120    /// Rebuild a value from `src`, reading leaves named under `prefix`.
121    ///
122    /// Returns `None` when a required leaf is absent. The derive uses [`StateSource::has_under`] to
123    /// presence-test optional nested states, so an `Option<Nested>` field is `None` exactly when
124    /// nothing was recorded under its path.
125    ///
126    /// A scalar leaf that is present but holds an incompatible [`Scalar`] variant (e.g. a
127    /// hand-edited or forward-version file) is treated the same as absent.
128    fn state_unflatten(prefix: &str, src: &mut StateSource, device: &Device) -> Option<Self>;
129}
130
131/// The empty state — for stateless values (e.g. a constant learning rate scheduler).
132impl RecordState for () {
133    fn state_flatten(&self, _prefix: &str, _out: &mut StateSink) {}
134
135    fn state_unflatten(_prefix: &str, _src: &mut StateSource, _device: &Device) -> Option<Self> {
136        Some(())
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use burn::tensor::Tensor;
144    use burn_core as burn;
145
146    /// Flatten `state` then rebuild it from the produced tensors and scalars.
147    fn round_trip<T: RecordState>(state: &T) -> Option<T> {
148        let mut sink = StateSink::default();
149        state.state_flatten("p", &mut sink);
150
151        let scalars: BTreeMap<String, Scalar> = sink.scalars.into_iter().collect();
152        let mut source = StateSource::new(scalars);
153        for (name, data) in sink.tensors {
154            source.insert_tensor(name, data);
155        }
156
157        T::state_unflatten("p", &mut source, &Device::default())
158    }
159
160    fn tensor(values: &[f32]) -> Tensor<1> {
161        Tensor::from_data(TensorData::from(values), &Device::default())
162    }
163
164    fn data(t: &Tensor<1>) -> Vec<f32> {
165        t.clone().into_data().to_vec().unwrap()
166    }
167
168    #[derive(RecordState, Clone, Debug)]
169    struct Inner<const D: usize> {
170        weight: Tensor<D>,
171        step: i64,
172    }
173
174    #[derive(RecordState, Clone, Debug)]
175    struct Full<const D: usize> {
176        t: Tensor<D>,
177        opt_tensor: Option<Tensor<D>>,
178        history: Vec<Tensor<D>>,
179        count: usize,
180        opt_scalar: Option<f64>,
181        nested: Inner<D>,
182        opt_nested: Option<Inner<D>>,
183    }
184
185    #[test]
186    fn all_field_kinds_round_trip() {
187        let state = Full::<1> {
188            t: tensor(&[1.0, 2.0]),
189            opt_tensor: Some(tensor(&[3.0])),
190            history: vec![tensor(&[4.0]), tensor(&[5.0, 6.0])],
191            count: 7,
192            opt_scalar: Some(8.5),
193            nested: Inner {
194                weight: tensor(&[9.0]),
195                step: 10,
196            },
197            opt_nested: Some(Inner {
198                weight: tensor(&[11.0]),
199                step: 12,
200            }),
201        };
202
203        let out = round_trip(&state).unwrap();
204
205        assert_eq!(data(&out.t), vec![1.0, 2.0]);
206        assert_eq!(data(&out.opt_tensor.unwrap()), vec![3.0]);
207        assert_eq!(out.history.len(), 2);
208        assert_eq!(data(&out.history[0]), vec![4.0]);
209        assert_eq!(data(&out.history[1]), vec![5.0, 6.0]);
210        assert_eq!(out.count, 7);
211        assert_eq!(out.opt_scalar, Some(8.5));
212        assert_eq!(data(&out.nested.weight), vec![9.0]);
213        assert_eq!(out.nested.step, 10);
214        let opt_nested = out.opt_nested.unwrap();
215        assert_eq!(data(&opt_nested.weight), vec![11.0]);
216        assert_eq!(opt_nested.step, 12);
217    }
218
219    #[test]
220    fn absent_optionals_round_trip_to_none() {
221        let state = Full::<1> {
222            t: tensor(&[1.0]),
223            opt_tensor: None,
224            history: vec![],
225            count: 0,
226            opt_scalar: None,
227            nested: Inner {
228                weight: tensor(&[2.0]),
229                step: 0,
230            },
231            opt_nested: None,
232        };
233
234        let out = round_trip(&state).unwrap();
235
236        assert!(out.opt_tensor.is_none());
237        assert!(out.history.is_empty());
238        assert!(out.opt_scalar.is_none());
239        assert!(out.opt_nested.is_none());
240    }
241
242    /// Regression: an optional nested state whose fields are all optional must come back `None`
243    /// when nothing was recorded under its path — it must not be resurrected as `Some`.
244    #[derive(RecordState, Clone, Debug)]
245    struct AllOptional<const D: usize> {
246        x: Option<Tensor<D>>,
247        y: Option<f64>,
248    }
249
250    #[derive(RecordState, Clone, Debug)]
251    struct OuterOpt<const D: usize> {
252        inner: Option<AllOptional<D>>,
253    }
254
255    #[test]
256    fn optional_all_optional_nested_stays_none() {
257        let state = OuterOpt::<1> { inner: None };
258        let out = round_trip(&state).unwrap();
259        assert!(out.inner.is_none());
260    }
261
262    #[test]
263    fn optional_all_optional_nested_present_with_content() {
264        let state = OuterOpt::<1> {
265            inner: Some(AllOptional {
266                x: Some(tensor(&[1.0, 2.0])),
267                y: None,
268            }),
269        };
270        let out = round_trip(&state).unwrap();
271        let inner = out.inner.expect("present because a leaf was recorded");
272        assert_eq!(data(&inner.x.unwrap()), vec![1.0, 2.0]);
273        assert!(inner.y.is_none());
274    }
275
276    #[test]
277    fn missing_required_tensor_yields_none() {
278        // A source with nothing recorded cannot rebuild a struct that has a required tensor leaf.
279        let mut source = StateSource::new(BTreeMap::new());
280        assert!(Inner::<1>::state_unflatten("p", &mut source, &Device::default()).is_none());
281    }
282}