use super::{MetricBuilder, MetricExporter};
use crate::device::GpuInfo;
use crate::metrics::energy::{EnergyScope, PowerIntegrator};
pub struct EnergyMetricExporter<'a> {
integrator: &'a PowerIntegrator,
gpu_info: &'a [GpuInfo],
}
impl<'a> EnergyMetricExporter<'a> {
pub fn new(integrator: &'a PowerIntegrator, gpu_info: &'a [GpuInfo]) -> Self {
Self {
integrator,
gpu_info,
}
}
}
impl<'a> MetricExporter for EnergyMetricExporter<'a> {
fn export_metrics(&self) -> String {
let mut builder = MetricBuilder::new();
let stats: Vec<_> = self.integrator.iter_stats().collect();
if stats.is_empty() {
return builder.build();
}
builder
.help(
"all_smi_energy_consumed_joules_total",
"Cumulative energy consumption in Joules.",
)
.type_("all_smi_energy_consumed_joules_total", "counter");
for stat in stats {
if stat.lifetime_joules <= 0.0 {
continue;
}
match stat.key.scope {
EnergyScope::Gpu => {
let (host, uuid) = (stat.key.host.as_str(), stat.key.device.as_str());
let gpu_index = self
.gpu_info
.iter()
.position(|g| g.hostname == host && g.uuid == uuid)
.map(|i| i.to_string())
.unwrap_or_else(|| "0".to_string());
let labels = [
("host", host),
("scope", "gpu"),
("gpu_index", gpu_index.as_str()),
("gpu_uuid", uuid),
];
builder.metric(
"all_smi_energy_consumed_joules_total",
&labels,
format!("{:.3}", stat.lifetime_joules),
);
}
EnergyScope::Cpu => {
let labels = [("host", stat.key.host.as_str()), ("scope", "cpu")];
builder.metric(
"all_smi_energy_consumed_joules_total",
&labels,
format!("{:.3}", stat.lifetime_joules),
);
}
EnergyScope::Chassis => {
let labels = [("host", stat.key.host.as_str()), ("scope", "chassis")];
builder.metric(
"all_smi_energy_consumed_joules_total",
&labels,
format!("{:.3}", stat.lifetime_joules),
);
}
}
}
builder.build()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::metrics::energy::EnergyKey;
use std::collections::HashMap;
use std::time::{Duration, Instant};
fn make_gpu(hostname: &str, uuid: &str) -> GpuInfo {
GpuInfo {
uuid: uuid.to_string(),
time: String::new(),
name: "Mock GPU".to_string(),
device_type: "GPU".to_string(),
host_id: hostname.to_string(),
hostname: hostname.to_string(),
instance: hostname.to_string(),
utilization: 0.0,
ane_utilization: 0.0,
dla_utilization: None,
tensorcore_utilization: None,
temperature: 0,
used_memory: 0,
total_memory: 0,
frequency: 0,
power_consumption: 0.0,
gpu_core_count: None,
temperature_threshold_slowdown: None,
temperature_threshold_shutdown: None,
temperature_threshold_max_operating: None,
temperature_threshold_acoustic: None,
performance_state: None,
fan_speed_rpm: None,
numa_node_id: None,
gsp_firmware_mode: None,
gsp_firmware_version: None,
nvlink_remote_devices: Vec::new(),
gpm_metrics: None,
detail: HashMap::new(),
}
}
#[test]
fn empty_integrator_emits_nothing() {
let integ = PowerIntegrator::default();
let out = EnergyMetricExporter::new(&integ, &[]).export_metrics();
assert!(out.is_empty(), "expected empty output, got:\n{out}");
}
#[test]
fn emits_gpu_and_chassis_rows_with_expected_labels() {
let mut integ = PowerIntegrator::default();
let origin = Instant::now();
let gpu_key = EnergyKey::gpu("dgx-01", "GPU-AAA");
integ.record_sample(gpu_key.clone(), origin, 300.0);
integ.record_sample(gpu_key.clone(), origin + Duration::from_secs(10), 300.0);
let chassis_key = EnergyKey::chassis("dgx-01");
integ.record_sample(chassis_key.clone(), origin, 450.0);
integ.record_sample(chassis_key.clone(), origin + Duration::from_secs(10), 450.0);
let gpus = vec![make_gpu("dgx-01", "GPU-AAA")];
let out = EnergyMetricExporter::new(&integ, &gpus).export_metrics();
assert!(
out.contains("# TYPE all_smi_energy_consumed_joules_total counter"),
"exposition header missing:\n{out}"
);
assert!(out.contains(r#"scope="gpu""#), "gpu row missing:\n{out}");
assert!(
out.contains(r#"gpu_uuid="GPU-AAA""#),
"gpu_uuid label missing:\n{out}"
);
assert!(
out.contains(r#"gpu_index="0""#),
"gpu_index label missing:\n{out}"
);
assert!(
out.contains(r#"scope="chassis""#),
"chassis row missing:\n{out}"
);
}
#[test]
fn counter_is_monotonic_across_scrapes() {
let mut integ = PowerIntegrator::default();
let origin = Instant::now();
let key = EnergyKey::gpu("host", "uuid");
integ.record_sample(key.clone(), origin, 100.0);
integ.record_sample(key.clone(), origin + Duration::from_secs(10), 100.0);
let gpus = vec![make_gpu("host", "uuid")];
let scrape1 = EnergyMetricExporter::new(&integ, &gpus).export_metrics();
integ.record_sample(key.clone(), origin + Duration::from_secs(20), 100.0);
let scrape2 = EnergyMetricExporter::new(&integ, &gpus).export_metrics();
fn last_value(exposition: &str) -> f64 {
exposition
.lines()
.filter(|l| l.starts_with("all_smi_energy_consumed_joules_total"))
.filter_map(|l| l.rsplit(' ').next())
.filter_map(|s| s.parse::<f64>().ok())
.next_back()
.expect("expected at least one counter line")
}
assert!(
last_value(&scrape2) >= last_value(&scrape1),
"counter regressed across scrapes: {} -> {}",
last_value(&scrape1),
last_value(&scrape2)
);
}
#[test]
fn session_reset_does_not_rewind_exported_counter() {
let mut integ = PowerIntegrator::default();
let origin = Instant::now();
let key = EnergyKey::gpu("host", "uuid");
integ.record_sample(key.clone(), origin, 100.0);
integ.record_sample(key.clone(), origin + Duration::from_secs(10), 100.0);
let lifetime_before = integ.lifetime_joules(&key);
integ.reset_session();
assert_eq!(integ.session_joules(&key), 0.0);
assert!((integ.lifetime_joules(&key) - lifetime_before).abs() < 1e-9);
}
}