use crate::core::telemetry_types::{BenchmarkResult, GpuMetrics};
use anyhow::{Context, Result};
use std::process::Command;
use std::time::{Duration, Instant};
pub const MIN_BENCHMARK_DURATION_SECS: u64 = 180;
pub const SAMPLE_INTERVAL_SECS: u64 = 10;
pub const MIN_SAMPLE_COUNT: u32 = 18;
pub const MAX_TEMP_C: f32 = 120.0;
pub const MAX_PAYLOAD_SIZE_BYTES: usize = 65536;
#[derive(Debug, Clone)]
pub struct GpuSample {
pub utilization_pct: f32,
pub vram_used_mb: f64,
pub vram_total_mb: f64,
pub temp_c: f32,
pub throttling: bool,
pub clock_mhz: f32,
pub timestamp_secs: f64,
}
#[derive(Debug, Clone)]
pub struct BenchmarkOutcome {
pub samples: Vec<GpuSample>,
pub duration_secs: u64,
pub is_complete: bool,
pub aggregated_metrics: Option<GpuMetrics>,
pub benchmark_result: BenchmarkResult,
}
impl BenchmarkOutcome {
pub fn from_samples(samples: Vec<GpuSample>, duration_secs: u64) -> Self {
let is_complete = duration_secs >= MIN_BENCHMARK_DURATION_SECS
&& samples.len() >= MIN_SAMPLE_COUNT as usize;
let aggregated_metrics = if !samples.is_empty() {
Some(aggregate_samples(&samples))
} else {
None
};
let benchmark_result = BenchmarkResult {
duration_seconds: duration_secs,
completed: is_complete,
sample_count: samples.len() as u32,
};
Self {
samples,
duration_secs,
is_complete,
aggregated_metrics,
benchmark_result,
}
}
}
fn aggregate_samples(samples: &[GpuSample]) -> GpuMetrics {
if samples.is_empty() {
return GpuMetrics::default();
}
let count = samples.len() as f32;
let count_f64 = samples.len() as f64;
let avg_util = samples.iter().map(|s| s.utilization_pct).sum::<f32>() / count;
let avg_vram_used = samples.iter().map(|s| s.vram_used_mb).sum::<f64>() / count_f64;
let max_vram_total = samples
.iter()
.map(|s| s.vram_total_mb)
.fold(0.0_f64, f64::max);
let avg_temp = samples.iter().map(|s| s.temp_c).sum::<f32>() / count;
let any_throttling = samples.iter().any(|s| s.throttling);
let avg_clock = samples.iter().map(|s| s.clock_mhz).sum::<f32>() / count;
GpuMetrics {
gpu_utilization_pct: clamp_f32(avg_util, 0.0, 100.0),
vram_used_mb: avg_vram_used,
vram_total_mb: max_vram_total,
gpu_temp_c: clamp_f32(avg_temp, 0.0, MAX_TEMP_C),
gpu_throttling: any_throttling,
gpu_clock_mhz: avg_clock.max(0.0),
}
}
fn clamp_f32(value: f32, min: f32, max: f32) -> f32 {
value.max(min).min(max)
}
fn parse_gpu_utilization(output: &str) -> f32 {
for line in output.lines() {
if line.contains("GPU use (%)") {
if let Some(val) = line.split(':').next_back() {
if let Ok(pct) = val.trim().parse::<f32>() {
return clamp_f32(pct, 0.0, 100.0);
}
}
}
}
0.0
}
fn parse_vram(output: &str) -> (f64, f64) {
let mut total_bytes: f64 = 0.0;
let mut used_bytes: f64 = 0.0;
for line in output.lines() {
let trimmed = line.trim();
if trimmed.contains("VRAM Total Memory (B)") {
if let Some(val) = trimmed.split(':').next_back() {
total_bytes = val.trim().parse::<f64>().unwrap_or(0.0);
}
} else if trimmed.contains("VRAM Total Used Memory (B)") {
if let Some(val) = trimmed.split(':').next_back() {
used_bytes = val.trim().parse::<f64>().unwrap_or(0.0);
}
}
}
let total_mb = total_bytes / (1024.0 * 1024.0);
let used_mb = used_bytes / (1024.0 * 1024.0);
(used_mb, total_mb)
}
fn parse_temperature(output: &str) -> f32 {
for line in output.lines() {
if line.contains("Temperature") && line.contains("(C)") {
if let Some(val) = line.split(':').next_back() {
if let Ok(temp) = val.trim().parse::<f32>() {
return clamp_f32(temp, 0.0, MAX_TEMP_C);
}
}
}
}
0.0
}
fn parse_clock_mhz(output: &str) -> f32 {
for line in output.lines() {
if line.contains("sclk clock level") {
if let Some(start) = line.find('(') {
if let Some(end) = line.find("Mhz)") {
let num_str = &line[start + 1..end];
if let Ok(mhz) = num_str.trim().parse::<f32>() {
return mhz.max(0.0);
}
}
}
}
}
0.0
}
fn parse_throttling(output: &str) -> bool {
let lower = output.to_lowercase();
lower.contains("throttl") || lower.contains("performance level: manual")
}
pub fn sample_gpu_metrics() -> Result<GpuSample> {
let rocm_smi = find_rocm_smi()?;
let output = Command::new(&rocm_smi)
.arg("--showuse")
.arg("--showmeminfo")
.arg("vram")
.arg("--showtemp")
.arg("--showclock")
.output()
.context("failed to execute rocm-smi")?;
let stdout = String::from_utf8_lossy(&output.stdout);
let utilization_pct = parse_gpu_utilization(&stdout);
let (vram_used_mb, vram_total_mb) = parse_vram(&stdout);
let temp_c = parse_temperature(&stdout);
let clock_mhz = parse_clock_mhz(&stdout);
let throttling = parse_throttling(&stdout);
Ok(GpuSample {
utilization_pct,
vram_used_mb,
vram_total_mb,
temp_c,
throttling,
clock_mhz,
timestamp_secs: 0.0,
})
}
fn find_rocm_smi() -> Result<String> {
let candidates = ["/opt/rocm/bin/rocm-smi", "/usr/bin/rocm-smi", "rocm-smi"];
for candidate in &candidates {
if Command::new(candidate).arg("--version").output().is_ok() {
return Ok(candidate.to_string());
}
}
anyhow::bail!("rocm-smi not found in standard locations")
}
pub fn run_benchmark(duration: Duration) -> Result<BenchmarkOutcome> {
let start = Instant::now();
let mut samples: Vec<GpuSample> = Vec::new();
match sample_gpu_metrics() {
Ok(mut sample) => {
sample.timestamp_secs = 0.0;
samples.push(sample);
}
Err(e) => {
eprintln!("Warning: initial GPU sample failed: {}", e);
}
}
let interval = Duration::from_secs(SAMPLE_INTERVAL_SECS);
let mut next_sample = start + interval;
while start.elapsed() < duration {
let now = Instant::now();
if now >= next_sample {
match sample_gpu_metrics() {
Ok(mut sample) => {
sample.timestamp_secs = now.duration_since(start).as_secs_f64();
samples.push(sample);
}
Err(e) => {
eprintln!("Warning: GPU sample failed at {:?}: {}", now - start, e);
}
}
next_sample = now + interval;
}
std::thread::sleep(Duration::from_millis(100));
}
let elapsed = start.elapsed().as_secs();
Ok(BenchmarkOutcome::from_samples(samples, elapsed))
}
pub fn validate_benchmark(outcome: &BenchmarkOutcome) -> Result<(), Vec<String>> {
let mut errors = Vec::new();
if outcome.duration_secs < MIN_BENCHMARK_DURATION_SECS {
errors.push(format!(
"Benchmark duration {}s is below minimum {}s",
outcome.duration_secs, MIN_BENCHMARK_DURATION_SECS
));
}
if (outcome.samples.len() as u32) < MIN_SAMPLE_COUNT {
errors.push(format!(
"Sample count {} is below minimum {} for a valid benchmark",
outcome.samples.len(),
MIN_SAMPLE_COUNT
));
}
for (i, sample) in outcome.samples.iter().enumerate() {
if !(0.0..=100.0).contains(&sample.utilization_pct) {
errors.push(format!(
"Sample {}: utilization {} out of range [0.0, 100.0]",
i, sample.utilization_pct
));
}
if sample.vram_used_mb > sample.vram_total_mb {
errors.push(format!(
"Sample {}: vram_used_mb ({}) > vram_total_mb ({})",
i, sample.vram_used_mb, sample.vram_total_mb
));
}
if !(0.0..=MAX_TEMP_C).contains(&sample.temp_c) {
errors.push(format!(
"Sample {}: temperature {} out of range [0, {}]",
i, sample.temp_c, MAX_TEMP_C
));
}
if sample.clock_mhz < 0.0 {
errors.push(format!(
"Sample {}: clock speed {} is negative",
i, sample.clock_mhz
));
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_benchmark_rejects_short_duration() {
let samples = create_synthetic_samples(10, 90.0);
let outcome = BenchmarkOutcome::from_samples(samples, 90);
assert!(
!outcome.is_complete,
"90-second benchmark should be incomplete"
);
assert!(
validate_benchmark(&outcome).is_err(),
"90-second benchmark should fail validation"
);
}
#[test]
fn test_benchmark_accepts_180_seconds() {
let samples = create_synthetic_samples(19, 180.0);
let outcome = BenchmarkOutcome::from_samples(samples, 180);
assert!(
outcome.is_complete,
"180-second benchmark with 19 samples should be complete"
);
assert!(
validate_benchmark(&outcome).is_ok(),
"180-second benchmark should pass validation"
);
}
#[test]
fn test_benchmark_rejects_insufficient_samples() {
let samples = create_synthetic_samples(10, 180.0);
let outcome = BenchmarkOutcome::from_samples(samples, 180);
assert!(
!outcome.is_complete,
"180s benchmark with only 10 samples should be incomplete"
);
assert!(
validate_benchmark(&outcome).is_err(),
"Insufficient samples should fail validation"
);
}
#[test]
fn test_gpu_utilization_in_valid_range() {
let samples = create_synthetic_samples(19, 180.0);
for sample in &samples {
assert!(
(0.0..=100.0).contains(&sample.utilization_pct),
"Utilization {} out of range",
sample.utilization_pct
);
}
}
#[test]
fn test_sample_count_minimum_18_for_180s() {
let samples = create_synthetic_samples(19, 180.0);
assert!(
samples.len() >= MIN_SAMPLE_COUNT as usize,
"Expected at least {} samples, got {}",
MIN_SAMPLE_COUNT,
samples.len()
);
}
#[test]
fn test_vram_used_le_total() {
let samples = create_synthetic_samples(19, 180.0);
for (i, sample) in samples.iter().enumerate() {
assert!(
sample.vram_used_mb <= sample.vram_total_mb,
"Sample {}: vram_used_mb ({}) > vram_total_mb ({})",
i,
sample.vram_used_mb,
sample.vram_total_mb
);
}
}
#[test]
fn test_vram_validation_catches_used_exceeds_total() {
let mut sample = create_synthetic_sample(0.0);
sample.vram_used_mb = 30000.0;
sample.vram_total_mb = 24576.0;
let samples = vec![sample; 19];
let outcome = BenchmarkOutcome::from_samples(samples, 180);
let validation = validate_benchmark(&outcome);
assert!(validation.is_err());
let errors = validation.unwrap_err();
assert!(
errors.iter().any(|e| e.contains("vram_used_mb")),
"Should catch vram_used_mb > vram_total_mb"
);
}
#[test]
fn test_temperature_in_valid_range() {
let samples = create_synthetic_samples(19, 180.0);
for (i, sample) in samples.iter().enumerate() {
assert!(
(0.0..=MAX_TEMP_C).contains(&sample.temp_c),
"Sample {}: temperature {} out of range [0, {}]",
i,
sample.temp_c,
MAX_TEMP_C
);
}
}
#[test]
fn test_throttling_is_bool() {
let sample = create_synthetic_sample(0.0);
let _: bool = sample.throttling;
}
#[test]
fn test_clock_speed_positive() {
let samples = create_synthetic_samples(19, 180.0);
for (i, sample) in samples.iter().enumerate() {
assert!(
sample.clock_mhz >= 0.0,
"Sample {}: clock speed {} is negative",
i,
sample.clock_mhz
);
}
}
#[test]
fn test_aggregate_samples_averages_correctly() {
let mut samples = Vec::new();
for i in 0..3 {
let s = GpuSample {
utilization_pct: 50.0 + i as f32 * 10.0, vram_used_mb: 1000.0,
vram_total_mb: 24000.0,
temp_c: 60.0,
throttling: i == 2,
clock_mhz: 2000.0,
timestamp_secs: i as f64 * 10.0,
};
samples.push(s);
}
let metrics = aggregate_samples(&samples);
assert!(
(metrics.gpu_utilization_pct - 60.0).abs() < 0.1,
"Expected avg utilization ~60, got {}",
metrics.gpu_utilization_pct
);
assert!(
metrics.gpu_throttling,
"Any throttling should set throttling=true"
);
assert!(
(metrics.vram_used_mb - 1000.0).abs() < 0.1,
"Expected avg vram_used ~1000, got {}",
metrics.vram_used_mb
);
assert!(
(metrics.vram_total_mb - 24000.0).abs() < 0.1,
"Expected max vram_total ~24000, got {}",
metrics.vram_total_mb
);
}
#[test]
fn test_aggregate_empty_samples() {
let metrics = aggregate_samples(&[]);
assert_eq!(metrics, GpuMetrics::default());
}
#[test]
fn test_parse_gpu_utilization_valid() {
let output = "GPU[0]\t\t: GPU use (%): 42";
assert!((parse_gpu_utilization(output) - 42.0).abs() < 0.1);
}
#[test]
fn test_parse_gpu_utilization_zero() {
let output = "GPU[0]\t\t: GPU use (%): 0";
assert!((parse_gpu_utilization(output) - 0.0).abs() < 0.1);
}
#[test]
fn test_parse_gpu_utilization_empty() {
assert!((parse_gpu_utilization("") - 0.0).abs() < 0.1);
}
#[test]
fn test_parse_vram_valid() {
let output = "\
GPU[0]\t\t: VRAM Total Memory (B): 25753026560
GPU[0]\t\t: VRAM Total Used Memory (B): 63635456";
let (used, total) = parse_vram(output);
assert!(
total > 24000.0,
"Total VRAM should be ~24574 MB, got {}",
total
);
assert!(used < total, "Used VRAM should be less than total");
}
#[test]
fn test_parse_temperature_valid() {
let output = "GPU[0]\t\t: Temperature (Sensor edge) (C): 33.0";
assert!((parse_temperature(output) - 33.0).abs() < 0.1);
}
#[test]
fn test_parse_temperature_out_of_range_clamped() {
let output = "GPU[0]\t\t: Temperature (Sensor edge) (C): 150.0";
let temp = parse_temperature(output);
assert!(
temp <= MAX_TEMP_C,
"Temperature should be clamped to {}, got {}",
MAX_TEMP_C,
temp
);
}
#[test]
fn test_parse_clock_mhz_valid() {
let output = "GPU[0]\t\t: sclk clock level: S: (2500Mhz)";
assert!((parse_clock_mhz(output) - 2500.0).abs() < 0.1);
}
#[test]
fn test_parse_throttling_detected() {
let output = "GPU[0]\t\t: Throttling: active";
assert!(parse_throttling(output));
}
#[test]
fn test_parse_throttling_not_detected() {
let output = "GPU[0]\t\t: Performance Level: auto";
assert!(!parse_throttling(output));
}
#[test]
#[ignore]
fn test_sample_gpu_metrics_real_hardware() {
let sample = sample_gpu_metrics().expect("Should sample GPU metrics on real hardware");
assert!(
(0.0..=100.0).contains(&sample.utilization_pct),
"Utilization out of range: {}",
sample.utilization_pct
);
assert!(
sample.vram_total_mb > 0.0,
"VRAM total should be positive: {}",
sample.vram_total_mb
);
assert!(
sample.vram_used_mb <= sample.vram_total_mb,
"VRAM used ({}) exceeds total ({})",
sample.vram_used_mb,
sample.vram_total_mb
);
assert!(
(0.0..=MAX_TEMP_C).contains(&sample.temp_c),
"Temperature out of range: {}",
sample.temp_c
);
}
fn create_synthetic_samples(count: usize, _total_duration: f64) -> Vec<GpuSample> {
(0..count)
.map(|i| GpuSample {
utilization_pct: 50.0,
vram_used_mb: 8192.0,
vram_total_mb: 24576.0,
temp_c: 65.0,
throttling: false,
clock_mhz: 2000.0,
timestamp_secs: i as f64 * SAMPLE_INTERVAL_SECS as f64,
})
.collect()
}
fn create_synthetic_sample(timestamp: f64) -> GpuSample {
GpuSample {
utilization_pct: 50.0,
vram_used_mb: 8192.0,
vram_total_mb: 24576.0,
temp_c: 65.0,
throttling: false,
clock_mhz: 2000.0,
timestamp_secs: timestamp,
}
}
}