1use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::Path;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct PerfMeasurement {
18 pub name: String,
20 pub latency_ms: f64,
22 pub memory_mb: f64,
24 pub throughput: f64,
26 pub timestamp_secs: u64,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct PerfBaseline {
33 pub measurements: Vec<PerfMeasurement>,
35 pub created_at: u64,
37 pub description: String,
39}
40
41impl PerfBaseline {
42 pub fn stats_for(&self, name: &str) -> Option<BaselineStats> {
45 let latencies: Vec<f64> = self
46 .measurements
47 .iter()
48 .filter(|m| m.name == name)
49 .map(|m| m.latency_ms)
50 .collect();
51
52 if latencies.len() < 2 {
53 return None;
54 }
55
56 let mean = latencies.iter().sum::<f64>() / latencies.len() as f64;
57 let variance = latencies.iter().map(|v| (v - mean).powi(2)).sum::<f64>()
58 / (latencies.len() - 1) as f64;
59 let std = variance.sqrt();
60
61 let mut sorted = latencies.clone();
62 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
63
64 let p95 = percentile(&sorted, 95.0);
65 let p99 = percentile(&sorted, 99.0);
66
67 Some(BaselineStats {
68 mean_latency_ms: mean,
69 std_latency_ms: std,
70 p95_latency_ms: p95,
71 p99_latency_ms: p99,
72 })
73 }
74}
75
76#[derive(Debug, Clone)]
78pub struct BaselineStats {
79 pub mean_latency_ms: f64,
80 pub std_latency_ms: f64,
81 pub p95_latency_ms: f64,
82 pub p99_latency_ms: f64,
83}
84
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91pub enum RegressionMetric {
92 Latency,
93 Memory,
94 Throughput,
95}
96
97#[derive(Debug, Clone, PartialEq, PartialOrd, Serialize, Deserialize)]
99pub enum RegressionSeverity {
100 Minor,
102 Moderate,
104 Severe,
106 Critical,
108}
109
110impl RegressionSeverity {
111 pub fn from_pct(pct: f64) -> Self {
113 match pct.abs() as u64 {
114 0..=10 => Self::Minor,
115 11..=25 => Self::Moderate,
116 26..=50 => Self::Severe,
117 _ => Self::Critical,
118 }
119 }
120}
121
122#[derive(Debug, Clone)]
124pub struct RegressionAlert {
125 pub name: String,
127 pub metric: RegressionMetric,
129 pub severity: RegressionSeverity,
131 pub baseline_value: f64,
133 pub current_value: f64,
135 pub regression_pct: f64,
137 pub message: String,
139}
140
141#[derive(Debug, Clone)]
147pub struct RegressionConfig {
148 pub min_regression_pct: f64,
150 pub z_score_threshold: f64,
152 pub monitor_latency: bool,
154 pub monitor_memory: bool,
156 pub monitor_throughput: bool,
158}
159
160impl Default for RegressionConfig {
161 fn default() -> Self {
162 Self {
163 min_regression_pct: 5.0,
164 z_score_threshold: 2.0,
165 monitor_latency: true,
166 monitor_memory: true,
167 monitor_throughput: true,
168 }
169 }
170}
171
172pub struct RegressionDetector {
178 config: RegressionConfig,
179}
180
181impl RegressionDetector {
182 pub fn new(config: RegressionConfig) -> Self {
183 Self { config }
184 }
185
186 pub fn detect(
195 &self,
196 current: &[PerfMeasurement],
197 baseline: &PerfBaseline,
198 ) -> Vec<RegressionAlert> {
199 let mut current_by_name: HashMap<&str, Vec<&PerfMeasurement>> = HashMap::new();
201 for m in current {
202 current_by_name.entry(m.name.as_str()).or_default().push(m);
203 }
204
205 let mut alerts = Vec::new();
206
207 for (name, measurements) in ¤t_by_name {
208 let baseline_measurements: Vec<&PerfMeasurement> =
210 baseline.measurements.iter().filter(|m| m.name == *name).collect();
211
212 if baseline_measurements.is_empty() {
213 continue;
214 }
215
216 let current_mean_latency =
217 measurements.iter().map(|m| m.latency_ms).sum::<f64>() / measurements.len() as f64;
218 let current_mean_memory =
219 measurements.iter().map(|m| m.memory_mb).sum::<f64>() / measurements.len() as f64;
220 let current_mean_throughput =
221 measurements.iter().map(|m| m.throughput).sum::<f64>() / measurements.len() as f64;
222
223 let baseline_mean_latency =
224 baseline_measurements.iter().map(|m| m.latency_ms).sum::<f64>()
225 / baseline_measurements.len() as f64;
226 let baseline_mean_memory =
227 baseline_measurements.iter().map(|m| m.memory_mb).sum::<f64>()
228 / baseline_measurements.len() as f64;
229 let baseline_mean_throughput =
230 baseline_measurements.iter().map(|m| m.throughput).sum::<f64>()
231 / baseline_measurements.len() as f64;
232
233 let stats = baseline.stats_for(name);
234
235 if self.config.monitor_latency && baseline_mean_latency > 0.0 {
237 let pct =
238 (current_mean_latency - baseline_mean_latency) / baseline_mean_latency * 100.0;
239 if pct > self.config.min_regression_pct
240 && self.is_significant(
241 current_mean_latency,
242 baseline_mean_latency,
243 stats.as_ref().map(|s| s.std_latency_ms),
244 )
245 {
246 let severity = RegressionSeverity::from_pct(pct);
247 alerts.push(RegressionAlert {
248 name: name.to_string(),
249 metric: RegressionMetric::Latency,
250 severity: severity.clone(),
251 baseline_value: baseline_mean_latency,
252 current_value: current_mean_latency,
253 regression_pct: pct,
254 message: format!(
255 "[{name}] Latency regressed by {pct:.1}% ({baseline_mean_latency:.2}ms → {current_mean_latency:.2}ms) — severity: {severity:?}",
256 ),
257 });
258 }
259 }
260
261 if self.config.monitor_memory && baseline_mean_memory > 0.0 {
263 let pct =
264 (current_mean_memory - baseline_mean_memory) / baseline_mean_memory * 100.0;
265 if pct > self.config.min_regression_pct {
266 let severity = RegressionSeverity::from_pct(pct);
267 alerts.push(RegressionAlert {
268 name: name.to_string(),
269 metric: RegressionMetric::Memory,
270 severity: severity.clone(),
271 baseline_value: baseline_mean_memory,
272 current_value: current_mean_memory,
273 regression_pct: pct,
274 message: format!(
275 "[{name}] Memory regressed by {pct:.1}% ({baseline_mean_memory:.1}MB → {current_mean_memory:.1}MB) — severity: {severity:?}",
276 ),
277 });
278 }
279 }
280
281 if self.config.monitor_throughput && baseline_mean_throughput > 0.0 {
283 let pct = (baseline_mean_throughput - current_mean_throughput)
284 / baseline_mean_throughput
285 * 100.0;
286 if pct > self.config.min_regression_pct {
287 let severity = RegressionSeverity::from_pct(pct);
288 alerts.push(RegressionAlert {
289 name: name.to_string(),
290 metric: RegressionMetric::Throughput,
291 severity: severity.clone(),
292 baseline_value: baseline_mean_throughput,
293 current_value: current_mean_throughput,
294 regression_pct: pct,
295 message: format!(
296 "[{name}] Throughput dropped by {pct:.1}% ({baseline_mean_throughput:.1} → {current_mean_throughput:.1} tok/s) — severity: {severity:?}",
297 ),
298 });
299 }
300 }
301 }
302
303 alerts.sort_by(|a, b| {
305 b.severity.partial_cmp(&a.severity).unwrap_or(std::cmp::Ordering::Equal)
306 });
307 alerts
308 }
309
310 pub fn report(&self, alerts: &[RegressionAlert]) -> String {
312 if alerts.is_empty() {
313 return "No regressions detected — all metrics within acceptable range.\n".to_string();
314 }
315
316 let mut out = String::new();
317 out.push_str("=== Performance Regression Report ===\n\n");
318 out.push_str(&format!("Total alerts: {}\n\n", alerts.len()));
319
320 for (i, alert) in alerts.iter().enumerate() {
321 out.push_str(&format!("{}. {}\n", i + 1, alert.message));
322 }
323
324 out.push('\n');
325 out.push_str(&format!(
326 "Critical: {} Severe: {} Moderate: {} Minor: {}\n",
327 alerts.iter().filter(|a| a.severity == RegressionSeverity::Critical).count(),
328 alerts.iter().filter(|a| a.severity == RegressionSeverity::Severe).count(),
329 alerts.iter().filter(|a| a.severity == RegressionSeverity::Moderate).count(),
330 alerts.iter().filter(|a| a.severity == RegressionSeverity::Minor).count(),
331 ));
332 out
333 }
334
335 pub fn save_baseline(&self, baseline: &PerfBaseline, path: &Path) -> Result<()> {
337 let json =
338 serde_json::to_string_pretty(baseline).context("failed to serialize baseline")?;
339 if let Some(parent) = path.parent() {
340 std::fs::create_dir_all(parent).with_context(|| {
341 format!("failed to create baseline directory: {}", parent.display())
342 })?;
343 }
344 std::fs::write(path, json)
345 .with_context(|| format!("failed to write baseline: {}", path.display()))?;
346 tracing::info!(path = %path.display(), "saved performance baseline");
347 Ok(())
348 }
349
350 pub fn load_baseline(&self, path: &Path) -> Result<PerfBaseline> {
352 let content = std::fs::read_to_string(path)
353 .with_context(|| format!("failed to read baseline: {}", path.display()))?;
354 let baseline: PerfBaseline =
355 serde_json::from_str(&content).context("failed to deserialize baseline")?;
356 Ok(baseline)
357 }
358
359 pub fn create_baseline(measurements: Vec<PerfMeasurement>, description: &str) -> PerfBaseline {
361 let created_at = std::time::SystemTime::now()
362 .duration_since(std::time::UNIX_EPOCH)
363 .map(|d| d.as_secs())
364 .unwrap_or(0);
365
366 PerfBaseline {
367 measurements,
368 created_at,
369 description: description.to_string(),
370 }
371 }
372
373 pub fn recommendations(&self, alerts: &[RegressionAlert]) -> Vec<String> {
375 let mut recs: Vec<String> = Vec::new();
376
377 let has_latency = alerts.iter().any(|a| a.metric == RegressionMetric::Latency);
378 let has_memory = alerts.iter().any(|a| a.metric == RegressionMetric::Memory);
379 let has_throughput = alerts.iter().any(|a| a.metric == RegressionMetric::Throughput);
380 let has_critical = alerts.iter().any(|a| a.severity == RegressionSeverity::Critical);
381 let has_severe = alerts.iter().any(|a| a.severity == RegressionSeverity::Severe);
382
383 if has_latency {
384 recs.push("Profile forward/backward passes to identify new hot-spots (use flame_graph_profiler).".to_string());
385 recs.push(
386 "Check for inadvertent Python or C FFI call sites introduced in recent commits."
387 .to_string(),
388 );
389 recs.push("Consider operator fusion or kernel-level optimisations for attention / FFN layers.".to_string());
390 }
391 if has_memory {
392 recs.push(
393 "Audit tensor lifetime to ensure activations are freed promptly after use."
394 .to_string(),
395 );
396 recs.push(
397 "Enable gradient checkpointing or reduce batch size to stay within memory budget."
398 .to_string(),
399 );
400 recs.push("Use memory_profiler to locate the largest allocations.".to_string());
401 }
402 if has_throughput {
403 recs.push(
404 "Check data-loading pipeline: a slow DataLoader can mask compute regressions."
405 .to_string(),
406 );
407 recs.push(
408 "Investigate whether new ops are preventing Tensor-Core utilisation.".to_string(),
409 );
410 }
411 if has_critical || has_severe {
412 recs.push("URGENT: consider reverting the most recent change and bisecting to isolate the regression.".to_string());
413 }
414
415 if recs.is_empty() {
416 recs.push(
417 "No actionable recommendations — regressions are within acceptable bounds."
418 .to_string(),
419 );
420 }
421
422 recs
423 }
424
425 fn is_significant(&self, current: f64, baseline_mean: f64, baseline_std: Option<f64>) -> bool {
430 match baseline_std {
431 Some(std) if std > 0.0 => {
432 let z = (current - baseline_mean).abs() / std;
433 z > self.config.z_score_threshold
434 },
435 _ => true,
436 }
437 }
438}
439
440fn percentile(sorted: &[f64], p: f64) -> f64 {
445 if sorted.is_empty() {
446 return 0.0;
447 }
448 let idx = (p / 100.0 * (sorted.len() - 1) as f64).round() as usize;
449 sorted[idx.min(sorted.len() - 1)]
450}
451
452#[cfg(test)]
457mod tests {
458 use super::*;
459 use std::env::temp_dir;
460
461 fn make_measurement(
462 name: &str,
463 latency_ms: f64,
464 memory_mb: f64,
465 throughput: f64,
466 ) -> PerfMeasurement {
467 PerfMeasurement {
468 name: name.to_string(),
469 latency_ms,
470 memory_mb,
471 throughput,
472 timestamp_secs: 0,
473 }
474 }
475
476 fn make_baseline(measurements: Vec<PerfMeasurement>) -> PerfBaseline {
477 PerfBaseline {
478 measurements,
479 created_at: 0,
480 description: "test baseline".to_string(),
481 }
482 }
483
484 #[test]
485 fn test_no_regression_when_within_threshold() {
486 let detector = RegressionDetector::new(RegressionConfig::default());
487 let baseline = make_baseline(vec![make_measurement("attn", 10.0, 100.0, 1000.0)]);
488 let current = vec![make_measurement("attn", 10.2, 101.0, 998.0)]; let alerts = detector.detect(¤t, &baseline);
490 assert!(alerts.is_empty(), "should not alert on < 5% changes");
491 }
492
493 #[test]
494 fn test_latency_regression_detected() {
495 let config = RegressionConfig {
496 monitor_memory: false,
497 monitor_throughput: false,
498 ..Default::default()
499 };
500 let detector = RegressionDetector::new(config);
501 let baseline = make_baseline(vec![
502 make_measurement("attn", 10.0, 0.0, 0.0),
503 make_measurement("attn", 10.0, 0.0, 0.0),
504 ]);
505 let current = vec![make_measurement("attn", 15.0, 0.0, 0.0)]; let alerts = detector.detect(¤t, &baseline);
507 assert!(!alerts.is_empty());
508 assert_eq!(alerts[0].metric, RegressionMetric::Latency);
509 assert_eq!(alerts[0].severity, RegressionSeverity::Severe);
510 }
511
512 #[test]
513 fn test_memory_regression_detected() {
514 let config = RegressionConfig {
515 monitor_latency: false,
516 monitor_throughput: false,
517 ..Default::default()
518 };
519 let detector = RegressionDetector::new(config);
520 let baseline = make_baseline(vec![make_measurement("ffn", 0.0, 100.0, 0.0)]);
521 let current = vec![make_measurement("ffn", 0.0, 150.0, 0.0)]; let alerts = detector.detect(¤t, &baseline);
523 assert!(!alerts.is_empty());
524 assert_eq!(alerts[0].metric, RegressionMetric::Memory);
525 }
526
527 #[test]
528 fn test_throughput_regression_detected() {
529 let config = RegressionConfig {
530 monitor_latency: false,
531 monitor_memory: false,
532 ..Default::default()
533 };
534 let detector = RegressionDetector::new(config);
535 let baseline = make_baseline(vec![make_measurement("decode", 0.0, 0.0, 1000.0)]);
536 let current = vec![make_measurement("decode", 0.0, 0.0, 600.0)]; let alerts = detector.detect(¤t, &baseline);
538 assert!(!alerts.is_empty());
539 assert_eq!(alerts[0].metric, RegressionMetric::Throughput);
540 }
541
542 #[test]
543 fn test_severity_from_pct() {
544 assert_eq!(RegressionSeverity::from_pct(7.0), RegressionSeverity::Minor);
545 assert_eq!(
546 RegressionSeverity::from_pct(15.0),
547 RegressionSeverity::Moderate
548 );
549 assert_eq!(
550 RegressionSeverity::from_pct(35.0),
551 RegressionSeverity::Severe
552 );
553 assert_eq!(
554 RegressionSeverity::from_pct(75.0),
555 RegressionSeverity::Critical
556 );
557 }
558
559 #[test]
560 fn test_report_no_alerts() {
561 let detector = RegressionDetector::new(RegressionConfig::default());
562 let report = detector.report(&[]);
563 assert!(report.contains("No regressions detected"));
564 }
565
566 #[test]
567 fn test_save_and_load_baseline() -> Result<()> {
568 let detector = RegressionDetector::new(RegressionConfig::default());
569 let baseline = RegressionDetector::create_baseline(
570 vec![make_measurement("attn", 10.0, 100.0, 500.0)],
571 "test baseline v1",
572 );
573 let path = temp_dir().join(format!("baseline_{}.json", uuid::Uuid::new_v4()));
574 detector.save_baseline(&baseline, &path)?;
575 let loaded = detector.load_baseline(&path)?;
576 assert_eq!(loaded.description, "test baseline v1");
577 assert_eq!(loaded.measurements.len(), 1);
578 Ok(())
579 }
580
581 #[test]
582 fn test_recommendations_populated_for_latency() {
583 let config = RegressionConfig {
584 monitor_memory: false,
585 monitor_throughput: false,
586 ..Default::default()
587 };
588 let detector = RegressionDetector::new(config);
589 let baseline = make_baseline(vec![
590 make_measurement("a", 10.0, 0.0, 0.0),
591 make_measurement("a", 10.0, 0.0, 0.0),
592 ]);
593 let current = vec![make_measurement("a", 20.0, 0.0, 0.0)];
594 let alerts = detector.detect(¤t, &baseline);
595 let recs = detector.recommendations(&alerts);
596 assert!(!recs.is_empty());
597 }
598
599 #[test]
600 fn test_baseline_stats_for() {
601 let baseline = make_baseline(vec![
602 make_measurement("layer_a", 10.0, 0.0, 0.0),
603 make_measurement("layer_a", 12.0, 0.0, 0.0),
604 make_measurement("layer_a", 11.0, 0.0, 0.0),
605 ]);
606 let stats = baseline.stats_for("layer_a");
607 assert!(stats.is_some());
608 let s = stats.expect("should have stats");
609 assert!((s.mean_latency_ms - 11.0).abs() < 0.1);
610 assert!(s.std_latency_ms > 0.0);
611 }
612
613 #[test]
616 fn test_regression_config_default() {
617 let cfg = RegressionConfig::default();
618 assert!(cfg.monitor_latency);
619 assert!(cfg.monitor_memory);
620 assert!(cfg.monitor_throughput);
621 assert!(cfg.min_regression_pct > 0.0);
622 assert!(cfg.z_score_threshold > 0.0);
623 }
624
625 #[test]
626 fn test_create_baseline_sets_description() {
627 let baseline = RegressionDetector::create_baseline(
628 vec![make_measurement("l0", 5.0, 50.0, 200.0)],
629 "v1.0 baseline",
630 );
631 assert_eq!(baseline.description, "v1.0 baseline");
632 assert_eq!(baseline.measurements.len(), 1);
633 }
634
635 #[test]
636 fn test_create_baseline_empty_measurements() {
637 let baseline = RegressionDetector::create_baseline(vec![], "empty");
638 assert!(baseline.measurements.is_empty());
639 }
640
641 #[test]
642 fn test_detect_no_baseline_data_no_alerts() {
643 let detector = RegressionDetector::new(RegressionConfig::default());
644 let baseline = make_baseline(vec![make_measurement("other_layer", 10.0, 100.0, 500.0)]);
645 let current = vec![make_measurement("attn", 20.0, 200.0, 100.0)];
646 let alerts = detector.detect(¤t, &baseline);
648 assert!(alerts.is_empty());
649 }
650
651 #[test]
652 fn test_detect_alerts_sorted_by_severity() {
653 let baseline = make_baseline(vec![
654 make_measurement("a", 10.0, 0.0, 0.0),
655 make_measurement("a", 10.0, 0.0, 0.0),
656 make_measurement("b", 10.0, 0.0, 0.0),
657 make_measurement("b", 10.0, 0.0, 0.0),
658 ]);
659 let current = vec![
660 make_measurement("a", 16.0, 0.0, 0.0), make_measurement("b", 11.5, 0.0, 0.0), ];
663 let cfg = RegressionConfig {
664 monitor_memory: false,
665 monitor_throughput: false,
666 ..Default::default()
667 };
668 let det2 = RegressionDetector::new(cfg);
669 let alerts = det2.detect(¤t, &baseline);
670 if alerts.len() >= 2 {
671 assert!(alerts[0].severity >= alerts[1].severity);
672 }
673 }
674
675 #[test]
676 fn test_regression_metric_variants() {
677 let metrics = [
678 RegressionMetric::Latency,
679 RegressionMetric::Memory,
680 RegressionMetric::Throughput,
681 ];
682 for m in &metrics {
683 assert!(!format!("{:?}", m).is_empty());
684 }
685 }
686
687 #[test]
688 fn test_regression_severity_variants() {
689 let severities = [
690 RegressionSeverity::Minor,
691 RegressionSeverity::Moderate,
692 RegressionSeverity::Severe,
693 RegressionSeverity::Critical,
694 ];
695 for s in &severities {
696 assert!(!format!("{:?}", s).is_empty());
697 }
698 }
699
700 #[test]
701 fn test_regression_alert_fields() {
702 let alert = RegressionAlert {
703 name: "attention".to_string(),
704 metric: RegressionMetric::Latency,
705 severity: RegressionSeverity::Severe,
706 baseline_value: 10.0,
707 current_value: 15.0,
708 regression_pct: 50.0,
709 message: "50% regression".to_string(),
710 };
711 assert_eq!(alert.name, "attention");
712 assert!((alert.regression_pct - 50.0).abs() < 1e-6);
713 }
714
715 #[test]
716 fn test_report_with_alerts_includes_count() {
717 let detector = RegressionDetector::new(RegressionConfig::default());
718 let alerts = vec![RegressionAlert {
719 name: "a".to_string(),
720 metric: RegressionMetric::Latency,
721 severity: RegressionSeverity::Minor,
722 baseline_value: 10.0,
723 current_value: 10.5,
724 regression_pct: 5.0,
725 message: "Minor regression".to_string(),
726 }];
727 let report = detector.report(&alerts);
728 assert!(report.contains("Total alerts: 1"));
729 }
730
731 #[test]
732 fn test_perf_measurement_fields() {
733 let m = PerfMeasurement {
734 name: "ffn".to_string(),
735 latency_ms: 12.5,
736 memory_mb: 256.0,
737 throughput: 1024.0,
738 timestamp_secs: 1000,
739 };
740 assert_eq!(m.name, "ffn");
741 assert!((m.latency_ms - 12.5).abs() < 1e-6);
742 }
743
744 #[test]
745 fn test_baseline_stats_for_single_measurement_returns_none() {
746 let baseline = make_baseline(vec![make_measurement("solo", 10.0, 0.0, 0.0)]);
748 assert!(baseline.stats_for("solo").is_none());
749 }
750
751 #[test]
752 fn test_baseline_stats_p95_p99() {
753 let measurements: Vec<PerfMeasurement> =
754 (1..=10).map(|i| make_measurement("l", i as f64 * 10.0, 0.0, 0.0)).collect();
755 let baseline = make_baseline(measurements);
756 let stats = baseline.stats_for("l").expect("should have stats");
757 assert!(stats.p95_latency_ms >= stats.mean_latency_ms);
758 assert!(stats.p99_latency_ms >= stats.p95_latency_ms);
759 }
760}