use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use crate::backend::{BackendError, GpuBackend};
use crate::model::{
now_ms, DeviceId, DynamicSample, ProcessKind, ProcessSample, StaticInfo, ThrottleReasons,
Vendor,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Dialect {
I915,
Xe,
}
fn read_parse<T: std::str::FromStr>(path: &Path) -> Option<T> {
fs::read_to_string(path).ok()?.trim().parse().ok()
}
fn read_trim(path: &Path) -> Option<String> {
fs::read_to_string(path).ok().map(|s| s.trim().to_string())
}
fn read_hex_id(path: &Path) -> Option<String> {
let s = read_trim(path)?;
let s = s.strip_prefix("0x").unwrap_or(&s).to_ascii_lowercase();
(!s.is_empty()).then_some(s)
}
fn uevent_field(dev_path: &Path, key: &str) -> Option<String> {
fs::read_to_string(dev_path.join("uevent"))
.ok()?
.lines()
.find_map(|l| l.strip_prefix(key))
.map(|s| s.trim().to_string())
}
fn known_gpu_name(device: &str) -> Option<&'static str> {
Some(match device {
"56a0" => "Intel Arc A770",
"56a1" => "Intel Arc A750",
"56a5" => "Intel Arc A380",
"e20b" => "Intel Arc B580",
"e20c" => "Intel Arc B570",
_ => return None,
})
}
fn gpu_name(dev_path: &Path) -> String {
match read_hex_id(&dev_path.join("device")) {
Some(id) => known_gpu_name(&id)
.map(str::to_string)
.unwrap_or_else(|| format!("Intel GPU [8086:{id}]")),
None => "Intel GPU".into(),
}
}
fn temp_c(hwmon: &Path, dialect: Dialect) -> Option<f32> {
let file = match dialect {
Dialect::I915 => "temp1_input",
Dialect::Xe => "temp2_input",
};
let millic: i64 = read_parse(&hwmon.join(file))?;
Some(millic as f32 / 1000.0)
}
fn power_cap_mw(hwmon: &Path) -> Option<u32> {
let uw: u64 = read_parse(&hwmon.join("power1_max"))?;
Some((uw / 1000) as u32)
}
fn energy_delta_mw(prev_uj: u64, prev_ts_ms: u64, cur_uj: u64, cur_ts_ms: u64) -> Option<u32> {
let wall_ms = cur_ts_ms.checked_sub(prev_ts_ms)?;
if wall_ms == 0 {
return None;
}
let uj = cur_uj.checked_sub(prev_uj)?;
Some((uj / wall_ms) as u32)
}
#[derive(Default)]
struct FdinfoDrm {
pdev: Option<String>,
render_ns: Option<u64>,
video_ns: Option<u64>,
venh_ns: Option<u64>,
compute_ns: Option<u64>,
rcs_cycles: Option<u64>,
rcs_total_cycles: Option<u64>,
vcs_cycles: Option<u64>,
vecs_cycles: Option<u64>,
ccs_cycles: Option<u64>,
total_local_bytes: Option<u64>,
resident_local_bytes: Option<u64>,
}
impl FdinfoDrm {
fn merge_max(&mut self, other: FdinfoDrm) {
fn mx(a: &mut Option<u64>, b: Option<u64>) {
*a = match (*a, b) {
(Some(x), Some(y)) => Some(x.max(y)),
(x, y) => x.or(y),
};
}
mx(&mut self.render_ns, other.render_ns);
mx(&mut self.video_ns, other.video_ns);
mx(&mut self.venh_ns, other.venh_ns);
mx(&mut self.compute_ns, other.compute_ns);
mx(&mut self.rcs_cycles, other.rcs_cycles);
mx(&mut self.rcs_total_cycles, other.rcs_total_cycles);
mx(&mut self.vcs_cycles, other.vcs_cycles);
mx(&mut self.vecs_cycles, other.vecs_cycles);
mx(&mut self.ccs_cycles, other.ccs_cycles);
mx(&mut self.total_local_bytes, other.total_local_bytes);
mx(&mut self.resident_local_bytes, other.resident_local_bytes);
}
fn local_mem_bytes(&self) -> Option<u64> {
self.total_local_bytes.or(self.resident_local_bytes)
}
fn kind(&self) -> ProcessKind {
let any_busy = |fields: &[Option<u64>]| fields.iter().any(|f| f.is_some_and(|v| v > 0));
if any_busy(&[
self.video_ns,
self.venh_ns,
self.vcs_cycles,
self.vecs_cycles,
]) {
ProcessKind::Graphics
} else if any_busy(&[self.compute_ns, self.ccs_cycles]) {
ProcessKind::Compute
} else {
ProcessKind::Unknown
}
}
}
fn parse_fdinfo(contents: &str) -> FdinfoDrm {
let mut out = FdinfoDrm::default();
for line in contents.lines() {
let Some((key, val)) = line.split_once(':') else {
continue;
};
let val = val.trim();
match key.trim() {
"drm-pdev" => out.pdev = Some(val.to_ascii_lowercase()),
"drm-engine-render" => out.render_ns = parse_suffixed(val, "ns"),
"drm-engine-video" => out.video_ns = parse_suffixed(val, "ns"),
"drm-engine-video-enhance" => out.venh_ns = parse_suffixed(val, "ns"),
"drm-engine-compute" => out.compute_ns = parse_suffixed(val, "ns"),
"drm-cycles-rcs" => out.rcs_cycles = val.parse().ok(),
"drm-total-cycles-rcs" => out.rcs_total_cycles = val.parse().ok(),
"drm-cycles-vcs" => out.vcs_cycles = val.parse().ok(),
"drm-cycles-vecs" => out.vecs_cycles = val.parse().ok(),
"drm-cycles-ccs" => out.ccs_cycles = val.parse().ok(),
"drm-total-local0" | "drm-total-vram0" => {
out.total_local_bytes = parse_mem_bytes(val);
}
"drm-resident-local0" | "drm-resident-vram0" => {
out.resident_local_bytes = parse_mem_bytes(val);
}
_ => {}
}
}
out
}
fn parse_suffixed(val: &str, unit: &str) -> Option<u64> {
val.strip_suffix(unit)?.trim().parse().ok()
}
fn parse_mem_bytes(val: &str) -> Option<u64> {
if let Some(kib) = val.strip_suffix("KiB") {
return kib.trim().parse::<u64>().ok()?.checked_mul(1024);
}
if let Some(mib) = val.strip_suffix("MiB") {
return mib.trim().parse::<u64>().ok()?.checked_mul(1024 * 1024);
}
val.parse().ok()
}
fn engine_util_pct(prev_ns: u64, prev_ts_ms: u64, cur_ns: u64, cur_ts_ms: u64) -> Option<f32> {
let wall_ms = cur_ts_ms.checked_sub(prev_ts_ms)?;
if wall_ms == 0 {
return None;
}
let busy_ns = cur_ns.checked_sub(prev_ns)?;
let pct = busy_ns as f64 / (wall_ms as f64 * 1_000_000.0) * 100.0;
Some(pct.min(100.0) as f32)
}
fn cycles_util_pct(prev_c: u64, prev_t: u64, cur_c: u64, cur_t: u64) -> Option<f32> {
let total = cur_t.checked_sub(prev_t)?;
if total == 0 {
return None;
}
let busy = cur_c.checked_sub(prev_c)?;
let pct = busy as f64 / total as f64 * 100.0;
Some(pct.min(100.0) as f32)
}
fn status_grants_full_proc_scan(status: &str) -> bool {
const CAP_SYS_PTRACE: u32 = 19;
for line in status.lines() {
if let Some(uids) = line.strip_prefix("Uid:") {
if uids.split_whitespace().nth(1) == Some("0") {
return true;
}
}
if let Some(mask) = line.strip_prefix("CapEff:") {
if let Ok(bits) = u64::from_str_radix(mask.trim(), 16) {
if bits & (1 << CAP_SYS_PTRACE) != 0 {
return true;
}
}
}
}
false
}
fn fdinfo_process_hint(root: &Path) -> Option<String> {
let full = fs::read_to_string(root.join("proc/self/status"))
.is_ok_and(|s| status_grants_full_proc_scan(&s));
(!full)
.then(|| "showing your processes only — others need root or CAP_SYS_PTRACE (fdinfo)".into())
}
fn comm_name(root: &Path, pid: u32) -> String {
if let Ok(comm) = fs::read_to_string(root.join(format!("proc/{pid}/comm"))) {
let comm = comm.trim();
if !comm.is_empty() {
return comm.to_string();
}
}
format!("pid {pid}")
}
fn first_hwmon(dev_path: &Path) -> Option<PathBuf> {
let entries = fs::read_dir(dev_path.join("hwmon")).ok()?;
let mut dirs: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
dirs.sort(); dirs.into_iter().next()
}
struct IntelDevice {
id: DeviceId,
dialect: Dialect,
card_path: PathBuf,
dev_path: PathBuf,
hwmon: Option<PathBuf>,
}
impl IntelDevice {
fn xe_freq(&self) -> PathBuf {
self.dev_path.join("tile0/gt0/freq0")
}
fn act_freq_mhz(&self) -> Option<u32> {
let (act, cur) = match self.dialect {
Dialect::I915 => (
self.card_path.join("gt_act_freq_mhz"),
self.card_path.join("gt_cur_freq_mhz"),
),
Dialect::Xe => (
self.xe_freq().join("act_freq"),
self.xe_freq().join("cur_freq"),
),
};
match read_parse::<u32>(&act) {
Some(0) => None,
Some(mhz) => Some(mhz),
None => read_parse(&cur),
}
}
fn max_freq_mhz(&self) -> Option<u32> {
match self.dialect {
Dialect::I915 => read_parse(&self.card_path.join("gt_RP0_freq_mhz")),
Dialect::Xe => read_parse(&self.xe_freq().join("rp0_freq")),
}
}
fn throttle(&self) -> Option<ThrottleReasons> {
let (dir, status, pl1, pl2, pl4, thermal, prochot, ratl, vr_thermalert, vr_tdc) =
match self.dialect {
Dialect::I915 => (
self.card_path.join("gt/gt0"),
"throttle_reason_status",
"throttle_reason_pl1",
"throttle_reason_pl2",
"throttle_reason_pl4",
"throttle_reason_thermal",
"throttle_reason_prochot",
"throttle_reason_ratl",
"throttle_reason_vr_thermalert",
"throttle_reason_vr_tdc",
),
Dialect::Xe => (
self.xe_freq().join("throttle"),
"status",
"reason_pl1",
"reason_pl2",
"reason_pl4",
"reason_thermal",
"reason_prochot",
"reason_ratl",
"reason_vr_thermalert",
"reason_vr_tdc",
),
};
let status_raw = fs::read_to_string(dir.join(status)).ok()?;
match status_raw.trim() {
"1" => {}
"0" => return Some(ThrottleReasons::default()),
_ => return None,
}
let r = |name: &str| read_throttle_bit(&dir.join(name));
let reasons = ThrottleReasons {
thermal: r(thermal),
power_cap: r(pl1) || r(pl2) || r(pl4),
hw_slowdown: r(prochot),
other: r(ratl) || r(vr_thermalert) || r(vr_tdc),
sync_boost: false, };
if reasons.any() {
Some(reasons)
} else {
Some(ThrottleReasons {
other: true,
..ThrottleReasons::default()
})
}
}
}
fn read_throttle_bit(path: &Path) -> bool {
read_trim(path).as_deref() == Some("1")
}
fn discover(root: &Path) -> Vec<IntelDevice> {
let Ok(entries) = fs::read_dir(root.join("sys/class/drm")) else {
return Vec::new();
};
let mut cards: Vec<(u32, PathBuf)> = entries
.flatten()
.filter_map(|e| {
let name = e.file_name().into_string().ok()?;
let idx: u32 = name.strip_prefix("card")?.parse().ok()?;
Some((idx, e.path()))
})
.collect();
cards.sort_by_key(|(idx, _)| *idx);
let mut devs = Vec::new();
for (_, card_path) in cards {
let dev_path = card_path.join("device");
if read_trim(&dev_path.join("vendor")).as_deref() != Some("0x8086") {
continue;
}
let dialect = match uevent_field(&dev_path, "DRIVER=").as_deref() {
Some("i915") => Dialect::I915,
Some("xe") => Dialect::Xe,
_ => continue,
};
let Some(pci) = uevent_field(&dev_path, "PCI_SLOT_NAME=").filter(|s| !s.is_empty()) else {
continue;
};
let hwmon = first_hwmon(&dev_path);
devs.push(IntelDevice {
id: DeviceId(pci.to_ascii_lowercase()),
dialect,
card_path,
dev_path,
hwmon,
});
}
devs
}
pub struct IntelBackend {
root: PathBuf,
devs: Vec<IntelDevice>,
last_render: HashMap<(DeviceId, u32), (u64, u64)>,
last_cycles: HashMap<(DeviceId, u32), (u64, u64)>,
last_energy: HashMap<DeviceId, (u64, u64)>,
process_hint: Option<String>,
#[cfg(target_os = "linux")]
cpu: crate::proc_meta::CpuTracker,
}
impl IntelBackend {
pub fn init() -> Result<Self, BackendError> {
Self::with_root("/")
}
pub fn with_root(root: impl Into<PathBuf>) -> Result<Self, BackendError> {
let root = root.into();
let devs = discover(&root);
if devs.is_empty() {
return Err(BackendError::Unavailable(
"no i915/xe devices under sys/class/drm".into(),
));
}
let process_hint = fdinfo_process_hint(&root);
Ok(Self {
root,
devs,
last_render: HashMap::new(),
last_cycles: HashMap::new(),
last_energy: HashMap::new(),
process_hint,
#[cfg(target_os = "linux")]
cpu: crate::proc_meta::CpuTracker::new(),
})
}
fn device(&self, dev: &DeviceId) -> Result<&IntelDevice, BackendError> {
self.devs
.iter()
.find(|d| &d.id == dev)
.ok_or_else(|| BackendError::DeviceNotFound(dev.clone()))
}
}
impl GpuBackend for IntelBackend {
fn name(&self) -> &'static str {
"intel"
}
fn devices(&mut self) -> Vec<DeviceId> {
self.devs.iter().map(|d| d.id.clone()).collect()
}
fn static_info(&mut self, dev: &DeviceId) -> Result<StaticInfo, BackendError> {
let d = self.device(dev)?;
Ok(StaticInfo {
id: dev.clone(),
vendor: Vendor::Intel,
name: gpu_name(&d.dev_path),
backend: "intel".into(),
mem_total_bytes: match d.dialect {
Dialect::I915 => read_parse(&d.card_path.join("lmem_total_bytes")),
Dialect::Xe => None,
},
power_limit_mw: d.hwmon.as_deref().and_then(power_cap_mw),
max_sm_clock_mhz: d.max_freq_mhz(),
temp_slowdown_c: None,
driver_version: None,
process_hint: self.process_hint.clone(),
source_caveat: None,
})
}
fn refresh_dynamic(&mut self, dev: &DeviceId) -> Result<DynamicSample, BackendError> {
let d = self.device(dev)?;
let sm_clock_mhz = d.act_freq_mhz();
let temp_c = d.hwmon.as_deref().and_then(|h| temp_c(h, d.dialect));
let energy_uj: Option<u64> = d
.hwmon
.as_deref()
.and_then(|h| read_parse(&h.join("energy1_input")));
let throttle = d.throttle();
let ts = now_ms();
let power_mw = energy_uj.and_then(|cur| {
let prev = self.last_energy.insert(dev.clone(), (cur, ts));
prev.and_then(|(p_uj, p_ts)| energy_delta_mw(p_uj, p_ts, cur, ts))
});
Ok(DynamicSample {
ts_ms: ts,
util_pct: None,
util_engine: None,
mem_used_bytes: None,
power_mw,
temp_c,
fan_pct: None,
sm_clock_mhz,
mem_clock_mhz: None,
encoder_pct: None,
decoder_pct: None,
throttle,
})
}
fn refresh_processes(&mut self, dev: &DeviceId) -> Result<Vec<ProcessSample>, BackendError> {
let (pci, dialect) = {
let d = self.device(dev)?;
(d.id.0.clone(), d.dialect)
};
let ts = now_ms();
let mut by_pid: HashMap<u32, FdinfoDrm> = HashMap::new();
if let Ok(entries) = fs::read_dir(self.root.join("proc")) {
for entry in entries.flatten() {
let Some(pid) = entry
.file_name()
.to_str()
.and_then(|s| s.parse::<u32>().ok())
else {
continue;
};
let Ok(fds) = fs::read_dir(entry.path().join("fdinfo")) else {
continue;
};
for fd in fds.flatten() {
let Ok(contents) = fs::read_to_string(fd.path()) else {
continue;
};
let info = parse_fdinfo(&contents);
if info.pdev.as_deref() != Some(pci.as_str()) {
continue;
}
by_pid.entry(pid).or_default().merge_max(info);
}
}
}
let mut out: Vec<ProcessSample> = Vec::with_capacity(by_pid.len());
for (&pid, agg) in &by_pid {
let util_pct = match dialect {
Dialect::I915 => agg.render_ns.and_then(|cur| {
let prev = self.last_render.insert((dev.clone(), pid), (cur, ts));
prev.and_then(|(p_ns, p_ts)| engine_util_pct(p_ns, p_ts, cur, ts))
}),
Dialect::Xe => match (agg.rcs_cycles, agg.rcs_total_cycles) {
(Some(c), Some(t)) => {
let prev = self.last_cycles.insert((dev.clone(), pid), (c, t));
prev.and_then(|(p_c, p_t)| cycles_util_pct(p_c, p_t, c, t))
}
_ => None,
},
};
out.push(ProcessSample {
pid,
name: comm_name(&self.root, pid),
kind: agg.kind(),
mem_bytes: agg.local_mem_bytes(),
util_pct,
cpu_pct: None,
container: None,
});
}
self.last_render
.retain(|(d, pid), _| d != dev || by_pid.contains_key(pid));
self.last_cycles
.retain(|(d, pid), _| d != dev || by_pid.contains_key(pid));
#[cfg(target_os = "linux")]
{
for p in &mut out {
p.cpu_pct = self.cpu.sample(p.pid);
p.container = crate::proc_meta::container_of(p.pid);
}
let live: Vec<u32> = out.iter().map(|p| p.pid).collect();
self.cpu.prune(&live);
}
out.sort_by_key(|p| p.pid); Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fdinfo_i915_keys_require_ns_suffix() {
let blob = "drm-pdev:\t0000:03:00.0\ndrm-engine-render:\t123 ns\n\
drm-engine-video:\t456 ns\ndrm-total-local0:\t786432 KiB\n";
let f = parse_fdinfo(blob);
assert_eq!(f.pdev.as_deref(), Some("0000:03:00.0"));
assert_eq!(f.render_ns, Some(123));
assert_eq!(f.video_ns, Some(456));
assert_eq!(f.total_local_bytes, Some(786_432 * 1024));
assert_eq!(f.compute_ns, None, "absent key stays None");
assert_eq!(f.rcs_cycles, None, "no xe keys in an i915 blob");
assert_eq!(parse_suffixed("123", "ns"), None);
assert_eq!(parse_suffixed("123 ms", "ns"), None);
}
#[test]
fn fdinfo_xe_keys_are_bare_cycle_counts() {
let blob = "drm-pdev:\t0000:03:00.0\ndrm-cycles-rcs:\t1000000\n\
drm-total-cycles-rcs:\t50000000\ndrm-cycles-ccs:\t8000000\n\
drm-total-vram0:\t2097152 KiB\ndrm-resident-vram0:\t1048576 KiB\n";
let f = parse_fdinfo(blob);
assert_eq!(f.rcs_cycles, Some(1_000_000));
assert_eq!(f.rcs_total_cycles, Some(50_000_000));
assert_eq!(f.ccs_cycles, Some(8_000_000));
assert_eq!(f.total_local_bytes, Some(2_147_483_648));
assert_eq!(f.resident_local_bytes, Some(1_073_741_824));
assert_eq!(f.render_ns, None, "no i915 keys in an xe blob");
assert_eq!(f.local_mem_bytes(), Some(2_147_483_648));
}
#[test]
fn mem_values_scale_per_drm_print_memory_stats() {
assert_eq!(parse_mem_bytes("4096"), Some(4096)); assert_eq!(parse_mem_bytes("4096 KiB"), Some(4_194_304));
assert_eq!(parse_mem_bytes("12 MiB"), Some(12_582_912));
assert_eq!(parse_mem_bytes("12 GiB"), None); }
#[test]
fn hostile_fdinfo_values_cannot_panic_or_wrap() {
assert_eq!(parse_mem_bytes("18446744073709551615 KiB"), None);
assert_eq!(parse_mem_bytes("18446744073709551615 MiB"), None);
let f = parse_fdinfo(
"drm-engine-video:\t18446744073709551615 ns\ndrm-engine-video-enhance:\t1 ns\n",
);
assert_eq!(f.kind(), ProcessKind::Graphics);
let f = parse_fdinfo(
"drm-engine-compute:\t18446744073709551615 ns\ndrm-cycles-ccs:\t18446744073709551615\n",
);
assert_eq!(f.kind(), ProcessKind::Compute);
}
#[test]
fn i915_engine_util_needs_baseline_and_handles_resets() {
assert_eq!(engine_util_pct(0, 0, 500_000_000, 1_000), Some(50.0));
assert_eq!(engine_util_pct(900, 0, 100, 1_000), None);
assert_eq!(engine_util_pct(0, 1_000, 100, 1_000), None);
assert_eq!(engine_util_pct(0, 0, 10_000_000_000, 1_000), Some(100.0));
}
#[test]
fn xe_cycles_util_is_over_total_cycles_not_wall_time() {
assert_eq!(
cycles_util_pct(1_000_000, 50_000_000, 1_600_000, 51_200_000),
Some(50.0)
);
assert_eq!(cycles_util_pct(900, 0, 100, 1_000), None);
assert_eq!(cycles_util_pct(0, 900, 100, 100), None);
assert_eq!(cycles_util_pct(0, 500, 100, 500), None);
assert_eq!(cycles_util_pct(0, 0, 4_000, 1_000), Some(100.0));
}
#[test]
fn energy_delta_is_microjoules_over_milliseconds() {
assert_eq!(energy_delta_mw(0, 0, 5_000_000, 1_000), Some(5_000));
assert_eq!(energy_delta_mw(900, 0, 100, 1_000), None);
assert_eq!(energy_delta_mw(0, 1_000, 100, 1_000), None);
}
#[test]
fn kind_is_honest_about_render_only_clients() {
let render_only = parse_fdinfo("drm-engine-render:\t100 ns\n");
assert_eq!(render_only.kind(), ProcessKind::Unknown);
let media = parse_fdinfo("drm-engine-render:\t100 ns\ndrm-engine-video:\t5 ns\n");
assert_eq!(media.kind(), ProcessKind::Graphics);
let ccs_i915 = parse_fdinfo("drm-engine-compute:\t5 ns\n");
assert_eq!(ccs_i915.kind(), ProcessKind::Compute);
let ccs_xe = parse_fdinfo("drm-cycles-ccs:\t5\ndrm-cycles-rcs:\t9\n");
assert_eq!(ccs_xe.kind(), ProcessKind::Compute);
assert_eq!(parse_fdinfo("").kind(), ProcessKind::Unknown);
}
#[test]
fn proc_status_privilege_detection() {
assert!(status_grants_full_proc_scan(
"Uid:\t1000\t0\t1000\t1000\nCapEff:\t0000000000000000"
));
assert!(status_grants_full_proc_scan(
"Uid:\t1000\t1000\t1000\t1000\nCapEff:\t0000000000080000"
));
assert!(!status_grants_full_proc_scan(
"Uid:\t1000\t1000\t1000\t1000\nCapEff:\t0000000000000000"
));
assert!(!status_grants_full_proc_scan(""));
}
#[test]
fn known_arc_names_resolve_and_unknowns_do_not() {
assert_eq!(known_gpu_name("56a0"), Some("Intel Arc A770"));
assert_eq!(known_gpu_name("e20b"), Some("Intel Arc B580"));
assert_eq!(
known_gpu_name("46a6"),
None,
"iGPUs fall back to the PCI id"
);
}
}