1#[derive(Clone, Debug, PartialEq)]
8pub enum ClippingStrategy {
9 GlobalNorm {
11 max_norm: f64,
13 },
14 PerTensorNorm {
16 max_norm: f64,
18 },
19 ValueClip {
21 min: f64,
23 max: f64,
25 },
26 Adaptive {
32 target_norm: f64,
34 momentum: f64,
36 },
37}
38
39#[derive(Clone, Debug)]
43pub struct GradientTensor {
44 pub tensor_id: u64,
46 pub values: Vec<f64>,
48}
49
50impl GradientTensor {
51 pub fn l2_norm(&self) -> f64 {
55 if self.values.is_empty() {
56 return 0.0;
57 }
58 let sum_sq: f64 = self.values.iter().map(|v| v * v).sum();
59 sum_sq.sqrt()
60 }
61
62 pub fn max_abs_value(&self) -> f64 {
66 self.values.iter().map(|v| v.abs()).fold(0.0_f64, f64::max)
67 }
68}
69
70#[derive(Clone, Debug)]
74pub struct ClippingResult {
75 pub tensor_id: u64,
77 pub original_norm: f64,
79 pub clipped_norm: f64,
81 pub was_clipped: bool,
83}
84
85#[derive(Clone, Debug, Default)]
89pub struct ClipperStats {
90 pub total_clip_calls: u64,
92 pub total_tensors_processed: u64,
94 pub total_clipped: u64,
96 pub avg_clip_ratio: f64,
100}
101
102pub struct TensorGradientClipper {
106 pub strategy: ClippingStrategy,
108 pub stats: ClipperStats,
110 pub ema_norm: f64,
112}
113
114impl TensorGradientClipper {
115 pub fn new(strategy: ClippingStrategy) -> Self {
117 Self {
118 strategy,
119 stats: ClipperStats {
120 avg_clip_ratio: 1.0,
121 ..ClipperStats::default()
122 },
123 ema_norm: 0.0,
124 }
125 }
126
127 pub fn clip(&mut self, tensors: &mut [GradientTensor]) -> Vec<ClippingResult> {
131 self.stats.total_clip_calls += 1;
132 self.stats.total_tensors_processed += tensors.len() as u64;
133
134 let results = match &self.strategy.clone() {
135 ClippingStrategy::GlobalNorm { max_norm } => self.apply_global_norm(tensors, *max_norm),
136 ClippingStrategy::PerTensorNorm { max_norm } => {
137 self.apply_per_tensor_norm(tensors, *max_norm)
138 }
139 ClippingStrategy::ValueClip { min, max } => self.apply_value_clip(tensors, *min, *max),
140 ClippingStrategy::Adaptive { momentum, .. } => {
141 let momentum = *momentum;
142 self.apply_adaptive(tensors, momentum)
143 }
144 };
145
146 for result in &results {
148 if result.was_clipped {
149 self.stats.total_clipped += 1;
150 let ratio = if result.original_norm > 0.0 {
151 result.clipped_norm / result.original_norm
152 } else {
153 1.0
154 };
155 let n = self.stats.total_clipped as f64;
157 self.stats.avg_clip_ratio =
158 self.stats.avg_clip_ratio + (ratio - self.stats.avg_clip_ratio) / n;
159 }
160 }
161
162 results
163 }
164
165 pub fn reset_stats(&mut self) {
167 self.stats = ClipperStats {
168 avg_clip_ratio: 1.0,
169 ..ClipperStats::default()
170 };
171 self.ema_norm = 0.0;
172 }
173
174 pub fn stats(&self) -> &ClipperStats {
176 &self.stats
177 }
178
179 fn apply_global_norm(
182 &self,
183 tensors: &mut [GradientTensor],
184 max_norm: f64,
185 ) -> Vec<ClippingResult> {
186 let sum_sq: f64 = tensors.iter().map(|t| t.l2_norm().powi(2)).sum();
188 let global_norm = sum_sq.sqrt();
189
190 if global_norm > max_norm && global_norm > 0.0 {
191 let scale = max_norm / global_norm;
192 tensors.iter_mut().for_each(|t| {
193 t.values.iter_mut().for_each(|v| *v *= scale);
194 });
195 tensors
196 .iter()
197 .map(|t| {
198 let original = t.l2_norm() / scale; let clipped = t.l2_norm();
201 ClippingResult {
202 tensor_id: t.tensor_id,
203 original_norm: original,
204 clipped_norm: clipped,
205 was_clipped: true,
206 }
207 })
208 .collect()
209 } else {
210 tensors
211 .iter()
212 .map(|t| {
213 let norm = t.l2_norm();
214 ClippingResult {
215 tensor_id: t.tensor_id,
216 original_norm: norm,
217 clipped_norm: norm,
218 was_clipped: false,
219 }
220 })
221 .collect()
222 }
223 }
224
225 fn apply_per_tensor_norm(
226 &self,
227 tensors: &mut [GradientTensor],
228 max_norm: f64,
229 ) -> Vec<ClippingResult> {
230 tensors
231 .iter_mut()
232 .map(|t| {
233 let original_norm = t.l2_norm();
234 if original_norm > max_norm && original_norm > 0.0 {
235 let scale = max_norm / original_norm;
236 t.values.iter_mut().for_each(|v| *v *= scale);
237 let clipped_norm = t.l2_norm();
238 ClippingResult {
239 tensor_id: t.tensor_id,
240 original_norm,
241 clipped_norm,
242 was_clipped: true,
243 }
244 } else {
245 ClippingResult {
246 tensor_id: t.tensor_id,
247 original_norm,
248 clipped_norm: original_norm,
249 was_clipped: false,
250 }
251 }
252 })
253 .collect()
254 }
255
256 fn apply_value_clip(
257 &self,
258 tensors: &mut [GradientTensor],
259 min: f64,
260 max: f64,
261 ) -> Vec<ClippingResult> {
262 tensors
263 .iter_mut()
264 .map(|t| {
265 let original_norm = t.l2_norm();
266 let mut any_changed = false;
267 t.values.iter_mut().for_each(|v| {
268 let clamped = v.clamp(min, max);
269 if clamped != *v {
270 any_changed = true;
271 *v = clamped;
272 }
273 });
274 let clipped_norm = t.l2_norm();
275 ClippingResult {
276 tensor_id: t.tensor_id,
277 original_norm,
278 clipped_norm,
279 was_clipped: any_changed,
280 }
281 })
282 .collect()
283 }
284
285 fn apply_adaptive(
286 &mut self,
287 tensors: &mut [GradientTensor],
288 momentum: f64,
289 ) -> Vec<ClippingResult> {
290 const SPIKE_THRESHOLD: f64 = 1.5;
291
292 let sum_sq: f64 = tensors.iter().map(|t| t.l2_norm().powi(2)).sum();
294 let global_norm = sum_sq.sqrt();
295
296 if self.ema_norm == 0.0 {
298 self.ema_norm = global_norm;
299 } else {
300 self.ema_norm = momentum * self.ema_norm + (1.0 - momentum) * global_norm;
301 }
302
303 let clip_threshold = self.ema_norm * SPIKE_THRESHOLD;
304
305 if global_norm > clip_threshold && global_norm > 0.0 {
306 let scale = clip_threshold / global_norm;
308 tensors.iter_mut().for_each(|t| {
309 t.values.iter_mut().for_each(|v| *v *= scale);
310 });
311 tensors
312 .iter()
313 .map(|t| {
314 let clipped_norm = t.l2_norm();
315 let original_norm = clipped_norm / scale;
316 ClippingResult {
317 tensor_id: t.tensor_id,
318 original_norm,
319 clipped_norm,
320 was_clipped: true,
321 }
322 })
323 .collect()
324 } else {
325 tensors
326 .iter()
327 .map(|t| {
328 let norm = t.l2_norm();
329 ClippingResult {
330 tensor_id: t.tensor_id,
331 original_norm: norm,
332 clipped_norm: norm,
333 was_clipped: false,
334 }
335 })
336 .collect()
337 }
338 }
339}
340
341#[cfg(test)]
344mod tests {
345 use super::*;
346
347 const EPS: f64 = 1e-9;
348
349 fn make_tensor(id: u64, values: Vec<f64>) -> GradientTensor {
350 GradientTensor {
351 tensor_id: id,
352 values,
353 }
354 }
355
356 #[test]
359 fn test_l2_norm_empty() {
360 let t = make_tensor(0, vec![]);
361 assert!((t.l2_norm() - 0.0).abs() < EPS);
362 }
363
364 #[test]
365 fn test_l2_norm_single() {
366 let t = make_tensor(1, vec![3.0]);
367 assert!((t.l2_norm() - 3.0).abs() < EPS);
368 }
369
370 #[test]
371 fn test_l2_norm_pythagorean() {
372 let t = make_tensor(2, vec![3.0, 4.0]);
374 assert!((t.l2_norm() - 5.0).abs() < EPS);
375 }
376
377 #[test]
378 fn test_l2_norm_negative_values() {
379 let t = make_tensor(3, vec![-3.0, -4.0]);
380 assert!((t.l2_norm() - 5.0).abs() < EPS);
381 }
382
383 #[test]
384 fn test_max_abs_value_empty() {
385 let t = make_tensor(4, vec![]);
386 assert!((t.max_abs_value() - 0.0).abs() < EPS);
387 }
388
389 #[test]
390 fn test_max_abs_value_mixed() {
391 let t = make_tensor(5, vec![-10.0, 5.0, 3.0]);
392 assert!((t.max_abs_value() - 10.0).abs() < EPS);
393 }
394
395 #[test]
396 fn test_max_abs_value_all_negative() {
397 let t = make_tensor(6, vec![-1.0, -2.0, -0.5]);
398 assert!((t.max_abs_value() - 2.0).abs() < EPS);
399 }
400
401 #[test]
404 fn test_global_norm_no_clip() {
405 let mut clipper =
406 TensorGradientClipper::new(ClippingStrategy::GlobalNorm { max_norm: 10.0 });
407 let mut tensors = vec![make_tensor(1, vec![3.0, 4.0])]; let results = clipper.clip(&mut tensors);
409 assert_eq!(results.len(), 1);
410 assert!(!results[0].was_clipped);
411 assert!((results[0].original_norm - 5.0).abs() < EPS);
412 assert!((results[0].clipped_norm - 5.0).abs() < EPS);
413 }
414
415 #[test]
416 fn test_global_norm_clip_proportionally() {
417 let mut clipper =
418 TensorGradientClipper::new(ClippingStrategy::GlobalNorm { max_norm: 1.0 });
419 let mut tensors = vec![make_tensor(1, vec![3.0, 4.0])];
421 let results = clipper.clip(&mut tensors);
422 assert!(results[0].was_clipped);
423 let norm_after = tensors[0].l2_norm();
425 assert!((norm_after - 1.0).abs() < 1e-9);
426 }
427
428 #[test]
429 fn test_global_norm_clip_multi_tensor_proportional() {
430 let mut clipper =
431 TensorGradientClipper::new(ClippingStrategy::GlobalNorm { max_norm: 5.0 });
432 let mut tensors = vec![make_tensor(1, vec![3.0, 4.0]), make_tensor(2, vec![5.0])];
434 let results = clipper.clip(&mut tensors);
435 assert!(results[0].was_clipped);
436 assert!(results[1].was_clipped);
437 let new_global: f64 = tensors
439 .iter()
440 .map(|t| t.l2_norm().powi(2))
441 .sum::<f64>()
442 .sqrt();
443 assert!((new_global - 5.0).abs() < 1e-9);
444 }
445
446 #[test]
447 fn test_global_norm_exactly_at_threshold() {
448 let mut clipper =
449 TensorGradientClipper::new(ClippingStrategy::GlobalNorm { max_norm: 5.0 });
450 let mut tensors = vec![make_tensor(1, vec![3.0, 4.0])]; let results = clipper.clip(&mut tensors);
452 assert!(!results[0].was_clipped);
453 }
454
455 #[test]
456 fn test_global_norm_empty_tensor_list() {
457 let mut clipper =
458 TensorGradientClipper::new(ClippingStrategy::GlobalNorm { max_norm: 1.0 });
459 let mut tensors: Vec<GradientTensor> = vec![];
460 let results = clipper.clip(&mut tensors);
461 assert!(results.is_empty());
462 }
463
464 #[test]
467 fn test_per_tensor_norm_clips_independently() {
468 let mut clipper =
469 TensorGradientClipper::new(ClippingStrategy::PerTensorNorm { max_norm: 3.0 });
470 let mut tensors = vec![
471 make_tensor(1, vec![3.0, 4.0]), make_tensor(2, vec![1.0, 2.0]), ];
474 let results = clipper.clip(&mut tensors);
475 assert!(results[0].was_clipped);
476 assert!(!results[1].was_clipped);
477 assert!((tensors[0].l2_norm() - 3.0).abs() < 1e-9);
479 assert!((tensors[1].values[0] - 1.0).abs() < EPS);
481 }
482
483 #[test]
484 fn test_per_tensor_norm_no_clip_when_under() {
485 let mut clipper =
486 TensorGradientClipper::new(ClippingStrategy::PerTensorNorm { max_norm: 10.0 });
487 let mut tensors = vec![make_tensor(1, vec![1.0, 1.0])];
488 let results = clipper.clip(&mut tensors);
489 assert!(!results[0].was_clipped);
490 }
491
492 #[test]
493 fn test_per_tensor_norm_scale_correctness() {
494 let mut clipper =
495 TensorGradientClipper::new(ClippingStrategy::PerTensorNorm { max_norm: 1.0 });
496 let mut tensors = vec![make_tensor(1, vec![0.0, 5.0])]; clipper.clip(&mut tensors);
498 assert!((tensors[0].values[0] - 0.0).abs() < EPS);
500 assert!((tensors[0].values[1] - 1.0).abs() < 1e-9);
501 }
502
503 #[test]
506 fn test_value_clip_clamps_values() {
507 let mut clipper = TensorGradientClipper::new(ClippingStrategy::ValueClip {
508 min: -1.0,
509 max: 1.0,
510 });
511 let mut tensors = vec![make_tensor(1, vec![-5.0, 0.5, 3.0])];
512 let results = clipper.clip(&mut tensors);
513 assert!(results[0].was_clipped);
514 assert!((tensors[0].values[0] - (-1.0)).abs() < EPS);
515 assert!((tensors[0].values[1] - 0.5).abs() < EPS);
516 assert!((tensors[0].values[2] - 1.0).abs() < EPS);
517 }
518
519 #[test]
520 fn test_value_clip_not_clipped_when_in_range() {
521 let mut clipper = TensorGradientClipper::new(ClippingStrategy::ValueClip {
522 min: -5.0,
523 max: 5.0,
524 });
525 let mut tensors = vec![make_tensor(1, vec![-1.0, 0.0, 2.5])];
526 let results = clipper.clip(&mut tensors);
527 assert!(!results[0].was_clipped);
528 }
529
530 #[test]
531 fn test_value_clip_norm_changes() {
532 let mut clipper =
533 TensorGradientClipper::new(ClippingStrategy::ValueClip { min: 0.0, max: 1.0 });
534 let mut tensors = vec![make_tensor(1, vec![2.0, 2.0])];
535 let results = clipper.clip(&mut tensors);
536 assert!((results[0].original_norm - 8_f64.sqrt()).abs() < 1e-9);
538 assert!((results[0].clipped_norm - 2_f64.sqrt()).abs() < 1e-9);
540 }
541
542 #[test]
543 fn test_value_clip_empty_list() {
544 let mut clipper = TensorGradientClipper::new(ClippingStrategy::ValueClip {
545 min: -1.0,
546 max: 1.0,
547 });
548 let mut tensors: Vec<GradientTensor> = vec![];
549 let results = clipper.clip(&mut tensors);
550 assert!(results.is_empty());
551 }
552
553 #[test]
556 fn test_adaptive_no_clip_on_first_call() {
557 let mut clipper = TensorGradientClipper::new(ClippingStrategy::Adaptive {
558 target_norm: 5.0,
559 momentum: 0.9,
560 });
561 let mut tensors = vec![make_tensor(1, vec![3.0, 4.0])]; let results = clipper.clip(&mut tensors);
563 assert!(!results[0].was_clipped, "First call should never clip");
565 }
566
567 #[test]
568 fn test_adaptive_clips_on_spike() {
569 let mut clipper = TensorGradientClipper::new(ClippingStrategy::Adaptive {
570 target_norm: 5.0,
571 momentum: 0.9,
572 });
573 let mut tensors1 = vec![make_tensor(1, vec![1.0])];
575 clipper.clip(&mut tensors1);
576
577 let mut tensors2 = vec![make_tensor(2, vec![3.0])];
579 let results2 = clipper.clip(&mut tensors2);
580 assert!(results2[0].was_clipped, "Spike should be clipped");
581 }
583
584 #[test]
585 fn test_adaptive_ema_is_updated() {
586 let mut clipper = TensorGradientClipper::new(ClippingStrategy::Adaptive {
587 target_norm: 5.0,
588 momentum: 0.5,
589 });
590 let mut tensors = vec![make_tensor(1, vec![2.0])]; clipper.clip(&mut tensors);
592 assert!((clipper.ema_norm - 2.0).abs() < EPS);
594
595 let mut tensors2 = vec![make_tensor(2, vec![4.0])]; clipper.clip(&mut tensors2);
597 assert!((clipper.ema_norm - 3.0).abs() < EPS);
601 }
602
603 #[test]
604 fn test_adaptive_no_clip_when_below_threshold() {
605 let mut clipper = TensorGradientClipper::new(ClippingStrategy::Adaptive {
606 target_norm: 5.0,
607 momentum: 0.9,
608 });
609 let mut tensors1 = vec![make_tensor(1, vec![10.0])];
611 clipper.clip(&mut tensors1);
612
613 let mut tensors2 = vec![make_tensor(2, vec![5.0])];
615 let results = clipper.clip(&mut tensors2);
616 assert!(!results[0].was_clipped);
617 }
618
619 #[test]
622 fn test_stats_total_clip_calls() {
623 let mut clipper =
624 TensorGradientClipper::new(ClippingStrategy::GlobalNorm { max_norm: 10.0 });
625 let mut t = vec![make_tensor(1, vec![1.0])];
626 clipper.clip(&mut t);
627 clipper.clip(&mut t);
628 assert_eq!(clipper.stats().total_clip_calls, 2);
629 }
630
631 #[test]
632 fn test_stats_total_tensors_processed() {
633 let mut clipper =
634 TensorGradientClipper::new(ClippingStrategy::GlobalNorm { max_norm: 10.0 });
635 let mut tensors = vec![make_tensor(1, vec![1.0]), make_tensor(2, vec![2.0])];
636 clipper.clip(&mut tensors);
637 assert_eq!(clipper.stats().total_tensors_processed, 2);
638 clipper.clip(&mut tensors);
639 assert_eq!(clipper.stats().total_tensors_processed, 4);
640 }
641
642 #[test]
643 fn test_stats_total_clipped_counts_correctly() {
644 let mut clipper =
645 TensorGradientClipper::new(ClippingStrategy::PerTensorNorm { max_norm: 3.0 });
646 let mut tensors = vec![
647 make_tensor(1, vec![3.0, 4.0]), make_tensor(2, vec![1.0]), ];
650 clipper.clip(&mut tensors);
651 assert_eq!(clipper.stats().total_clipped, 1);
652 }
653
654 #[test]
655 fn test_stats_avg_clip_ratio_when_no_clipping() {
656 let mut clipper =
657 TensorGradientClipper::new(ClippingStrategy::GlobalNorm { max_norm: 100.0 });
658 let mut tensors = vec![make_tensor(1, vec![1.0])];
659 clipper.clip(&mut tensors);
660 assert!((clipper.stats().avg_clip_ratio - 1.0).abs() < EPS);
662 }
663
664 #[test]
665 fn test_stats_avg_clip_ratio_running_mean() {
666 let mut clipper =
667 TensorGradientClipper::new(ClippingStrategy::PerTensorNorm { max_norm: 1.0 });
668 let mut t1 = vec![make_tensor(1, vec![0.0, 5.0])];
670 clipper.clip(&mut t1);
671 assert!((clipper.stats().avg_clip_ratio - 0.2).abs() < 1e-6);
672
673 let mut t2 = vec![make_tensor(2, vec![0.0, 10.0])];
676 clipper.clip(&mut t2);
677 assert!((clipper.stats().avg_clip_ratio - 0.15).abs() < 1e-6);
678 }
679
680 #[test]
681 fn test_reset_stats() {
682 let mut clipper =
683 TensorGradientClipper::new(ClippingStrategy::GlobalNorm { max_norm: 1.0 });
684 let mut tensors = vec![make_tensor(1, vec![5.0])];
685 clipper.clip(&mut tensors);
686 clipper.reset_stats();
687 assert_eq!(clipper.stats().total_clip_calls, 0);
688 assert_eq!(clipper.stats().total_tensors_processed, 0);
689 assert_eq!(clipper.stats().total_clipped, 0);
690 assert!((clipper.stats().avg_clip_ratio - 1.0).abs() < EPS);
691 assert!((clipper.ema_norm - 0.0).abs() < EPS);
692 }
693
694 #[test]
695 fn test_empty_tensor_values_l2_norm() {
696 let t = make_tensor(99, vec![]);
697 assert!((t.l2_norm() - 0.0).abs() < EPS);
698 }
699
700 #[test]
701 fn test_global_norm_single_zero_tensor() {
702 let mut clipper =
703 TensorGradientClipper::new(ClippingStrategy::GlobalNorm { max_norm: 1.0 });
704 let mut tensors = vec![make_tensor(1, vec![0.0, 0.0])];
705 let results = clipper.clip(&mut tensors);
706 assert!(!results[0].was_clipped);
708 }
709
710 #[test]
711 fn test_per_tensor_norm_zero_norm_no_clip() {
712 let mut clipper =
713 TensorGradientClipper::new(ClippingStrategy::PerTensorNorm { max_norm: 1.0 });
714 let mut tensors = vec![make_tensor(1, vec![0.0])];
715 let results = clipper.clip(&mut tensors);
716 assert!(!results[0].was_clipped);
717 }
718
719 #[test]
720 fn test_value_clip_boundary_values_not_clipped() {
721 let mut clipper = TensorGradientClipper::new(ClippingStrategy::ValueClip {
722 min: -1.0,
723 max: 1.0,
724 });
725 let mut tensors = vec![make_tensor(1, vec![-1.0, 1.0])];
726 let results = clipper.clip(&mut tensors);
727 assert!(!results[0].was_clipped);
728 }
729
730 #[test]
731 fn test_adaptive_multiple_stable_calls_no_clip() {
732 let mut clipper = TensorGradientClipper::new(ClippingStrategy::Adaptive {
733 target_norm: 5.0,
734 momentum: 0.9,
735 });
736 for i in 0..5 {
737 let mut tensors = vec![make_tensor(i, vec![1.0, 1.0])]; let results = clipper.clip(&mut tensors);
739 assert!(
740 !results[0].was_clipped,
741 "Stable gradients should not be clipped (call {i})"
742 );
743 }
744 }
745
746 #[test]
747 fn test_clipping_result_fields() {
748 let mut clipper =
749 TensorGradientClipper::new(ClippingStrategy::GlobalNorm { max_norm: 5.0 });
750 let mut tensors = vec![make_tensor(42, vec![3.0, 4.0])]; let results = clipper.clip(&mut tensors);
752 assert_eq!(results[0].tensor_id, 42);
753 }
754}