use std::collections::VecDeque;
use std::fs;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
const GPU_POLL_INTERVAL: Duration = Duration::from_millis(250);
const GPU_UTIL_WINDOW: usize = 32;
#[derive(Debug, Clone, Default)]
pub struct GpuSnapshot {
pub device_index: u8,
pub name: String,
pub util_percent: Option<f32>,
pub vram_allocated_bytes: Option<u64>,
pub vram_total_bytes: Option<u64>,
}
#[derive(Debug, Clone, Default)]
pub struct ResourceSample {
pub cpu_percent: Option<f32>,
pub ram_used_bytes: Option<u64>,
pub ram_total_bytes: Option<u64>,
pub gpu_util_percent: Option<f32>,
pub vram_total_bytes: Option<u64>,
pub vram_allocated_bytes: Option<u64>,
pub aggregate_rank: Option<u8>,
pub gpus: Vec<GpuSnapshot>,
}
impl ResourceSample {
pub fn summary(&self) -> String {
let mut parts = Vec::new();
if let Some(cpu) = self.cpu_percent {
parts.push(format!("CPU: {:.0}%", cpu));
}
if let (Some(used), Some(total)) = (self.ram_used_bytes, self.ram_total_bytes) {
parts.push(format!(
"RAM: {}/{}",
super::format::format_bytes(used),
super::format::format_bytes(total),
));
}
if self.gpus.len() > 1 {
for gpu in &self.gpus {
if let Some(alloc) = gpu.vram_allocated_bytes {
let spill = match gpu.vram_total_bytes {
Some(total) if alloc > total => alloc - total,
_ => 0,
};
let util = gpu.util_percent.map(|u| format!(" ({:.0}%)", u)).unwrap_or_default();
parts.push(format!(
"GPU{}: {} / {}{}",
gpu.device_index,
super::format::format_bytes(alloc),
super::format::format_bytes(spill),
util,
));
}
}
} else {
if let Some(gpu) = self.gpu_util_percent {
parts.push(format!("GPU: {:.0}%", gpu));
}
if let Some(alloc) = self.vram_allocated_bytes {
let spill = match self.vram_total_bytes {
Some(total) if alloc > total => alloc - total,
_ => 0,
};
parts.push(format!(
"VRAM: {} / {}",
super::format::format_bytes(alloc),
super::format::format_bytes(spill),
));
}
}
parts.join(" | ")
}
}
#[derive(Clone)]
pub(super) struct CpuTimes {
pub(super) total: u64,
pub(super) idle: u64,
}
struct GpuUtilAccum {
samples: Vec<VecDeque<f32>>,
}
struct GpuPollerHandle {
accum: Arc<Mutex<GpuUtilAccum>>,
stop: Arc<AtomicBool>,
thread: Option<thread::JoinHandle<()>>,
}
impl Drop for GpuPollerHandle {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
if let Some(h) = self.thread.take() {
let _ = h.join();
}
}
}
struct GpuStatic {
physical_index: u8,
name: String,
total_bytes: Option<u64>,
}
fn detect_gpu_statics() -> Vec<GpuStatic> {
if !cfg!(feature = "cuda") {
return Vec::new();
}
crate::sys::detect_gpus()
.into_iter()
.map(|g| GpuStatic {
physical_index: g.index,
name: g.name,
total_bytes: Some(g.total_memory_mb * 1024 * 1024),
})
.collect()
}
pub struct ResourceSampler {
prev_cpu: Option<CpuTimes>,
gpus: Vec<GpuStatic>,
gpu_poller: Option<GpuPollerHandle>,
}
impl Default for ResourceSampler {
fn default() -> Self {
Self::new()
}
}
impl ResourceSampler {
pub fn new() -> Self {
let prev_cpu = read_cpu_times();
let gpus = detect_gpu_statics();
let gpu_poller = Self::start_gpu_poller(&gpus);
Self { prev_cpu, gpus, gpu_poller }
}
fn start_gpu_poller(gpus: &[GpuStatic]) -> Option<GpuPollerHandle> {
if gpus.is_empty() {
return None;
}
let physical: Vec<u8> = gpus.iter().map(|g| g.physical_index).collect();
let accum = Arc::new(Mutex::new(GpuUtilAccum {
samples: physical.iter().map(|_| VecDeque::with_capacity(GPU_UTIL_WINDOW)).collect(),
}));
let stop = Arc::new(AtomicBool::new(false));
let accum2 = accum.clone();
let stop2 = stop.clone();
let thread = thread::Builder::new()
.name("gpu-util-poller".into())
.spawn(move || {
while !stop2.load(Ordering::Relaxed) {
thread::sleep(GPU_POLL_INTERVAL);
if stop2.load(Ordering::Relaxed) {
break;
}
if let Ok(mut acc) = accum2.lock() {
for (i, &phys) in physical.iter().enumerate() {
if let Some(util) = crate::tensor::cuda_utilization_idx(phys as i32) {
let buf = &mut acc.samples[i];
if buf.len() == GPU_UTIL_WINDOW {
buf.pop_front();
}
buf.push_back(util as f32);
}
}
}
}
})
.ok()?;
Some(GpuPollerHandle {
accum,
stop,
thread: Some(thread),
})
}
pub fn sample(&mut self) -> ResourceSample {
let mut s = ResourceSample::default();
if let Some(current) = read_cpu_times() {
if let Some(ref prev) = self.prev_cpu {
let d_total = current.total.saturating_sub(prev.total);
let d_idle = current.idle.saturating_sub(prev.idle);
if d_total > 0 {
s.cpu_percent = Some(
(d_total.saturating_sub(d_idle) as f32 / d_total as f32) * 100.0,
);
}
}
self.prev_cpu = Some(current);
}
if let Some((used, total)) = read_meminfo() {
s.ram_used_bytes = Some(used);
s.ram_total_bytes = Some(total);
}
let n = self.gpus.len();
let util_averages: Vec<Option<f32>> = if let Some(ref poller) = self.gpu_poller {
if let Ok(acc) = poller.accum.lock() {
acc.samples
.iter()
.map(|buf| {
if buf.is_empty() {
None
} else {
let sum: f32 = buf.iter().sum();
Some(sum / buf.len() as f32)
}
})
.collect()
} else {
vec![None; n]
}
} else {
vec![None; n]
};
for (i, g) in self.gpus.iter().enumerate() {
let mut gpu = GpuSnapshot {
device_index: g.physical_index,
name: g.name.clone(),
vram_total_bytes: g.total_bytes,
..Default::default()
};
if crate::tensor::cuda_has_primary_context(i as i32) {
if let Ok(alloc) = crate::tensor::cuda_allocated_bytes_idx(i as i32) {
gpu.vram_allocated_bytes = Some(alloc);
}
}
gpu.util_percent = util_averages.get(i).copied().flatten()
.or_else(|| {
crate::tensor::cuda_utilization_idx(g.physical_index as i32)
.map(|u| u as f32)
});
s.gpus.push(gpu);
}
if !s.gpus.is_empty() {
let pick = crate::rng::Rng::from_entropy().usize(s.gpus.len());
let g = &s.gpus[pick];
s.aggregate_rank = Some(g.device_index);
s.vram_total_bytes = g.vram_total_bytes;
s.vram_allocated_bytes = g.vram_allocated_bytes;
s.gpu_util_percent = g.util_percent;
}
s
}
}
pub(super) fn read_cpu_times() -> Option<CpuTimes> {
let content = fs::read_to_string("/proc/stat").ok()?;
let line = content.lines().next()?;
if !line.starts_with("cpu ") {
return None;
}
let fields: Vec<u64> = line
.split_whitespace()
.skip(1)
.filter_map(|s| s.parse().ok())
.collect();
if fields.len() < 4 {
return None;
}
let total: u64 = fields.iter().sum();
let idle = fields[3] + fields.get(4).copied().unwrap_or(0); Some(CpuTimes { total, idle })
}
pub(super) fn read_meminfo() -> Option<(u64, u64)> {
let content = fs::read_to_string("/proc/meminfo").ok()?;
let mut total: Option<u64> = None;
let mut available: Option<u64> = None;
for line in content.lines() {
if let Some(rest) = line.strip_prefix("MemTotal:") {
total = parse_kb_value(rest);
} else if let Some(rest) = line.strip_prefix("MemAvailable:") {
available = parse_kb_value(rest);
}
if total.is_some() && available.is_some() {
break;
}
}
match (total, available) {
(Some(t), Some(a)) => Some((t.saturating_sub(a), t)),
_ => None,
}
}
pub(super) fn parse_kb_value(s: &str) -> Option<u64> {
let val: u64 = s.split_whitespace().next()?.parse().ok()?;
Some(val * 1024) }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resource_sampler_never_initializes_cuda() {
if crate::tensor::cuda_has_primary_context(0) {
eprintln!("skipped: CUDA context already present in this process");
return;
}
let mut sampler = ResourceSampler::new();
let s = sampler.sample();
std::thread::sleep(Duration::from_millis(550));
let _ = sampler.sample();
assert!(
!crate::tensor::cuda_has_primary_context(0),
"ResourceSampler must not create a CUDA context"
);
for gpu in &s.gpus {
assert!(gpu.vram_allocated_bytes.is_none());
}
}
}