1use crate::{Result, Tensor, TensorError};
38use std::collections::HashMap;
39use std::sync::{Arc, Mutex};
40
41#[derive(Debug, Clone, PartialEq)]
43pub enum CheckpointPolicy {
44 EveryNLayers(usize),
46 SpecificLayers(Vec<usize>),
48 Automatic {
50 memory_budget: usize,
52 avg_activation_size: usize,
54 },
55 Custom,
57 None,
59}
60
61impl Default for CheckpointPolicy {
62 fn default() -> Self {
63 CheckpointPolicy::EveryNLayers(1)
64 }
65}
66
67#[derive(Debug, Clone)]
69pub struct CheckpointingConfig {
70 pub policy: CheckpointPolicy,
72 pub recompute_on_backward: bool,
74 pub save_rng_state: bool,
82 pub enable_statistics: bool,
84 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 save_rng_state: false,
96 enable_statistics: false,
97 max_checkpoints: None,
98 }
99 }
100}
101
102#[derive(Debug, Clone, Default)]
104pub struct CheckpointStatistics {
105 pub forward_passes: usize,
107 pub backward_passes: usize,
109 pub recompute_count: usize,
111 pub memory_saved_bytes: usize,
113 pub additional_compute_time_us: u64,
115 pub active_checkpoints: usize,
117}
118
119impl CheckpointStatistics {
120 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 pub fn memory_saved_mb(&self) -> f64 {
131 self.memory_saved_bytes as f64 / (1024.0 * 1024.0)
132 }
133
134 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#[derive(Debug, Clone)]
146pub struct Checkpoint<T> {
147 pub layer_index: usize,
149 pub activations: Vec<Tensor<T>>,
151 pub rng_state: Option<Vec<u8>>,
153 pub timestamp: std::time::Instant,
155 pub memory_bytes: usize,
157}
158
159impl<T> Checkpoint<T>
160where
161 T: Clone + Default,
162{
163 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 pub fn with_rng_state(mut self, rng_state: Vec<u8>) -> Self {
181 self.rng_state = Some(rng_state);
182 self
183 }
184
185 pub fn age(&self) -> std::time::Duration {
187 self.timestamp.elapsed()
188 }
189}
190
191pub 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 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 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 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 let max_checkpoints = memory_budget / avg_activation_size;
233 if max_checkpoints == 0 {
234 return false;
235 }
236 let checkpoint_every = total_layers / max_checkpoints.max(1);
238 layer_index % checkpoint_every.max(1) == 0
239 }
240 CheckpointPolicy::Custom => {
241 false
243 }
244 CheckpointPolicy::None => false,
245 }
246 }
247
248 pub fn save_checkpoint(&self, layer_index: usize, activations: Vec<Tensor<T>>) -> Result<()> {
250 let mut checkpoint = Checkpoint::new(layer_index, activations);
251
252 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 let mut checkpoints = self.checkpoints.lock().map_err(|_| {
263 TensorError::invalid_operation_simple("Failed to acquire checkpoint lock".to_string())
264 })?;
265
266 if let Some(max_cp) = self.config.max_checkpoints {
268 if checkpoints.len() >= max_cp {
269 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 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 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 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 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 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 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 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 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 pub fn checkpoint_count(&self) -> usize {
384 self.checkpoints.lock().map(|cp| cp.len()).unwrap_or(0)
385 }
386
387 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 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 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 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 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 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 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, 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}