Skip to main content

tenflowers_core/
checkpointing.rs

1//! Activation Checkpointing for Memory-Efficient Training
2//!
3//! This module implements gradient checkpointing (also known as activation checkpointing),
4//! a technique that trades computation for memory by recomputing intermediate activations
5//! during the backward pass instead of storing them. This is essential for training large
6//! models that would otherwise not fit in GPU memory.
7//!
8//! # Overview
9//!
10//! In standard backpropagation, all intermediate activations must be stored during the
11//! forward pass to compute gradients during the backward pass. For deep networks, this
12//! can require enormous amounts of memory. Gradient checkpointing selectively stores
13//! only certain activations (checkpoints) and recomputes the others on-demand during
14//! backpropagation.
15//!
16//! # Trade-offs
17//!
18//! - **Memory**: Reduces memory usage by up to 10x for very deep networks
19//! - **Computation**: Increases training time by ~20-30% due to recomputation
20//! - **Optimal for**: Large transformers, vision models, any memory-bound training
21//!
22//! # Example
23//!
24//! ```rust
25//! use tenflowers_core::checkpointing::{CheckpointPolicy, CheckpointingConfig};
26//!
27//! // Configure checkpointing for transformer layers
28//! let config = CheckpointingConfig {
29//!     policy: CheckpointPolicy::EveryNLayers(2), // Checkpoint every 2 layers
30//!     recompute_on_backward: true,
31//!     save_rng_state: false, // RNG state capture is not supported by the current backend
32//!     enable_statistics: false,
33//!     max_checkpoints: None,
34//! };
35//! ```
36
37use crate::{Result, Tensor, TensorError};
38use std::collections::HashMap;
39use std::sync::{Arc, Mutex};
40
41/// Policy for selecting which activations to checkpoint
42#[derive(Debug, Clone, PartialEq)]
43pub enum CheckpointPolicy {
44    /// Checkpoint every N layers (most common for transformers)
45    EveryNLayers(usize),
46    /// Checkpoint at specific layer indices
47    SpecificLayers(Vec<usize>),
48    /// Automatic policy based on memory constraints
49    Automatic {
50        /// Target memory budget in bytes
51        memory_budget: usize,
52        /// Estimated activation size per layer in bytes
53        avg_activation_size: usize,
54    },
55    /// Custom policy via user-defined function
56    Custom,
57    /// No checkpointing (store all activations)
58    None,
59}
60
61impl Default for CheckpointPolicy {
62    fn default() -> Self {
63        CheckpointPolicy::EveryNLayers(1)
64    }
65}
66
67/// Configuration for activation checkpointing
68#[derive(Debug, Clone)]
69pub struct CheckpointingConfig {
70    /// Policy for selecting checkpoint locations
71    pub policy: CheckpointPolicy,
72    /// Whether to recompute activations during backward pass
73    pub recompute_on_backward: bool,
74    /// Save and restore RNG state for deterministic dropout.
75    ///
76    /// Note: the current `scirs2_core` RNG (a `rand` 0.10 ChaCha-backed
77    /// generator) does not expose serializable PRNG state. Enabling this makes
78    /// [`CheckpointManager::save_checkpoint`] return an honest
79    /// `UnsupportedOperation` error instead of fabricating state, so it is
80    /// disabled by default.
81    pub save_rng_state: bool,
82    /// Enable gradient checkpointing statistics tracking
83    pub enable_statistics: bool,
84    /// Maximum number of checkpoints to store
85    pub max_checkpoints: Option<usize>,
86}
87
88impl Default for CheckpointingConfig {
89    fn default() -> Self {
90        Self {
91            policy: CheckpointPolicy::default(),
92            recompute_on_backward: true,
93            // Disabled by default: the current RNG backend exposes no
94            // serializable state, so honoring this would error on every save.
95            save_rng_state: false,
96            enable_statistics: false,
97            max_checkpoints: None,
98        }
99    }
100}
101
102/// Statistics for checkpointing performance
103#[derive(Debug, Clone, Default)]
104pub struct CheckpointStatistics {
105    /// Total number of forward passes
106    pub forward_passes: usize,
107    /// Total number of backward passes
108    pub backward_passes: usize,
109    /// Number of recomputations performed
110    pub recompute_count: usize,
111    /// Total memory saved (estimated, in bytes)
112    pub memory_saved_bytes: usize,
113    /// Additional computation time due to recomputation (in microseconds)
114    pub additional_compute_time_us: u64,
115    /// Number of active checkpoints
116    pub active_checkpoints: usize,
117}
118
119impl CheckpointStatistics {
120    /// Get average recomputations per backward pass
121    pub fn avg_recomputations(&self) -> f64 {
122        if self.backward_passes == 0 {
123            0.0
124        } else {
125            self.recompute_count as f64 / self.backward_passes as f64
126        }
127    }
128
129    /// Get memory saved in MB
130    pub fn memory_saved_mb(&self) -> f64 {
131        self.memory_saved_bytes as f64 / (1024.0 * 1024.0)
132    }
133
134    /// Get overhead percentage
135    pub fn compute_overhead_percent(&self) -> f64 {
136        if self.forward_passes == 0 {
137            0.0
138        } else {
139            (self.recompute_count as f64 / self.forward_passes as f64) * 100.0
140        }
141    }
142}
143
144/// A checkpoint storing activations and metadata
145#[derive(Debug, Clone)]
146pub struct Checkpoint<T> {
147    /// Layer index where checkpoint was taken
148    pub layer_index: usize,
149    /// Stored activation tensors
150    pub activations: Vec<Tensor<T>>,
151    /// RNG state at checkpoint time (if enabled)
152    pub rng_state: Option<Vec<u8>>,
153    /// Timestamp when checkpoint was created
154    pub timestamp: std::time::Instant,
155    /// Estimated memory usage in bytes
156    pub memory_bytes: usize,
157}
158
159impl<T> Checkpoint<T>
160where
161    T: Clone + Default,
162{
163    /// Create a new checkpoint
164    pub fn new(layer_index: usize, activations: Vec<Tensor<T>>) -> Self {
165        let memory_bytes = activations
166            .iter()
167            .map(|t| t.shape().size() * std::mem::size_of::<T>())
168            .sum();
169
170        Self {
171            layer_index,
172            activations,
173            rng_state: None,
174            timestamp: std::time::Instant::now(),
175            memory_bytes,
176        }
177    }
178
179    /// Add RNG state to checkpoint
180    pub fn with_rng_state(mut self, rng_state: Vec<u8>) -> Self {
181        self.rng_state = Some(rng_state);
182        self
183    }
184
185    /// Get age of checkpoint
186    pub fn age(&self) -> std::time::Duration {
187        self.timestamp.elapsed()
188    }
189}
190
191/// Manager for activation checkpointing
192pub struct CheckpointManager<T> {
193    config: CheckpointingConfig,
194    checkpoints: Arc<Mutex<HashMap<usize, Checkpoint<T>>>>,
195    statistics: Arc<Mutex<CheckpointStatistics>>,
196}
197
198impl<T> CheckpointManager<T>
199where
200    T: Clone + Default + Send + Sync,
201{
202    /// Create a new checkpoint manager
203    pub fn new(config: CheckpointingConfig) -> Self {
204        Self {
205            config,
206            checkpoints: Arc::new(Mutex::new(HashMap::new())),
207            statistics: Arc::new(Mutex::new(CheckpointStatistics::default())),
208        }
209    }
210
211    /// Create with automatic memory-based policy
212    pub fn with_memory_budget(memory_budget_mb: usize, avg_activation_mb: usize) -> Self {
213        Self::new(CheckpointingConfig {
214            policy: CheckpointPolicy::Automatic {
215                memory_budget: memory_budget_mb * 1024 * 1024,
216                avg_activation_size: avg_activation_mb * 1024 * 1024,
217            },
218            ..Default::default()
219        })
220    }
221
222    /// Check if layer should be checkpointed according to policy
223    pub fn should_checkpoint(&self, layer_index: usize, total_layers: usize) -> bool {
224        match &self.config.policy {
225            CheckpointPolicy::EveryNLayers(n) => layer_index % n == 0,
226            CheckpointPolicy::SpecificLayers(indices) => indices.contains(&layer_index),
227            CheckpointPolicy::Automatic {
228                memory_budget,
229                avg_activation_size,
230            } => {
231                // Calculate how many checkpoints we can afford
232                let max_checkpoints = memory_budget / avg_activation_size;
233                if max_checkpoints == 0 {
234                    return false;
235                }
236                // Distribute checkpoints evenly across layers
237                let checkpoint_every = total_layers / max_checkpoints.max(1);
238                layer_index % checkpoint_every.max(1) == 0
239            }
240            CheckpointPolicy::Custom => {
241                // User must override this with custom logic
242                false
243            }
244            CheckpointPolicy::None => false,
245        }
246    }
247
248    /// Save a checkpoint for the given layer
249    pub fn save_checkpoint(&self, layer_index: usize, activations: Vec<Tensor<T>>) -> Result<()> {
250        let mut checkpoint = Checkpoint::new(layer_index, activations);
251
252        // Save RNG state if configured. Capture returns an honest error when the
253        // backend cannot serialize RNG state, rather than storing fake bytes, so
254        // the failure surfaces here instead of silently corrupting determinism.
255        if self.config.save_rng_state {
256            checkpoint = checkpoint.with_rng_state(self.capture_rng_state()?);
257        }
258
259        let memory_bytes = checkpoint.memory_bytes;
260
261        // Store checkpoint
262        let mut checkpoints = self.checkpoints.lock().map_err(|_| {
263            TensorError::invalid_operation_simple("Failed to acquire checkpoint lock".to_string())
264        })?;
265
266        // Enforce max checkpoints limit
267        if let Some(max_cp) = self.config.max_checkpoints {
268            if checkpoints.len() >= max_cp {
269                // Remove oldest checkpoint
270                if let Some(oldest_key) = checkpoints
271                    .iter()
272                    .min_by_key(|(_, cp)| cp.timestamp)
273                    .map(|(k, _)| *k)
274                {
275                    checkpoints.remove(&oldest_key);
276                }
277            }
278        }
279
280        checkpoints.insert(layer_index, checkpoint);
281
282        // Update statistics
283        if self.config.enable_statistics {
284            let mut stats = self.statistics.lock().map_err(|_| {
285                TensorError::invalid_operation_simple(
286                    "Failed to acquire statistics lock".to_string(),
287                )
288            })?;
289            stats.forward_passes += 1;
290            stats.memory_saved_bytes += memory_bytes;
291            stats.active_checkpoints = checkpoints.len();
292        }
293
294        Ok(())
295    }
296
297    /// Retrieve a checkpoint for the given layer
298    pub fn get_checkpoint(&self, layer_index: usize) -> Result<Option<Checkpoint<T>>> {
299        let checkpoints = self.checkpoints.lock().map_err(|_| {
300            TensorError::invalid_operation_simple("Failed to acquire checkpoint lock".to_string())
301        })?;
302
303        Ok(checkpoints.get(&layer_index).cloned())
304    }
305
306    /// Restore RNG state previously captured by `capture_rng_state`.
307    ///
308    /// The current `scirs2_core` RNG (a `rand` 0.10 ChaCha-backed generator)
309    /// does not expose serializable PRNG state, so there is nothing that can be
310    /// faithfully restored. This returns an honest `UnsupportedOperation` error
311    /// instead of silently pretending the state was restored.
312    pub fn restore_rng_state(&self, _rng_state: &[u8]) -> Result<()> {
313        Err(TensorError::unsupported_operation_simple(
314            "RNG state restore is not supported: the current scirs2_core RNG \
315             does not expose serializable PRNG state"
316                .to_string(),
317        ))
318    }
319
320    /// Capture the current RNG state for deterministic recomputation.
321    ///
322    /// The current `scirs2_core` RNG (a `rand` 0.10 ChaCha-backed generator)
323    /// only supports seeding at construction; it provides no way to read back the
324    /// live internal state. Rather than fabricating a zero buffer that would make
325    /// "deterministic" recomputation silently wrong, this returns an honest
326    /// `UnsupportedOperation` error.
327    fn capture_rng_state(&self) -> Result<Vec<u8>> {
328        Err(TensorError::unsupported_operation_simple(
329            "RNG state capture is not supported: the current scirs2_core RNG \
330             does not expose serializable PRNG state"
331                .to_string(),
332        ))
333    }
334
335    /// Record a recomputation event
336    pub fn record_recomputation(&self, compute_time_us: u64) {
337        if !self.config.enable_statistics {
338            return;
339        }
340
341        if let Ok(mut stats) = self.statistics.lock() {
342            stats.recompute_count += 1;
343            stats.additional_compute_time_us += compute_time_us;
344        }
345    }
346
347    /// Record a backward pass
348    pub fn record_backward_pass(&self) {
349        if !self.config.enable_statistics {
350            return;
351        }
352
353        if let Ok(mut stats) = self.statistics.lock() {
354            stats.backward_passes += 1;
355        }
356    }
357
358    /// Get current statistics
359    pub fn get_statistics(&self) -> Result<CheckpointStatistics> {
360        let stats = self.statistics.lock().map_err(|_| {
361            TensorError::invalid_operation_simple("Failed to acquire statistics lock".to_string())
362        })?;
363        Ok(stats.clone())
364    }
365
366    /// Clear all checkpoints
367    pub fn clear(&self) -> Result<()> {
368        let mut checkpoints = self.checkpoints.lock().map_err(|_| {
369            TensorError::invalid_operation_simple("Failed to acquire checkpoint lock".to_string())
370        })?;
371        checkpoints.clear();
372
373        if self.config.enable_statistics {
374            if let Ok(mut stats) = self.statistics.lock() {
375                stats.active_checkpoints = 0;
376            }
377        }
378
379        Ok(())
380    }
381
382    /// Get number of active checkpoints
383    pub fn checkpoint_count(&self) -> usize {
384        self.checkpoints.lock().map(|cp| cp.len()).unwrap_or(0)
385    }
386
387    /// Get total memory used by checkpoints
388    pub fn total_memory_bytes(&self) -> usize {
389        self.checkpoints
390            .lock()
391            .map(|cp| cp.values().map(|c| c.memory_bytes).sum())
392            .unwrap_or(0)
393    }
394}
395
396impl<T> Clone for CheckpointManager<T>
397where
398    T: Clone + Default + Send + Sync,
399{
400    fn clone(&self) -> Self {
401        Self {
402            config: self.config.clone(),
403            checkpoints: Arc::clone(&self.checkpoints),
404            statistics: Arc::clone(&self.statistics),
405        }
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use scirs2_core::ndarray::array;
413
414    #[test]
415    fn test_checkpoint_policy_every_n() {
416        let manager = CheckpointManager::<f32>::new(CheckpointingConfig {
417            policy: CheckpointPolicy::EveryNLayers(2),
418            ..Default::default()
419        });
420
421        assert!(manager.should_checkpoint(0, 10));
422        assert!(!manager.should_checkpoint(1, 10));
423        assert!(manager.should_checkpoint(2, 10));
424        assert!(!manager.should_checkpoint(3, 10));
425        assert!(manager.should_checkpoint(4, 10));
426    }
427
428    #[test]
429    fn test_checkpoint_policy_specific() {
430        let manager = CheckpointManager::<f32>::new(CheckpointingConfig {
431            policy: CheckpointPolicy::SpecificLayers(vec![1, 3, 7]),
432            ..Default::default()
433        });
434
435        assert!(!manager.should_checkpoint(0, 10));
436        assert!(manager.should_checkpoint(1, 10));
437        assert!(!manager.should_checkpoint(2, 10));
438        assert!(manager.should_checkpoint(3, 10));
439        assert!(manager.should_checkpoint(7, 10));
440    }
441
442    #[test]
443    fn test_checkpoint_save_and_retrieve() {
444        let manager = CheckpointManager::<f32>::new(CheckpointingConfig::default());
445        let tensor = Tensor::from_array(array![[1.0, 2.0], [3.0, 4.0]].into_dyn());
446
447        manager
448            .save_checkpoint(5, vec![tensor.clone()])
449            .expect("test: operation should succeed");
450
451        let checkpoint = manager
452            .get_checkpoint(5)
453            .expect("test: get_checkpoint should succeed");
454        assert!(checkpoint.is_some());
455
456        let cp = checkpoint.expect("test: operation should succeed");
457        assert_eq!(cp.layer_index, 5);
458        assert_eq!(cp.activations.len(), 1);
459    }
460
461    #[test]
462    fn test_max_checkpoints_limit() {
463        let manager = CheckpointManager::<f32>::new(CheckpointingConfig {
464            max_checkpoints: Some(2),
465            ..Default::default()
466        });
467
468        let tensor = Tensor::from_array(array![1.0, 2.0].into_dyn());
469
470        manager
471            .save_checkpoint(0, vec![tensor.clone()])
472            .expect("test: operation should succeed");
473        manager
474            .save_checkpoint(1, vec![tensor.clone()])
475            .expect("test: operation should succeed");
476        manager
477            .save_checkpoint(2, vec![tensor.clone()])
478            .expect("test: operation should succeed");
479
480        // Should have only 2 checkpoints (oldest removed)
481        assert_eq!(manager.checkpoint_count(), 2);
482    }
483
484    #[test]
485    fn test_statistics_tracking() {
486        let manager = CheckpointManager::<f32>::new(CheckpointingConfig {
487            enable_statistics: true,
488            ..Default::default()
489        });
490
491        let tensor = Tensor::from_array(array![1.0, 2.0, 3.0].into_dyn());
492
493        manager
494            .save_checkpoint(0, vec![tensor.clone()])
495            .expect("test: operation should succeed");
496        manager.record_backward_pass();
497        manager.record_recomputation(1000);
498        manager.record_recomputation(2000);
499
500        let stats = manager
501            .get_statistics()
502            .expect("test: get_statistics should succeed");
503        assert_eq!(stats.forward_passes, 1);
504        assert_eq!(stats.backward_passes, 1);
505        assert_eq!(stats.recompute_count, 2);
506        assert_eq!(stats.additional_compute_time_us, 3000);
507    }
508
509    #[test]
510    fn test_checkpoint_clear() {
511        let manager = CheckpointManager::<f32>::new(CheckpointingConfig::default());
512        let tensor = Tensor::from_array(array![1.0, 2.0].into_dyn());
513
514        manager
515            .save_checkpoint(0, vec![tensor.clone()])
516            .expect("test: operation should succeed");
517        manager
518            .save_checkpoint(1, vec![tensor.clone()])
519            .expect("test: operation should succeed");
520
521        assert_eq!(manager.checkpoint_count(), 2);
522
523        manager.clear().expect("test: clear should succeed");
524        assert_eq!(manager.checkpoint_count(), 0);
525    }
526
527    #[test]
528    fn test_automatic_policy() {
529        let manager = CheckpointManager::<f32>::with_memory_budget(100, 10);
530
531        // With 100MB budget and 10MB per activation, we can afford 10 checkpoints
532        // For 50 layers, checkpoint every 5 layers
533        assert!(manager.should_checkpoint(0, 50));
534        assert!(manager.should_checkpoint(5, 50));
535        assert!(manager.should_checkpoint(10, 50));
536        assert!(!manager.should_checkpoint(1, 50));
537        assert!(!manager.should_checkpoint(7, 50));
538    }
539
540    #[test]
541    fn test_rng_state_capture_is_honest_error() {
542        // Capture must report that the operation is unsupported rather than
543        // returning a fabricated zero buffer.
544        let manager = CheckpointManager::<f32>::new(CheckpointingConfig::default());
545        let err = manager
546            .capture_rng_state()
547            .expect_err("test: capture must report unsupported, not fabricate bytes");
548        assert!(matches!(err, TensorError::UnsupportedOperation { .. }));
549    }
550
551    #[test]
552    fn test_rng_state_restore_is_honest_error() {
553        // Restore is consistent with capture: it honestly reports unsupported.
554        let manager = CheckpointManager::<f32>::new(CheckpointingConfig::default());
555        let err = manager
556            .restore_rng_state(&[1, 2, 3, 4])
557            .expect_err("test: restore must report unsupported");
558        assert!(matches!(err, TensorError::UnsupportedOperation { .. }));
559    }
560
561    #[test]
562    fn test_save_checkpoint_requesting_rng_state_fails_loudly() {
563        // Explicitly opting into RNG-state saving must fail loudly (because it is
564        // unsupported) instead of storing fake state, and must not insert a
565        // partial checkpoint.
566        let manager = CheckpointManager::<f32>::new(CheckpointingConfig {
567            save_rng_state: true,
568            ..Default::default()
569        });
570        let tensor = Tensor::from_array(array![1.0, 2.0].into_dyn());
571
572        assert!(manager.save_checkpoint(0, vec![tensor]).is_err());
573        assert_eq!(manager.checkpoint_count(), 0);
574    }
575
576    #[test]
577    fn test_default_checkpointing_omits_unsupported_rng_state() {
578        // Default config disables RNG-state saving, so a checkpoint stores no RNG
579        // state and saving succeeds without fabricating anything.
580        let manager = CheckpointManager::<f32>::new(CheckpointingConfig::default());
581        assert!(!manager.config.save_rng_state);
582
583        let tensor = Tensor::from_array(array![1.0, 2.0, 3.0].into_dyn());
584        manager
585            .save_checkpoint(0, vec![tensor])
586            .expect("test: default save should succeed");
587
588        let cp = manager
589            .get_checkpoint(0)
590            .expect("test: get_checkpoint should succeed")
591            .expect("test: checkpoint should exist");
592        assert!(cp.rng_state.is_none());
593    }
594
595    #[test]
596    fn test_checkpoint_statistics_calculations() {
597        let mut stats = CheckpointStatistics {
598            forward_passes: 100,
599            backward_passes: 100,
600            recompute_count: 300,
601            memory_saved_bytes: 1024 * 1024 * 500, // 500 MB
602            additional_compute_time_us: 1_000_000,
603            active_checkpoints: 10,
604        };
605
606        assert_eq!(stats.avg_recomputations(), 3.0);
607        assert_eq!(stats.memory_saved_mb(), 500.0);
608        assert_eq!(stats.compute_overhead_percent(), 300.0);
609    }
610}