use crate::metrics::catalog::{EnergyMetrics, SystemHealthMetrics, VramMetrics};
use std::process::Command;
pub fn collect_system_health() -> Option<SystemHealthMetrics> {
let gpu = query_nvidia_smi(&[
"temperature.gpu",
"power.draw",
"clocks.current.sm",
"clocks.current.memory",
"memory.used",
"memory.total",
])?;
let fields: Vec<&str> = gpu.split(", ").collect();
if fields.len() < 6 {
return None;
}
let cpu_freq = read_cpu_frequency().unwrap_or(0.0);
let cpu_temp = read_cpu_temperature().unwrap_or(0.0);
let mut gpu_mem_total = parse_nvidia_val(fields[5]);
if gpu_mem_total <= 0.0 {
gpu_mem_total = read_system_memory_total_mb().unwrap_or(0.0);
}
Some(SystemHealthMetrics {
gpu_temperature_celsius: parse_nvidia_val(fields[0]),
gpu_power_watts: parse_nvidia_val(fields[1]),
gpu_clock_mhz: parse_nvidia_val(fields[2]),
gpu_memory_clock_mhz: parse_nvidia_val(fields[3]),
cpu_frequency_mhz: cpu_freq,
cpu_temperature_celsius: cpu_temp,
gpu_memory_used_mb: parse_nvidia_val(fields[4]),
gpu_memory_total_mb: gpu_mem_total,
})
}
pub fn collect_vram() -> Option<VramMetrics> {
let gpu = query_nvidia_smi(&["memory.used", "memory.total", "memory.free"])?;
let fields: Vec<&str> = gpu.split(", ").collect();
if fields.len() < 3 {
return None;
}
let used = parse_nvidia_val(fields[0]);
let mut total = parse_nvidia_val(fields[1]);
let free = parse_nvidia_val(fields[2]);
if total <= 0.0 {
total = read_system_memory_total_mb().unwrap_or(0.0);
}
let utilization = if total > 0.0 {
used / total * 100.0
} else {
0.0
};
Some(VramMetrics {
vram_used_mb: used,
vram_total_mb: total,
vram_free_mb: free,
vram_utilization_pct: utilization,
vram_peak_mb: used, vram_allocation_count: 0,
vram_fragmentation_pct: 0.0,
})
}
pub fn compute_energy(power_watts: f64, tflops: f64, duration_us: f64) -> Option<EnergyMetrics> {
if power_watts <= 0.0 {
return None;
}
let tflops_per_watt = if power_watts > 0.0 {
tflops / power_watts
} else {
0.0
};
let joules = power_watts * duration_us * 1e-6;
Some(EnergyMetrics {
tflops_per_watt,
joules_per_inference: joules,
})
}
fn query_nvidia_smi(fields: &[&str]) -> Option<String> {
let query = fields.join(",");
let output = Command::new("nvidia-smi")
.args(["--query-gpu", &query, "--format=csv,noheader,nounits"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let line = stdout.trim();
if line.is_empty() || line.contains("[N/A]") && line.chars().all(|c| c == ',' || c == ' ') {
return None;
}
Some(line.to_string())
}
fn parse_nvidia_val(s: &str) -> f64 {
let s = s.trim();
if s == "[N/A]" || s == "N/A" {
return 0.0;
}
s.split_whitespace()
.next()
.and_then(|token| token.parse::<f64>().ok())
.unwrap_or(0.0)
}
fn read_system_memory_total_mb() -> Option<f64> {
let content = std::fs::read_to_string("/proc/meminfo").ok()?;
for line in content.lines() {
if let Some(rest) = line.strip_prefix("MemTotal:") {
let kb = rest.split_whitespace().next()?.parse::<f64>().ok()?;
return Some(kb / 1024.0);
}
}
None
}
fn read_cpu_frequency() -> Option<f64> {
let content = std::fs::read_to_string("/proc/cpuinfo").ok()?;
let mut total = 0.0;
let mut count = 0;
for line in content.lines() {
if line.starts_with("cpu MHz") {
if let Some(val) = line.split(':').nth(1) {
if let Ok(mhz) = val.trim().parse::<f64>() {
total += mhz;
count += 1;
}
}
}
}
if count > 0 {
Some(total / count as f64)
} else {
None
}
}
fn read_cpu_temperature() -> Option<f64> {
for i in 0..10 {
let path = format!("/sys/class/thermal/thermal_zone{i}/temp");
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(millidegrees) = content.trim().parse::<f64>() {
return Some(millidegrees / 1000.0);
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_nvidia_val() {
assert!((parse_nvidia_val("285.32 W") - 285.32).abs() < 0.01);
assert!((parse_nvidia_val("24564 MiB") - 24564.0).abs() < 1.0);
assert!((parse_nvidia_val("62") - 62.0).abs() < 0.01);
assert!((parse_nvidia_val("[N/A]")).abs() < 0.01);
assert!((parse_nvidia_val("N/A")).abs() < 0.01);
}
#[test]
fn test_compute_energy() {
let e = compute_energy(300.0, 11.6, 23.2).unwrap();
assert!((e.tflops_per_watt - 11.6 / 300.0).abs() < 0.001);
assert!((e.joules_per_inference - 300.0 * 23.2e-6).abs() < 0.001);
}
#[test]
fn test_compute_energy_zero_power() {
assert!(compute_energy(0.0, 11.6, 23.2).is_none());
}
#[test]
fn test_collect_system_health_no_panic() {
let _ = collect_system_health();
}
#[test]
fn test_collect_vram_no_panic() {
let _ = collect_vram();
}
#[test]
fn test_read_cpu_frequency_no_panic() {
let _ = read_cpu_frequency();
}
#[test]
fn test_read_system_memory_total_mb() {
let total = read_system_memory_total_mb();
assert!(total.is_some(), "/proc/meminfo MemTotal should be readable");
assert!(total.unwrap() > 0.0, "system memory total should be > 0 MB");
}
#[test]
fn test_read_cpu_temperature_no_panic() {
let _ = read_cpu_temperature();
}
fn gpus_reported() -> usize {
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
let Ok(mut child) = Command::new("nvidia-smi")
.args(["--query-gpu=index", "--format=csv,noheader"])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
else {
return 0; };
let deadline = Instant::now() + Duration::from_secs(10);
loop {
match child.try_wait() {
Ok(Some(status)) => {
if !status.success() {
return 0; }
break;
}
Ok(None) => {
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return 0; }
std::thread::sleep(Duration::from_millis(50));
}
Err(_) => return 0,
}
}
let Ok(out) = child.wait_with_output() else {
return 0;
};
String::from_utf8_lossy(&out.stdout)
.lines()
.filter(|l| l.trim().parse::<u32>().is_ok())
.count()
}
#[test]
fn test_gpu_collectors_match_what_nvidia_smi_reports() {
if gpus_reported() > 0 {
let health = collect_system_health().expect("a reporting GPU must yield health data");
assert!(
health.gpu_temperature_celsius > 0.0,
"GPU temp should be > 0"
);
assert!(
health.gpu_memory_total_mb > 0.0,
"GPU memory total should be > 0"
);
let vram = collect_vram().expect("a reporting GPU must yield VRAM data");
assert!(vram.vram_total_mb > 0.0, "VRAM total should be > 0");
assert!(vram.vram_utilization_pct >= 0.0 && vram.vram_utilization_pct <= 100.0);
} else {
assert!(
collect_system_health().is_none(),
"with no GPU reported, health must be None rather than a fabricated reading"
);
assert!(
collect_vram().is_none(),
"with no GPU reported, VRAM must be None rather than a fabricated reading"
);
}
}
#[test]
fn test_the_probe_counts_only_lines_that_parse_as_an_index() {
fn count(stdout: &str) -> usize {
stdout
.lines()
.filter(|l| l.trim().parse::<u32>().is_ok())
.count()
}
assert_eq!(count("0\n1\n"), 2, "two indices are two GPUs");
assert_eq!(count(""), 0, "no output is no GPU");
assert_eq!(count("\n \n"), 0, "blank lines are no GPU");
assert_eq!(
count("NVIDIA-SMI has failed because it couldn't communicate with the driver\n"),
0,
"an error banner on stdout is NOT a GPU -- the defect a non-empty-lines filter has"
);
assert_eq!(
count("Please update your driver\n0\n"),
1,
"a notice beside a real index counts the index only"
);
}
}