Skip to main content

trustformers_optim/
param_id.rs

1//! Stable parameter identity for optimizer state.
2//!
3//! # Why this module exists
4//!
5//! Optimizers keep per-parameter state (momentum, variance, slow weights, …) in
6//! `HashMap<String, _>` keyed by a *parameter id*. Historically that id was the
7//! parameter's heap address (`format!("{:p}", array.as_ptr())`) or a hash of the
8//! parameter's current values. Both are unusable as a durable identity:
9//!
10//! * A heap address is different in every process, so a checkpoint written in one run
11//!   never matches the parameters of the next run — `load_state_dict` appeared to
12//!   succeed while restoring nothing, and training silently resumed from zeroed
13//!   moments.
14//! * A value hash changes the instant the parameter moves, so every single step
15//!   allocated a *fresh* state entry: the optimizer degenerated to its first step
16//!   forever and the state map grew without bound.
17//!
18//! [`ParamRegistry`] replaces both with a dense, registration-ordered [`ParamId`].
19//!
20//! # Identity contract
21//!
22//! A [`ParamId`] is an index assigned **the first time the registry sees a
23//! parameter**, and it never changes for the lifetime of the optimizer. Two
24//! resolution paths exist:
25//!
26//! 1. **Named** — [`ParamRegistry::key_for_named_tensor`]. The caller supplies a
27//!    stable name (`"encoder.layer.0.weight"`). This is the preferred path: names
28//!    are written into the checkpoint keys, so resume is *order independent*.
29//!    Frameworks whose API already carries names (`HashMap<String, Tensor>` of
30//!    gradients, PyTorch/TensorFlow/JAX compatibility layers) should always use it.
31//!
32//! 2. **Anonymous** — [`ParamRegistry::key_for_tensor`] /
33//!    [`ParamRegistry::key_for_addr`]. Used by the bare
34//!    [`Optimizer::update`](trustformers_core::traits::Optimizer::update) signature,
35//!    which carries no name. Identity within a process comes from the tensor's data
36//!    address; identity *across* processes comes from registration order. The
37//!    contract is therefore:
38//!
39//!    > **A run that resumes from a checkpoint must present its parameters to
40//!    > `update()` in the same order as the run that wrote the checkpoint.**
41//!
42//!    This is the same contract PyTorch imposes on `param_groups` ordering. A
43//!    violation is *detected*, not ignored: binding a restored slot to a parameter
44//!    of a different element count returns an error rather than silently starting
45//!    from zero.
46//!
47//! # Checkpoint round trip
48//!
49//! State keys are `"n:<name>"` for named parameters and `"p:<index>"` for anonymous
50//! ones. Because the key embeds the identity, the registry can be rebuilt purely
51//! from the keys found in a checkpoint — see [`ParamRegistry::restore_key`]. After
52//! restoring, entries are *unbound* (they have no address yet); the first `update()`
53//! calls of the new run bind them in registration order.
54
55use std::collections::HashMap;
56use trustformers_core::errors::{Result, TrustformersError};
57use trustformers_core::tensor::Tensor;
58
59/// Prefix marking a state key that identifies a parameter by name.
60pub const NAMED_KEY_PREFIX: &str = "n:";
61/// Prefix marking a state key that identifies a parameter by registration index.
62pub const INDEXED_KEY_PREFIX: &str = "p:";
63
64/// A stable, dense identifier for one parameter tensor within a single optimizer.
65///
66/// Ids are assigned in registration order starting at zero and are never reused or
67/// renumbered. See the [module documentation](self) for the identity contract.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
69pub struct ParamId(usize);
70
71impl ParamId {
72    /// The dense registration index behind this id.
73    pub fn index(self) -> usize {
74        self.0
75    }
76}
77
78/// One registry slot: the durable identity of a single parameter.
79#[derive(Debug, Clone)]
80struct ParamEntry {
81    /// Stable caller-supplied name, when the caller had one.
82    name: Option<String>,
83    /// Number of elements, used to validate bindings after a checkpoint restore.
84    numel: usize,
85    /// Canonical state-map key (`"n:<name>"` or `"p:<index>"`).
86    key: String,
87    /// Data address of the tensor currently bound to this slot, if any.
88    ///
89    /// This is an in-process identity cache only; it is never persisted.
90    addr: Option<usize>,
91}
92
93/// Assigns and remembers a stable [`ParamId`] for every parameter an optimizer sees.
94///
95/// Cheap to clone and free of interior mutability, so optimizers embedding it stay
96/// `Clone + Send + Sync`.
97#[derive(Debug, Clone, Default)]
98pub struct ParamRegistry {
99    /// Registration-ordered slots; `ParamId(i)` indexes `entries[i]`.
100    entries: Vec<ParamEntry>,
101    /// Name → registration index.
102    by_name: HashMap<String, usize>,
103    /// Data address → registration index (in-process cache, never persisted).
104    by_addr: HashMap<usize, usize>,
105    /// Lowest index that may still be waiting for an address binding.
106    bind_cursor: usize,
107}
108
109impl ParamRegistry {
110    /// Creates an empty registry.
111    pub fn new() -> Self {
112        Self::default()
113    }
114
115    /// Number of parameters registered so far.
116    pub fn len(&self) -> usize {
117        self.entries.len()
118    }
119
120    /// Whether no parameter has been registered yet.
121    pub fn is_empty(&self) -> bool {
122        self.entries.is_empty()
123    }
124
125    /// Forgets every registration. Call this alongside clearing optimizer state.
126    pub fn clear(&mut self) {
127        self.entries.clear();
128        self.by_name.clear();
129        self.by_addr.clear();
130        self.bind_cursor = 0;
131    }
132
133    /// The canonical state-map key for `id`, if it has been registered.
134    pub fn key(&self, id: ParamId) -> Option<&str> {
135        self.entries.get(id.0).map(|e| e.key.as_str())
136    }
137
138    /// The stable name of `id`, if it was registered through the named path.
139    pub fn name(&self, id: ParamId) -> Option<&str> {
140        self.entries.get(id.0).and_then(|e| e.name.as_deref())
141    }
142
143    /// The element count recorded for `id`, if it has been registered.
144    pub fn numel(&self, id: ParamId) -> Option<usize> {
145        self.entries.get(id.0).map(|e| e.numel)
146    }
147
148    /// Resolves the id of a parameter identified by a stable caller-supplied name.
149    ///
150    /// The first call for a given name registers it; later calls return the same id
151    /// regardless of where the tensor lives in memory.
152    pub fn id_for_named_tensor(&mut self, name: &str, tensor: &Tensor) -> Result<ParamId> {
153        let (addr, numel) = tensor_identity(tensor)?;
154        Ok(self.id_for_named_addr(name, addr, numel))
155    }
156
157    /// Resolves the id for a name plus an already-extracted address / element count.
158    pub fn id_for_named_addr(&mut self, name: &str, addr: usize, numel: usize) -> ParamId {
159        if let Some(&index) = self.by_name.get(name) {
160            // Rebind: the tensor may have been reallocated between steps.
161            if let Some(entry) = self.entries.get_mut(index) {
162                if let Some(old) = entry.addr.replace(addr) {
163                    if old != addr {
164                        self.by_addr.remove(&old);
165                    }
166                }
167                // A restored slot has numel 0 until the first real binding.
168                if entry.numel == 0 {
169                    entry.numel = numel;
170                }
171            }
172            self.by_addr.insert(addr, index);
173            self.advance_bind_cursor();
174            return ParamId(index);
175        }
176
177        let index = self.entries.len();
178        self.entries.push(ParamEntry {
179            name: Some(name.to_string()),
180            numel,
181            key: format!("{NAMED_KEY_PREFIX}{name}"),
182            addr: Some(addr),
183        });
184        self.by_name.insert(name.to_string(), index);
185        self.by_addr.insert(addr, index);
186        self.advance_bind_cursor();
187        ParamId(index)
188    }
189
190    /// Resolves the id of an anonymous parameter tensor.
191    ///
192    /// See the [module documentation](self) for the ordering contract this implies
193    /// for checkpoint resume.
194    ///
195    /// # Errors
196    ///
197    /// Returns an error when a slot restored from a checkpoint is bound to a
198    /// parameter whose element count does not match, which means the caller is
199    /// presenting parameters in a different order than the checkpointed run.
200    pub fn id_for_tensor(&mut self, tensor: &Tensor) -> Result<ParamId> {
201        let (addr, numel) = tensor_identity(tensor)?;
202        self.id_for_addr(addr, numel)
203    }
204
205    /// Resolves the id for an already-extracted address / element count.
206    ///
207    /// # Errors
208    ///
209    /// See [`ParamRegistry::id_for_tensor`].
210    pub fn id_for_addr(&mut self, addr: usize, numel: usize) -> Result<ParamId> {
211        // Fast path: this exact buffer was seen before.
212        if let Some(&index) = self.by_addr.get(&addr) {
213            if self.entries.get(index).map(|e| e.numel) == Some(numel) {
214                return Ok(ParamId(index));
215            }
216            // The address was reused by a differently-sized tensor: drop the stale
217            // binding rather than corrupting another parameter's state.
218            self.by_addr.remove(&addr);
219            if let Some(entry) = self.entries.get_mut(index) {
220                entry.addr = None;
221            }
222        }
223
224        // Adopt the next slot that is still waiting for a binding. This is what makes
225        // checkpoint resume work: `restore_key` creates unbound slots in registration
226        // order, and the first updates of the new run claim them in the same order.
227        self.advance_bind_cursor();
228        if let Some(entry) = self.entries.get_mut(self.bind_cursor) {
229            if entry.name.is_some() {
230                // A named slot must be claimed through the named path, otherwise an
231                // anonymous update would hijack a named parameter's state.
232                return Err(TrustformersError::invalid_input(format!(
233                    "optimizer state slot {} was checkpointed under the name '{}' but is \
234                     being resumed through the anonymous update path; use `update_named` \
235                     so the name can be matched",
236                    self.bind_cursor,
237                    entry.name.as_deref().unwrap_or("<unknown>")
238                )));
239            }
240            if entry.numel != 0 && entry.numel != numel {
241                return Err(TrustformersError::invalid_input(format!(
242                    "optimizer state slot {} holds {} elements but the parameter being \
243                     bound to it has {}; parameters must be passed to `update()` in the \
244                     same order as the run that wrote the checkpoint (or use \
245                     `update_named`)",
246                    self.bind_cursor, entry.numel, numel
247                )));
248            }
249            entry.numel = numel;
250            entry.addr = Some(addr);
251            let index = self.bind_cursor;
252            self.by_addr.insert(addr, index);
253            self.advance_bind_cursor();
254            return Ok(ParamId(index));
255        }
256
257        // Genuinely new parameter.
258        let index = self.entries.len();
259        self.entries.push(ParamEntry {
260            name: None,
261            numel,
262            key: format!("{INDEXED_KEY_PREFIX}{index}"),
263            addr: Some(addr),
264        });
265        self.by_addr.insert(addr, index);
266        self.advance_bind_cursor();
267        Ok(ParamId(index))
268    }
269
270    /// Re-points an existing slot at a new data address.
271    ///
272    /// Optimizers that write results back with
273    /// [`Tensor::set_data_f32`](trustformers_core::tensor::Tensor::set_data_f32) — which
274    /// replaces the underlying buffer rather than mutating it — must call this after the
275    /// write so the anonymous identity cache keeps tracking the parameter. Optimizers
276    /// that mutate through `iter_mut()` keep their address and need not call it.
277    ///
278    /// # Errors
279    ///
280    /// Returns an error when `id` was never registered or the tensor dtype is unsupported.
281    pub fn rebind(&mut self, id: ParamId, tensor: &Tensor) -> Result<()> {
282        let (addr, numel) = tensor_identity(tensor)?;
283        let entry = self.entries.get_mut(id.0).ok_or_else(|| {
284            TrustformersError::invalid_input(format!(
285                "cannot rebind unregistered parameter id {}",
286                id.0
287            ))
288        })?;
289        if let Some(old) = entry.addr.replace(addr) {
290            if old != addr {
291                self.by_addr.remove(&old);
292            }
293        }
294        entry.numel = numel;
295        self.by_addr.insert(addr, id.0);
296        self.advance_bind_cursor();
297        Ok(())
298    }
299
300    /// Convenience wrapper returning the canonical state-map key for a named tensor.
301    ///
302    /// # Errors
303    ///
304    /// Returns an error for tensor dtypes whose data address cannot be taken.
305    pub fn key_for_named_tensor(&mut self, name: &str, tensor: &Tensor) -> Result<String> {
306        let id = self.id_for_named_tensor(name, tensor)?;
307        Ok(self.key_string(id))
308    }
309
310    /// Convenience wrapper returning the canonical state-map key for a named
311    /// parameter given its address and element count.
312    pub fn key_for_named_addr(&mut self, name: &str, addr: usize, numel: usize) -> String {
313        let id = self.id_for_named_addr(name, addr, numel);
314        self.key_string(id)
315    }
316
317    /// Convenience wrapper returning the canonical state-map key for a tensor.
318    ///
319    /// This is the direct replacement for the old `format!("{:p}", …)` idiom.
320    ///
321    /// # Errors
322    ///
323    /// See [`ParamRegistry::id_for_tensor`].
324    pub fn key_for_tensor(&mut self, tensor: &Tensor) -> Result<String> {
325        let id = self.id_for_tensor(tensor)?;
326        Ok(self.key_string(id))
327    }
328
329    /// Convenience wrapper returning the canonical state-map key for an anonymous
330    /// parameter given its data address and element count.
331    ///
332    /// # Errors
333    ///
334    /// See [`ParamRegistry::id_for_tensor`].
335    pub fn key_for_addr(&mut self, addr: usize, numel: usize) -> Result<String> {
336        let id = self.id_for_addr(addr, numel)?;
337        Ok(self.key_string(id))
338    }
339
340    /// Re-creates the registry slot described by a checkpointed state key.
341    ///
342    /// `numel` is the length of the restored buffer and is used to validate later
343    /// bindings. The recreated slot has no address, so the first matching `update()`
344    /// of the resuming run claims it.
345    ///
346    /// # Errors
347    ///
348    /// Returns an error when `key` does not use a recognised identity prefix.
349    pub fn restore_key(&mut self, key: &str, numel: usize) -> Result<ParamId> {
350        if let Some(name) = key.strip_prefix(NAMED_KEY_PREFIX) {
351            if let Some(&index) = self.by_name.get(name) {
352                if let Some(entry) = self.entries.get_mut(index) {
353                    if entry.numel == 0 {
354                        entry.numel = numel;
355                    }
356                }
357                return Ok(ParamId(index));
358            }
359            let index = self.entries.len();
360            self.entries.push(ParamEntry {
361                name: Some(name.to_string()),
362                numel,
363                key: key.to_string(),
364                addr: None,
365            });
366            self.by_name.insert(name.to_string(), index);
367            self.reset_bind_cursor();
368            return Ok(ParamId(index));
369        }
370
371        if let Some(raw_index) = key.strip_prefix(INDEXED_KEY_PREFIX) {
372            let index: usize = raw_index.parse().map_err(|_| {
373                TrustformersError::invalid_input(format!(
374                    "malformed optimizer state key '{key}': '{raw_index}' is not an index"
375                ))
376            })?;
377            while self.entries.len() <= index {
378                let placeholder = self.entries.len();
379                self.entries.push(ParamEntry {
380                    name: None,
381                    numel: 0,
382                    key: format!("{INDEXED_KEY_PREFIX}{placeholder}"),
383                    addr: None,
384                });
385            }
386            if let Some(entry) = self.entries.get_mut(index) {
387                if entry.name.is_none() {
388                    entry.numel = numel;
389                }
390            }
391            self.reset_bind_cursor();
392            return Ok(ParamId(index));
393        }
394
395        Err(TrustformersError::invalid_input(format!(
396            "unrecognised optimizer state key '{key}': expected a '{NAMED_KEY_PREFIX}' or \
397             '{INDEXED_KEY_PREFIX}' identity prefix"
398        )))
399    }
400
401    /// All canonical keys in registration order.
402    pub fn keys(&self) -> Vec<String> {
403        self.entries.iter().map(|e| e.key.clone()).collect()
404    }
405
406    fn key_string(&self, id: ParamId) -> String {
407        self.entries
408            .get(id.0)
409            .map(|e| e.key.clone())
410            .unwrap_or_else(|| format!("{INDEXED_KEY_PREFIX}{}", id.0))
411    }
412
413    fn advance_bind_cursor(&mut self) {
414        while self.entries.get(self.bind_cursor).is_some_and(|e| e.addr.is_some()) {
415            self.bind_cursor += 1;
416        }
417    }
418
419    fn reset_bind_cursor(&mut self) {
420        self.bind_cursor = 0;
421        self.advance_bind_cursor();
422    }
423}
424
425/// Extracts a tensor's data address and element count.
426///
427/// The address is used only as an in-process identity cache; it is never persisted.
428fn tensor_identity(tensor: &Tensor) -> Result<(usize, usize)> {
429    let numel: usize = tensor.shape().iter().product();
430    let addr = match tensor {
431        Tensor::F32(a) => a.as_ptr() as usize,
432        Tensor::F64(a) => a.as_ptr() as usize,
433        Tensor::F16(a) => a.as_ptr() as usize,
434        Tensor::BF16(a) => a.as_ptr() as usize,
435        Tensor::I64(a) => a.as_ptr() as usize,
436        Tensor::C32(a) => a.as_ptr() as usize,
437        Tensor::C64(a) => a.as_ptr() as usize,
438        Tensor::CF16(a) => a.as_ptr() as usize,
439        Tensor::CBF16(a) => a.as_ptr() as usize,
440        other => {
441            return Err(TrustformersError::tensor_op_error(
442                &format!(
443                    "cannot derive a parameter identity for tensor dtype {:?}",
444                    other.dtype()
445                ),
446                "ParamRegistry::id_for_tensor",
447            ))
448        },
449    };
450    Ok((addr, numel))
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    fn tensor(len: usize) -> Tensor {
458        Tensor::from_vec(vec![0.0_f32; len], &[len]).expect("tensor")
459    }
460
461    #[test]
462    fn same_tensor_resolves_to_one_id() {
463        let mut registry = ParamRegistry::new();
464        let t = tensor(4);
465        let a = registry.id_for_tensor(&t).expect("first");
466        let b = registry.id_for_tensor(&t).expect("second");
467        assert_eq!(a, b);
468        assert_eq!(registry.len(), 1, "state must not grow per call");
469    }
470
471    #[test]
472    fn distinct_tensors_get_distinct_ids() {
473        let mut registry = ParamRegistry::new();
474        let t1 = tensor(4);
475        let t2 = tensor(8);
476        let a = registry.id_for_tensor(&t1).expect("t1");
477        let b = registry.id_for_tensor(&t2).expect("t2");
478        assert_ne!(a, b);
479        assert_eq!(registry.len(), 2);
480    }
481
482    #[test]
483    fn mutating_a_tensor_in_place_does_not_change_its_id() {
484        // Regression for value-hash keying (adafisher_simple): moving the parameter
485        // used to allocate a brand-new state entry every step. Optimizers mutate
486        // parameters through `iter_mut()`, which keeps the buffer address.
487        let mut registry = ParamRegistry::new();
488        let mut t = tensor(4);
489        let before = registry.id_for_tensor(&t).expect("before");
490        match &mut t {
491            Tensor::F32(array) => {
492                for value in array.iter_mut() {
493                    *value = 9.0;
494                }
495            },
496            _ => panic!("expected F32"),
497        }
498        let after = registry.id_for_tensor(&t).expect("after");
499        assert_eq!(before, after);
500        assert_eq!(registry.len(), 1);
501    }
502
503    #[test]
504    fn rebind_tracks_a_reallocated_buffer() {
505        // `set_data_f32` replaces the buffer, so optimizers using it must rebind.
506        let mut registry = ParamRegistry::new();
507        let mut t = tensor(4);
508        let id = registry.id_for_tensor(&t).expect("register");
509        t.set_data_f32(&[9.0, 9.0, 9.0, 9.0]).expect("reallocate");
510        registry.rebind(id, &t).expect("rebind");
511        assert_eq!(registry.id_for_tensor(&t).expect("after"), id);
512        assert_eq!(registry.len(), 1, "rebinding must not append a slot");
513    }
514
515    #[test]
516    fn named_ids_are_address_independent() {
517        let mut registry = ParamRegistry::new();
518        let first = tensor(4);
519        let id1 = registry.id_for_named_tensor("w", &first).expect("first");
520        drop(first);
521        let second = tensor(4);
522        let id2 = registry.id_for_named_tensor("w", &second).expect("second");
523        assert_eq!(id1, id2, "a name must outlive the tensor allocation");
524        assert_eq!(registry.key(id1), Some("n:w"));
525    }
526
527    #[test]
528    fn keys_are_stable_and_prefixed() {
529        let mut registry = ParamRegistry::new();
530        let t = tensor(2);
531        assert_eq!(registry.key_for_tensor(&t).expect("key"), "p:0");
532        assert_eq!(
533            registry.key_for_named_tensor("bias", &t).expect("key"),
534            "n:bias"
535        );
536    }
537
538    #[test]
539    fn restored_anonymous_slots_are_claimed_in_order() {
540        // Regression for address keying: a new process has different addresses, so a
541        // restored checkpoint used to match nothing.
542        let mut registry = ParamRegistry::new();
543        registry.restore_key("p:0", 4).expect("restore 0");
544        registry.restore_key("p:1", 8).expect("restore 1");
545
546        let t1 = tensor(4);
547        let t2 = tensor(8);
548        assert_eq!(registry.key_for_tensor(&t1).expect("bind 0"), "p:0");
549        assert_eq!(registry.key_for_tensor(&t2).expect("bind 1"), "p:1");
550        assert_eq!(registry.len(), 2, "resume must not append new slots");
551    }
552
553    #[test]
554    fn restored_named_slots_match_by_name_in_any_order() {
555        let mut registry = ParamRegistry::new();
556        registry.restore_key("n:a", 4).expect("restore a");
557        registry.restore_key("n:b", 8).expect("restore b");
558
559        let tb = tensor(8);
560        let ta = tensor(4);
561        // Deliberately reversed relative to registration order.
562        assert_eq!(registry.key_for_named_tensor("b", &tb).expect("b"), "n:b");
563        assert_eq!(registry.key_for_named_tensor("a", &ta).expect("a"), "n:a");
564        assert_eq!(registry.len(), 2);
565    }
566
567    #[test]
568    fn order_mismatch_on_resume_is_an_error_not_silent_reset() {
569        let mut registry = ParamRegistry::new();
570        registry.restore_key("p:0", 4).expect("restore 0");
571        let wrong = tensor(9);
572        let err = registry.id_for_tensor(&wrong);
573        assert!(
574            err.is_err(),
575            "binding a 9-element tensor to a 4-element slot must be reported"
576        );
577    }
578
579    #[test]
580    fn anonymous_path_refuses_to_hijack_a_named_slot() {
581        let mut registry = ParamRegistry::new();
582        registry.restore_key("n:w", 4).expect("restore");
583        let t = tensor(4);
584        assert!(registry.id_for_tensor(&t).is_err());
585    }
586
587    #[test]
588    fn restore_rejects_unprefixed_keys() {
589        let mut registry = ParamRegistry::new();
590        assert!(registry.restore_key("0x7f9c2a001234", 4).is_err());
591    }
592
593    #[test]
594    fn clear_resets_everything() {
595        let mut registry = ParamRegistry::new();
596        let t = tensor(4);
597        registry.id_for_tensor(&t).expect("register");
598        registry.clear();
599        assert!(registry.is_empty());
600        assert_eq!(registry.key_for_tensor(&t).expect("re-register"), "p:0");
601    }
602}