1use crate::core::telemetry_types::{BenchmarkResult, GpuMetrics};
26use anyhow::{Context, Result};
27use std::process::Command;
28use std::time::{Duration, Instant};
29
30pub const MIN_BENCHMARK_DURATION_SECS: u64 = 180;
32
33pub const SAMPLE_INTERVAL_SECS: u64 = 10;
35
36pub const MIN_SAMPLE_COUNT: u32 = 18;
39
40pub const MAX_TEMP_C: f32 = 120.0;
42
43pub const MAX_PAYLOAD_SIZE_BYTES: usize = 65536;
45
46#[derive(Debug, Clone)]
48pub struct GpuSample {
49 pub utilization_pct: f32,
51 pub vram_used_mb: f64,
53 pub vram_total_mb: f64,
55 pub temp_c: f32,
57 pub throttling: bool,
59 pub clock_mhz: f32,
61 pub timestamp_secs: f64,
63}
64
65#[derive(Debug, Clone)]
67pub struct BenchmarkOutcome {
68 pub samples: Vec<GpuSample>,
70 pub duration_secs: u64,
72 pub is_complete: bool,
74 pub aggregated_metrics: Option<GpuMetrics>,
76 pub benchmark_result: BenchmarkResult,
78}
79
80impl BenchmarkOutcome {
81 pub fn from_samples(samples: Vec<GpuSample>, duration_secs: u64) -> Self {
88 let is_complete = duration_secs >= MIN_BENCHMARK_DURATION_SECS
89 && samples.len() >= MIN_SAMPLE_COUNT as usize;
90
91 let aggregated_metrics = if !samples.is_empty() {
92 Some(aggregate_samples(&samples))
93 } else {
94 None
95 };
96
97 let benchmark_result = BenchmarkResult {
98 duration_seconds: duration_secs,
99 completed: is_complete,
100 sample_count: samples.len() as u32,
101 };
102
103 Self {
104 samples,
105 duration_secs,
106 is_complete,
107 aggregated_metrics,
108 benchmark_result,
109 }
110 }
111}
112
113fn aggregate_samples(samples: &[GpuSample]) -> GpuMetrics {
115 if samples.is_empty() {
116 return GpuMetrics::default();
117 }
118
119 let count = samples.len() as f32;
120 let count_f64 = samples.len() as f64;
121
122 let avg_util = samples.iter().map(|s| s.utilization_pct).sum::<f32>() / count;
123 let avg_vram_used = samples.iter().map(|s| s.vram_used_mb).sum::<f64>() / count_f64;
124 let max_vram_total = samples
125 .iter()
126 .map(|s| s.vram_total_mb)
127 .fold(0.0_f64, f64::max);
128 let avg_temp = samples.iter().map(|s| s.temp_c).sum::<f32>() / count;
129 let any_throttling = samples.iter().any(|s| s.throttling);
130 let avg_clock = samples.iter().map(|s| s.clock_mhz).sum::<f32>() / count;
131
132 GpuMetrics {
133 gpu_utilization_pct: clamp_f32(avg_util, 0.0, 100.0),
134 vram_used_mb: avg_vram_used,
135 vram_total_mb: max_vram_total,
136 gpu_temp_c: clamp_f32(avg_temp, 0.0, MAX_TEMP_C),
137 gpu_throttling: any_throttling,
138 gpu_clock_mhz: avg_clock.max(0.0),
139 }
140}
141
142fn clamp_f32(value: f32, min: f32, max: f32) -> f32 {
144 value.max(min).min(max)
145}
146
147fn parse_gpu_utilization(output: &str) -> f32 {
151 for line in output.lines() {
152 if line.contains("GPU use (%)") {
153 if let Some(val) = line.split(':').next_back() {
154 if let Ok(pct) = val.trim().parse::<f32>() {
155 return clamp_f32(pct, 0.0, 100.0);
156 }
157 }
158 }
159 }
160 0.0
161}
162
163fn parse_vram(output: &str) -> (f64, f64) {
169 let mut total_bytes: f64 = 0.0;
170 let mut used_bytes: f64 = 0.0;
171
172 for line in output.lines() {
173 let trimmed = line.trim();
174 if trimmed.contains("VRAM Total Memory (B)") {
175 if let Some(val) = trimmed.split(':').next_back() {
176 total_bytes = val.trim().parse::<f64>().unwrap_or(0.0);
177 }
178 } else if trimmed.contains("VRAM Total Used Memory (B)") {
179 if let Some(val) = trimmed.split(':').next_back() {
180 used_bytes = val.trim().parse::<f64>().unwrap_or(0.0);
181 }
182 }
183 }
184
185 let total_mb = total_bytes / (1024.0 * 1024.0);
186 let used_mb = used_bytes / (1024.0 * 1024.0);
187 (used_mb, total_mb)
188}
189
190fn parse_temperature(output: &str) -> f32 {
194 for line in output.lines() {
195 if line.contains("Temperature") && line.contains("(C)") {
196 if let Some(val) = line.split(':').next_back() {
197 if let Ok(temp) = val.trim().parse::<f32>() {
198 return clamp_f32(temp, 0.0, MAX_TEMP_C);
199 }
200 }
201 }
202 }
203 0.0
204}
205
206fn parse_clock_mhz(output: &str) -> f32 {
210 for line in output.lines() {
211 if line.contains("sclk clock level") {
212 if let Some(start) = line.find('(') {
214 if let Some(end) = line.find("Mhz)") {
215 let num_str = &line[start + 1..end];
216 if let Ok(mhz) = num_str.trim().parse::<f32>() {
217 return mhz.max(0.0);
218 }
219 }
220 }
221 }
222 }
223 0.0
224}
225
226fn parse_throttling(output: &str) -> bool {
230 let lower = output.to_lowercase();
231 lower.contains("throttl") || lower.contains("performance level: manual")
232}
233
234pub fn sample_gpu_metrics() -> Result<GpuSample> {
239 let rocm_smi = find_rocm_smi()?;
240
241 let output = Command::new(&rocm_smi)
243 .arg("--showuse")
244 .arg("--showmeminfo")
245 .arg("vram")
246 .arg("--showtemp")
247 .arg("--showclock")
248 .output()
249 .context("failed to execute rocm-smi")?;
250
251 let stdout = String::from_utf8_lossy(&output.stdout);
252
253 let utilization_pct = parse_gpu_utilization(&stdout);
254 let (vram_used_mb, vram_total_mb) = parse_vram(&stdout);
255 let temp_c = parse_temperature(&stdout);
256 let clock_mhz = parse_clock_mhz(&stdout);
257 let throttling = parse_throttling(&stdout);
258
259 Ok(GpuSample {
260 utilization_pct,
261 vram_used_mb,
262 vram_total_mb,
263 temp_c,
264 throttling,
265 clock_mhz,
266 timestamp_secs: 0.0,
267 })
268}
269
270fn find_rocm_smi() -> Result<String> {
272 let candidates = ["/opt/rocm/bin/rocm-smi", "/usr/bin/rocm-smi", "rocm-smi"];
274
275 for candidate in &candidates {
276 if Command::new(candidate).arg("--version").output().is_ok() {
277 return Ok(candidate.to_string());
278 }
279 }
280
281 anyhow::bail!("rocm-smi not found in standard locations")
282}
283
284pub fn run_benchmark(duration: Duration) -> Result<BenchmarkOutcome> {
300 let start = Instant::now();
301 let mut samples: Vec<GpuSample> = Vec::new();
302
303 match sample_gpu_metrics() {
305 Ok(mut sample) => {
306 sample.timestamp_secs = 0.0;
307 samples.push(sample);
308 }
309 Err(e) => {
310 eprintln!("Warning: initial GPU sample failed: {}", e);
312 }
313 }
314
315 let interval = Duration::from_secs(SAMPLE_INTERVAL_SECS);
317 let mut next_sample = start + interval;
318
319 while start.elapsed() < duration {
320 let now = Instant::now();
321 if now >= next_sample {
322 match sample_gpu_metrics() {
323 Ok(mut sample) => {
324 sample.timestamp_secs = now.duration_since(start).as_secs_f64();
325 samples.push(sample);
326 }
327 Err(e) => {
328 eprintln!("Warning: GPU sample failed at {:?}: {}", now - start, e);
329 }
330 }
331 next_sample = now + interval;
332 }
333
334 std::thread::sleep(Duration::from_millis(100));
336 }
337
338 let elapsed = start.elapsed().as_secs();
339 Ok(BenchmarkOutcome::from_samples(samples, elapsed))
340}
341
342pub fn validate_benchmark(outcome: &BenchmarkOutcome) -> Result<(), Vec<String>> {
352 let mut errors = Vec::new();
353
354 if outcome.duration_secs < MIN_BENCHMARK_DURATION_SECS {
356 errors.push(format!(
357 "Benchmark duration {}s is below minimum {}s",
358 outcome.duration_secs, MIN_BENCHMARK_DURATION_SECS
359 ));
360 }
361
362 if (outcome.samples.len() as u32) < MIN_SAMPLE_COUNT {
364 errors.push(format!(
365 "Sample count {} is below minimum {} for a valid benchmark",
366 outcome.samples.len(),
367 MIN_SAMPLE_COUNT
368 ));
369 }
370
371 for (i, sample) in outcome.samples.iter().enumerate() {
373 if !(0.0..=100.0).contains(&sample.utilization_pct) {
375 errors.push(format!(
376 "Sample {}: utilization {} out of range [0.0, 100.0]",
377 i, sample.utilization_pct
378 ));
379 }
380
381 if sample.vram_used_mb > sample.vram_total_mb {
383 errors.push(format!(
384 "Sample {}: vram_used_mb ({}) > vram_total_mb ({})",
385 i, sample.vram_used_mb, sample.vram_total_mb
386 ));
387 }
388
389 if !(0.0..=MAX_TEMP_C).contains(&sample.temp_c) {
391 errors.push(format!(
392 "Sample {}: temperature {} out of range [0, {}]",
393 i, sample.temp_c, MAX_TEMP_C
394 ));
395 }
396
397 if sample.clock_mhz < 0.0 {
399 errors.push(format!(
400 "Sample {}: clock speed {} is negative",
401 i, sample.clock_mhz
402 ));
403 }
404 }
405
406 if errors.is_empty() {
407 Ok(())
408 } else {
409 Err(errors)
410 }
411}
412
413#[cfg(test)]
418mod tests {
419 use super::*;
420
421 #[test]
426 fn test_benchmark_rejects_short_duration() {
427 let samples = create_synthetic_samples(10, 90.0);
429 let outcome = BenchmarkOutcome::from_samples(samples, 90);
430 assert!(
431 !outcome.is_complete,
432 "90-second benchmark should be incomplete"
433 );
434 assert!(
435 validate_benchmark(&outcome).is_err(),
436 "90-second benchmark should fail validation"
437 );
438 }
439
440 #[test]
441 fn test_benchmark_accepts_180_seconds() {
442 let samples = create_synthetic_samples(19, 180.0);
444 let outcome = BenchmarkOutcome::from_samples(samples, 180);
445 assert!(
446 outcome.is_complete,
447 "180-second benchmark with 19 samples should be complete"
448 );
449 assert!(
450 validate_benchmark(&outcome).is_ok(),
451 "180-second benchmark should pass validation"
452 );
453 }
454
455 #[test]
456 fn test_benchmark_rejects_insufficient_samples() {
457 let samples = create_synthetic_samples(10, 180.0);
459 let outcome = BenchmarkOutcome::from_samples(samples, 180);
460 assert!(
461 !outcome.is_complete,
462 "180s benchmark with only 10 samples should be incomplete"
463 );
464 assert!(
465 validate_benchmark(&outcome).is_err(),
466 "Insufficient samples should fail validation"
467 );
468 }
469
470 #[test]
475 fn test_gpu_utilization_in_valid_range() {
476 let samples = create_synthetic_samples(19, 180.0);
477 for sample in &samples {
478 assert!(
479 (0.0..=100.0).contains(&sample.utilization_pct),
480 "Utilization {} out of range",
481 sample.utilization_pct
482 );
483 }
484 }
485
486 #[test]
487 fn test_sample_count_minimum_18_for_180s() {
488 let samples = create_synthetic_samples(19, 180.0);
490 assert!(
491 samples.len() >= MIN_SAMPLE_COUNT as usize,
492 "Expected at least {} samples, got {}",
493 MIN_SAMPLE_COUNT,
494 samples.len()
495 );
496 }
497
498 #[test]
503 fn test_vram_used_le_total() {
504 let samples = create_synthetic_samples(19, 180.0);
505 for (i, sample) in samples.iter().enumerate() {
506 assert!(
507 sample.vram_used_mb <= sample.vram_total_mb,
508 "Sample {}: vram_used_mb ({}) > vram_total_mb ({})",
509 i,
510 sample.vram_used_mb,
511 sample.vram_total_mb
512 );
513 }
514 }
515
516 #[test]
517 fn test_vram_validation_catches_used_exceeds_total() {
518 let mut sample = create_synthetic_sample(0.0);
519 sample.vram_used_mb = 30000.0;
520 sample.vram_total_mb = 24576.0;
521 let samples = vec![sample; 19];
522 let outcome = BenchmarkOutcome::from_samples(samples, 180);
523 let validation = validate_benchmark(&outcome);
524 assert!(validation.is_err());
525 let errors = validation.unwrap_err();
526 assert!(
527 errors.iter().any(|e| e.contains("vram_used_mb")),
528 "Should catch vram_used_mb > vram_total_mb"
529 );
530 }
531
532 #[test]
537 fn test_temperature_in_valid_range() {
538 let samples = create_synthetic_samples(19, 180.0);
539 for (i, sample) in samples.iter().enumerate() {
540 assert!(
541 (0.0..=MAX_TEMP_C).contains(&sample.temp_c),
542 "Sample {}: temperature {} out of range [0, {}]",
543 i,
544 sample.temp_c,
545 MAX_TEMP_C
546 );
547 }
548 }
549
550 #[test]
551 fn test_throttling_is_bool() {
552 let sample = create_synthetic_sample(0.0);
553 let _: bool = sample.throttling;
555 }
556
557 #[test]
558 fn test_clock_speed_positive() {
559 let samples = create_synthetic_samples(19, 180.0);
560 for (i, sample) in samples.iter().enumerate() {
561 assert!(
562 sample.clock_mhz >= 0.0,
563 "Sample {}: clock speed {} is negative",
564 i,
565 sample.clock_mhz
566 );
567 }
568 }
569
570 #[test]
575 fn test_aggregate_samples_averages_correctly() {
576 let mut samples = Vec::new();
577 for i in 0..3 {
578 let s = GpuSample {
579 utilization_pct: 50.0 + i as f32 * 10.0, vram_used_mb: 1000.0,
581 vram_total_mb: 24000.0,
582 temp_c: 60.0,
583 throttling: i == 2,
584 clock_mhz: 2000.0,
585 timestamp_secs: i as f64 * 10.0,
586 };
587 samples.push(s);
588 }
589
590 let metrics = aggregate_samples(&samples);
591 assert!(
593 (metrics.gpu_utilization_pct - 60.0).abs() < 0.1,
594 "Expected avg utilization ~60, got {}",
595 metrics.gpu_utilization_pct
596 );
597 assert!(
598 metrics.gpu_throttling,
599 "Any throttling should set throttling=true"
600 );
601 assert!(
602 (metrics.vram_used_mb - 1000.0).abs() < 0.1,
603 "Expected avg vram_used ~1000, got {}",
604 metrics.vram_used_mb
605 );
606 assert!(
607 (metrics.vram_total_mb - 24000.0).abs() < 0.1,
608 "Expected max vram_total ~24000, got {}",
609 metrics.vram_total_mb
610 );
611 }
612
613 #[test]
614 fn test_aggregate_empty_samples() {
615 let metrics = aggregate_samples(&[]);
616 assert_eq!(metrics, GpuMetrics::default());
617 }
618
619 #[test]
624 fn test_parse_gpu_utilization_valid() {
625 let output = "GPU[0]\t\t: GPU use (%): 42";
626 assert!((parse_gpu_utilization(output) - 42.0).abs() < 0.1);
627 }
628
629 #[test]
630 fn test_parse_gpu_utilization_zero() {
631 let output = "GPU[0]\t\t: GPU use (%): 0";
632 assert!((parse_gpu_utilization(output) - 0.0).abs() < 0.1);
633 }
634
635 #[test]
636 fn test_parse_gpu_utilization_empty() {
637 assert!((parse_gpu_utilization("") - 0.0).abs() < 0.1);
638 }
639
640 #[test]
641 fn test_parse_vram_valid() {
642 let output = "\
643GPU[0]\t\t: VRAM Total Memory (B): 25753026560
644GPU[0]\t\t: VRAM Total Used Memory (B): 63635456";
645 let (used, total) = parse_vram(output);
646 assert!(
647 total > 24000.0,
648 "Total VRAM should be ~24574 MB, got {}",
649 total
650 );
651 assert!(used < total, "Used VRAM should be less than total");
652 }
653
654 #[test]
655 fn test_parse_temperature_valid() {
656 let output = "GPU[0]\t\t: Temperature (Sensor edge) (C): 33.0";
657 assert!((parse_temperature(output) - 33.0).abs() < 0.1);
658 }
659
660 #[test]
661 fn test_parse_temperature_out_of_range_clamped() {
662 let output = "GPU[0]\t\t: Temperature (Sensor edge) (C): 150.0";
663 let temp = parse_temperature(output);
664 assert!(
665 temp <= MAX_TEMP_C,
666 "Temperature should be clamped to {}, got {}",
667 MAX_TEMP_C,
668 temp
669 );
670 }
671
672 #[test]
673 fn test_parse_clock_mhz_valid() {
674 let output = "GPU[0]\t\t: sclk clock level: S: (2500Mhz)";
675 assert!((parse_clock_mhz(output) - 2500.0).abs() < 0.1);
676 }
677
678 #[test]
679 fn test_parse_throttling_detected() {
680 let output = "GPU[0]\t\t: Throttling: active";
681 assert!(parse_throttling(output));
682 }
683
684 #[test]
685 fn test_parse_throttling_not_detected() {
686 let output = "GPU[0]\t\t: Performance Level: auto";
687 assert!(!parse_throttling(output));
688 }
689
690 #[test]
695 #[ignore]
696 fn test_sample_gpu_metrics_real_hardware() {
697 let sample = sample_gpu_metrics().expect("Should sample GPU metrics on real hardware");
698 assert!(
699 (0.0..=100.0).contains(&sample.utilization_pct),
700 "Utilization out of range: {}",
701 sample.utilization_pct
702 );
703 assert!(
704 sample.vram_total_mb > 0.0,
705 "VRAM total should be positive: {}",
706 sample.vram_total_mb
707 );
708 assert!(
709 sample.vram_used_mb <= sample.vram_total_mb,
710 "VRAM used ({}) exceeds total ({})",
711 sample.vram_used_mb,
712 sample.vram_total_mb
713 );
714 assert!(
715 (0.0..=MAX_TEMP_C).contains(&sample.temp_c),
716 "Temperature out of range: {}",
717 sample.temp_c
718 );
719 }
720
721 fn create_synthetic_samples(count: usize, _total_duration: f64) -> Vec<GpuSample> {
727 (0..count)
728 .map(|i| GpuSample {
729 utilization_pct: 50.0,
730 vram_used_mb: 8192.0,
731 vram_total_mb: 24576.0,
732 temp_c: 65.0,
733 throttling: false,
734 clock_mhz: 2000.0,
735 timestamp_secs: i as f64 * SAMPLE_INTERVAL_SECS as f64,
736 })
737 .collect()
738 }
739
740 fn create_synthetic_sample(timestamp: f64) -> GpuSample {
742 GpuSample {
743 utilization_pct: 50.0,
744 vram_used_mb: 8192.0,
745 vram_total_mb: 24576.0,
746 temp_c: 65.0,
747 throttling: false,
748 clock_mhz: 2000.0,
749 timestamp_secs: timestamp,
750 }
751 }
752}