gpuviewer_core/intel.rs
1//! Intel Linux backend — hand-rolled `std::fs` readers over sysfs/fdinfo, no library
2//! linkage (no Level Zero, no IGCL: per docs/research/02 their Linux coverage is gappy
3//! and root-gated; plain fdinfo+sysfs is the strong path).
4//!
5//! The defining caveat: **i915 and xe are two different worlds** (research 02). Same
6//! vendor, two in-tree drivers, different fdinfo keys AND different sysfs freq layouts —
7//! every read below is dispatched on a per-device [`Dialect`] detected from the uevent:
8//! - i915 (Gen9 → Meteor Lake, DG2 default): fdinfo `drm-engine-*` cumulative busy-ns
9//! (kernel 5.19+), per-process memory regions named `local0`/`system0` (6.8+),
10//! card-level `gt_*_freq_mhz` files, dGPU-only `lmem_total_bytes`, hwmon package
11//! temp on `temp1_input` (6.12+).
12//! - xe (Lunar Lake, Battlemage+): fdinfo `drm-cycles-*` / `drm-total-cycles-*` GT-clock
13//! counters (6.11+), memory regions named `vram0`/`system`/`gtt` (6.8+),
14//! `device/tile0/gt0/freq0/*` freq files, NO VRAM-total sysfs at all, hwmon package
15//! temp on `temp2_input` — there is NO temp1, and temp3 is the VRAM sensor (6.15+).
16//!
17//! Per the domain rules in CLAUDE.md:
18//! - Every path derives from a root-dir parameter (`with_root`), so the whole backend
19//! runs against committed fixture trees; `init()` is just `with_root("/")`.
20//! - A missing file or unparsable value is `None`, never a failure — an iGPU has NO
21//! hwmon at all, so temp/power/fan absence is the NORMAL case, not a broken device.
22//! hwmon itself is effectively dGPU-only and recent-kernel-gated (i915 fan/temp
23//! 6.12+; xe temps 6.15+, fans 6.16+).
24//! - Device-level utilization is deliberately `None`: the i915/xe perf PMU needs
25//! root/CAP_PERFMON (intel_gpu_top's infamous "Failed to initialize PMU"; the xe PMU
26//! only exists since 6.15), and summing fdinfo across clients we may not be able to
27//! see would understate — an invented number is worse than an honest absence.
28//! - The xe per-process utilization math is NOT the i915 math: Δbusy-cycles over
29//! Δtotal-cycles, never over wall time — see [`cycles_util_pct`].
30//! - Other users' fdinfo needs root/CAP_SYS_PTRACE, so unprivileged runs carry an
31//! honest "your processes only" `process_hint` (same model as the AMD backend).
32
33use std::collections::HashMap;
34use std::fs;
35use std::path::{Path, PathBuf};
36
37use crate::backend::{BackendError, GpuBackend};
38use crate::model::{
39 now_ms, DeviceId, DynamicSample, ProcessKind, ProcessSample, StaticInfo, ThrottleReasons,
40 Vendor,
41};
42
43/// Which in-tree Intel KMS driver owns a device, from the uevent's DRIVER= field.
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45enum Dialect {
46 I915,
47 Xe,
48}
49
50/// Read a sysfs file and parse its trimmed contents; absent file or bad value → `None`.
51fn read_parse<T: std::str::FromStr>(path: &Path) -> Option<T> {
52 fs::read_to_string(path).ok()?.trim().parse().ok()
53}
54
55/// Read a sysfs file as a trimmed string; absent file → `None`.
56fn read_trim(path: &Path) -> Option<String> {
57 fs::read_to_string(path).ok().map(|s| s.trim().to_string())
58}
59
60/// Read a sysfs PCI id ("0x56a0\n" → "56a0", lowercased). An empty file is not an id —
61/// `None`, so the name fallback says "Intel GPU" rather than "Intel GPU [8086:]".
62fn read_hex_id(path: &Path) -> Option<String> {
63 let s = read_trim(path)?;
64 let s = s.strip_prefix("0x").unwrap_or(&s).to_ascii_lowercase();
65 (!s.is_empty()).then_some(s)
66}
67
68/// One uevent field by key ("DRIVER=" → "i915").
69fn uevent_field(dev_path: &Path, key: &str) -> Option<String> {
70 fs::read_to_string(dev_path.join("uevent"))
71 .ok()?
72 .lines()
73 .find_map(|l| l.strip_prefix(key))
74 .map(|s| s.trim().to_string())
75}
76
77/// Tiny well-known-id table for the discrete Arc parts a dGPU fixture/user is likely to
78/// hit; anything else falls back to the honest PCI-id form rather than a guessed name
79/// (there is no shipped equivalent of libdrm's `amdgpu.ids` for Intel).
80fn known_gpu_name(device: &str) -> Option<&'static str> {
81 Some(match device {
82 "56a0" => "Intel Arc A770",
83 "56a1" => "Intel Arc A750",
84 "56a5" => "Intel Arc A380",
85 "e20b" => "Intel Arc B580",
86 "e20c" => "Intel Arc B570",
87 _ => return None,
88 })
89}
90
91fn gpu_name(dev_path: &Path) -> String {
92 match read_hex_id(&dev_path.join("device")) {
93 Some(id) => known_gpu_name(&id)
94 .map(str::to_string)
95 .unwrap_or_else(|| format!("Intel GPU [8086:{id}]")),
96 None => "Intel GPU".into(),
97 }
98}
99
100/// hwmon temps are MILLI-degrees C — but the package/GT sensor lives on a DIFFERENT
101/// channel per dialect (research 07 §3.2): i915 exposes it as `temp1_input` (gate
102/// 6.12+); xe hwmon has NO temp1 — its package temp is `temp2_input` and `temp3_input`
103/// is the VRAM sensor (gate 6.15+, ABI-documented). Each dialect reads exactly its
104/// documented channel and never falls back to another one: temp3 (VRAM) surfaced as
105/// device temp would be a different physical claim, and a wrong-channel read on either
106/// dialect presents some other sensor as GPU temp. The designated file being absent
107/// (older kernels, the no-hwmon iGPU case) is the normal outcome → `None`.
108fn temp_c(hwmon: &Path, dialect: Dialect) -> Option<f32> {
109 let file = match dialect {
110 Dialect::I915 => "temp1_input",
111 Dialect::Xe => "temp2_input",
112 };
113 let millic: i64 = read_parse(&hwmon.join(file))?;
114 Some(millic as f32 / 1000.0)
115}
116
117/// `power1_max` (the sustained power limit — Intel hwmon has no `power1_cap`) is
118/// MICROwatts; the model carries milliwatts.
119fn power_cap_mw(hwmon: &Path) -> Option<u32> {
120 let uw: u64 = read_parse(&hwmon.join("power1_max"))?;
121 Some((uw / 1000) as u32)
122}
123
124/// i915/xe hwmon exposes no instantaneous power reading — only the cumulative
125/// `energy1_input` MICROjoule counter. Power is the delta between two sightings:
126/// ΔµJ / Δms = mW exactly. No baseline or a counter that went backwards → `None`.
127fn energy_delta_mw(prev_uj: u64, prev_ts_ms: u64, cur_uj: u64, cur_ts_ms: u64) -> Option<u32> {
128 let wall_ms = cur_ts_ms.checked_sub(prev_ts_ms)?;
129 if wall_ms == 0 {
130 return None;
131 }
132 let uj = cur_uj.checked_sub(prev_uj)?;
133 Some((uj / wall_ms) as u32)
134}
135
136/// The fdinfo keys this backend consumes, across BOTH dialects (the key sets are
137/// disjoint, so one parser serves both). Anything missing — older kernel (i915 engine
138/// busy-ns 5.19+, per-process memory 6.8+, xe cycles 6.11+), non-DRM fd — simply stays
139/// `None`: the process is still listed, its absent columns are honest.
140#[derive(Default)]
141struct FdinfoDrm {
142 pdev: Option<String>,
143 // i915 dialect: cumulative per-engine-class busy-ns.
144 render_ns: Option<u64>,
145 video_ns: Option<u64>,
146 venh_ns: Option<u64>,
147 compute_ns: Option<u64>,
148 // xe dialect: cumulative busy-cycles and the matching elapsed-cycles base, in
149 // GT-clock ticks (NOT time units — see `cycles_util_pct`).
150 rcs_cycles: Option<u64>,
151 rcs_total_cycles: Option<u64>,
152 vcs_cycles: Option<u64>,
153 vecs_cycles: Option<u64>,
154 ccs_cycles: Option<u64>,
155 // Device-local memory, instance 0 only: i915 names the region "local0", xe names
156 // it "vram0". System-RAM regions are deliberately NOT read — an iGPU's buffers in
157 // system memory must not masquerade as VRAM.
158 total_local_bytes: Option<u64>,
159 resident_local_bytes: Option<u64>,
160}
161
162impl FdinfoDrm {
163 /// Max-merge across one pid's many fds on the same device: the fds describe the same
164 /// client's buffers, so summing would double-count — max keeps the fullest view.
165 fn merge_max(&mut self, other: FdinfoDrm) {
166 fn mx(a: &mut Option<u64>, b: Option<u64>) {
167 *a = match (*a, b) {
168 (Some(x), Some(y)) => Some(x.max(y)),
169 (x, y) => x.or(y),
170 };
171 }
172 mx(&mut self.render_ns, other.render_ns);
173 mx(&mut self.video_ns, other.video_ns);
174 mx(&mut self.venh_ns, other.venh_ns);
175 mx(&mut self.compute_ns, other.compute_ns);
176 mx(&mut self.rcs_cycles, other.rcs_cycles);
177 mx(&mut self.rcs_total_cycles, other.rcs_total_cycles);
178 mx(&mut self.vcs_cycles, other.vcs_cycles);
179 mx(&mut self.vecs_cycles, other.vecs_cycles);
180 mx(&mut self.ccs_cycles, other.ccs_cycles);
181 mx(&mut self.total_local_bytes, other.total_local_bytes);
182 mx(&mut self.resident_local_bytes, other.resident_local_bytes);
183 }
184
185 /// `drm-total-<region>` (all buffers) preferred; `drm-resident-<region>` is the
186 /// fallback when a kernel exposes only the resident split.
187 fn local_mem_bytes(&self) -> Option<u64> {
188 self.total_local_bytes.or(self.resident_local_bytes)
189 }
190
191 /// Honest kind attribution: a busy video/video-enhance engine is decisively media
192 /// (rendered to the table as Graphics); a busy compute engine (i915 CCS class /
193 /// xe ccs) is decisively Compute. Render-only activity stays Unknown — the render
194 /// engine runs BOTH 3D and pre-CCS GPGPU, so calling it either would be a guess.
195 fn kind(&self) -> ProcessKind {
196 // Per-field "is any busy" checks, never a sum: these are unvalidated u64s from
197 // fdinfo, and summing values near u64::MAX panics debug builds / wraps in
198 // release. (Blobs parse before the pdev filter, so one corrupt blob anywhere
199 // in /proc would take the whole scan down.)
200 let any_busy = |fields: &[Option<u64>]| fields.iter().any(|f| f.is_some_and(|v| v > 0));
201 if any_busy(&[
202 self.video_ns,
203 self.venh_ns,
204 self.vcs_cycles,
205 self.vecs_cycles,
206 ]) {
207 ProcessKind::Graphics
208 } else if any_busy(&[self.compute_ns, self.ccs_cycles]) {
209 ProcessKind::Compute
210 } else {
211 ProcessKind::Unknown
212 }
213 }
214}
215
216/// Parse one fdinfo blob ("key:\tvalue" lines). i915 engine values carry a mandatory
217/// "ns" suffix; xe cycle counters are bare uints (no unit is the unit, per
218/// drm-usage-stats); memory values follow `drm_print_memory_stats` scaling — bytes by
219/// default, "KiB"/"MiB" when the kernel scaled them. Guessing units is the classic
220/// fdinfo parsing bug, so each key gets exactly its documented unit handling.
221fn parse_fdinfo(contents: &str) -> FdinfoDrm {
222 let mut out = FdinfoDrm::default();
223 for line in contents.lines() {
224 let Some((key, val)) = line.split_once(':') else {
225 continue;
226 };
227 let val = val.trim();
228 match key.trim() {
229 "drm-pdev" => out.pdev = Some(val.to_ascii_lowercase()),
230 "drm-engine-render" => out.render_ns = parse_suffixed(val, "ns"),
231 "drm-engine-video" => out.video_ns = parse_suffixed(val, "ns"),
232 "drm-engine-video-enhance" => out.venh_ns = parse_suffixed(val, "ns"),
233 "drm-engine-compute" => out.compute_ns = parse_suffixed(val, "ns"),
234 "drm-cycles-rcs" => out.rcs_cycles = val.parse().ok(),
235 "drm-total-cycles-rcs" => out.rcs_total_cycles = val.parse().ok(),
236 "drm-cycles-vcs" => out.vcs_cycles = val.parse().ok(),
237 "drm-cycles-vecs" => out.vecs_cycles = val.parse().ok(),
238 "drm-cycles-ccs" => out.ccs_cycles = val.parse().ok(),
239 "drm-total-local0" | "drm-total-vram0" => {
240 out.total_local_bytes = parse_mem_bytes(val);
241 }
242 "drm-resident-local0" | "drm-resident-vram0" => {
243 out.resident_local_bytes = parse_mem_bytes(val);
244 }
245 _ => {}
246 }
247 }
248 out
249}
250
251fn parse_suffixed(val: &str, unit: &str) -> Option<u64> {
252 val.strip_suffix(unit)?.trim().parse().ok()
253}
254
255/// drm-usage-stats memory value: bytes by default, the kernel's print helper scales to
256/// "KiB"/"MiB" when evenly divisible. Scaling is overflow-checked: a count that exceeds
257/// u64 bytes cannot be real memory, and unchecked `*` would panic a debug build or wrap
258/// to a fabricated number in release.
259fn parse_mem_bytes(val: &str) -> Option<u64> {
260 if let Some(kib) = val.strip_suffix("KiB") {
261 return kib.trim().parse::<u64>().ok()?.checked_mul(1024);
262 }
263 if let Some(mib) = val.strip_suffix("MiB") {
264 return mib.trim().parse::<u64>().ok()?.checked_mul(1024 * 1024);
265 }
266 val.parse().ok()
267}
268
269/// i915 utilization: engine counters are cumulative busy-NANOSECONDS, so utilization is
270/// the busy delta over WALL time between two sightings. No baseline (first sighting) or
271/// a counter that went backwards (pid reuse re-created the client) → `None`, never a
272/// guess.
273fn engine_util_pct(prev_ns: u64, prev_ts_ms: u64, cur_ns: u64, cur_ts_ms: u64) -> Option<f32> {
274 let wall_ms = cur_ts_ms.checked_sub(prev_ts_ms)?;
275 if wall_ms == 0 {
276 return None;
277 }
278 let busy_ns = cur_ns.checked_sub(prev_ns)?;
279 let pct = busy_ns as f64 / (wall_ms as f64 * 1_000_000.0) * 100.0;
280 Some(pct.min(100.0) as f32)
281}
282
283/// xe utilization: `drm-cycles-*` are GT-clock ticks, paired with a `drm-total-cycles-*`
284/// elapsed base in the SAME ticks. Utilization is Δbusy-cycles / Δtotal-cycles — NOT
285/// divided by wall time. Dividing cycle counts by wall nanoseconds is the classic
286/// i915→xe porting bug: GT-clock frequency changes with DVFS, so cycles have no fixed
287/// time value and only the kernel-provided base is a valid denominator. Clamped because
288/// multi-engine classes (drm-engine-capacity > 1) can run busy-cycles past the base.
289fn cycles_util_pct(prev_c: u64, prev_t: u64, cur_c: u64, cur_t: u64) -> Option<f32> {
290 let total = cur_t.checked_sub(prev_t)?;
291 if total == 0 {
292 return None;
293 }
294 let busy = cur_c.checked_sub(prev_c)?;
295 let pct = busy as f64 / total as f64 * 100.0;
296 Some(pct.min(100.0) as f32)
297}
298
299/// `/proc/self/status` says whether the fdinfo scan can see every user's processes:
300/// euid 0, or CAP_SYS_PTRACE (bit 19) in the effective capability mask.
301fn status_grants_full_proc_scan(status: &str) -> bool {
302 const CAP_SYS_PTRACE: u32 = 19;
303 for line in status.lines() {
304 if let Some(uids) = line.strip_prefix("Uid:") {
305 // Fields: real, effective, saved, fs — effective is what access checks use.
306 if uids.split_whitespace().nth(1) == Some("0") {
307 return true;
308 }
309 }
310 if let Some(mask) = line.strip_prefix("CapEff:") {
311 if let Ok(bits) = u64::from_str_radix(mask.trim(), 16) {
312 if bits & (1 << CAP_SYS_PTRACE) != 0 {
313 return true;
314 }
315 }
316 }
317 }
318 false
319}
320
321/// `StaticInfo::process_hint` for unprivileged runs: other users' fdinfo is unreadable
322/// without root/CAP_SYS_PTRACE, so the process table is honestly incomplete — say so up
323/// front instead of pretending it covers the machine. An unreadable status file reads as
324/// unprivileged: overstating incompleteness is safe, understating it would be a lie.
325fn fdinfo_process_hint(root: &Path) -> Option<String> {
326 let full = fs::read_to_string(root.join("proc/self/status"))
327 .is_ok_and(|s| status_grants_full_proc_scan(&s));
328 (!full)
329 .then(|| "showing your processes only — others need root or CAP_SYS_PTRACE (fdinfo)".into())
330}
331
332/// Process name from `{root}/proc/<pid>/comm` (kernel-truncated to 15 chars); a pid
333/// placeholder when even that is unreadable.
334fn comm_name(root: &Path, pid: u32) -> String {
335 if let Ok(comm) = fs::read_to_string(root.join(format!("proc/{pid}/comm"))) {
336 let comm = comm.trim();
337 if !comm.is_empty() {
338 return comm.to_string();
339 }
340 }
341 format!("pid {pid}")
342}
343
344/// First hwmon dir under the device. hwmon indices are not stable across boots, so it is
345/// resolved through the device dir at init; absence (every iGPU, pre-gate kernels,
346/// fixtures) is normal.
347fn first_hwmon(dev_path: &Path) -> Option<PathBuf> {
348 let entries = fs::read_dir(dev_path.join("hwmon")).ok()?;
349 let mut dirs: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
350 dirs.sort(); // read_dir order is arbitrary
351 dirs.into_iter().next()
352}
353
354/// One enumerated i915/xe PCI device, resolved at init.
355struct IntelDevice {
356 id: DeviceId,
357 dialect: Dialect,
358 /// `{root}/sys/class/drm/cardN` — i915 keeps `gt_*_freq_mhz` and `lmem_*` HERE,
359 /// at the card level, not under the PCI dir.
360 card_path: PathBuf,
361 /// `{root}/sys/class/drm/cardN/device` — the PCI dir; xe's freq tree hangs off it.
362 dev_path: PathBuf,
363 hwmon: Option<PathBuf>,
364}
365
366impl IntelDevice {
367 /// xe per-GT freq dir (kernel 6.8+ layout; the fixtures model 6.11). tile0/gt0 is
368 /// the primary render GT — a media GT's clock is not the "SM clock".
369 fn xe_freq(&self) -> PathBuf {
370 self.dev_path.join("tile0/gt0/freq0")
371 }
372
373 /// Current frequency, from the actual (measured) file. It reads 0 while the GT is
374 /// power-gated in RC6 — that is "not clocked right now", not a measured rate, and
375 /// in `--json` a literal 0 is indistinguishable from a measurement: per-field
376 /// absence is the model's idiom for it. The requested file (`cur`) only covers an
377 /// absent `act` file (old kernels) — it is never consulted for a sleeping GT,
378 /// because claiming the requested clock while the GT sleeps would be a lie.
379 fn act_freq_mhz(&self) -> Option<u32> {
380 let (act, cur) = match self.dialect {
381 Dialect::I915 => (
382 self.card_path.join("gt_act_freq_mhz"),
383 self.card_path.join("gt_cur_freq_mhz"),
384 ),
385 Dialect::Xe => (
386 self.xe_freq().join("act_freq"),
387 self.xe_freq().join("cur_freq"),
388 ),
389 };
390 match read_parse::<u32>(&act) {
391 Some(0) => None,
392 Some(mhz) => Some(mhz),
393 None => read_parse(&cur),
394 }
395 }
396
397 /// Hardware maximum (RP0). `gt_max_freq_mhz`/`max_freq` are user-settable caps,
398 /// not the hardware limit, so they are deliberately not read here.
399 fn max_freq_mhz(&self) -> Option<u32> {
400 match self.dialect {
401 Dialect::I915 => read_parse(&self.card_path.join("gt_RP0_freq_mhz")),
402 Dialect::Xe => read_parse(&self.xe_freq().join("rp0_freq")),
403 }
404 }
405
406 /// Decode the per-GT throttle ("performance limit") reasons that BOTH drivers publish
407 /// as one-bit-per-file sysfs flags — the metric `intel_gpu_top` still does not surface
408 /// on xe, so this is the displacement story for that hardware. The two dialects expose
409 /// the SAME nine reasons under different paths and different filename spellings
410 /// (verified against the in-tree drivers, June 2026):
411 ///
412 /// - i915: `{card}/gt/gt0/throttle_reason_{status,pl1,pl2,pl4,thermal,prochot,ratl,vr_thermalert,vr_tdc}`,
413 /// created per-GT in `intel_gt_sysfs_pm.c`.
414 /// - xe: `{dev}/tile0/gt0/freq0/throttle/{status,reason_pl1,reason_pl2,reason_pl4,reason_thermal,reason_prochot,reason_ratl,reason_vr_thermalert,reason_vr_tdc}`,
415 /// the "throttle" attribute group on the freq0 kobject in `xe_gt_throttle.c`.
416 ///
417 /// Each file contains 0 or 1. tile0/gt0 is the primary render GT, matching the freq
418 /// reads above; a media GT throttling is not the render device's story.
419 ///
420 /// `status` is the GATE, never the union of the reason files: some kernels report a
421 /// stale 1 in a reason file while `status` is 0 (the limit cleared but the latched
422 /// reason bit lingers). Trusting `status` means we only ever narrate a throttle that
423 /// is actually happening now — a confidently-wrong throttle event would kill the
424 /// product's trust thesis.
425 ///
426 /// Observability split (§5.4): an absent/unreadable/garbage `status` file means this
427 /// kernel exposes no working throttle interface for the GT — **unobservable**,
428 /// `None`, never an asserted "not throttling". An explicit `0` IS an observation
429 /// (`Some(default)`: the hardware says no limit is active), REGARDLESS of what the
430 /// (possibly stale-latched) reason files say.
431 fn throttle(&self) -> Option<ThrottleReasons> {
432 // Filenames differ per dialect; the meaning of each does not.
433 let (dir, status, pl1, pl2, pl4, thermal, prochot, ratl, vr_thermalert, vr_tdc) =
434 match self.dialect {
435 Dialect::I915 => (
436 self.card_path.join("gt/gt0"),
437 "throttle_reason_status",
438 "throttle_reason_pl1",
439 "throttle_reason_pl2",
440 "throttle_reason_pl4",
441 "throttle_reason_thermal",
442 "throttle_reason_prochot",
443 "throttle_reason_ratl",
444 "throttle_reason_vr_thermalert",
445 "throttle_reason_vr_tdc",
446 ),
447 Dialect::Xe => (
448 self.xe_freq().join("throttle"),
449 "status",
450 "reason_pl1",
451 "reason_pl2",
452 "reason_pl4",
453 "reason_thermal",
454 "reason_prochot",
455 "reason_ratl",
456 "reason_vr_thermalert",
457 "reason_vr_tdc",
458 ),
459 };
460
461 // Missing/unreadable status file: no throttle interface → unobservable (None).
462 // An explicit "0": observed not-throttling — the reason files are not even
463 // consulted (some report stale latched 1s). Garbage content is a broken
464 // interface, which is unobservable too — refusing beats guessing.
465 let status_raw = fs::read_to_string(dir.join(status)).ok()?;
466 match status_raw.trim() {
467 "1" => {}
468 "0" => return Some(ThrottleReasons::default()),
469 _ => return None,
470 }
471
472 // status==1: something IS limiting performance. Map each recognized reason file
473 // into the model's vocabulary; unreadable/absent reason files read as not-set.
474 let r = |name: &str| read_throttle_bit(&dir.join(name));
475 let reasons = ThrottleReasons {
476 thermal: r(thermal),
477 power_cap: r(pl1) || r(pl2) || r(pl4),
478 hw_slowdown: r(prochot),
479 // RATL (run-average thermal limit) and the VR alerts have no closer model
480 // bucket than "other"; they ARE real limits, just not ones the UI names.
481 other: r(ratl) || r(vr_thermalert) || r(vr_tdc),
482 sync_boost: false, // an NVIDIA-only concept; no Intel sysfs source.
483 };
484 // status says we're throttling but no reason file we recognize is set (a reason
485 // bit this build doesn't map, or files the kernel didn't expose): assert the
486 // honest minimum — throttling, cause unknown — rather than swallow the signal.
487 if reasons.any() {
488 Some(reasons)
489 } else {
490 Some(ThrottleReasons {
491 other: true,
492 ..ThrottleReasons::default()
493 })
494 }
495 }
496}
497
498/// Read a 0/1 throttle flag file: `true` only on an explicit "1". Absent, unreadable, or
499/// non-"1" (including garbage) → `false`, so a broken/old kernel never fabricates a
500/// throttle. Mirrors the "absence is normal" contract used everywhere else here.
501fn read_throttle_bit(path: &Path) -> bool {
502 read_trim(path).as_deref() == Some("1")
503}
504
505/// Enumerate `{root}/sys/class/drm/cardN` dirs whose vendor id is Intel (0x8086).
506/// Connector nodes ("card2-DP-1") and render nodes are skipped; a card without a
507/// PCI_SLOT_NAME has no stable identity and is skipped rather than guessed; an Intel
508/// device bound to neither i915 nor xe speaks neither fdinfo dialect and is skipped.
509fn discover(root: &Path) -> Vec<IntelDevice> {
510 let Ok(entries) = fs::read_dir(root.join("sys/class/drm")) else {
511 return Vec::new();
512 };
513 let mut cards: Vec<(u32, PathBuf)> = entries
514 .flatten()
515 .filter_map(|e| {
516 let name = e.file_name().into_string().ok()?;
517 let idx: u32 = name.strip_prefix("card")?.parse().ok()?;
518 Some((idx, e.path()))
519 })
520 .collect();
521 cards.sort_by_key(|(idx, _)| *idx); // deterministic device order
522
523 let mut devs = Vec::new();
524 for (_, card_path) in cards {
525 let dev_path = card_path.join("device");
526 if read_trim(&dev_path.join("vendor")).as_deref() != Some("0x8086") {
527 continue;
528 }
529 let dialect = match uevent_field(&dev_path, "DRIVER=").as_deref() {
530 Some("i915") => Dialect::I915,
531 Some("xe") => Dialect::Xe,
532 _ => continue,
533 };
534 // An empty PCI_SLOT_NAME is no identity at all — skip, per the contract above,
535 // rather than registering a ghost device with a blank id.
536 let Some(pci) = uevent_field(&dev_path, "PCI_SLOT_NAME=").filter(|s| !s.is_empty()) else {
537 continue;
538 };
539 let hwmon = first_hwmon(&dev_path);
540 devs.push(IntelDevice {
541 id: DeviceId(pci.to_ascii_lowercase()),
542 dialect,
543 card_path,
544 dev_path,
545 hwmon,
546 });
547 }
548 devs
549}
550
551pub struct IntelBackend {
552 root: PathBuf,
553 devs: Vec<IntelDevice>,
554 /// i915: per-(device, pid) render-engine watermark (cumulative busy-ns, wall ms).
555 last_render: HashMap<(DeviceId, u32), (u64, u64)>,
556 /// xe: per-(device, pid) rcs watermark (cumulative busy-cycles, total-cycles) —
557 /// both axes are GT ticks, wall time is deliberately not part of this watermark.
558 last_cycles: HashMap<(DeviceId, u32), (u64, u64)>,
559 /// Per-device hwmon energy watermark (cumulative µJ, wall ms) for derived power.
560 last_energy: HashMap<DeviceId, (u64, u64)>,
561 /// Set once at init: explanation for a known-incomplete process list, if any.
562 process_hint: Option<String>,
563 /// Turns the kernel's cumulative per-PID CPU counter into a per-tick rate. The CPU%/
564 /// container columns come from `/proc` (never the device root: a fixture tree's pids are
565 /// not real processes), shared with the other Linux backends via `crate::proc_meta`.
566 #[cfg(target_os = "linux")]
567 cpu: crate::proc_meta::CpuTracker,
568}
569
570impl IntelBackend {
571 /// Production entry point: the live sysfs/procfs under `/`.
572 pub fn init() -> Result<Self, BackendError> {
573 Self::with_root("/")
574 }
575
576 /// Fixture entry point: every path below derives from `root`, so tests run against
577 /// committed trees (see `tests/fixtures/`).
578 pub fn with_root(root: impl Into<PathBuf>) -> Result<Self, BackendError> {
579 let root = root.into();
580 let devs = discover(&root);
581 if devs.is_empty() {
582 return Err(BackendError::Unavailable(
583 "no i915/xe devices under sys/class/drm".into(),
584 ));
585 }
586 let process_hint = fdinfo_process_hint(&root);
587 Ok(Self {
588 root,
589 devs,
590 last_render: HashMap::new(),
591 last_cycles: HashMap::new(),
592 last_energy: HashMap::new(),
593 process_hint,
594 #[cfg(target_os = "linux")]
595 cpu: crate::proc_meta::CpuTracker::new(),
596 })
597 }
598
599 fn device(&self, dev: &DeviceId) -> Result<&IntelDevice, BackendError> {
600 self.devs
601 .iter()
602 .find(|d| &d.id == dev)
603 .ok_or_else(|| BackendError::DeviceNotFound(dev.clone()))
604 }
605}
606
607impl GpuBackend for IntelBackend {
608 fn name(&self) -> &'static str {
609 "intel"
610 }
611
612 fn devices(&mut self) -> Vec<DeviceId> {
613 self.devs.iter().map(|d| d.id.clone()).collect()
614 }
615
616 fn static_info(&mut self, dev: &DeviceId) -> Result<StaticInfo, BackendError> {
617 let d = self.device(dev)?;
618
619 Ok(StaticInfo {
620 id: dev.clone(),
621 vendor: Vendor::Intel,
622 name: gpu_name(&d.dev_path),
623 backend: "intel".into(),
624 // Dedicated VRAM only. i915 dGPUs expose it as card-level lmem_total_bytes;
625 // an iGPU has no such file and shares system RAM, which must NOT be reported
626 // as VRAM. xe has no VRAM-total sysfs at all (research 02) — honest None.
627 mem_total_bytes: match d.dialect {
628 Dialect::I915 => read_parse(&d.card_path.join("lmem_total_bytes")),
629 Dialect::Xe => None,
630 },
631 power_limit_mw: d.hwmon.as_deref().and_then(power_cap_mw),
632 max_sm_clock_mhz: d.max_freq_mhz(),
633 // No sysfs source for the thermal-slowdown knee; claiming one would
634 // mis-narrate throttle events. Honest absence.
635 temp_slowdown_c: None,
636 // i915/xe are in-tree drivers: there is no driver version distinct from the
637 // kernel, and the uevent DRIVER= field is a name, not a version.
638 driver_version: None,
639 process_hint: self.process_hint.clone(),
640 // sysfs/fdinfo numbers carry their plain meanings — nothing to qualify.
641 source_caveat: None,
642 })
643 }
644
645 fn refresh_dynamic(&mut self, dev: &DeviceId) -> Result<DynamicSample, BackendError> {
646 let d = self.device(dev)?;
647 let sm_clock_mhz = d.act_freq_mhz();
648 let temp_c = d.hwmon.as_deref().and_then(|h| temp_c(h, d.dialect));
649 let energy_uj: Option<u64> = d
650 .hwmon
651 .as_deref()
652 .and_then(|h| read_parse(&h.join("energy1_input")));
653 // Decode throttle reasons before any &mut self borrow below releases `d`.
654 let throttle = d.throttle();
655
656 // One wall timestamp for the whole frame (per CLAUDE.md).
657 let ts = now_ms();
658 // Derived power from the cumulative energy counter; first sighting → None.
659 let power_mw = energy_uj.and_then(|cur| {
660 let prev = self.last_energy.insert(dev.clone(), (cur, ts));
661 prev.and_then(|(p_uj, p_ts)| energy_delta_mw(p_uj, p_ts, cur, ts))
662 });
663
664 Ok(DynamicSample {
665 ts_ms: ts,
666 // Device-level busyness is not directly exposed: the perf PMU needs
667 // root/CAP_PERFMON (xe PMU 6.15+), and summing fdinfo across clients we may
668 // not be able to see would understate. None is the honest answer.
669 util_pct: None,
670 util_engine: None,
671 // No reliable per-device counter: an iGPU has no VRAM, and xe exposes no
672 // device-wide VRAM-used sysfs — per-process fdinfo totals are not a device
673 // total under a privilege wall.
674 mem_used_bytes: None,
675 power_mw,
676 temp_c,
677 // hwmon fan (i915 6.12+/xe 6.16+) is RPM-only with no fan1_max; the model
678 // carries percent-of-max, so there is no honest value to derive.
679 fan_pct: None,
680 sm_clock_mhz,
681 // No unprivileged sysfs for the memory clock on either driver.
682 mem_clock_mhz: None,
683 // Video-engine busyness exists only per-process (fdinfo); the device-level
684 // numbers live behind the same PMU privilege wall as util.
685 encoder_pct: None,
686 decoder_pct: None,
687 // Per-GT throttle ("performance limit") reasons, decoded per dialect from the
688 // one-bit-per-file sysfs flags (i915 gt/gt0/throttle_reason_*, xe
689 // freq0/throttle/*). `status` is the gate — absent file → None (this kernel
690 // exposes no throttle interface: unobservable, §5.4); status==0 →
691 // Some(default): observed honest silence. This is the metric intel_gpu_top
692 // still does not surface on xe.
693 throttle,
694 })
695 }
696
697 fn refresh_processes(&mut self, dev: &DeviceId) -> Result<Vec<ProcessSample>, BackendError> {
698 let (pci, dialect) = {
699 let d = self.device(dev)?;
700 (d.id.0.clone(), d.dialect)
701 };
702 // One wall timestamp for the whole scan (one timestamp per frame, per CLAUDE.md).
703 let ts = now_ms();
704
705 // pid → max-merged fdinfo values across that pid's fds on this device.
706 let mut by_pid: HashMap<u32, FdinfoDrm> = HashMap::new();
707 if let Ok(entries) = fs::read_dir(self.root.join("proc")) {
708 for entry in entries.flatten() {
709 let Some(pid) = entry
710 .file_name()
711 .to_str()
712 .and_then(|s| s.parse::<u32>().ok())
713 else {
714 continue;
715 };
716 // Other users' fdinfo is unreadable without root/CAP_SYS_PTRACE — skip
717 // silently; the static-info hint already explains the incompleteness.
718 let Ok(fds) = fs::read_dir(entry.path().join("fdinfo")) else {
719 continue;
720 };
721 for fd in fds.flatten() {
722 let Ok(contents) = fs::read_to_string(fd.path()) else {
723 continue;
724 };
725 let info = parse_fdinfo(&contents);
726 if info.pdev.as_deref() != Some(pci.as_str()) {
727 continue;
728 }
729 by_pid.entry(pid).or_default().merge_max(info);
730 }
731 }
732 }
733
734 let mut out: Vec<ProcessSample> = Vec::with_capacity(by_pid.len());
735 for (&pid, agg) in &by_pid {
736 // Per-dialect watermark math; first sighting has no baseline → None. A
737 // kernel below the dialect's gate never produces the keys, so util stays
738 // None while the process itself is still listed.
739 let util_pct = match dialect {
740 Dialect::I915 => agg.render_ns.and_then(|cur| {
741 let prev = self.last_render.insert((dev.clone(), pid), (cur, ts));
742 prev.and_then(|(p_ns, p_ts)| engine_util_pct(p_ns, p_ts, cur, ts))
743 }),
744 Dialect::Xe => match (agg.rcs_cycles, agg.rcs_total_cycles) {
745 (Some(c), Some(t)) => {
746 let prev = self.last_cycles.insert((dev.clone(), pid), (c, t));
747 prev.and_then(|(p_c, p_t)| cycles_util_pct(p_c, p_t, c, t))
748 }
749 _ => None,
750 },
751 };
752 out.push(ProcessSample {
753 pid,
754 name: comm_name(&self.root, pid),
755 kind: agg.kind(),
756 mem_bytes: agg.local_mem_bytes(),
757 util_pct,
758 cpu_pct: None,
759 container: None,
760 });
761 }
762 // Drop watermarks for pids that vanished from this device (exited processes).
763 self.last_render
764 .retain(|(d, pid), _| d != dev || by_pid.contains_key(pid));
765 self.last_cycles
766 .retain(|(d, pid), _| d != dev || by_pid.contains_key(pid));
767
768 // CPU% and container identity come from /proc, mirroring the NVIDIA backend. The
769 // CpuTracker holds per-PID state, so prune it to the PIDs we still see to keep it
770 // from growing across a long session; container_of is stateless.
771 #[cfg(target_os = "linux")]
772 {
773 for p in &mut out {
774 p.cpu_pct = self.cpu.sample(p.pid);
775 p.container = crate::proc_meta::container_of(p.pid);
776 }
777 let live: Vec<u32> = out.iter().map(|p| p.pid).collect();
778 self.cpu.prune(&live);
779 }
780
781 out.sort_by_key(|p| p.pid); // deterministic order for the table and tests
782 Ok(out)
783 }
784}
785
786#[cfg(test)]
787mod tests {
788 use super::*;
789
790 #[test]
791 fn fdinfo_i915_keys_require_ns_suffix() {
792 let blob = "drm-pdev:\t0000:03:00.0\ndrm-engine-render:\t123 ns\n\
793 drm-engine-video:\t456 ns\ndrm-total-local0:\t786432 KiB\n";
794 let f = parse_fdinfo(blob);
795 assert_eq!(f.pdev.as_deref(), Some("0000:03:00.0"));
796 assert_eq!(f.render_ns, Some(123));
797 assert_eq!(f.video_ns, Some(456));
798 assert_eq!(f.total_local_bytes, Some(786_432 * 1024));
799 assert_eq!(f.compute_ns, None, "absent key stays None");
800 assert_eq!(f.rcs_cycles, None, "no xe keys in an i915 blob");
801 // A value with the wrong/missing suffix is a key we do not understand.
802 assert_eq!(parse_suffixed("123", "ns"), None);
803 assert_eq!(parse_suffixed("123 ms", "ns"), None);
804 }
805
806 #[test]
807 fn fdinfo_xe_keys_are_bare_cycle_counts() {
808 let blob = "drm-pdev:\t0000:03:00.0\ndrm-cycles-rcs:\t1000000\n\
809 drm-total-cycles-rcs:\t50000000\ndrm-cycles-ccs:\t8000000\n\
810 drm-total-vram0:\t2097152 KiB\ndrm-resident-vram0:\t1048576 KiB\n";
811 let f = parse_fdinfo(blob);
812 assert_eq!(f.rcs_cycles, Some(1_000_000));
813 assert_eq!(f.rcs_total_cycles, Some(50_000_000));
814 assert_eq!(f.ccs_cycles, Some(8_000_000));
815 assert_eq!(f.total_local_bytes, Some(2_147_483_648));
816 assert_eq!(f.resident_local_bytes, Some(1_073_741_824));
817 assert_eq!(f.render_ns, None, "no i915 keys in an xe blob");
818 // total preferred over resident.
819 assert_eq!(f.local_mem_bytes(), Some(2_147_483_648));
820 }
821
822 #[test]
823 fn mem_values_scale_per_drm_print_memory_stats() {
824 assert_eq!(parse_mem_bytes("4096"), Some(4096)); // default unit is bytes
825 assert_eq!(parse_mem_bytes("4096 KiB"), Some(4_194_304));
826 assert_eq!(parse_mem_bytes("12 MiB"), Some(12_582_912));
827 assert_eq!(parse_mem_bytes("12 GiB"), None); // unknown suffix is not a guess
828 }
829
830 #[test]
831 fn hostile_fdinfo_values_cannot_panic_or_wrap() {
832 // u64::MAX KiB/MiB cannot be a real byte count: None — never a debug panic,
833 // never a release-mode wrap to a fabricated number.
834 assert_eq!(parse_mem_bytes("18446744073709551615 KiB"), None);
835 assert_eq!(parse_mem_bytes("18446744073709551615 MiB"), None);
836 // kind() must not overflow either: near-MAX engine counters used to panic the
837 // video/compute sums in debug builds.
838 let f = parse_fdinfo(
839 "drm-engine-video:\t18446744073709551615 ns\ndrm-engine-video-enhance:\t1 ns\n",
840 );
841 assert_eq!(f.kind(), ProcessKind::Graphics);
842 let f = parse_fdinfo(
843 "drm-engine-compute:\t18446744073709551615 ns\ndrm-cycles-ccs:\t18446744073709551615\n",
844 );
845 assert_eq!(f.kind(), ProcessKind::Compute);
846 }
847
848 #[test]
849 fn i915_engine_util_needs_baseline_and_handles_resets() {
850 // 500ms of busy-ns over 1000ms of wall = 50%.
851 assert_eq!(engine_util_pct(0, 0, 500_000_000, 1_000), Some(50.0));
852 // Counter went backwards (pid reuse re-created the client): no claim.
853 assert_eq!(engine_util_pct(900, 0, 100, 1_000), None);
854 // Zero wall delta cannot produce a rate.
855 assert_eq!(engine_util_pct(0, 1_000, 100, 1_000), None);
856 // More busy-ns than wall time (multi-queue accounting) clamps, never exceeds.
857 assert_eq!(engine_util_pct(0, 0, 10_000_000_000, 1_000), Some(100.0));
858 }
859
860 #[test]
861 fn xe_cycles_util_is_over_total_cycles_not_wall_time() {
862 // 600k busy cycles over 1.2M elapsed cycles = 50%, regardless of wall time.
863 assert_eq!(
864 cycles_util_pct(1_000_000, 50_000_000, 1_600_000, 51_200_000),
865 Some(50.0)
866 );
867 // Either counter going backwards (client re-created): no claim.
868 assert_eq!(cycles_util_pct(900, 0, 100, 1_000), None);
869 assert_eq!(cycles_util_pct(0, 900, 100, 100), None);
870 // Zero elapsed-cycles base cannot produce a rate.
871 assert_eq!(cycles_util_pct(0, 500, 100, 500), None);
872 // capacity > 1 classes can run busy past the base: clamps, never exceeds.
873 assert_eq!(cycles_util_pct(0, 0, 4_000, 1_000), Some(100.0));
874 }
875
876 #[test]
877 fn energy_delta_is_microjoules_over_milliseconds() {
878 // 5,000,000 µJ over 1000 ms = 5 J/s = 5 W = 5000 mW.
879 assert_eq!(energy_delta_mw(0, 0, 5_000_000, 1_000), Some(5_000));
880 // Counter reset or zero wall delta: no claim.
881 assert_eq!(energy_delta_mw(900, 0, 100, 1_000), None);
882 assert_eq!(energy_delta_mw(0, 1_000, 100, 1_000), None);
883 }
884
885 #[test]
886 fn kind_is_honest_about_render_only_clients() {
887 // Render-only could be 3D or pre-CCS GPGPU — Unknown, not a guess.
888 let render_only = parse_fdinfo("drm-engine-render:\t100 ns\n");
889 assert_eq!(render_only.kind(), ProcessKind::Unknown);
890 // A busy video engine is decisively media.
891 let media = parse_fdinfo("drm-engine-render:\t100 ns\ndrm-engine-video:\t5 ns\n");
892 assert_eq!(media.kind(), ProcessKind::Graphics);
893 // A busy compute engine is decisively compute — in either dialect.
894 let ccs_i915 = parse_fdinfo("drm-engine-compute:\t5 ns\n");
895 assert_eq!(ccs_i915.kind(), ProcessKind::Compute);
896 let ccs_xe = parse_fdinfo("drm-cycles-ccs:\t5\ndrm-cycles-rcs:\t9\n");
897 assert_eq!(ccs_xe.kind(), ProcessKind::Compute);
898 // No engine signal at all: Unknown.
899 assert_eq!(parse_fdinfo("").kind(), ProcessKind::Unknown);
900 }
901
902 #[test]
903 fn proc_status_privilege_detection() {
904 // euid is the second Uid field — root euid grants the full scan.
905 assert!(status_grants_full_proc_scan(
906 "Uid:\t1000\t0\t1000\t1000\nCapEff:\t0000000000000000"
907 ));
908 // CAP_SYS_PTRACE (bit 19) alone suffices.
909 assert!(status_grants_full_proc_scan(
910 "Uid:\t1000\t1000\t1000\t1000\nCapEff:\t0000000000080000"
911 ));
912 assert!(!status_grants_full_proc_scan(
913 "Uid:\t1000\t1000\t1000\t1000\nCapEff:\t0000000000000000"
914 ));
915 assert!(!status_grants_full_proc_scan(""));
916 }
917
918 #[test]
919 fn known_arc_names_resolve_and_unknowns_do_not() {
920 assert_eq!(known_gpu_name("56a0"), Some("Intel Arc A770"));
921 assert_eq!(known_gpu_name("e20b"), Some("Intel Arc B580"));
922 assert_eq!(
923 known_gpu_name("46a6"),
924 None,
925 "iGPUs fall back to the PCI id"
926 );
927 }
928}