Skip to main content

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}