#[derive(Clone, Debug, PartialEq)]
pub enum ClippingStrategy {
GlobalNorm {
max_norm: f64,
},
PerTensorNorm {
max_norm: f64,
},
ValueClip {
min: f64,
max: f64,
},
Adaptive {
target_norm: f64,
momentum: f64,
},
}
#[derive(Clone, Debug)]
pub struct GradientTensor {
pub tensor_id: u64,
pub values: Vec<f64>,
}
impl GradientTensor {
pub fn l2_norm(&self) -> f64 {
if self.values.is_empty() {
return 0.0;
}
let sum_sq: f64 = self.values.iter().map(|v| v * v).sum();
sum_sq.sqrt()
}
pub fn max_abs_value(&self) -> f64 {
self.values.iter().map(|v| v.abs()).fold(0.0_f64, f64::max)
}
}
#[derive(Clone, Debug)]
pub struct ClippingResult {
pub tensor_id: u64,
pub original_norm: f64,
pub clipped_norm: f64,
pub was_clipped: bool,
}
#[derive(Clone, Debug, Default)]
pub struct ClipperStats {
pub total_clip_calls: u64,
pub total_tensors_processed: u64,
pub total_clipped: u64,
pub avg_clip_ratio: f64,
}
pub struct TensorGradientClipper {
pub strategy: ClippingStrategy,
pub stats: ClipperStats,
pub ema_norm: f64,
}
impl TensorGradientClipper {
pub fn new(strategy: ClippingStrategy) -> Self {
Self {
strategy,
stats: ClipperStats {
avg_clip_ratio: 1.0,
..ClipperStats::default()
},
ema_norm: 0.0,
}
}
pub fn clip(&mut self, tensors: &mut [GradientTensor]) -> Vec<ClippingResult> {
self.stats.total_clip_calls += 1;
self.stats.total_tensors_processed += tensors.len() as u64;
let results = match &self.strategy.clone() {
ClippingStrategy::GlobalNorm { max_norm } => self.apply_global_norm(tensors, *max_norm),
ClippingStrategy::PerTensorNorm { max_norm } => {
self.apply_per_tensor_norm(tensors, *max_norm)
}
ClippingStrategy::ValueClip { min, max } => self.apply_value_clip(tensors, *min, *max),
ClippingStrategy::Adaptive { momentum, .. } => {
let momentum = *momentum;
self.apply_adaptive(tensors, momentum)
}
};
for result in &results {
if result.was_clipped {
self.stats.total_clipped += 1;
let ratio = if result.original_norm > 0.0 {
result.clipped_norm / result.original_norm
} else {
1.0
};
let n = self.stats.total_clipped as f64;
self.stats.avg_clip_ratio =
self.stats.avg_clip_ratio + (ratio - self.stats.avg_clip_ratio) / n;
}
}
results
}
pub fn reset_stats(&mut self) {
self.stats = ClipperStats {
avg_clip_ratio: 1.0,
..ClipperStats::default()
};
self.ema_norm = 0.0;
}
pub fn stats(&self) -> &ClipperStats {
&self.stats
}
fn apply_global_norm(
&self,
tensors: &mut [GradientTensor],
max_norm: f64,
) -> Vec<ClippingResult> {
let sum_sq: f64 = tensors.iter().map(|t| t.l2_norm().powi(2)).sum();
let global_norm = sum_sq.sqrt();
if global_norm > max_norm && global_norm > 0.0 {
let scale = max_norm / global_norm;
tensors.iter_mut().for_each(|t| {
t.values.iter_mut().for_each(|v| *v *= scale);
});
tensors
.iter()
.map(|t| {
let original = t.l2_norm() / scale; let clipped = t.l2_norm();
ClippingResult {
tensor_id: t.tensor_id,
original_norm: original,
clipped_norm: clipped,
was_clipped: true,
}
})
.collect()
} else {
tensors
.iter()
.map(|t| {
let norm = t.l2_norm();
ClippingResult {
tensor_id: t.tensor_id,
original_norm: norm,
clipped_norm: norm,
was_clipped: false,
}
})
.collect()
}
}
fn apply_per_tensor_norm(
&self,
tensors: &mut [GradientTensor],
max_norm: f64,
) -> Vec<ClippingResult> {
tensors
.iter_mut()
.map(|t| {
let original_norm = t.l2_norm();
if original_norm > max_norm && original_norm > 0.0 {
let scale = max_norm / original_norm;
t.values.iter_mut().for_each(|v| *v *= scale);
let clipped_norm = t.l2_norm();
ClippingResult {
tensor_id: t.tensor_id,
original_norm,
clipped_norm,
was_clipped: true,
}
} else {
ClippingResult {
tensor_id: t.tensor_id,
original_norm,
clipped_norm: original_norm,
was_clipped: false,
}
}
})
.collect()
}
fn apply_value_clip(
&self,
tensors: &mut [GradientTensor],
min: f64,
max: f64,
) -> Vec<ClippingResult> {
tensors
.iter_mut()
.map(|t| {
let original_norm = t.l2_norm();
let mut any_changed = false;
t.values.iter_mut().for_each(|v| {
let clamped = v.clamp(min, max);
if clamped != *v {
any_changed = true;
*v = clamped;
}
});
let clipped_norm = t.l2_norm();
ClippingResult {
tensor_id: t.tensor_id,
original_norm,
clipped_norm,
was_clipped: any_changed,
}
})
.collect()
}
fn apply_adaptive(
&mut self,
tensors: &mut [GradientTensor],
momentum: f64,
) -> Vec<ClippingResult> {
const SPIKE_THRESHOLD: f64 = 1.5;
let sum_sq: f64 = tensors.iter().map(|t| t.l2_norm().powi(2)).sum();
let global_norm = sum_sq.sqrt();
if self.ema_norm == 0.0 {
self.ema_norm = global_norm;
} else {
self.ema_norm = momentum * self.ema_norm + (1.0 - momentum) * global_norm;
}
let clip_threshold = self.ema_norm * SPIKE_THRESHOLD;
if global_norm > clip_threshold && global_norm > 0.0 {
let scale = clip_threshold / global_norm;
tensors.iter_mut().for_each(|t| {
t.values.iter_mut().for_each(|v| *v *= scale);
});
tensors
.iter()
.map(|t| {
let clipped_norm = t.l2_norm();
let original_norm = clipped_norm / scale;
ClippingResult {
tensor_id: t.tensor_id,
original_norm,
clipped_norm,
was_clipped: true,
}
})
.collect()
} else {
tensors
.iter()
.map(|t| {
let norm = t.l2_norm();
ClippingResult {
tensor_id: t.tensor_id,
original_norm: norm,
clipped_norm: norm,
was_clipped: false,
}
})
.collect()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const EPS: f64 = 1e-9;
fn make_tensor(id: u64, values: Vec<f64>) -> GradientTensor {
GradientTensor {
tensor_id: id,
values,
}
}
#[test]
fn test_l2_norm_empty() {
let t = make_tensor(0, vec![]);
assert!((t.l2_norm() - 0.0).abs() < EPS);
}
#[test]
fn test_l2_norm_single() {
let t = make_tensor(1, vec![3.0]);
assert!((t.l2_norm() - 3.0).abs() < EPS);
}
#[test]
fn test_l2_norm_pythagorean() {
let t = make_tensor(2, vec![3.0, 4.0]);
assert!((t.l2_norm() - 5.0).abs() < EPS);
}
#[test]
fn test_l2_norm_negative_values() {
let t = make_tensor(3, vec![-3.0, -4.0]);
assert!((t.l2_norm() - 5.0).abs() < EPS);
}
#[test]
fn test_max_abs_value_empty() {
let t = make_tensor(4, vec![]);
assert!((t.max_abs_value() - 0.0).abs() < EPS);
}
#[test]
fn test_max_abs_value_mixed() {
let t = make_tensor(5, vec![-10.0, 5.0, 3.0]);
assert!((t.max_abs_value() - 10.0).abs() < EPS);
}
#[test]
fn test_max_abs_value_all_negative() {
let t = make_tensor(6, vec![-1.0, -2.0, -0.5]);
assert!((t.max_abs_value() - 2.0).abs() < EPS);
}
#[test]
fn test_global_norm_no_clip() {
let mut clipper =
TensorGradientClipper::new(ClippingStrategy::GlobalNorm { max_norm: 10.0 });
let mut tensors = vec![make_tensor(1, vec![3.0, 4.0])]; let results = clipper.clip(&mut tensors);
assert_eq!(results.len(), 1);
assert!(!results[0].was_clipped);
assert!((results[0].original_norm - 5.0).abs() < EPS);
assert!((results[0].clipped_norm - 5.0).abs() < EPS);
}
#[test]
fn test_global_norm_clip_proportionally() {
let mut clipper =
TensorGradientClipper::new(ClippingStrategy::GlobalNorm { max_norm: 1.0 });
let mut tensors = vec![make_tensor(1, vec![3.0, 4.0])];
let results = clipper.clip(&mut tensors);
assert!(results[0].was_clipped);
let norm_after = tensors[0].l2_norm();
assert!((norm_after - 1.0).abs() < 1e-9);
}
#[test]
fn test_global_norm_clip_multi_tensor_proportional() {
let mut clipper =
TensorGradientClipper::new(ClippingStrategy::GlobalNorm { max_norm: 5.0 });
let mut tensors = vec![make_tensor(1, vec![3.0, 4.0]), make_tensor(2, vec![5.0])];
let results = clipper.clip(&mut tensors);
assert!(results[0].was_clipped);
assert!(results[1].was_clipped);
let new_global: f64 = tensors
.iter()
.map(|t| t.l2_norm().powi(2))
.sum::<f64>()
.sqrt();
assert!((new_global - 5.0).abs() < 1e-9);
}
#[test]
fn test_global_norm_exactly_at_threshold() {
let mut clipper =
TensorGradientClipper::new(ClippingStrategy::GlobalNorm { max_norm: 5.0 });
let mut tensors = vec![make_tensor(1, vec![3.0, 4.0])]; let results = clipper.clip(&mut tensors);
assert!(!results[0].was_clipped);
}
#[test]
fn test_global_norm_empty_tensor_list() {
let mut clipper =
TensorGradientClipper::new(ClippingStrategy::GlobalNorm { max_norm: 1.0 });
let mut tensors: Vec<GradientTensor> = vec![];
let results = clipper.clip(&mut tensors);
assert!(results.is_empty());
}
#[test]
fn test_per_tensor_norm_clips_independently() {
let mut clipper =
TensorGradientClipper::new(ClippingStrategy::PerTensorNorm { max_norm: 3.0 });
let mut tensors = vec![
make_tensor(1, vec![3.0, 4.0]), make_tensor(2, vec![1.0, 2.0]), ];
let results = clipper.clip(&mut tensors);
assert!(results[0].was_clipped);
assert!(!results[1].was_clipped);
assert!((tensors[0].l2_norm() - 3.0).abs() < 1e-9);
assert!((tensors[1].values[0] - 1.0).abs() < EPS);
}
#[test]
fn test_per_tensor_norm_no_clip_when_under() {
let mut clipper =
TensorGradientClipper::new(ClippingStrategy::PerTensorNorm { max_norm: 10.0 });
let mut tensors = vec![make_tensor(1, vec![1.0, 1.0])];
let results = clipper.clip(&mut tensors);
assert!(!results[0].was_clipped);
}
#[test]
fn test_per_tensor_norm_scale_correctness() {
let mut clipper =
TensorGradientClipper::new(ClippingStrategy::PerTensorNorm { max_norm: 1.0 });
let mut tensors = vec![make_tensor(1, vec![0.0, 5.0])]; clipper.clip(&mut tensors);
assert!((tensors[0].values[0] - 0.0).abs() < EPS);
assert!((tensors[0].values[1] - 1.0).abs() < 1e-9);
}
#[test]
fn test_value_clip_clamps_values() {
let mut clipper = TensorGradientClipper::new(ClippingStrategy::ValueClip {
min: -1.0,
max: 1.0,
});
let mut tensors = vec![make_tensor(1, vec![-5.0, 0.5, 3.0])];
let results = clipper.clip(&mut tensors);
assert!(results[0].was_clipped);
assert!((tensors[0].values[0] - (-1.0)).abs() < EPS);
assert!((tensors[0].values[1] - 0.5).abs() < EPS);
assert!((tensors[0].values[2] - 1.0).abs() < EPS);
}
#[test]
fn test_value_clip_not_clipped_when_in_range() {
let mut clipper = TensorGradientClipper::new(ClippingStrategy::ValueClip {
min: -5.0,
max: 5.0,
});
let mut tensors = vec![make_tensor(1, vec![-1.0, 0.0, 2.5])];
let results = clipper.clip(&mut tensors);
assert!(!results[0].was_clipped);
}
#[test]
fn test_value_clip_norm_changes() {
let mut clipper =
TensorGradientClipper::new(ClippingStrategy::ValueClip { min: 0.0, max: 1.0 });
let mut tensors = vec![make_tensor(1, vec![2.0, 2.0])];
let results = clipper.clip(&mut tensors);
assert!((results[0].original_norm - 8_f64.sqrt()).abs() < 1e-9);
assert!((results[0].clipped_norm - 2_f64.sqrt()).abs() < 1e-9);
}
#[test]
fn test_value_clip_empty_list() {
let mut clipper = TensorGradientClipper::new(ClippingStrategy::ValueClip {
min: -1.0,
max: 1.0,
});
let mut tensors: Vec<GradientTensor> = vec![];
let results = clipper.clip(&mut tensors);
assert!(results.is_empty());
}
#[test]
fn test_adaptive_no_clip_on_first_call() {
let mut clipper = TensorGradientClipper::new(ClippingStrategy::Adaptive {
target_norm: 5.0,
momentum: 0.9,
});
let mut tensors = vec![make_tensor(1, vec![3.0, 4.0])]; let results = clipper.clip(&mut tensors);
assert!(!results[0].was_clipped, "First call should never clip");
}
#[test]
fn test_adaptive_clips_on_spike() {
let mut clipper = TensorGradientClipper::new(ClippingStrategy::Adaptive {
target_norm: 5.0,
momentum: 0.9,
});
let mut tensors1 = vec![make_tensor(1, vec![1.0])];
clipper.clip(&mut tensors1);
let mut tensors2 = vec![make_tensor(2, vec![3.0])];
let results2 = clipper.clip(&mut tensors2);
assert!(results2[0].was_clipped, "Spike should be clipped");
}
#[test]
fn test_adaptive_ema_is_updated() {
let mut clipper = TensorGradientClipper::new(ClippingStrategy::Adaptive {
target_norm: 5.0,
momentum: 0.5,
});
let mut tensors = vec![make_tensor(1, vec![2.0])]; clipper.clip(&mut tensors);
assert!((clipper.ema_norm - 2.0).abs() < EPS);
let mut tensors2 = vec![make_tensor(2, vec![4.0])]; clipper.clip(&mut tensors2);
assert!((clipper.ema_norm - 3.0).abs() < EPS);
}
#[test]
fn test_adaptive_no_clip_when_below_threshold() {
let mut clipper = TensorGradientClipper::new(ClippingStrategy::Adaptive {
target_norm: 5.0,
momentum: 0.9,
});
let mut tensors1 = vec![make_tensor(1, vec![10.0])];
clipper.clip(&mut tensors1);
let mut tensors2 = vec![make_tensor(2, vec![5.0])];
let results = clipper.clip(&mut tensors2);
assert!(!results[0].was_clipped);
}
#[test]
fn test_stats_total_clip_calls() {
let mut clipper =
TensorGradientClipper::new(ClippingStrategy::GlobalNorm { max_norm: 10.0 });
let mut t = vec![make_tensor(1, vec![1.0])];
clipper.clip(&mut t);
clipper.clip(&mut t);
assert_eq!(clipper.stats().total_clip_calls, 2);
}
#[test]
fn test_stats_total_tensors_processed() {
let mut clipper =
TensorGradientClipper::new(ClippingStrategy::GlobalNorm { max_norm: 10.0 });
let mut tensors = vec![make_tensor(1, vec![1.0]), make_tensor(2, vec![2.0])];
clipper.clip(&mut tensors);
assert_eq!(clipper.stats().total_tensors_processed, 2);
clipper.clip(&mut tensors);
assert_eq!(clipper.stats().total_tensors_processed, 4);
}
#[test]
fn test_stats_total_clipped_counts_correctly() {
let mut clipper =
TensorGradientClipper::new(ClippingStrategy::PerTensorNorm { max_norm: 3.0 });
let mut tensors = vec![
make_tensor(1, vec![3.0, 4.0]), make_tensor(2, vec![1.0]), ];
clipper.clip(&mut tensors);
assert_eq!(clipper.stats().total_clipped, 1);
}
#[test]
fn test_stats_avg_clip_ratio_when_no_clipping() {
let mut clipper =
TensorGradientClipper::new(ClippingStrategy::GlobalNorm { max_norm: 100.0 });
let mut tensors = vec![make_tensor(1, vec![1.0])];
clipper.clip(&mut tensors);
assert!((clipper.stats().avg_clip_ratio - 1.0).abs() < EPS);
}
#[test]
fn test_stats_avg_clip_ratio_running_mean() {
let mut clipper =
TensorGradientClipper::new(ClippingStrategy::PerTensorNorm { max_norm: 1.0 });
let mut t1 = vec![make_tensor(1, vec![0.0, 5.0])];
clipper.clip(&mut t1);
assert!((clipper.stats().avg_clip_ratio - 0.2).abs() < 1e-6);
let mut t2 = vec![make_tensor(2, vec![0.0, 10.0])];
clipper.clip(&mut t2);
assert!((clipper.stats().avg_clip_ratio - 0.15).abs() < 1e-6);
}
#[test]
fn test_reset_stats() {
let mut clipper =
TensorGradientClipper::new(ClippingStrategy::GlobalNorm { max_norm: 1.0 });
let mut tensors = vec![make_tensor(1, vec![5.0])];
clipper.clip(&mut tensors);
clipper.reset_stats();
assert_eq!(clipper.stats().total_clip_calls, 0);
assert_eq!(clipper.stats().total_tensors_processed, 0);
assert_eq!(clipper.stats().total_clipped, 0);
assert!((clipper.stats().avg_clip_ratio - 1.0).abs() < EPS);
assert!((clipper.ema_norm - 0.0).abs() < EPS);
}
#[test]
fn test_empty_tensor_values_l2_norm() {
let t = make_tensor(99, vec![]);
assert!((t.l2_norm() - 0.0).abs() < EPS);
}
#[test]
fn test_global_norm_single_zero_tensor() {
let mut clipper =
TensorGradientClipper::new(ClippingStrategy::GlobalNorm { max_norm: 1.0 });
let mut tensors = vec![make_tensor(1, vec![0.0, 0.0])];
let results = clipper.clip(&mut tensors);
assert!(!results[0].was_clipped);
}
#[test]
fn test_per_tensor_norm_zero_norm_no_clip() {
let mut clipper =
TensorGradientClipper::new(ClippingStrategy::PerTensorNorm { max_norm: 1.0 });
let mut tensors = vec![make_tensor(1, vec![0.0])];
let results = clipper.clip(&mut tensors);
assert!(!results[0].was_clipped);
}
#[test]
fn test_value_clip_boundary_values_not_clipped() {
let mut clipper = TensorGradientClipper::new(ClippingStrategy::ValueClip {
min: -1.0,
max: 1.0,
});
let mut tensors = vec![make_tensor(1, vec![-1.0, 1.0])];
let results = clipper.clip(&mut tensors);
assert!(!results[0].was_clipped);
}
#[test]
fn test_adaptive_multiple_stable_calls_no_clip() {
let mut clipper = TensorGradientClipper::new(ClippingStrategy::Adaptive {
target_norm: 5.0,
momentum: 0.9,
});
for i in 0..5 {
let mut tensors = vec![make_tensor(i, vec![1.0, 1.0])]; let results = clipper.clip(&mut tensors);
assert!(
!results[0].was_clipped,
"Stable gradients should not be clipped (call {i})"
);
}
}
#[test]
fn test_clipping_result_fields() {
let mut clipper =
TensorGradientClipper::new(ClippingStrategy::GlobalNorm { max_norm: 5.0 });
let mut tensors = vec![make_tensor(42, vec![3.0, 4.0])]; let results = clipper.clip(&mut tensors);
assert_eq!(results[0].tensor_id, 42);
}
}