1use std::collections::{HashMap, VecDeque};
10use thiserror::Error;
11
12#[derive(Debug, Error, Clone, PartialEq)]
18pub enum GradientCheckpointerError {
19 #[error("no accumulated gradients available")]
21 NoAccumulatedGradients,
22
23 #[error("checkpoint not found")]
25 CheckpointNotFound,
26
27 #[error("dimension mismatch for layer '{layer}': expected {expected}, got {got}")]
30 DimensionMismatch {
31 layer: String,
32 expected: usize,
33 got: usize,
34 },
35}
36
37#[derive(Debug, Clone, PartialEq)]
46pub struct GcGradientTensor {
47 pub layer_id: String,
49 pub values: Vec<f64>,
51 pub step: u64,
53 pub norm: f64,
55}
56
57impl GcGradientTensor {
58 pub fn new(layer_id: impl Into<String>, values: Vec<f64>, step: u64) -> Self {
60 let norm = Self::compute_norm(&values);
61 Self {
62 layer_id: layer_id.into(),
63 values,
64 step,
65 norm,
66 }
67 }
68
69 #[inline]
73 pub fn compute_norm(values: &[f64]) -> f64 {
74 values.iter().map(|v| v * v).sum::<f64>().sqrt()
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
84pub struct CheckpointId(pub u64);
85
86impl std::fmt::Display for CheckpointId {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 write!(f, "ckpt:{}", self.0)
89 }
90}
91
92#[derive(Debug, Clone)]
99pub struct GcGradientCheckpoint {
100 pub id: CheckpointId,
102 pub step: u64,
104 pub gradients: HashMap<String, GcGradientTensor>,
106 pub created_at: u64,
109 pub compressed_size: usize,
112 pub checksum: u64,
114}
115
116#[derive(Debug, Clone)]
123pub enum GcAccumulationMode {
124 Sum,
126 Mean,
128 WeightedMean {
132 weights: Vec<f64>,
134 },
135}
136
137#[derive(Debug, Clone)]
143pub struct CheckpointerConfig {
144 pub max_checkpoints: usize,
148 pub compression_threshold: usize,
151 pub accumulation_mode: GcAccumulationMode,
153 pub clip_norm: Option<f64>,
156}
157
158impl Default for CheckpointerConfig {
159 fn default() -> Self {
160 Self {
161 max_checkpoints: 10,
162 compression_threshold: 1000,
163 accumulation_mode: GcAccumulationMode::Sum,
164 clip_norm: None,
165 }
166 }
167}
168
169#[derive(Debug, Clone, PartialEq)]
175pub struct GcCheckpointerStats {
176 pub total_checkpoints: usize,
178 pub total_steps: u64,
180 pub avg_checkpoint_norm: f64,
182 pub max_checkpoint_norm: f64,
184 pub pending_tensors: usize,
186}
187
188pub struct GradientCheckpointer {
198 pub config: CheckpointerConfig,
200 accumulated: HashMap<String, Vec<GcGradientTensor>>,
202 checkpoints: VecDeque<GcGradientCheckpoint>,
204 next_checkpoint_id: u64,
206 pub global_step: u64,
208}
209
210impl GradientCheckpointer {
211 pub fn new(config: CheckpointerConfig) -> Self {
213 Self {
214 config,
215 accumulated: HashMap::new(),
216 checkpoints: VecDeque::new(),
217 next_checkpoint_id: 0,
218 global_step: 0,
219 }
220 }
221
222 pub fn accumulate(&mut self, gradient: GcGradientTensor) {
230 self.global_step += 1;
231 self.accumulated
232 .entry(gradient.layer_id.clone())
233 .or_default()
234 .push(gradient);
235 }
236
237 pub fn flush(&mut self, now: u64) -> Result<GcGradientCheckpoint, GradientCheckpointerError> {
253 if self.accumulated.is_empty() {
254 return Err(GradientCheckpointerError::NoAccumulatedGradients);
255 }
256
257 let mut merged: HashMap<String, GcGradientTensor> = HashMap::new();
259 for (layer_id, tensors) in &self.accumulated {
260 let merged_values = self.combine_tensors(layer_id, tensors)?;
261 let step = tensors.last().map(|t| t.step).unwrap_or(self.global_step);
262 let tensor = GcGradientTensor::new(layer_id.clone(), merged_values, step);
263 merged.insert(layer_id.clone(), tensor);
264 }
265
266 if let Some(max_norm) = self.config.clip_norm {
268 let global_norm = Self::compute_global_norm_map(&merged);
269 if global_norm > max_norm && global_norm > 0.0 {
270 let scale = max_norm / global_norm;
271 for tensor in merged.values_mut() {
272 for v in &mut tensor.values {
273 *v *= scale;
274 }
275 tensor.norm = GcGradientTensor::compute_norm(&tensor.values);
276 }
277 }
278 }
279
280 let checksum = compute_checksum(&merged);
282
283 let total_values: usize = merged.values().map(|t| t.values.len()).sum();
285 let compressed_size = if total_values > self.config.compression_threshold {
286 (total_values as f64 * 8.0 * 0.6) as usize
287 } else {
288 total_values * 8
289 };
290
291 let id = CheckpointId(self.next_checkpoint_id);
293 self.next_checkpoint_id += 1;
294
295 let checkpoint = GcGradientCheckpoint {
296 id,
297 step: self.global_step,
298 gradients: merged,
299 created_at: now,
300 compressed_size,
301 checksum,
302 };
303
304 if self.checkpoints.len() >= self.config.max_checkpoints {
306 self.checkpoints.pop_front();
307 }
308 self.checkpoints.push_back(checkpoint.clone());
309
310 self.accumulated.clear();
312
313 Ok(checkpoint)
314 }
315
316 pub fn replay(&self, checkpoint: &GcGradientCheckpoint) -> Vec<GcGradientTensor> {
322 let mut tensors: Vec<GcGradientTensor> = checkpoint.gradients.values().cloned().collect();
323 tensors.sort_by(|a, b| a.layer_id.cmp(&b.layer_id));
324 tensors
325 }
326
327 pub fn latest_checkpoint(&self) -> Option<&GcGradientCheckpoint> {
333 self.checkpoints.back()
334 }
335
336 pub fn checkpoint_by_id(&self, id: CheckpointId) -> Option<&GcGradientCheckpoint> {
339 self.checkpoints.iter().find(|c| c.id == id)
340 }
341
342 pub fn diff(&self, a: &GcGradientCheckpoint, b: &GcGradientCheckpoint) -> HashMap<String, f64> {
352 let mut result: HashMap<String, f64> = HashMap::new();
353
354 let mut all_layers: Vec<String> = a
356 .gradients
357 .keys()
358 .chain(b.gradients.keys())
359 .cloned()
360 .collect();
361 all_layers.sort();
362 all_layers.dedup();
363
364 for layer in all_layers {
365 let dist = match (a.gradients.get(&layer), b.gradients.get(&layer)) {
366 (Some(ta), Some(tb)) => l2_distance(&ta.values, &tb.values),
367 (Some(ta), None) => GcGradientTensor::compute_norm(&ta.values),
368 (None, Some(tb)) => GcGradientTensor::compute_norm(&tb.values),
369 (None, None) => 0.0,
370 };
371 result.insert(layer, dist);
372 }
373
374 result
375 }
376
377 pub fn global_norm(&self, checkpoint: &GcGradientCheckpoint) -> f64 {
380 Self::compute_global_norm_map(&checkpoint.gradients)
381 }
382
383 pub fn pending_layers(&self) -> Vec<&str> {
389 let mut ids: Vec<&str> = self.accumulated.keys().map(String::as_str).collect();
390 ids.sort();
391 ids
392 }
393
394 pub fn pending_count(&self) -> usize {
396 self.accumulated.values().map(Vec::len).sum()
397 }
398
399 pub fn stats(&self) -> GcCheckpointerStats {
405 let norms: Vec<f64> = self
406 .checkpoints
407 .iter()
408 .map(|c| Self::compute_global_norm_map(&c.gradients))
409 .collect();
410
411 let total = norms.len();
412 let avg_checkpoint_norm = if total == 0 {
413 0.0
414 } else {
415 norms.iter().sum::<f64>() / total as f64
416 };
417 let max_checkpoint_norm = norms.iter().cloned().fold(0.0_f64, f64::max);
418
419 GcCheckpointerStats {
420 total_checkpoints: total,
421 total_steps: self.global_step,
422 avg_checkpoint_norm,
423 max_checkpoint_norm,
424 pending_tensors: self.pending_count(),
425 }
426 }
427
428 fn combine_tensors(
433 &self,
434 layer_id: &str,
435 tensors: &[GcGradientTensor],
436 ) -> Result<Vec<f64>, GradientCheckpointerError> {
437 debug_assert!(
438 !tensors.is_empty(),
439 "combine_tensors called with empty slice"
440 );
441
442 let dim = tensors[0].values.len();
443
444 for t in tensors.iter().skip(1) {
446 if t.values.len() != dim {
447 return Err(GradientCheckpointerError::DimensionMismatch {
448 layer: layer_id.to_string(),
449 expected: dim,
450 got: t.values.len(),
451 });
452 }
453 }
454
455 match &self.config.accumulation_mode {
456 GcAccumulationMode::Sum => {
457 let mut result = vec![0.0f64; dim];
458 for t in tensors {
459 for (r, v) in result.iter_mut().zip(t.values.iter()) {
460 *r += v;
461 }
462 }
463 Ok(result)
464 }
465
466 GcAccumulationMode::Mean => {
467 let mut result = vec![0.0f64; dim];
468 for t in tensors {
469 for (r, v) in result.iter_mut().zip(t.values.iter()) {
470 *r += v;
471 }
472 }
473 let n = tensors.len() as f64;
474 for r in &mut result {
475 *r /= n;
476 }
477 Ok(result)
478 }
479
480 GcAccumulationMode::WeightedMean { weights } => {
481 let mut result = vec![0.0f64; dim];
482 let mut weight_sum = 0.0f64;
483
484 for (i, t) in tensors.iter().enumerate() {
485 let w = weights.get(i).copied().unwrap_or(1.0);
486 weight_sum += w;
487 for (r, v) in result.iter_mut().zip(t.values.iter()) {
488 *r += v * w;
489 }
490 }
491
492 if weight_sum != 0.0 {
493 for r in &mut result {
494 *r /= weight_sum;
495 }
496 }
497
498 Ok(result)
499 }
500 }
501 }
502
503 fn compute_global_norm_map(gradients: &HashMap<String, GcGradientTensor>) -> f64 {
504 gradients
505 .values()
506 .map(|t| t.norm * t.norm)
507 .sum::<f64>()
508 .sqrt()
509 }
510}
511
512#[inline]
521pub fn fnv1a_f64_slice(values: &[f64]) -> u64 {
522 let mut h: u64 = 14695981039346656037;
523 for v in values {
524 for b in v.to_bits().to_le_bytes() {
525 h ^= b as u64;
526 h = h.wrapping_mul(1099511628211);
527 }
528 }
529 h
530}
531
532#[inline]
536fn l2_distance(a: &[f64], b: &[f64]) -> f64 {
537 let min_len = a.len().min(b.len());
538 let mut sum = 0.0f64;
539 for i in 0..min_len {
540 let d = a[i] - b[i];
541 sum += d * d;
542 }
543 for &v in a.iter().skip(min_len) {
545 sum += v * v;
546 }
547 for &v in b.iter().skip(min_len) {
548 sum += v * v;
549 }
550 sum.sqrt()
551}
552
553fn compute_checksum(gradients: &HashMap<String, GcGradientTensor>) -> u64 {
556 let mut layer_ids: Vec<&String> = gradients.keys().collect();
557 layer_ids.sort();
558
559 let mut all_values: Vec<f64> = Vec::new();
560 for id in layer_ids {
561 if let Some(t) = gradients.get(id) {
562 all_values.extend_from_slice(&t.values);
563 }
564 }
565
566 fnv1a_f64_slice(&all_values)
567}
568
569#[cfg(test)]
574mod tests {
575 use super::{
576 fnv1a_f64_slice, CheckpointId, CheckpointerConfig, GcAccumulationMode, GcGradientTensor,
577 GradientCheckpointer, GradientCheckpointerError,
578 };
579
580 fn default_checkpointer() -> GradientCheckpointer {
585 GradientCheckpointer::new(CheckpointerConfig::default())
586 }
587
588 fn tensor(layer: &str, values: Vec<f64>, step: u64) -> GcGradientTensor {
589 GcGradientTensor::new(layer, values, step)
590 }
591
592 #[test]
597 fn test_gradient_tensor_norm_zero() {
598 let t = tensor("l0", vec![0.0, 0.0, 0.0], 1);
599 assert_eq!(t.norm, 0.0);
600 }
601
602 #[test]
603 fn test_gradient_tensor_norm_unit() {
604 let t = tensor("l0", vec![1.0, 0.0, 0.0], 1);
605 assert!((t.norm - 1.0).abs() < 1e-12);
606 }
607
608 #[test]
609 fn test_gradient_tensor_norm_3_4_5() {
610 let t = tensor("l0", vec![3.0, 4.0], 1);
611 assert!((t.norm - 5.0).abs() < 1e-12);
612 }
613
614 #[test]
615 fn test_gradient_tensor_compute_norm_static() {
616 let norm = GcGradientTensor::compute_norm(&[1.0, 2.0, 2.0]);
617 assert!((norm - 3.0).abs() < 1e-12);
618 }
619
620 #[test]
621 fn test_gradient_tensor_layer_id_stored() {
622 let t = tensor("my_layer", vec![1.0], 42);
623 assert_eq!(t.layer_id, "my_layer");
624 assert_eq!(t.step, 42);
625 }
626
627 #[test]
632 fn test_checkpoint_id_display() {
633 let id = CheckpointId(7);
634 assert_eq!(id.to_string(), "ckpt:7");
635 }
636
637 #[test]
638 fn test_checkpoint_id_ordering() {
639 assert!(CheckpointId(1) < CheckpointId(2));
640 assert_eq!(CheckpointId(5), CheckpointId(5));
641 }
642
643 #[test]
648 fn test_accumulate_increments_global_step() {
649 let mut cp = default_checkpointer();
650 assert_eq!(cp.global_step, 0);
651 cp.accumulate(tensor("l0", vec![1.0], 1));
652 assert_eq!(cp.global_step, 1);
653 cp.accumulate(tensor("l0", vec![2.0], 2));
654 assert_eq!(cp.global_step, 2);
655 }
656
657 #[test]
658 fn test_pending_count_and_layers() {
659 let mut cp = default_checkpointer();
660 cp.accumulate(tensor("layer_a", vec![1.0], 1));
661 cp.accumulate(tensor("layer_b", vec![2.0], 2));
662 cp.accumulate(tensor("layer_a", vec![3.0], 3));
663
664 assert_eq!(cp.pending_count(), 3);
665 let layers = cp.pending_layers();
666 assert_eq!(layers, vec!["layer_a", "layer_b"]);
667 }
668
669 #[test]
670 fn test_pending_layers_sorted() {
671 let mut cp = default_checkpointer();
672 cp.accumulate(tensor("z_layer", vec![1.0], 1));
673 cp.accumulate(tensor("a_layer", vec![1.0], 2));
674 cp.accumulate(tensor("m_layer", vec![1.0], 3));
675 let layers = cp.pending_layers();
676 assert_eq!(layers, vec!["a_layer", "m_layer", "z_layer"]);
677 }
678
679 #[test]
680 fn test_flush_clears_accumulated() {
681 let mut cp = default_checkpointer();
682 cp.accumulate(tensor("l0", vec![1.0], 1));
683 let _ckpt = cp.flush(100).expect("flush failed");
684 assert_eq!(cp.pending_count(), 0);
685 assert!(cp.pending_layers().is_empty());
686 }
687
688 #[test]
693 fn test_flush_empty_returns_error() {
694 let mut cp = default_checkpointer();
695 let result = cp.flush(0);
696 assert!(matches!(
697 result,
698 Err(GradientCheckpointerError::NoAccumulatedGradients)
699 ));
700 }
701
702 #[test]
707 fn test_flush_sum_mode() {
708 let mut cp = default_checkpointer(); cp.accumulate(tensor("l0", vec![1.0, 2.0], 1));
710 cp.accumulate(tensor("l0", vec![3.0, 4.0], 2));
711 let ckpt = cp.flush(0).expect("flush failed");
712 let t = ckpt.gradients.get("l0").expect("layer missing");
713 assert!((t.values[0] - 4.0).abs() < 1e-12);
714 assert!((t.values[1] - 6.0).abs() < 1e-12);
715 }
716
717 #[test]
718 fn test_flush_mean_mode() {
719 let config = CheckpointerConfig {
720 accumulation_mode: GcAccumulationMode::Mean,
721 ..Default::default()
722 };
723 let mut cp = GradientCheckpointer::new(config);
724 cp.accumulate(tensor("l0", vec![2.0, 4.0], 1));
725 cp.accumulate(tensor("l0", vec![4.0, 8.0], 2));
726 let ckpt = cp.flush(0).expect("flush failed");
727 let t = ckpt.gradients.get("l0").expect("layer missing");
728 assert!((t.values[0] - 3.0).abs() < 1e-12);
729 assert!((t.values[1] - 6.0).abs() < 1e-12);
730 }
731
732 #[test]
733 fn test_flush_weighted_mean_mode() {
734 let config = CheckpointerConfig {
735 accumulation_mode: GcAccumulationMode::WeightedMean {
736 weights: vec![1.0, 3.0],
737 },
738 ..Default::default()
739 };
740 let mut cp = GradientCheckpointer::new(config);
741 cp.accumulate(tensor("l0", vec![0.0, 0.0], 1)); cp.accumulate(tensor("l0", vec![4.0, 8.0], 2)); let ckpt = cp.flush(0).expect("flush failed");
744 let t = ckpt.gradients.get("l0").expect("layer missing");
745 assert!((t.values[0] - 3.0).abs() < 1e-12);
747 assert!((t.values[1] - 6.0).abs() < 1e-12);
749 }
750
751 #[test]
752 fn test_flush_weighted_mean_uses_unit_weights_for_missing() {
753 let config = CheckpointerConfig {
755 accumulation_mode: GcAccumulationMode::WeightedMean { weights: vec![2.0] },
756 ..Default::default()
757 };
758 let mut cp = GradientCheckpointer::new(config);
759 cp.accumulate(tensor("l0", vec![2.0], 1)); cp.accumulate(tensor("l0", vec![2.0], 2)); let ckpt = cp.flush(0).expect("flush failed");
762 let t = ckpt.gradients.get("l0").expect("layer missing");
763 assert!((t.values[0] - 2.0).abs() < 1e-12);
765 }
766
767 #[test]
772 fn test_clip_norm_scales_down() {
773 let config = CheckpointerConfig {
774 clip_norm: Some(1.0),
775 ..Default::default()
776 };
777 let mut cp = GradientCheckpointer::new(config);
778 cp.accumulate(tensor("l0", vec![3.0, 4.0], 1));
780 let ckpt = cp.flush(0).expect("flush failed");
781 let global = cp.global_norm(&ckpt);
782 assert!(global <= 1.0 + 1e-9, "global norm {} > 1.0", global);
783 }
784
785 #[test]
786 fn test_clip_norm_no_change_when_below_threshold() {
787 let config = CheckpointerConfig {
788 clip_norm: Some(10.0),
789 ..Default::default()
790 };
791 let mut cp = GradientCheckpointer::new(config);
792 cp.accumulate(tensor("l0", vec![1.0, 1.0], 1));
793 let ckpt = cp.flush(0).expect("flush failed");
794 let t = ckpt.gradients.get("l0").expect("layer missing");
795 assert!((t.values[0] - 1.0).abs() < 1e-10);
797 }
798
799 #[test]
800 fn test_clip_norm_multi_layer() {
801 let config = CheckpointerConfig {
803 clip_norm: Some(2.0),
804 ..Default::default()
805 };
806 let mut cp = GradientCheckpointer::new(config);
807 cp.accumulate(tensor("l0", vec![3.0, 0.0], 1));
808 cp.accumulate(tensor("l1", vec![0.0, 3.0], 1));
809 let ckpt = cp.flush(0).expect("flush failed");
810 let global = cp.global_norm(&ckpt);
811 assert!(global <= 2.0 + 1e-9, "global norm {} > 2.0", global);
812 }
813
814 #[test]
819 fn test_max_checkpoints_eviction() {
820 let config = CheckpointerConfig {
821 max_checkpoints: 3,
822 ..Default::default()
823 };
824 let mut cp = GradientCheckpointer::new(config);
825 let mut ids = Vec::new();
826 for i in 0..5u64 {
827 cp.accumulate(tensor("l0", vec![i as f64], i));
828 let ckpt = cp.flush(i).expect("flush failed");
829 ids.push(ckpt.id);
830 }
831 assert!(cp.checkpoint_by_id(ids[0]).is_none());
833 assert!(cp.checkpoint_by_id(ids[1]).is_none());
834 assert!(cp.checkpoint_by_id(ids[2]).is_some());
835 assert!(cp.checkpoint_by_id(ids[3]).is_some());
836 assert!(cp.checkpoint_by_id(ids[4]).is_some());
837 }
838
839 #[test]
840 fn test_latest_checkpoint_returns_last_flushed() {
841 let mut cp = default_checkpointer();
842 cp.accumulate(tensor("l0", vec![1.0], 1));
843 let first = cp.flush(10).expect("flush failed");
844 cp.accumulate(tensor("l0", vec![2.0], 2));
845 let second = cp.flush(20).expect("flush failed");
846 let latest = cp.latest_checkpoint().expect("no latest");
847 assert_eq!(latest.id, second.id);
848 assert_ne!(latest.id, first.id);
849 }
850
851 #[test]
852 fn test_latest_checkpoint_none_initially() {
853 let cp = default_checkpointer();
854 assert!(cp.latest_checkpoint().is_none());
855 }
856
857 #[test]
858 fn test_checkpoint_by_id_found() {
859 let mut cp = default_checkpointer();
860 cp.accumulate(tensor("l0", vec![1.0], 1));
861 let ckpt = cp.flush(0).expect("flush failed");
862 let found = cp.checkpoint_by_id(ckpt.id).expect("not found");
863 assert_eq!(found.id, ckpt.id);
864 }
865
866 #[test]
867 fn test_checkpoint_by_id_not_found() {
868 let cp = default_checkpointer();
869 assert!(cp.checkpoint_by_id(CheckpointId(9999)).is_none());
870 }
871
872 #[test]
877 fn test_replay_sorted_by_layer_id() {
878 let mut cp = default_checkpointer();
879 cp.accumulate(tensor("z_layer", vec![3.0], 1));
880 cp.accumulate(tensor("a_layer", vec![1.0], 2));
881 cp.accumulate(tensor("m_layer", vec![2.0], 3));
882 let ckpt = cp.flush(0).expect("flush failed");
883 let replayed = cp.replay(&ckpt);
884 let ids: Vec<&str> = replayed.iter().map(|t| t.layer_id.as_str()).collect();
885 assert_eq!(ids, vec!["a_layer", "m_layer", "z_layer"]);
886 }
887
888 #[test]
889 fn test_replay_values_preserved() {
890 let mut cp = default_checkpointer();
891 cp.accumulate(tensor("l0", vec![1.5, 2.5], 1));
892 let ckpt = cp.flush(0).expect("flush failed");
893 let replayed = cp.replay(&ckpt);
894 assert_eq!(replayed.len(), 1);
895 assert!((replayed[0].values[0] - 1.5).abs() < 1e-12);
896 assert!((replayed[0].values[1] - 2.5).abs() < 1e-12);
897 }
898
899 #[test]
904 fn test_diff_same_checkpoint_is_zero() {
905 let mut cp = default_checkpointer();
906 cp.accumulate(tensor("l0", vec![1.0, 2.0], 1));
907 let ckpt = cp.flush(0).expect("flush failed");
908 let diff = cp.diff(&ckpt, &ckpt);
909 let d = diff["l0"];
910 assert!(d.abs() < 1e-12, "expected 0 distance, got {}", d);
911 }
912
913 #[test]
914 fn test_diff_known_distance() {
915 let mut cp = default_checkpointer();
916 cp.accumulate(tensor("l0", vec![0.0, 0.0], 1));
917 let a = cp.flush(0).expect("flush a");
918 cp.accumulate(tensor("l0", vec![3.0, 4.0], 2));
919 let b = cp.flush(1).expect("flush b");
920 let diff = cp.diff(&a, &b);
921 assert!(
922 (diff["l0"] - 5.0).abs() < 1e-12,
923 "expected 5.0, got {}",
924 diff["l0"]
925 );
926 }
927
928 #[test]
929 fn test_diff_missing_layer_returns_norm() {
930 let mut cp = default_checkpointer();
931 cp.accumulate(tensor("l0", vec![3.0, 4.0], 1));
932 let a = cp.flush(0).expect("flush a");
933 cp.accumulate(tensor("l1", vec![1.0], 2));
935 let b = cp.flush(1).expect("flush b");
936 let diff = cp.diff(&a, &b);
937 assert!(
939 (diff["l0"] - 5.0).abs() < 1e-12,
940 "expected 5.0, got {}",
941 diff["l0"]
942 );
943 assert!(
945 (diff["l1"] - 1.0).abs() < 1e-12,
946 "expected 1.0, got {}",
947 diff["l1"]
948 );
949 }
950
951 #[test]
956 fn test_global_norm_single_layer() {
957 let mut cp = default_checkpointer();
958 cp.accumulate(tensor("l0", vec![3.0, 4.0], 1));
959 let ckpt = cp.flush(0).expect("flush failed");
960 let gnorm = cp.global_norm(&ckpt);
961 assert!((gnorm - 5.0).abs() < 1e-12, "expected 5.0, got {}", gnorm);
962 }
963
964 #[test]
965 fn test_global_norm_multi_layer() {
966 let mut cp = default_checkpointer();
967 cp.accumulate(tensor("l0", vec![3.0, 0.0], 1));
969 cp.accumulate(tensor("l1", vec![0.0, 4.0], 2));
970 let ckpt = cp.flush(0).expect("flush failed");
971 let gnorm = cp.global_norm(&ckpt);
972 assert!((gnorm - 5.0).abs() < 1e-12, "expected 5.0, got {}", gnorm);
973 }
974
975 #[test]
980 fn test_stats_empty() {
981 let cp = default_checkpointer();
982 let s = cp.stats();
983 assert_eq!(s.total_checkpoints, 0);
984 assert_eq!(s.total_steps, 0);
985 assert_eq!(s.avg_checkpoint_norm, 0.0);
986 assert_eq!(s.max_checkpoint_norm, 0.0);
987 assert_eq!(s.pending_tensors, 0);
988 }
989
990 #[test]
991 fn test_stats_after_flush() {
992 let mut cp = default_checkpointer();
993 cp.accumulate(tensor("l0", vec![3.0, 4.0], 1));
994 let _ = cp.flush(0).expect("flush failed");
995 let s = cp.stats();
996 assert_eq!(s.total_checkpoints, 1);
997 assert_eq!(s.total_steps, 1);
998 assert!((s.avg_checkpoint_norm - 5.0).abs() < 1e-9);
1000 assert!((s.max_checkpoint_norm - 5.0).abs() < 1e-9);
1001 assert_eq!(s.pending_tensors, 0);
1002 }
1003
1004 #[test]
1005 fn test_stats_pending_tensors() {
1006 let mut cp = default_checkpointer();
1007 cp.accumulate(tensor("l0", vec![1.0], 1));
1008 cp.accumulate(tensor("l1", vec![2.0], 2));
1009 let s = cp.stats();
1010 assert_eq!(s.pending_tensors, 2);
1011 }
1012
1013 #[test]
1018 fn test_checksum_deterministic() {
1019 let mut cp = default_checkpointer();
1020 cp.accumulate(tensor("l0", vec![1.0, 2.0, 3.0], 1));
1021 let a = cp.flush(0).expect("flush a");
1022 let mut cp2 = default_checkpointer();
1023 cp2.accumulate(tensor("l0", vec![1.0, 2.0, 3.0], 1));
1024 let b = cp2.flush(0).expect("flush b");
1025 assert_eq!(a.checksum, b.checksum);
1026 }
1027
1028 #[test]
1029 fn test_checksum_differs_for_different_values() {
1030 let mut cp = default_checkpointer();
1031 cp.accumulate(tensor("l0", vec![1.0], 1));
1032 let a = cp.flush(0).expect("flush a");
1033 cp.accumulate(tensor("l0", vec![2.0], 2));
1034 let b = cp.flush(1).expect("flush b");
1035 assert_ne!(a.checksum, b.checksum);
1036 }
1037
1038 #[test]
1039 fn test_fnv1a_empty_slice() {
1040 let h = fnv1a_f64_slice(&[]);
1041 assert_eq!(h, 14695981039346656037u64);
1042 }
1043
1044 #[test]
1045 fn test_fnv1a_known_value() {
1046 let h1 = fnv1a_f64_slice(&[1.0]);
1047 let h2 = fnv1a_f64_slice(&[1.0]);
1048 assert_eq!(h1, h2);
1049 let h3 = fnv1a_f64_slice(&[2.0]);
1051 assert_ne!(h1, h3);
1052 }
1053
1054 #[test]
1059 fn test_dimension_mismatch_error() {
1060 let mut cp = default_checkpointer();
1061 cp.accumulate(tensor("l0", vec![1.0, 2.0], 1));
1062 cp.accumulate(tensor("l0", vec![1.0, 2.0, 3.0], 2)); let result = cp.flush(0);
1064 assert!(matches!(
1065 result,
1066 Err(GradientCheckpointerError::DimensionMismatch { .. })
1067 ));
1068 }
1069
1070 #[test]
1075 fn test_checkpoint_created_at() {
1076 let mut cp = default_checkpointer();
1077 cp.accumulate(tensor("l0", vec![1.0], 1));
1078 let ckpt = cp.flush(12345).expect("flush failed");
1079 assert_eq!(ckpt.created_at, 12345);
1080 }
1081
1082 #[test]
1083 fn test_compressed_size_positive() {
1084 let mut cp = default_checkpointer();
1085 cp.accumulate(tensor("l0", vec![1.0; 10], 1));
1086 let ckpt = cp.flush(0).expect("flush failed");
1087 assert!(ckpt.compressed_size > 0);
1088 }
1089
1090 #[test]
1091 fn test_checkpoint_ids_are_monotonically_increasing() {
1092 let mut cp = default_checkpointer();
1093 let mut prev = CheckpointId(0);
1094 for i in 0..5u64 {
1095 cp.accumulate(tensor("l0", vec![i as f64], i));
1096 let ckpt = cp.flush(i).expect("flush failed");
1097 if i > 0 {
1098 assert!(ckpt.id > prev, "id {:?} not > {:?}", ckpt.id, prev);
1099 }
1100 prev = ckpt.id;
1101 }
1102 }
1103
1104 #[test]
1105 fn test_step_recorded_in_checkpoint() {
1106 let mut cp = default_checkpointer();
1107 cp.accumulate(tensor("l0", vec![1.0], 1));
1108 cp.accumulate(tensor("l0", vec![2.0], 2));
1109 let ckpt = cp.flush(0).expect("flush failed");
1110 assert_eq!(ckpt.step, 2);
1112 }
1113
1114 #[test]
1119 fn test_multiple_layers_flush_and_replay() {
1120 let mut cp = default_checkpointer();
1121 for layer in ["encoder", "decoder", "classifier"] {
1122 for step in 0..5u64 {
1123 cp.accumulate(tensor(layer, vec![step as f64, (step + 1) as f64], step));
1124 }
1125 }
1126 let ckpt = cp.flush(999).expect("flush failed");
1127 assert_eq!(ckpt.gradients.len(), 3);
1128 let replayed = cp.replay(&ckpt);
1129 assert_eq!(replayed.len(), 3);
1130 assert_eq!(replayed[0].layer_id, "classifier");
1132 assert_eq!(replayed[1].layer_id, "decoder");
1133 assert_eq!(replayed[2].layer_id, "encoder");
1134 }
1135
1136 #[test]
1137 fn test_multiple_flush_cycles() {
1138 let mut cp = default_checkpointer();
1139 for cycle in 0..5u64 {
1140 cp.accumulate(tensor("l0", vec![cycle as f64], cycle));
1141 let ckpt = cp.flush(cycle).expect("flush failed");
1142 assert_eq!(ckpt.id, CheckpointId(cycle));
1143 }
1144 let s = cp.stats();
1145 assert_eq!(s.total_checkpoints, 5);
1146 }
1147
1148 #[test]
1149 fn test_no_unwrap_path_checkpoint_not_found_error() {
1150 let err = GradientCheckpointerError::CheckpointNotFound;
1151 assert_eq!(err.to_string(), "checkpoint not found");
1152 }
1153
1154 #[test]
1155 fn test_dimension_mismatch_error_message() {
1156 let err = GradientCheckpointerError::DimensionMismatch {
1157 layer: "l0".to_string(),
1158 expected: 4,
1159 got: 3,
1160 };
1161 let msg = err.to_string();
1162 assert!(msg.contains("l0"));
1163 assert!(msg.contains('4'));
1164 assert!(msg.contains('3'));
1165 }
1166}