use super::{GpuProcess, ProcKind, clamp_pct};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::fs;
use std::os::unix::fs::{FileTypeExt, MetadataExt};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Condvar, Mutex, OnceLock};
use std::time::{Duration, Instant};
pub const PCI_IDS_PATHS: &[&str] = &["/usr/share/hwdata/pci.ids", "/usr/share/misc/pci.ids"];
const DRM_MAJOR: u64 = 226;
#[derive(Debug, Default)]
pub struct FdClient {
pub driver: String,
pub id: u64,
pub pdev: Option<String>,
pub engine_ns: HashMap<String, u64>,
pub cycles: HashMap<String, (u64, u64)>,
pub memory: HashMap<String, u64>,
}
impl FdClient {
pub fn total_engine_ns(&self) -> u64 {
self.engine_ns.values().sum()
}
pub fn engine_ns_where(&self, pred: impl Fn(&str) -> bool) -> u64 {
self.engine_ns
.iter()
.filter(|(k, _)| pred(k))
.map(|(_, v)| *v)
.sum()
}
pub fn xe_ratio(&self, prev: &FdClient, pred: impl Fn(&str) -> bool) -> f64 {
let mut best = 0.0f64;
for (name, (cyc, total)) in &self.cycles {
if !pred(name) {
continue;
}
let (pcyc, ptotal) = prev.cycles.get(name).copied().unwrap_or((0, 0));
let dt = total.saturating_sub(ptotal);
if dt == 0 {
continue;
}
best = best.max(cyc.saturating_sub(pcyc) as f64 / dt as f64);
}
best
}
}
pub fn ns_delta_util(
prev: Option<&(u64, u64, Instant)>,
engine_ns: u64,
video_ns: u64,
now: Instant,
) -> (f64, f64) {
let Some((prev_ns, prev_video, prev_at)) = prev else {
return (0.0, 0.0);
};
let wall = now.duration_since(*prev_at).as_nanos() as f64;
if wall <= 0.0 {
return (0.0, 0.0);
}
(
(engine_ns.saturating_sub(*prev_ns) as f64 / wall * 100.0).clamp(0.0, 100.0),
(video_ns.saturating_sub(*prev_video) as f64 / wall * 100.0).clamp(0.0, 100.0),
)
}
pub fn build_proc(pid: u32, gpu_index: usize, util: f64, mem: u64, graphics: bool) -> GpuProcess {
GpuProcess {
pid,
gpu_index,
kind: if graphics {
ProcKind::Graphics
} else {
ProcKind::Compute
},
gpu_util_pct: Some(clamp_pct(util)),
gpu_mem_bytes: Some(mem),
..Default::default()
}
}
pub struct SweepDevice {
pub pdev: Option<String>,
pub driver: String,
}
#[derive(Debug, Default, Clone, Copy)]
pub struct ClientSample {
pub util_pct: f64,
pub video_pct: f64,
pub mem_bytes: u64,
pub graphics: bool,
}
#[derive(Debug, Default)]
pub struct Sweep {
pub util: HashMap<usize, f64>,
pub video_util: HashMap<usize, f64>,
pub procs: Vec<GpuProcess>,
pub seen: HashSet<(u32, u64)>,
}
pub struct ProcSnapshot {
pub clients: Vec<(u32, FdClient)>,
pub at: Instant,
pub seq: u64,
}
pub fn sweep_clients<F>(snap: &ProcSnapshot, devices: &[SweepDevice], mut per_client: F) -> Sweep
where
F: FnMut(u32, usize, &FdClient) -> ClientSample,
{
let mut sweep = Sweep::default();
let mut agg: HashMap<(u32, usize), (f64, u64, bool)> = HashMap::new();
for (pid, client) in &snap.clients {
let pid = *pid;
let Some(gpu) = client_device(devices, client) else {
continue;
};
if !sweep.seen.insert((pid, client.id)) {
continue;
}
let s = per_client(pid, gpu, client);
*sweep.util.entry(gpu).or_default() += s.util_pct;
*sweep.video_util.entry(gpu).or_default() += s.video_pct;
let e = agg.entry((pid, gpu)).or_insert((0.0, 0, false));
e.0 += s.util_pct;
e.1 += s.mem_bytes;
e.2 |= s.graphics;
}
sweep.procs = agg
.into_iter()
.map(|((pid, gpu_index), (util, mem, graphics))| {
build_proc(pid, gpu_index, util, mem, graphics)
})
.collect();
sweep
}
fn scan_proc(seq: u64) -> ProcSnapshot {
let mut clients = Vec::new();
for pid in proc_pids() {
clients.extend(drm_clients(pid).into_iter().map(|c| (pid, c)));
}
ProcSnapshot {
clients,
at: Instant::now(),
seq,
}
}
const FIRST_SCAN_WAIT: Duration = Duration::from_secs(2);
const MIN_WALK_INTERVAL: Duration = Duration::from_millis(200);
pub struct ProcScanner {
state: Mutex<ScanState>,
wanted: Condvar,
published: Condvar,
synchronous: AtomicBool,
}
#[derive(Default)]
struct ScanState {
latest: Option<Arc<ProcSnapshot>>,
wanted: bool,
walking: bool,
seq: u64,
}
impl ProcScanner {
pub fn shared() -> &'static Arc<ProcScanner> {
static SCANNER: OnceLock<Arc<ProcScanner>> = OnceLock::new();
SCANNER.get_or_init(|| {
let scanner = Arc::new(ProcScanner::new());
scanner.spawn_worker();
scanner
})
}
fn new() -> ProcScanner {
ProcScanner {
state: Mutex::new(ScanState {
wanted: true,
..ScanState::default()
}),
wanted: Condvar::new(),
published: Condvar::new(),
synchronous: AtomicBool::new(false),
}
}
fn spawn_worker(self: &Arc<Self>) {
let scanner = Arc::clone(self);
let spawned = std::thread::Builder::new()
.name("gpur-proc-sweep".into())
.spawn(move || scanner.run())
.is_ok();
self.on_spawn_outcome(spawned);
}
fn on_spawn_outcome(self: &Arc<Self>, spawned: bool) {
if !spawned {
self.set_synchronous(true);
}
}
pub fn set_synchronous(&self, on: bool) {
self.synchronous.store(on, Ordering::Relaxed);
}
pub fn latest(&self) -> Option<Arc<ProcSnapshot>> {
if self.synchronous.load(Ordering::Relaxed) {
return Some(self.scan_here());
}
let mut st = self.lock();
let fresh = st
.latest
.as_ref()
.is_some_and(|s| Instant::now().saturating_duration_since(s.at) < MIN_WALK_INTERVAL);
if !st.walking && !fresh {
st.wanted = true;
self.wanted.notify_one();
}
if st.latest.is_none() {
let (guard, _) = self
.published
.wait_timeout_while(st, FIRST_SCAN_WAIT, |st| st.latest.is_none())
.unwrap_or_else(|e| e.into_inner());
st = guard;
}
st.latest.clone()
}
fn scan_here(&self) -> Arc<ProcSnapshot> {
let seq = {
let mut st = self.lock();
st.seq += 1;
st.seq
};
let snap = Arc::new(scan_proc(seq));
self.lock().latest = Some(Arc::clone(&snap));
snap
}
fn lock(&self) -> std::sync::MutexGuard<'_, ScanState> {
self.state.lock().unwrap_or_else(|e| e.into_inner())
}
fn run(self: Arc<Self>) {
loop {
let mut st = self.lock();
while !st.wanted {
st = self.wanted.wait(st).unwrap_or_else(|e| e.into_inner());
}
st.wanted = false;
st.walking = true;
let seq = st.seq + 1;
drop(st);
let snap = Arc::new(scan_proc(seq));
let mut st = self.lock();
st.seq = seq;
st.latest = Some(snap);
st.walking = false;
self.published.notify_all();
}
}
}
#[cfg(test)]
impl ProcScanner {
pub fn detached() -> Arc<ProcScanner> {
Arc::new(ProcScanner::new())
}
pub fn detached_with_worker() -> Arc<ProcScanner> {
let scanner = ProcScanner::detached();
scanner.spawn_worker();
scanner
}
pub fn publish(&self, clients: Vec<(u32, FdClient)>, at: Instant) {
let mut st = self.lock();
st.seq += 1;
st.latest = Some(Arc::new(ProcSnapshot {
clients,
at,
seq: st.seq,
}));
}
}
pub struct SweepCursor {
scanner: Arc<ProcScanner>,
seen_seq: Option<u64>,
}
impl Default for SweepCursor {
fn default() -> Self {
SweepCursor::on(Arc::clone(ProcScanner::shared()))
}
}
impl SweepCursor {
pub fn on(scanner: Arc<ProcScanner>) -> Self {
SweepCursor {
scanner,
seen_seq: None,
}
}
pub fn next(&mut self) -> Option<Arc<ProcSnapshot>> {
let snap = self.scanner.latest()?;
if self.seen_seq == Some(snap.seq) {
return None;
}
self.seen_seq = Some(snap.seq);
Some(snap)
}
}
pub fn client_device(devices: &[SweepDevice], client: &FdClient) -> Option<usize> {
let pdev = client.pdev.as_deref()?;
devices
.iter()
.position(|d| d.pdev.as_deref() == Some(pdev) && d.driver == client.driver)
}
pub fn proc_pids() -> Vec<u32> {
fs::read_dir("/proc")
.map(|rd| {
rd.flatten()
.filter_map(|e| e.file_name().to_string_lossy().parse().ok())
.collect()
})
.unwrap_or_default()
}
pub fn drm_clients(pid: u32) -> Vec<FdClient> {
let fd_dir = format!("/proc/{pid}/fd");
let Ok(entries) = fs::read_dir(&fd_dir) else {
return Vec::new(); };
entries
.flatten()
.filter_map(|e| {
let meta = fs::metadata(e.path()).ok()?;
if !meta.file_type().is_char_device() || linux_major(meta.rdev()) != DRM_MAJOR {
return None;
}
let fd = e.file_name();
let info =
fs::read_to_string(format!("/proc/{pid}/fdinfo/{}", fd.to_string_lossy())).ok()?;
parse_fdinfo(&info)
})
.collect()
}
fn linux_major(rdev: u64) -> u64 {
((rdev >> 8) & 0xfff) | ((rdev >> 32) & !0xfff)
}
pub fn parse_fdinfo(info: &str) -> Option<FdClient> {
let mut c = FdClient::default();
let mut have_id = false;
let mut resident: HashMap<String, u64> = HashMap::new();
for line in info.lines() {
let Some((key, value)) = line.split_once(':') else {
continue;
};
let value = value.trim();
if key == "drm-driver" {
c.driver = value.to_string();
} else if key == "drm-client-id" {
c.id = value.parse().ok()?;
have_id = true;
} else if key == "drm-pdev" {
c.pdev = Some(value.to_string());
} else if let Some(name) = key.strip_prefix("drm-engine-") {
if !name.starts_with("capacity") {
c.engine_ns.insert(name.to_string(), parse_ns(value));
}
} else if let Some(name) = key.strip_prefix("drm-total-cycles-") {
c.cycles.entry(name.to_string()).or_default().1 = parse_ns(value);
} else if let Some(name) = key.strip_prefix("drm-cycles-") {
c.cycles.entry(name.to_string()).or_default().0 = parse_ns(value);
} else if let Some(region) = key.strip_prefix("drm-memory-") {
c.memory.insert(region.to_string(), parse_size(value));
} else if let Some(region) = key.strip_prefix("drm-resident-") {
resident.insert(region.to_string(), parse_size(value));
}
}
for (region, bytes) in resident {
c.memory.entry(region).or_insert(bytes);
}
(have_id && !c.driver.is_empty()).then_some(c)
}
fn parse_ns(v: &str) -> u64 {
v.split_whitespace()
.next()
.and_then(|n| n.parse().ok())
.unwrap_or(0)
}
fn parse_size(v: &str) -> u64 {
let mut it = v.split_whitespace();
let n: u64 = it.next().and_then(|n| n.parse().ok()).unwrap_or(0);
match it.next() {
Some("KiB") => n.saturating_mul(1 << 10),
Some("MiB") => n.saturating_mul(1 << 20),
Some("GiB") => n.saturating_mul(1 << 30),
_ => n,
}
}
pub fn read_u64(path: &Path) -> Option<u64> {
read_trim(path)?.parse().ok()
}
pub fn hwmon_u64(hwmon: Option<&Path>, file: &str) -> Option<u64> {
read_u64(&hwmon?.join(file))
}
pub fn fan_pct(hwmon: Option<&Path>) -> Option<f64> {
let pwm = hwmon_u64(hwmon, "pwm1")?;
let max = hwmon_u64(hwmon, "pwm1_max")
.filter(|v| *v > 0)
.unwrap_or(255);
Some(pwm as f64 / max as f64 * 100.0)
}
pub fn read_trim(path: &Path) -> Option<String> {
fs::read_to_string(path).ok().map(|s| s.trim().to_string())
}
pub fn hwmon_dir(dev: &Path, driver: &str) -> Option<PathBuf> {
let dirs: Vec<PathBuf> = fs::read_dir(dev.join("hwmon"))
.ok()?
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
dirs.iter()
.find(|p| {
read_trim(&p.join("name"))
.is_some_and(|n| n == driver || n.starts_with(&format!("{driver}_")))
})
.cloned()
.or_else(|| dirs.into_iter().next())
}
pub fn card_index(file_name: &str) -> Option<u32> {
file_name.strip_prefix("card")?.parse().ok()
}
pub fn driver_of(dev: &Path) -> Option<String> {
Some(
fs::read_link(dev.join("driver"))
.ok()?
.file_name()?
.to_string_lossy()
.into_owned(),
)
}
pub fn cards_with_driver(
drm: &str,
vendor: &str,
accept: impl Fn(&str) -> bool,
) -> Vec<(u32, PathBuf, String)> {
let Ok(entries) = fs::read_dir(drm) else {
return Vec::new();
};
let mut cards: Vec<(u32, PathBuf, String)> = entries
.flatten()
.filter_map(|e| {
let idx = card_index(&e.file_name().to_string_lossy())?;
let dev = e.path().join("device");
if read_trim(&dev.join("vendor")).as_deref() != Some(vendor) {
return None;
}
let driver = driver_of(&dev).filter(|d| accept(d))?;
Some((idx, dev, driver))
})
.collect();
cards.sort_by_key(|(idx, _, _)| *idx);
cards
}
pub fn gts_to_gen(speed: &str) -> Option<u8> {
let gts: f64 = speed.split_whitespace().next()?.parse().ok()?;
Some(match gts {
s if s >= 128.0 => 7,
s if s >= 64.0 => 6,
s if s >= 32.0 => 5,
s if s >= 16.0 => 4,
s if s >= 8.0 => 3,
s if s >= 5.0 => 2,
_ => 1,
})
}
pub fn pcie_current_link(dev: &Path) -> (Option<u8>, Option<u32>) {
let speed = |f: &str| read_trim(&dev.join(f)).as_deref().and_then(gts_to_gen);
let width = |f: &str| read_trim(&dev.join(f)).and_then(|w| w.parse().ok());
(speed("current_link_speed"), width("current_link_width"))
}
pub fn pcie_max_link(dev: &Path) -> (Option<u8>, Option<u32>) {
let speed = |f: &str| read_trim(&dev.join(f)).as_deref().and_then(gts_to_gen);
let width = |f: &str| read_trim(&dev.join(f)).and_then(|w| w.parse().ok());
(speed("max_link_speed"), width("max_link_width"))
}
#[cfg(test)]
pub fn pcie_link(dev: &Path) -> (Option<u8>, Option<u32>, Option<u8>, Option<u32>) {
let (cur_gen, cur_width) = pcie_current_link(dev);
let (max_gen, max_width) = pcie_max_link(dev);
(cur_gen, cur_width, max_gen, max_width)
}
pub fn driver_line(prefix: &str) -> Option<String> {
sysinfo::System::kernel_version().map(|k| format!("{prefix} · kernel {k}"))
}
pub fn driver_line_for<'a>(drivers: impl IntoIterator<Item = &'a str>) -> Option<String> {
let unique: BTreeSet<&str> = drivers.into_iter().collect();
driver_line(&unique.into_iter().collect::<Vec<_>>().join("+"))
}
pub fn sys_mem_total_bytes() -> Option<u64> {
parse_meminfo_total(&fs::read_to_string("/proc/meminfo").ok()?)
}
fn parse_meminfo_total(meminfo: &str) -> Option<u64> {
let line = meminfo.lines().find(|l| l.starts_with("MemTotal:"))?;
let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
kb.checked_mul(1024)
}
pub fn pdev_of(dev: &Path) -> Option<String> {
fs::canonicalize(dev)
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
}
pub fn pci_device_id(pdev: Option<&str>) -> Option<String> {
pdev.map(|p| format!("pci:{p}"))
}
pub fn pci_device_name(ids: &str, vendor: &str, device: &str) -> Option<String> {
let mut in_vendor = false;
for line in ids.lines() {
if line.starts_with('#') || line.is_empty() {
continue;
}
if !line.starts_with('\t') {
in_vendor = line
.split_whitespace()
.next()
.is_some_and(|v| v.eq_ignore_ascii_case(vendor));
continue;
}
if !in_vendor || line.starts_with("\t\t") {
continue; }
let rest = line.trim_start();
if let Some((id, name)) = rest.split_once(char::is_whitespace)
&& id.eq_ignore_ascii_case(device)
{
return Some(name.trim().to_string());
}
}
None
}
static PCI_IDS: OnceLock<Option<String>> = OnceLock::new();
fn pci_ids() -> Option<&'static str> {
PCI_IDS
.get_or_init(|| {
PCI_IDS_PATHS
.iter()
.find_map(|p| fs::read_to_string(p).ok())
})
.as_deref()
}
pub fn marketing_name(entry: &str) -> String {
entry
.rsplit_once('[')
.and_then(|(_, rest)| rest.strip_suffix(']'))
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.unwrap_or_else(|| entry.to_string())
}
pub fn card_name(dev: &Path, idx: u32, vendor_hex: &str, fallback_brand: &str) -> String {
let device_id = read_trim(&dev.join("device")).unwrap_or_default();
pci_ids()
.and_then(|ids| pci_device_name(ids, vendor_hex, device_id.trim_start_matches("0x")))
.map(|name| marketing_name(&name))
.unwrap_or_else(|| format!("{fallback_brand} GPU {device_id} (card{idx})"))
}
#[cfg(test)]
pub mod testing {
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
pub struct Sandbox(PathBuf);
impl Sandbox {
pub fn new(tag: &str) -> Self {
static N: AtomicU32 = AtomicU32::new(0);
let dir = std::env::temp_dir().join(format!(
"gpur-unit-{tag}-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&dir).unwrap();
Sandbox(dir)
}
}
impl std::ops::Deref for Sandbox {
type Target = Path;
fn deref(&self) -> &Path {
&self.0
}
}
impl Drop for Sandbox {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
pub fn card(root: &Path, idx: u32, bdf: &str, vendor: &str, device: &str, driver: &str) {
let pci = root.join("pci").join(bdf);
fs::create_dir_all(&pci).unwrap();
fs::write(pci.join("vendor"), format!("{vendor}\n")).unwrap();
fs::write(pci.join("device"), format!("{device}\n")).unwrap();
if !driver.is_empty() {
let target = root.join("pci/drivers").join(driver);
fs::create_dir_all(&target).unwrap();
std::os::unix::fs::symlink(&target, pci.join("driver")).unwrap();
}
let drm = root.join("drm");
for entry in [
format!("card{idx}"),
format!("renderD{}", 128 + idx),
format!("card{idx}-DP-1"),
] {
let d = drm.join(entry);
fs::create_dir_all(&d).unwrap();
std::os::unix::fs::symlink(&pci, d.join("device")).unwrap();
}
}
pub fn platform_card(root: &Path, idx: u32) {
let plat = root.join("platform/simple-framebuffer.0");
fs::create_dir_all(&plat).unwrap();
let d = root.join("drm").join(format!("card{idx}"));
fs::create_dir_all(&d).unwrap();
std::os::unix::fs::symlink(&plat, d.join("device")).unwrap();
}
pub fn tri_vendor(name: &str) -> Sandbox {
let root = Sandbox::new(name);
card(&root, 0, "0000:00:02.0", "0x8086", "0x7d55", "i915");
card(&root, 1, "0000:03:00.0", "0x1002", "0x744c", "amdgpu");
card(&root, 2, "0000:01:00.0", "0x10de", "0x2684", "nvidia");
card(&root, 3, "0000:04:00.0", "0x10de", "0x1c03", "nouveau");
card(&root, 4, "0000:05:00.0", "0x1002", "0x6779", "radeon");
card(&root, 5, "0000:06:00.0", "0x8086", "0xe20b", "xe");
card(&root, 6, "0000:07:00.0", "0x1002", "0x1636", "");
card(&root, 7, "0000:08:00.0", "0x8086", "0x9a49", "");
card(&root, 8, "0000:09:00.0", "0x10de", "0x2504", "");
platform_card(&root, 9);
root
}
pub fn drm(root: &Path) -> String {
root.join("drm").to_string_lossy().into_owned()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pci_device_ids_are_namespaced_and_optional() {
assert_eq!(
pci_device_id(Some("0000:75:00.0")).as_deref(),
Some("pci:0000:75:00.0")
);
assert_eq!(pci_device_id(None), None);
}
const AMD_FDINFO: &str = "\
drm-driver:\tamdgpu
drm-client-id:\t7568
drm-pdev:\t0000:75:00.0
drm-engine-gfx:\t123456789 ns
drm-engine-dec:\t1000 ns
drm-memory-vram:\t12 KiB
drm-memory-gtt: \t2048 KiB
";
const I915_FDINFO: &str = "\
drm-driver:\ti915
drm-client-id:\t42
drm-pdev:\t0000:00:02.0
drm-engine-render:\t9876543 ns
drm-engine-video:\t100 ns
drm-engine-capacity-video:\t2
drm-total-local0:\t512 MiB
drm-resident-local0:\t256 MiB
drm-resident-stolen-local0:\t131072 KiB
drm-resident-system0:\t1234567
";
const XE_FDINFO: &str = "\
drm-driver:\txe
drm-client-id:\t7
drm-pdev:\t0000:03:00.0
drm-cycles-rcs:\t500
drm-total-cycles-rcs:\t1000
drm-cycles-vcs:\t10
drm-total-cycles-vcs:\t1000
drm-resident-vram0:\t4096 KiB
";
#[test]
fn parses_amdgpu_client() {
let c = parse_fdinfo(AMD_FDINFO).unwrap();
assert_eq!(c.driver, "amdgpu");
assert_eq!(c.id, 7568);
assert_eq!(c.pdev.as_deref(), Some("0000:75:00.0"));
assert_eq!(c.engine_ns["gfx"], 123_456_789);
assert_eq!(c.total_engine_ns(), 123_456_789 + 1000);
assert_eq!(c.memory["vram"], 12 << 10);
assert_eq!(c.memory["gtt"], 2048 << 10);
}
#[test]
fn parses_i915_client_skipping_capacity() {
let c = parse_fdinfo(I915_FDINFO).unwrap();
assert_eq!(c.driver, "i915");
assert_eq!(c.engine_ns["render"], 9_876_543);
assert!(!c.engine_ns.contains_key("capacity-video"));
assert_eq!(c.memory["local0"], 256 << 20);
assert_eq!(c.memory["stolen-local0"], 131_072 << 10);
assert_eq!(c.memory["system0"], 1_234_567);
assert!(!c.memory.contains_key("total-local0"));
}
#[test]
fn parse_size_honours_unit_suffix() {
assert_eq!(parse_size("1234567"), 1_234_567); assert_eq!(parse_size("12 KiB"), 12 << 10);
assert_eq!(parse_size("512 MiB"), 512 << 20);
assert_eq!(parse_size("2 GiB"), 2 << 30);
assert_eq!(parse_size("0"), 0);
assert_eq!(parse_size("0 KiB"), 0);
assert_eq!(parse_size(""), 0);
assert_eq!(parse_size("42 TiB"), 42);
assert_eq!(parse_size(&format!("{} GiB", u64::MAX)), u64::MAX);
}
#[test]
fn xe_cycles_utilization() {
let prev = parse_fdinfo(XE_FDINFO).unwrap();
let mut cur = parse_fdinfo(XE_FDINFO).unwrap();
cur.cycles.insert("rcs".into(), (800, 2000));
cur.cycles.insert("vcs".into(), (110, 2000));
assert!((cur.xe_ratio(&prev, |_| true) - 0.3).abs() < 1e-9);
assert!((cur.xe_ratio(&prev, |n| n.starts_with("vcs")) - 0.1).abs() < 1e-9);
assert_eq!(cur.memory["vram0"], 4096 * 1024);
}
#[test]
fn non_drm_fdinfo_is_none() {
assert!(parse_fdinfo("pos:\t0\nflags:\t0100002\n").is_none());
}
#[test]
fn card_index_filters_connectors_and_render_nodes() {
assert_eq!(card_index("card0"), Some(0));
assert_eq!(card_index("card12"), Some(12));
assert_eq!(card_index("card1-DP-1"), None);
assert_eq!(card_index("renderD128"), None);
assert_eq!(card_index("version"), None);
}
const IDS: &str = "\
# comment
1002 Advanced Micro Devices, Inc. [AMD/ATI]
\t13c0 Phoenix2
\t744c Navi 31 [Radeon RX 7900 XT/7900 XTX/7900M]
\t\t1002 0e3b Some subsystem
8086 Intel Corporation
\t56a0 DG2 [Arc A770]
";
fn scratch(name: &str) -> testing::Sandbox {
testing::Sandbox::new(name)
}
#[test]
fn pcie_gen_from_gts_string() {
assert_eq!(gts_to_gen("2.5 GT/s PCIe"), Some(1));
assert_eq!(gts_to_gen("8.0 GT/s PCIe"), Some(3));
assert_eq!(gts_to_gen("16.0 GT/s PCIe"), Some(4));
assert_eq!(gts_to_gen("32.0 GT/s PCIe"), Some(5));
assert_eq!(gts_to_gen("64.0 GT/s PCIe"), Some(6));
assert_eq!(gts_to_gen("Unknown"), None);
assert_eq!(gts_to_gen("garbage"), None);
}
#[test]
fn pcie_link_reads_current_and_max() {
let dev = scratch("pcie");
fs::write(dev.join("current_link_speed"), "8.0 GT/s PCIe\n").unwrap();
fs::write(dev.join("current_link_width"), "8\n").unwrap();
fs::write(dev.join("max_link_speed"), "16.0 GT/s PCIe\n").unwrap();
fs::write(dev.join("max_link_width"), "16\n").unwrap();
assert_eq!(pcie_link(&dev), (Some(3), Some(8), Some(4), Some(16)));
}
#[test]
fn pcie_link_absent_files_are_none() {
let dev = scratch("pcie-missing");
assert_eq!(pcie_link(&dev), (None, None, None, None));
fs::write(dev.join("current_link_width"), "4\n").unwrap();
fs::write(dev.join("max_link_speed"), "Unknown\n").unwrap();
assert_eq!(pcie_link(&dev), (None, Some(4), None, None));
}
#[test]
fn pcie_link_splits_current_from_max() {
let dev = scratch("pcie-halves");
fs::write(dev.join("current_link_speed"), "8.0 GT/s PCIe\n").unwrap();
fs::write(dev.join("current_link_width"), "8\n").unwrap();
assert_eq!(pcie_current_link(&dev), (Some(3), Some(8)));
assert_eq!(pcie_max_link(&dev), (None, None));
fs::write(dev.join("max_link_speed"), "16.0 GT/s PCIe\n").unwrap();
fs::write(dev.join("max_link_width"), "16\n").unwrap();
assert_eq!(pcie_max_link(&dev), (Some(4), Some(16)));
assert_eq!(pcie_link(&dev), (Some(3), Some(8), Some(4), Some(16)));
}
#[test]
fn fan_duty_is_a_percentage_of_this_cards_own_pwm_scale() {
let h = scratch("fan");
fs::write(h.join("pwm1"), "128\n").unwrap();
assert!((fan_pct(Some(&h)).unwrap() - 128.0 / 255.0 * 100.0).abs() < 1e-9);
fs::write(h.join("pwm1_max"), "100\n").unwrap();
fs::write(h.join("pwm1"), "50\n").unwrap();
assert!((fan_pct(Some(&h)).unwrap() - 50.0).abs() < 1e-9);
fs::write(h.join("pwm1_max"), "0\n").unwrap();
assert!((fan_pct(Some(&h)).unwrap() - 50.0 / 255.0 * 100.0).abs() < 1e-9);
fs::remove_file(h.join("pwm1")).unwrap();
assert_eq!(fan_pct(Some(&h)), None);
assert_eq!(fan_pct(None), None);
}
#[test]
fn hwmon_dir_prefers_the_driver_named_child_over_readdir_order() {
let dev = scratch("hwmon-driver");
let hwmon = dev.join("hwmon");
fs::create_dir_all(hwmon.join("hwmon0")).unwrap();
fs::create_dir_all(hwmon.join("hwmon1")).unwrap();
fs::write(hwmon.join("hwmon0/name"), "backlight\n").unwrap();
fs::write(hwmon.join("hwmon1/name"), "amdgpu\n").unwrap();
assert_eq!(hwmon_dir(&dev, "amdgpu"), Some(hwmon.join("hwmon1")));
}
#[test]
fn hwmon_dir_matches_the_legacy_driver_name() {
let dev = scratch("hwmon-legacy");
let hwmon = dev.join("hwmon");
fs::create_dir_all(hwmon.join("hwmon0")).unwrap();
fs::write(hwmon.join("hwmon0/name"), "amdgpu_legacy\n").unwrap();
assert_eq!(hwmon_dir(&dev, "amdgpu"), Some(hwmon.join("hwmon0")));
}
#[test]
fn hwmon_dir_falls_back_to_the_first_child_when_none_names_the_driver() {
let dev = scratch("hwmon-fallback");
let hwmon = dev.join("hwmon");
fs::create_dir_all(hwmon.join("hwmon0")).unwrap();
fs::create_dir_all(hwmon.join("hwmon1")).unwrap();
fs::write(hwmon.join("hwmon0/name"), "backlight\n").unwrap();
fs::write(hwmon.join("hwmon1/name"), "another_chip\n").unwrap();
let first = fs::read_dir(&hwmon)
.unwrap()
.flatten()
.map(|e| e.path())
.find(|p| p.is_dir())
.unwrap();
assert_eq!(hwmon_dir(&dev, "amdgpu"), Some(first));
}
#[test]
fn the_driver_line_names_every_driver_in_use_once() {
let line = |drivers: &[&str]| driver_line_for(drivers.to_vec());
let Some(kernel) = sysinfo::System::kernel_version() else {
return; };
assert_eq!(
line(&["amdgpu", "radeon"]),
Some(format!("amdgpu+radeon · kernel {kernel}"))
);
assert_eq!(line(&["radeon", "amdgpu"]), line(&["amdgpu", "radeon"]));
assert_eq!(
line(&["i915", "i915", "xe"]),
Some(format!("i915+xe · kernel {kernel}"))
);
assert_eq!(line(&["nouveau"]), driver_line("nouveau"));
}
#[test]
fn meminfo_total_parses_kb() {
assert_eq!(
parse_meminfo_total("MemTotal: 32770396 kB\nMemFree: 100 kB\n"),
Some(32_770_396 * 1024)
);
assert_eq!(parse_meminfo_total("MemFree: 100 kB\n"), None);
assert_eq!(parse_meminfo_total("MemTotal:\n"), None);
}
#[test]
fn card_scan_filters_by_name_and_by_driver() {
let root = testing::tri_vendor("scan-filters");
let drm = testing::drm(&root);
let amd = cards_with_driver(&drm, "0x1002", |d| d == "amdgpu");
assert_eq!(
amd.iter()
.map(|(i, _, d)| (*i, d.as_str()))
.collect::<Vec<_>>(),
[(1, "amdgpu")],
"renderD129/device and card1-DP-1/device read vendor 0x1002 too"
);
let both = cards_with_driver(&drm, "0x1002", |d| d == "amdgpu" || d == "radeon");
assert_eq!(
both.iter()
.map(|(i, _, d)| (*i, d.as_str()))
.collect::<Vec<_>>(),
[(1, "amdgpu"), (4, "radeon")]
);
assert!(!both.iter().any(|(i, _, _)| *i == 6));
assert!(
cards_with_driver(&drm, "0x1002", |_| true)
.iter()
.all(|(i, _, _)| *i != 9)
);
assert_eq!(
cards_with_driver(&drm, "0x8086", |d| d == "i915" || d == "xe")
.iter()
.map(|(i, _, d)| (*i, d.as_str()))
.collect::<Vec<_>>(),
[(0, "i915"), (5, "xe")]
);
assert_eq!(
cards_with_driver(&drm, "0x10de", |d| d == "nouveau")
.iter()
.map(|(i, _, d)| (*i, d.as_str()))
.collect::<Vec<_>>(),
[(3, "nouveau")]
);
assert!(
cards_with_driver(&drm, "0x1002", |_| true)
.windows(2)
.all(|w| w[0].0 < w[1].0)
);
}
#[test]
fn the_linux_backends_partition_one_drm_directory() {
let root = testing::tri_vendor("partition");
let drm = testing::drm(&root);
let amd = crate::backend::amd::claimed_ids(&drm);
let intel = crate::backend::intel::claimed_ids(&drm);
let nvidia = crate::backend::nvidia::claimed_ids(&drm);
assert_eq!(amd, ["pci:0000:03:00.0", "pci:0000:05:00.0"]);
assert_eq!(intel, ["pci:0000:00:02.0", "pci:0000:06:00.0"]);
assert_eq!(nvidia, ["pci:0000:04:00.0"]);
let all: Vec<&String> = amd.iter().chain(&intel).chain(&nvidia).collect();
let unique: HashSet<&&String> = all.iter().collect();
assert_eq!(all.len(), 5);
assert_eq!(unique.len(), all.len(), "a device claimed by two backends");
assert!(!all.iter().any(|id| id.ends_with("0000:01:00.0")));
}
#[test]
fn card_scan_resolves_the_pci_address() {
let root = testing::tri_vendor("scan-pdev");
let cards = cards_with_driver(&testing::drm(&root), "0x1002", |d| d == "amdgpu");
let (_, dev, _) = &cards[0];
assert_eq!(pdev_of(dev).as_deref(), Some("0000:03:00.0"));
assert_eq!(
pci_device_id(pdev_of(dev).as_deref()).as_deref(),
Some("pci:0000:03:00.0")
);
}
#[test]
fn clients_attribute_by_address_and_driver() {
let dev = |pdev: &str, driver: &str| SweepDevice {
pdev: Some(pdev.to_string()),
driver: driver.to_string(),
};
let amd = [dev("0000:03:00.0", "amdgpu")];
let intel = [dev("0000:00:02.0", "i915"), dev("0000:06:00.0", "xe")];
let i915_client = parse_fdinfo(I915_FDINFO).unwrap();
assert_eq!(client_device(&intel, &i915_client), Some(0));
assert_eq!(
client_device(&amd, &i915_client),
None,
"an Intel client must not land on the AMD backend's only card"
);
let amd_client = parse_fdinfo(AMD_FDINFO).unwrap();
let impostor = [dev("0000:75:00.0", "i915")];
assert_eq!(client_device(&impostor, &amd_client), None);
assert_eq!(
client_device(&[dev("0000:75:00.0", "amdgpu")], &amd_client),
Some(0)
);
let xe_client = parse_fdinfo(XE_FDINFO).unwrap();
assert_eq!(
client_device(
&[dev("0000:00:02.0", "xe"), dev("0000:03:00.0", "xe")],
&xe_client
),
Some(1)
);
let mut anon = parse_fdinfo(AMD_FDINFO).unwrap();
anon.pdev = None;
assert_eq!(client_device(&[dev("0000:75:00.0", "amdgpu")], &anon), None);
}
#[test]
fn pci_lookup_finds_device_in_vendor_section() {
assert_eq!(
pci_device_name(IDS, "1002", "744c").as_deref(),
Some("Navi 31 [Radeon RX 7900 XT/7900 XTX/7900M]")
);
assert_eq!(
pci_device_name(IDS, "8086", "56a0").as_deref(),
Some("DG2 [Arc A770]")
);
assert_eq!(pci_device_name(IDS, "1002", "0e3b"), None);
}
#[test]
fn marketing_name_prefers_the_bracketed_marketing_name() {
assert_eq!(
marketing_name("Navi 31 [Radeon RX 7900 XT/7900 XTX/7900M]"),
"Radeon RX 7900 XT/7900 XTX/7900M"
);
assert_eq!(marketing_name("DG2 [Arc A770]"), "Arc A770");
assert_eq!(
marketing_name("Vega 10 XT [Radeon RX Vega 64]"),
"Radeon RX Vega 64"
);
}
#[test]
fn marketing_name_falls_back_without_a_bracket() {
assert_eq!(
marketing_name("VGA compatible controller"),
"VGA compatible controller"
);
assert_eq!(marketing_name(""), "");
}
#[test]
fn marketing_name_keeps_an_empty_codename_out_of_the_result() {
assert_eq!(marketing_name("[Radeon RX 7900 XT]"), "Radeon RX 7900 XT");
}
fn snapshot(clients: Vec<(u32, FdClient)>) -> ProcSnapshot {
ProcSnapshot {
clients,
at: Instant::now(),
seq: 1,
}
}
fn sweep_dev(pdev: &str, driver: &str) -> SweepDevice {
SweepDevice {
pdev: Some(pdev.to_string()),
driver: driver.to_string(),
}
}
#[test]
fn the_sweep_totals_per_device_and_rows_per_process() {
let devices = [
sweep_dev("0000:75:00.0", "amdgpu"),
sweep_dev("0000:00:02.0", "i915"),
];
let amd = || parse_fdinfo(AMD_FDINFO).unwrap();
let i915 = || parse_fdinfo(I915_FDINFO).unwrap();
let mut second = amd();
second.id = 999;
let snap = snapshot(vec![(10, amd()), (10, second), (11, amd()), (11, i915())]);
let sweep = sweep_clients(&snap, &devices, |_, _, _| ClientSample {
util_pct: 10.0,
video_pct: 1.0,
mem_bytes: 1 << 20,
graphics: true,
});
assert_eq!(sweep.util[&0], 30.0, "three amdgpu clients");
assert_eq!(sweep.util[&1], 10.0, "one i915 client");
assert_eq!(sweep.video_util[&0], 3.0);
let mut rows: Vec<(u32, usize, Option<u64>)> = sweep
.procs
.iter()
.map(|p| (p.pid, p.gpu_index, p.gpu_mem_bytes))
.collect();
rows.sort_unstable();
assert_eq!(
rows,
[
(10, 0, Some(2 << 20)),
(11, 0, Some(1 << 20)),
(11, 1, Some(1 << 20)),
]
);
assert_eq!(sweep.seen.len(), 4, "one entry per (pid, client id)");
}
#[test]
fn one_client_seen_twice_is_counted_once() {
let devices = [sweep_dev("0000:75:00.0", "amdgpu")];
let client = || parse_fdinfo(AMD_FDINFO).unwrap();
let snap = snapshot(vec![(10, client()), (10, client())]);
let mut calls = 0;
let sweep = sweep_clients(&snap, &devices, |_, _, _| {
calls += 1;
ClientSample {
util_pct: 10.0,
mem_bytes: 1 << 20,
..ClientSample::default()
}
});
assert_eq!(calls, 1, "the per-client closure ran for the duplicate");
assert_eq!(sweep.util[&0], 10.0);
assert_eq!(sweep.procs.len(), 1);
assert_eq!(sweep.procs[0].gpu_mem_bytes, Some(1 << 20));
}
#[test]
fn a_backend_attributes_only_its_own_vendors_clients() {
let devices = [sweep_dev("0000:75:00.0", "amdgpu")];
let mut orphan = parse_fdinfo(AMD_FDINFO).unwrap();
orphan.pdev = None;
let snap = snapshot(vec![
(10, parse_fdinfo(AMD_FDINFO).unwrap()),
(11, parse_fdinfo(I915_FDINFO).unwrap()),
(12, parse_fdinfo(XE_FDINFO).unwrap()),
(13, orphan),
]);
let mut seen_pids = Vec::new();
let sweep = sweep_clients(&snap, &devices, |pid, _, _| {
seen_pids.push(pid);
ClientSample::default()
});
assert_eq!(seen_pids, [10]);
assert_eq!(sweep.procs.len(), 1);
assert_eq!(sweep.seen.len(), 1);
}
#[test]
fn a_cursor_attributes_each_walk_once() {
let scanner = ProcScanner::detached();
let mut cursor = SweepCursor::on(Arc::clone(&scanner));
let now = Instant::now();
scanner.publish(vec![(10, parse_fdinfo(AMD_FDINFO).unwrap())], now);
let first = cursor.next().expect("a published walk");
assert_eq!(first.clients.len(), 1);
assert!(cursor.next().is_none(), "the same walk came back twice");
assert!(cursor.next().is_none(), "and again");
scanner.publish(Vec::new(), now + Duration::from_secs(1));
let second = cursor.next().expect("the newer walk");
assert!(second.seq > first.seq);
assert!(cursor.next().is_none());
let mut other = SweepCursor::on(Arc::clone(&scanner));
assert_eq!(other.next().map(|s| s.seq), Some(second.seq));
}
#[test]
fn two_cursors_over_one_worker_share_each_walk() {
let scanner = ProcScanner::detached_with_worker();
let mut a = SweepCursor::on(Arc::clone(&scanner));
let mut b = SweepCursor::on(Arc::clone(&scanner));
let _ = a.next();
let deadline = Instant::now() + MIN_WALK_INTERVAL * 3;
let mut walks: HashSet<u64> = HashSet::new();
while Instant::now() < deadline {
if let Some(s) = a.next() {
walks.insert(s.seq);
}
if let Some(s) = b.next() {
walks.insert(s.seq);
}
std::thread::sleep(Duration::from_millis(50));
}
assert!(
walks.len() <= 5,
"{} distinct walks served to two cursors in ~3 pacing intervals",
walks.len()
);
}
#[test]
fn the_worker_thread_publishes_successive_walks() {
let scanner = ProcScanner::detached();
scanner.spawn_worker();
let mut cursor = SweepCursor::on(Arc::clone(&scanner));
let first = cursor.next().expect("the worker published a first walk");
let deadline = Instant::now() + FIRST_SCAN_WAIT;
let mut walks = vec![first];
while walks.len() < 3 && Instant::now() < deadline {
if let Some(s) = cursor.next() {
walks.push(s);
}
}
assert_eq!(walks.len(), 3, "the worker stopped publishing");
for w in walks.windows(2) {
assert!(w[1].seq > w[0].seq, "a walk was published twice");
assert!(w[1].at > w[0].at, "walks are not ordered in time");
}
}
#[test]
fn synchronous_mode_walks_on_the_calling_thread() {
let scanner = ProcScanner::detached();
scanner.set_synchronous(true);
let mut cursor = SweepCursor::on(Arc::clone(&scanner));
let asked = Instant::now();
let first = cursor.next().expect("walked here");
let second = cursor.next().expect("walked here again");
assert!(second.seq > first.seq, "the same walk was served twice");
assert!(first.at >= asked, "served a walk older than the request");
assert!(second.at > first.at);
}
#[test]
fn a_refused_worker_spawn_degrades_to_synchronous_mode() {
let refused = ProcScanner::detached();
refused.on_spawn_outcome(false);
assert!(
refused.synchronous.load(Ordering::Relaxed),
"a refused spawn did not switch to synchronous mode"
);
let accepted = ProcScanner::detached();
accepted.on_spawn_outcome(true);
assert!(
!accepted.synchronous.load(Ordering::Relaxed),
"a successful spawn switched to synchronous mode"
);
}
#[test]
fn the_worker_serves_paced_walks_not_one_per_request() {
let scanner = ProcScanner::detached_with_worker();
let mut cursor = SweepCursor::on(Arc::clone(&scanner));
let first = cursor.next().expect("a first walk");
let deadline = Instant::now() + MIN_WALK_INTERVAL * 3 / 2;
let mut walks = vec![first];
while Instant::now() < deadline {
if let Some(s) = cursor.next() {
walks.push(s);
}
std::thread::sleep(Duration::from_millis(10));
}
assert!(walks.len() <= 4, "walked per request: {}", walks.len());
for w in walks.windows(2) {
let gap = w[1].at.saturating_duration_since(w[0].at);
assert!(
gap >= MIN_WALK_INTERVAL.saturating_sub(Duration::from_millis(100)),
"walks {gap:?} apart"
);
}
}
#[test]
fn a_swept_row_reports_zero_memory_as_a_measurement() {
assert_eq!(build_proc(1, 0, 0.0, 0, false).gpu_mem_bytes, Some(0));
assert_eq!(
build_proc(2, 0, 50.0, 1 << 30, true).gpu_mem_bytes,
Some(1 << 30)
);
}
}