use crate::sensor::PowerSensor;
use crate::{Error, Result};
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
use std::process::Command;
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
pub(super) fn run_tool(
program: &str,
args: &[&str],
sensor: &'static str,
) -> Result<Option<String>> {
let mut command = Command::new(program);
command.args(args);
#[cfg(target_os = "windows")]
command.creation_flags(CREATE_NO_WINDOW);
let output = match command.output() {
Ok(output) => output,
Err(e) => {
log::debug!("vendor tool {program} could not be started: {e}");
return Ok(None);
}
};
if !output.status.success() {
return Err(Error::sensor(
sensor,
format!("{program} exited with {}", output.status),
));
}
String::from_utf8(output.stdout)
.map(Some)
.map_err(|e| Error::sensor(sensor, format!("{program} output is not UTF-8: {e}")))
}
#[derive(Debug, Default)]
pub(super) struct VendorGpu;
impl PowerSensor for VendorGpu {
fn power(&mut self) -> Result<f64> {
let nvidia = nvidia::power()?;
let amd = amd::power()?;
if nvidia.is_none() && amd.is_none() {
return Err(Error::sensor(
"GPU",
"no nvidia-smi, amd-smi or rocm-smi reported GPU power",
));
}
Ok(nvidia.unwrap_or(0.0) + amd.unwrap_or(0.0))
}
}
mod nvidia {
use super::run_tool;
use crate::Result;
use std::sync::OnceLock;
const SENSOR: &str = "NVIDIA GPU";
const PROGRAM: &str = "nvidia-smi";
const ARGS: &[&str] = &["--format=csv,noheader,nounits", "--query-gpu=power.draw"];
static INSTALLED: OnceLock<bool> = OnceLock::new();
pub(super) fn power() -> Result<Option<f64>> {
if !INSTALLED.get().copied().unwrap_or(true) {
return Ok(None);
}
let Some(stdout) = run_tool(PROGRAM, ARGS, SENSOR)? else {
let _ = INSTALLED.set(false);
return Ok(None);
};
let _ = INSTALLED.set(true);
Ok(parse_power(&stdout))
}
fn parse_power(output: &str) -> Option<f64> {
let mut total = 0.0;
let mut found = false;
for line in output.lines() {
if let Ok(watts) = line.trim().parse::<f64>() {
total += watts;
found = true;
}
}
found.then_some(total)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sums_every_gpu() {
assert_eq!(parse_power("120.5\n80.25\n"), Some(200.75));
}
#[test]
fn cards_without_a_reading_contribute_nothing() {
assert_eq!(parse_power("[N/A]\n42.0\n"), Some(42.0));
}
#[test]
fn output_with_no_figure_at_all_reports_nothing() {
assert_eq!(parse_power("[N/A]\n"), None);
assert_eq!(parse_power(""), None);
}
}
}
mod amd {
use super::run_tool;
use crate::{Error, Result};
use std::sync::OnceLock;
const SENSOR: &str = "AMD GPU";
static COMMAND: OnceLock<Option<AmdGpuCommand>> = OnceLock::new();
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AmdGpuCommand {
AmdSmi,
RocmSmi,
}
impl AmdGpuCommand {
fn program(self) -> &'static str {
match self {
AmdGpuCommand::AmdSmi => "amd-smi",
AmdGpuCommand::RocmSmi => "rocm-smi",
}
}
fn args(self) -> &'static [&'static str] {
match self {
AmdGpuCommand::AmdSmi => &["metric", "-p", "--csv"],
AmdGpuCommand::RocmSmi => &["--showpower", "--csv"],
}
}
}
const POWER_COLUMNS: &[&str] = &[
"average graphics package power (w)",
"current socket graphics package power (w)",
"current graphics package power (w)",
"power (w)",
"power_socket_power_value",
"socket_power_value",
"current_socket_graphics_package_power",
"current_graphics_package_power",
"average_socket_power",
"current_socket_power",
"socket_power",
"average_power",
];
pub(super) fn power() -> Result<Option<f64>> {
let Some(command) = detect_command() else {
return Ok(None);
};
let Some(stdout) = run_tool(command.program(), command.args(), SENSOR)? else {
return Ok(None);
};
parse_power(&stdout).map(Some).ok_or_else(|| {
Error::sensor(
SENSOR,
format!("no power column in {} CSV output", command.program()),
)
})
}
fn detect_command() -> Option<AmdGpuCommand> {
*COMMAND.get_or_init(|| {
[AmdGpuCommand::AmdSmi, AmdGpuCommand::RocmSmi]
.into_iter()
.find(|command| is_installed(command.program()))
})
}
fn is_installed(program: &str) -> bool {
matches!(run_tool(program, &["--version"], SENSOR), Ok(Some(_)))
}
fn parse_power(csv: &str) -> Option<f64> {
let mut rows = csv.lines().filter(|row| !row.trim().is_empty());
let index = power_column(rows.next()?)?;
let mut total = 0.0;
let mut found = false;
for row in rows {
if let Some(watts) = row.split(',').nth(index).and_then(parse_watts) {
total += watts;
found = true;
}
}
found.then_some(total)
}
fn power_column(header: &str) -> Option<usize> {
header.split(',').position(|column| {
let column = column.trim().trim_matches('"').to_ascii_lowercase();
POWER_COLUMNS.contains(&column.as_str())
})
}
fn parse_watts(cell: &str) -> Option<f64> {
let cell = cell.trim().trim_matches('"');
cell.parse()
.ok()
.or_else(|| cell.split_whitespace().next()?.parse().ok())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_rocm_smi_output() {
let csv = "\
device,Average Graphics Package Power (W)
card0,142.0
card1,38.0
";
assert_eq!(parse_power(csv), Some(180.0));
}
#[test]
fn reads_amd_smi_output() {
let csv = "\
gpu,power_socket_power_value,power_socket_power_unit
0,100.5,W
1,20.0,W
";
assert_eq!(parse_power(csv), Some(120.5));
}
#[test]
fn a_limit_column_is_never_mistaken_for_a_reading() {
let csv = "gpu,socket_power_limit,power_cap\n0,550,550\n";
assert_eq!(parse_power(csv), None);
}
#[test]
fn cards_without_a_reading_contribute_nothing() {
let csv = "\
device,Average Graphics Package Power (W)
card0,N/A (Secondary die)
card1,42.0
";
assert_eq!(parse_power(csv), Some(42.0));
}
#[test]
fn numbers_may_carry_their_unit() {
assert_eq!(parse_power("device,Power (W)\ncard0,75 W\n"), Some(75.0));
}
#[test]
fn output_without_a_power_column_reports_nothing() {
assert_eq!(parse_power("device,Temperature (C)\ncard0,55\n"), None);
assert_eq!(parse_power("not csv"), None);
assert_eq!(parse_power(""), None);
}
#[test]
fn a_header_with_no_rows_reports_nothing() {
assert_eq!(parse_power("device,Power (W)\n"), None);
}
}
}