gpuviewer_core/amd.rs
1//! AMD Linux backend — hand-rolled `std::fs` readers over sysfs/hwmon/fdinfo. No library
2//! linkage at all: librocm_smi64 is explicitly off the table (soname churn broke btop
3//! twice — btop #774) and libdrm ioctls are unnecessary for everything v1 needs.
4//!
5//! Per the domain rules in CLAUDE.md:
6//! - Every path derives from a root-dir parameter (`with_root`), so the whole backend runs
7//! against committed fixture trees; `init()` is just `with_root("/")`.
8//! - A missing file or unparsable value is `None`, never a failure — an APU without hwmon
9//! or `pp_dpm_*` tables is a normal device, not a broken one (Intel-iGPU-style absence).
10//! - Throttle bits live in the `gpu_metrics` binary struct, which is versioned (v1.0–v3.0)
11//! with per-version field offsets AND units, and therefore needs per-version decoders
12//! backed by fixtures. `decode_gpu_metrics_throttle` parses it directly off the sysfs
13//! blob (offsets derived from the in-tree kernel header — see that function). A blob that
14//! is absent, truncated, of an unknown revision, or whose self-declared `structure_size`
15//! is inconsistent decodes to `None`: that source is unobservable, and an asserted
16//! all-false would fabricate an "observed: not throttling" fact (§5.4) from bytes we
17//! never understood.
18//! - Only SMU-backed sysfs is polled (`gpu_busy_percent`, hwmon) — never GRBM registers,
19//! whose polling breaks GFXOFF (the monitor must not change what it measures).
20//! - Per-process attribution is DRM fdinfo (kernel 5.14+, standardized 5.19+): cumulative
21//! per-engine busy-ns → delta over wall time = util%; `drm-pdev` ties a client to a
22//! device; keys missing on older kernels degrade to `None` fields, never lost processes.
23//! Other users' fdinfo needs root/CAP_SYS_PTRACE, so unprivileged runs carry an honest
24//! "your processes only" `process_hint` instead of pretending the list is complete.
25
26use std::collections::HashMap;
27use std::fs;
28use std::path::{Path, PathBuf};
29
30use crate::backend::{BackendError, GpuBackend};
31use crate::model::{
32 now_ms, DeviceId, DynamicSample, ProcessKind, ProcessSample, StaticInfo, ThrottleReasons,
33 Vendor,
34};
35
36/// Read a sysfs file and parse its trimmed contents; absent file or bad value → `None`.
37fn read_parse<T: std::str::FromStr>(path: &Path) -> Option<T> {
38 fs::read_to_string(path).ok()?.trim().parse().ok()
39}
40
41/// Read a sysfs file as a trimmed string; absent file → `None`.
42fn read_trim(path: &Path) -> Option<String> {
43 fs::read_to_string(path).ok().map(|s| s.trim().to_string())
44}
45
46/// Read a sysfs PCI id ("0x744c\n" → "744c", lowercased). An empty file is not an id —
47/// `None`, so the name fallback says "AMD GPU" rather than "AMD GPU [1002:]".
48fn read_hex_id(path: &Path) -> Option<String> {
49 let s = read_trim(path)?;
50 let s = s.strip_prefix("0x").unwrap_or(&s).to_ascii_lowercase();
51 (!s.is_empty()).then_some(s)
52}
53
54/// MHz of one `pp_dpm_*` table line ("1: 1138Mhz *"). The unit's casing varies across
55/// kernels ("Mhz"/"MHz"), so only the leading digits after the colon are trusted.
56fn dpm_line_mhz(line: &str) -> Option<u32> {
57 let after = line.split(':').nth(1)?.trim_start();
58 let digits: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
59 digits.parse().ok()
60}
61
62/// The '*'-marked level of a DPM table is the currently selected one.
63fn dpm_current_mhz(table: &str) -> Option<u32> {
64 table
65 .lines()
66 .find(|l| l.contains('*'))
67 .and_then(dpm_line_mhz)
68}
69
70/// Levels are listed ascending — the last line is the hardware maximum.
71fn dpm_max_mhz(table: &str) -> Option<u32> {
72 table
73 .lines()
74 .rev()
75 .find(|l| !l.trim().is_empty())
76 .and_then(dpm_line_mhz)
77}
78
79/// hwmon temps are MILLI-degrees C. Prefer the sensor labeled "edge" (the die-edge value
80/// every vendor tool headlines); junction/mem run hotter and would overstate. Fall back
81/// to `temp1_input` when labels are absent.
82fn edge_temp_c(hwmon: &Path) -> Option<f32> {
83 let millic = edge_temp_millic(hwmon)?;
84 Some(millic as f32 / 1000.0)
85}
86
87fn edge_temp_millic(hwmon: &Path) -> Option<i64> {
88 if let Ok(entries) = fs::read_dir(hwmon) {
89 for entry in entries.flatten() {
90 let name = entry.file_name();
91 let Some(stem) = name.to_str().and_then(|n| n.strip_suffix("_label")) else {
92 continue;
93 };
94 if !stem.starts_with("temp") {
95 continue;
96 }
97 if read_trim(&entry.path()).as_deref() == Some("edge") {
98 if let Some(millic) = read_parse(&hwmon.join(format!("{stem}_input"))) {
99 return Some(millic);
100 }
101 }
102 }
103 }
104 read_parse(&hwmon.join("temp1_input"))
105}
106
107/// hwmon power is MICROwatts; the model carries milliwatts. RDNA3 may expose only the
108/// instantaneous `power1_input` (kernel 6.7+) and no `power1_average` — probe both.
109fn power_mw(hwmon: &Path) -> Option<u32> {
110 let uw: u64 = read_parse(&hwmon.join("power1_average"))
111 .or_else(|| read_parse(&hwmon.join("power1_input")))?;
112 Some((uw / 1000) as u32)
113}
114
115/// `power1_cap` is MICROwatts too.
116fn power_cap_mw(hwmon: &Path) -> Option<u32> {
117 let uw: u64 = read_parse(&hwmon.join("power1_cap"))?;
118 Some((uw / 1000) as u32)
119}
120
121/// `fan1_input`/`fan1_max` are RPM; percent-of-max is what the model carries.
122fn fan_pct(hwmon: &Path) -> Option<f32> {
123 let rpm: f32 = read_parse(&hwmon.join("fan1_input"))?;
124 let max: f32 = read_parse(&hwmon.join("fan1_max"))?;
125 fan_pct_of_max(rpm, max)
126}
127
128/// f32's parser happily accepts "nan"/"inf", and a broken sensor can report a negative
129/// RPM — none of those is a fan reading. Garbage in must be `None` out, never a
130/// confident percentage.
131fn fan_pct_of_max(rpm: f32, max: f32) -> Option<f32> {
132 if !rpm.is_finite() || !max.is_finite() || rpm < 0.0 || max <= 0.0 {
133 return None;
134 }
135 Some((rpm / max * 100.0).clamp(0.0, 100.0))
136}
137
138// ---- gpu_metrics throttle decoding ------------------------------------------------------
139//
140// The `gpu_metrics` sysfs node is a memcpy of the SMU firmware metrics table into a kernel
141// C struct that is then exposed verbatim to userspace. The structs (kgd_pp_interface.h) are
142// NOT `__attribute__((packed))`, so the on-disk layout is the firmware's NATURAL-aligned
143// layout: every field sits at an offset that is a multiple of its own size (u16→2, u32→4,
144// u64→8), and the kernel devs ordered members to minimise — but not always eliminate —
145// inter-field padding. All offsets below are derived by walking the member list with that
146// rule; the workings are shown inline so a future header bump can be re-verified by hand.
147//
148// Everything is little-endian (the only architectures amdgpu runs on). Every read is bounds-
149// and structure_size-gated: a short, lying, or unknown-revision blob is absence, never an
150// error and never a misattributed cause.
151
152/// The common header every `gpu_metrics_v*` opens with (kgd_pp_interface.h
153/// `struct metrics_table_header`): `u16 structure_size; u8 format_revision;
154/// u8 content_revision;` — 4 bytes, no trailing padding.
155const HEADER_LEN: usize = 4;
156
157/// Read a little-endian `u32` at `off`, or `None` if it would run past the buffer.
158fn read_u32_le(buf: &[u8], off: usize) -> Option<u32> {
159 let bytes = buf.get(off..off.checked_add(4)?)?;
160 Some(u32::from_le_bytes(bytes.try_into().ok()?))
161}
162
163/// Read a little-endian `u64` at `off`, or `None` if it would run past the buffer.
164fn read_u64_le(buf: &[u8], off: usize) -> Option<u64> {
165 let bytes = buf.get(off..off.checked_add(8)?)?;
166 Some(u64::from_le_bytes(bytes.try_into().ok()?))
167}
168
169// ASIC-INDEPENDENT throttler bits (`indep_throttle_status`), from amdgpu_smu.h
170// `SMU_THROTTLER_*_BIT`. These are normalised by the driver across ASICs, so unlike the
171// legacy `throttle_status` they are safe to map to specific causes.
172//
173// Power group (PPT = package power tracking, SPL/FPPT/SPPT = APU power limits):
174const SMU_THROTTLER_PPT0_BIT: u32 = 0;
175const SMU_THROTTLER_PPT1_BIT: u32 = 1;
176const SMU_THROTTLER_PPT2_BIT: u32 = 2;
177const SMU_THROTTLER_PPT3_BIT: u32 = 3;
178const SMU_THROTTLER_SPL_BIT: u32 = 4;
179const SMU_THROTTLER_FPPT_BIT: u32 = 5;
180const SMU_THROTTLER_SPPT_BIT: u32 = 6;
181const SMU_THROTTLER_SPPT_APU_BIT: u32 = 7;
182// Current group (TDC = thermal design current, EDC = electrical design current, APCC):
183const SMU_THROTTLER_TDC_GFX_BIT: u32 = 16;
184const SMU_THROTTLER_TDC_SOC_BIT: u32 = 17;
185const SMU_THROTTLER_TDC_MEM_BIT: u32 = 18;
186const SMU_THROTTLER_TDC_VDD_BIT: u32 = 19;
187const SMU_THROTTLER_TDC_CVIP_BIT: u32 = 20;
188const SMU_THROTTLER_EDC_CPU_BIT: u32 = 21;
189const SMU_THROTTLER_EDC_GFX_BIT: u32 = 22;
190const SMU_THROTTLER_APCC_BIT: u32 = 23;
191// Temperature group:
192const SMU_THROTTLER_TEMP_GPU_BIT: u32 = 32;
193const SMU_THROTTLER_TEMP_CORE_BIT: u32 = 33;
194const SMU_THROTTLER_TEMP_MEM_BIT: u32 = 34;
195const SMU_THROTTLER_TEMP_EDGE_BIT: u32 = 35;
196const SMU_THROTTLER_TEMP_HOTSPOT_BIT: u32 = 36;
197const SMU_THROTTLER_TEMP_SOC_BIT: u32 = 37;
198const SMU_THROTTLER_TEMP_VR_GFX_BIT: u32 = 38;
199const SMU_THROTTLER_TEMP_VR_SOC_BIT: u32 = 39;
200const SMU_THROTTLER_TEMP_VR_MEM0_BIT: u32 = 40;
201const SMU_THROTTLER_TEMP_VR_MEM1_BIT: u32 = 41;
202const SMU_THROTTLER_TEMP_LIQUID0_BIT: u32 = 42;
203const SMU_THROTTLER_TEMP_LIQUID1_BIT: u32 = 43;
204const SMU_THROTTLER_VRHOT0_BIT: u32 = 44;
205const SMU_THROTTLER_VRHOT1_BIT: u32 = 45;
206// PROCHOT (external "processor hot" assertion → hardware-forced slowdown):
207const SMU_THROTTLER_PROCHOT_CPU_BIT: u32 = 46;
208const SMU_THROTTLER_PROCHOT_GFX_BIT: u32 = 47;
209// Other:
210const SMU_THROTTLER_PPM_BIT: u32 = 56;
211const SMU_THROTTLER_FIT_BIT: u32 = 57;
212
213/// Thermal-group `indep_throttle_status` bits: any of these is a temperature limit. VRHOT
214/// (voltage-regulator over-temp) and the liquid-cooling sensors belong here too.
215const INDEP_THERMAL_MASK: u64 = (1 << SMU_THROTTLER_TEMP_GPU_BIT)
216 | (1 << SMU_THROTTLER_TEMP_CORE_BIT)
217 | (1 << SMU_THROTTLER_TEMP_MEM_BIT)
218 | (1 << SMU_THROTTLER_TEMP_EDGE_BIT)
219 | (1 << SMU_THROTTLER_TEMP_HOTSPOT_BIT)
220 | (1 << SMU_THROTTLER_TEMP_SOC_BIT)
221 | (1 << SMU_THROTTLER_TEMP_VR_GFX_BIT)
222 | (1 << SMU_THROTTLER_TEMP_VR_SOC_BIT)
223 | (1 << SMU_THROTTLER_TEMP_VR_MEM0_BIT)
224 | (1 << SMU_THROTTLER_TEMP_VR_MEM1_BIT)
225 | (1 << SMU_THROTTLER_TEMP_LIQUID0_BIT)
226 | (1 << SMU_THROTTLER_TEMP_LIQUID1_BIT)
227 | (1 << SMU_THROTTLER_VRHOT0_BIT)
228 | (1 << SMU_THROTTLER_VRHOT1_BIT);
229
230/// Power/current-group bits: PPT* (power-cap) and the TDC/EDC current limits all mean "you
231/// are being pulled back to stay inside an electrical/power envelope".
232const INDEP_POWER_MASK: u64 = (1 << SMU_THROTTLER_PPT0_BIT)
233 | (1 << SMU_THROTTLER_PPT1_BIT)
234 | (1 << SMU_THROTTLER_PPT2_BIT)
235 | (1 << SMU_THROTTLER_PPT3_BIT)
236 | (1 << SMU_THROTTLER_SPL_BIT)
237 | (1 << SMU_THROTTLER_FPPT_BIT)
238 | (1 << SMU_THROTTLER_SPPT_BIT)
239 | (1 << SMU_THROTTLER_SPPT_APU_BIT)
240 | (1 << SMU_THROTTLER_TDC_GFX_BIT)
241 | (1 << SMU_THROTTLER_TDC_SOC_BIT)
242 | (1 << SMU_THROTTLER_TDC_MEM_BIT)
243 | (1 << SMU_THROTTLER_TDC_VDD_BIT)
244 | (1 << SMU_THROTTLER_TDC_CVIP_BIT)
245 | (1 << SMU_THROTTLER_EDC_CPU_BIT)
246 | (1 << SMU_THROTTLER_EDC_GFX_BIT);
247
248/// PROCHOT is an external slowdown forced on the GPU by the platform — the AMD analogue of
249/// NVML's HW_SLOWDOWN.
250const INDEP_HW_SLOWDOWN_MASK: u64 =
251 (1 << SMU_THROTTLER_PROCHOT_CPU_BIT) | (1 << SMU_THROTTLER_PROCHOT_GFX_BIT);
252
253/// Recognised-but-uncategorised bits (APCC interconnect throttle, PPM, FIT failure-in-time).
254/// Kept explicit so the "unknown future bit" branch in [`map_indep_throttle`] only fires for
255/// genuinely new bits, not for ones we know about but do not split into a column.
256const INDEP_OTHER_MASK: u64 =
257 (1 << SMU_THROTTLER_APCC_BIT) | (1 << SMU_THROTTLER_PPM_BIT) | (1 << SMU_THROTTLER_FIT_BIT);
258
259/// Map an `indep_throttle_status` word to [`ThrottleReasons`]. Tolerant like the NVIDIA
260/// decoder: any bit outside the masks we recognise lands in `other` rather than being
261/// dropped, so a firmware/header that adds a throttler still surfaces "something is
262/// throttling" honestly.
263fn map_indep_throttle(bits: u64) -> ThrottleReasons {
264 let known = INDEP_THERMAL_MASK | INDEP_POWER_MASK | INDEP_HW_SLOWDOWN_MASK | INDEP_OTHER_MASK;
265 ThrottleReasons {
266 thermal: bits & INDEP_THERMAL_MASK != 0,
267 power_cap: bits & INDEP_POWER_MASK != 0,
268 hw_slowdown: bits & INDEP_HW_SLOWDOWN_MASK != 0,
269 // AMD has no cross-GPU sync-boost concept in this status word.
270 sync_boost: false,
271 other: (bits & INDEP_OTHER_MASK != 0) || (bits & !known != 0),
272 }
273}
274
275/// Where the throttle words live in each supported `(format_revision, content_revision)`,
276/// plus the struct's own byte length (for the `structure_size` sanity gate).
277struct ThrottleLayout {
278 /// Offset of `indep_throttle_status` (ASIC-independent u64), when the version carries it.
279 indep: Option<usize>,
280 /// Offset of the legacy ASIC-specific `throttle_status` (u32). Decoded only as a coarse
281 /// "something is throttling" when no `indep` word exists — or when the `indep` word
282 /// reads as the firmware's 0xFF "never written" sentinel.
283 legacy: Option<usize>,
284 /// `sizeof` the struct (natural-aligned). The blob must be at least this big and its
285 /// self-declared `structure_size` must match, or it is treated as absence.
286 size: usize,
287}
288
289/// Resolve the throttle layout for a `(format, content)` revision, or `None` for revisions
290/// that carry no throttle data we trust. Offsets are walked from the kernel structs in
291/// kgd_pp_interface.h under natural C alignment (see the module note above).
292fn throttle_layout(format: u8, content: u8) -> Option<ThrottleLayout> {
293 match (format, content) {
294 // gpu_metrics_v1_x (dGPU). Shared prefix, naturally aligned with no padding:
295 // header(4) + 10×u16 temps/activity/power(20)=24 → energy_accumulator u64 @24,
296 // system_clock_counter u64 @32, then 14×u16 avg+current clocks(28) @40 →
297 // throttle_status u32 @68. v1.0 reorders the prefix; we map only v1.1+.
298 (1, 1) => Some(ThrottleLayout {
299 indep: None,
300 legacy: Some(68),
301 // … fan_speed/pcie(3×u16)+padding(u16)@72, gfx/mem_activity_acc(2×u32)@80,
302 // temperature_hbm[4] u16 @88 → 96.
303 size: 96,
304 }),
305 (1, 2) => Some(ThrottleLayout {
306 indep: None,
307 legacy: Some(68),
308 // … as v1.1 up to @96, then firmware_timestamp u64 @96 → 104.
309 size: 104,
310 }),
311 (1, 3) => Some(ThrottleLayout {
312 // … v1.2 up to firmware_timestamp@96(→104), voltage_soc/gfx/mem+padding1
313 // (4×u16)@104 → indep_throttle_status u64 @112 → 120.
314 indep: Some(112),
315 legacy: Some(68),
316 size: 120,
317 }),
318 // gpu_metrics_v2_x (APU). v2.0's prefix differs (system_clock_counter first), so it
319 // needs its own offsets; v2.1+ share a prefix where throttle_status lands at @108.
320 (2, 0) => Some(ThrottleLayout {
321 indep: None,
322 // header(4) → pad(4) → system_clock_counter u64 @8; then temps/core/l3
323 // (1+1+8+2 u16)@16=24 → @40, activity/power(6 u16)@40 → @52,
324 // average_core_power[8](16)@52 → @68, 6 avg + 6 current clocks(12 u16)@68 → @92,
325 // current_coreclk[8](16)@92 → @108, current_l3clk[2](4)@108 → @112 →
326 // throttle_status u32 @112.
327 legacy: Some(112),
328 // fan_pwm u16 @116, padding u16 @118 → 120.
329 size: 120,
330 }),
331 (2, 1) => Some(ThrottleLayout {
332 indep: None,
333 // header(4) → temps/core/l3(1+1+8+2 u16)@4=24 → @28, gfx/mm activity(2 u16) →
334 // @32, system_clock_counter u64 @32 → @40, 4 power u16 @40 → @48,
335 // average_core_power[8](16)@48 → @64, 6 avg+6 current clocks(12 u16)@64 → @88,
336 // current_coreclk[8](16)@88 → @104, current_l3clk[2](4)@104 → @108 →
337 // throttle_status u32 @108.
338 legacy: Some(108),
339 // fan_pwm u16 @112, padding[3] u16 @114 → 120.
340 size: 120,
341 }),
342 (2, 2) => Some(ThrottleLayout {
343 // v2.1 prefix → throttle_status@108, fan_pwm@112, padding[3]@114 → @120 →
344 // indep_throttle_status u64 @120 → 128. (The kernel struct carries indep from
345 // v2.2 onward, one content-rev earlier than some docs claim — header wins.)
346 indep: Some(120),
347 legacy: Some(108),
348 size: 128,
349 }),
350 (2, 3) => Some(ThrottleLayout {
351 // v2.2 up to indep@120(→128), then average_temperature_gfx/soc/core[8]/l3[2]
352 // (12 u16)@128 → 152.
353 indep: Some(120),
354 legacy: Some(108),
355 size: 152,
356 }),
357 (2, 4) => Some(ThrottleLayout {
358 // v2.3 up to @152, then average cpu/soc/gfx voltage+current (6 u16)@152 → 164
359 // DATA bytes — but `sizeof` is 168, not 164: the struct's u64 members align the
360 // whole struct to 8, adding 4 tail-pad bytes, and the kernel sets
361 // structure_size = sizeof (smu_cmn.h). Real Van Gogh blobs (Steam Deck,
362 // kernel 6.6+ program-6 firmware) declare 168; gating on 164 rejected them all.
363 indep: Some(120),
364 legacy: Some(108),
365 size: 168,
366 }),
367 // gpu_metrics_v3_0 (newer APU) carries no instantaneous throttle word at all —
368 // see the explicit (3, 0) branch in `decode_gpu_metrics_throttle` for why its
369 // residency ACCUMULATORS cannot honestly decode from a single sample.
370 _ => None,
371 }
372}
373
374/// Decode AMD throttle status from a raw `gpu_metrics` sysfs blob.
375///
376/// Offsets are derived from the in-tree kernel headers
377/// (`drivers/gpu/drm/amd/include/kgd_pp_interface.h` for the struct layouts and
378/// `drivers/gpu/drm/amd/pm/swsmu/inc/amdgpu_smu.h` for the `SMU_THROTTLER_*_BIT` values).
379/// The structs are naturally aligned, little-endian.
380///
381/// The contract is *honest absence on any doubt*: a buffer too short for the header, a
382/// `structure_size` that disagrees with the version's known length (or is shorter than the
383/// fields we read), an unknown `(format, content)` revision, or an out-of-bounds field all
384/// yield `None`. `Some` is returned only off a successfully decoded struct, because under
385/// §5.4 an all-false `ThrottleReasons` is an *observation* of quiet — emitting it from a
386/// blob we could not decode would fabricate a fact-grade "not throttling" forever on any
387/// kernel shipping a revision newer than this table. Garbage content is a broken interface,
388/// which is unobservable too — refusing beats guessing. We never wrap, never panic, and
389/// never narrate a byte we are not certain of.
390///
391/// Mapping precedence: prefer `indep_throttle_status` (ASIC-independent bits) and split it
392/// into thermal / power_cap / hw_slowdown / other. For older revisions that expose only the
393/// legacy ASIC-specific `throttle_status`, the per-bit meaning varies by ASIC and is not
394/// safe to map; a nonzero value reliably means "some throttler is active", so it surfaces
395/// as `other` alone rather than as a fabricated specific cause.
396///
397/// 0xFF sentinel: the SMU memsets the whole metrics table to 0xFF before writing the
398/// fields this ASIC's firmware supports (smu_cmn.h), so an all-ones word — `u64::MAX`
399/// indep / `u32::MAX` legacy — is the firmware's "never wrote this" marker, NOT 64
400/// simultaneous throttlers. A sentinel word is unobservable: decoding falls through
401/// indep → legacy → `None` as each word in turn proves unwritten. Cyan Skillfish (BC-250
402/// class) is the known shipper: it emits v2_2 without ever writing `indep_throttle_status`,
403/// and without this guard every sample would narrate thermal+power+hw_slowdown+other,
404/// permanently.
405///
406/// v3_0 is recognised but its per-sample throttle decode is deliberately `None` — see the
407/// `(3, 0)` branch below.
408pub fn decode_gpu_metrics_throttle(buf: &[u8]) -> Option<ThrottleReasons> {
409 // Common header: structure_size u16 @0, format_revision u8 @2, content_revision u8 @3.
410 if buf.len() < HEADER_LEN {
411 return None;
412 }
413 let structure_size = u16::from_le_bytes([buf[0], buf[1]]) as usize;
414 let format = buf[2];
415 let content = buf[3];
416
417 // The blob must hold at least as many bytes as it claims, and the claim must be a real
418 // header (a zeroed/garbage node reads structure_size 0). Both guards reject truncation.
419 if structure_size < HEADER_LEN || buf.len() < structure_size {
420 return None;
421 }
422
423 if let Some(layout) = throttle_layout(format, content) {
424 // structure_size must match the version's known size exactly — a mismatch means we
425 // are not looking at the struct we think we are, so we decode nothing.
426 if structure_size != layout.size {
427 return None;
428 }
429 if let Some(off) = layout.indep {
430 // An OOB read after the size gate would mean a wrong layout table, not a quiet
431 // GPU — propagate the doubt as None rather than asserting all-false.
432 let bits = read_u64_le(buf, off)?;
433 // All-ones is the SMU's 0xFF-memset "field never written" sentinel (see the
434 // doc comment): this word is unobservable — fall through to the legacy word
435 // rather than asserting every throttler active.
436 if bits != u64::MAX {
437 return Some(map_indep_throttle(bits));
438 }
439 }
440 if let Some(off) = layout.legacy {
441 // Legacy throttle_status is ASIC-specific: nonzero = throttling, cause
442 // unmappable. Honest coarse signal beats both silence and a guessed column;
443 // a decoded zero is a genuine observation of quiet.
444 let v = read_u32_le(buf, off)?;
445 // All-ones is the same memset sentinel: "unsupported", not "throttling hard".
446 if v != u32::MAX {
447 return Some(ThrottleReasons {
448 other: v != 0,
449 ..Default::default()
450 });
451 }
452 }
453 // Every throttle word this version carries was either absent or the 0xFF
454 // sentinel: this firmware exposes no throttle observation at all.
455 return None;
456 }
457
458 // gpu_metrics_v3_0 (Strix Point / Strix Halo / Krackan class APUs, kernel 6.7+) is
459 // recognised but deliberately NOT decoded into a per-sample throttle state. It drops
460 // the status bitfields entirely; what it carries instead are `throttle_residency_*`
461 // u32 fields (compiled offsets 228..252, behind a 2-byte pad after
462 // `current_gfx_maxfreq` u16 @224) that are firmware ACCUMULATOR counters —
463 // "incremented on every metrics table update while X was engaged"
464 // (smu14_driver_if_v14_0_0.h). A nonzero accumulator records that the throttler fired
465 // at SOME point in the firmware's window, not that it is active now: decoding
466 // raw-nonzero as "throttling" would turn one historic PROCHOT into a permanent
467 // hw_slowdown claim on every subsequent sample. Deriving a live state needs a
468 // watermark DELTA between two reads (the fdinfo engine-ns pattern), which this
469 // single-blob pure decoder cannot hold state for — so per the honesty contract the
470 // per-sample decode is None (unobservable), never a stale-nonzero false positive.
471 // (Any future stateful delta decoder must also treat u32::MAX counters as the 0xFF
472 // memset sentinel — unsupported, not saturated.)
473 if (format, content) == (3, 0) {
474 return None;
475 }
476
477 // Unknown future revision: we cannot observe throttling through a struct we cannot
478 // decode, so this is absence — not an asserted "no throttle".
479 None
480}
481
482/// The fdinfo keys this backend consumes. Anything missing (older kernel, non-DRM fd)
483/// simply stays `None` — kernel gates: engine busy-ns 5.14+, standardized keys 5.19+.
484#[derive(Default)]
485struct FdinfoDrm {
486 pdev: Option<String>,
487 vram_kib: Option<u64>,
488 gfx_ns: Option<u64>,
489 compute_ns: Option<u64>,
490}
491
492impl FdinfoDrm {
493 /// Max-merge across one pid's many fds on the same device: the fds describe the same
494 /// client's buffers, so summing would double-count — max keeps the fullest view.
495 fn merge_max(&mut self, other: FdinfoDrm) {
496 fn mx(a: &mut Option<u64>, b: Option<u64>) {
497 *a = match (*a, b) {
498 (Some(x), Some(y)) => Some(x.max(y)),
499 (x, y) => x.or(y),
500 };
501 }
502 mx(&mut self.vram_kib, other.vram_kib);
503 mx(&mut self.gfx_ns, other.gfx_ns);
504 mx(&mut self.compute_ns, other.compute_ns);
505 }
506}
507
508/// Parse one fdinfo blob ("key:\tvalue" lines). Unit suffixes are part of the fdinfo ABI
509/// ("<n> ns", "<n> KiB") and are required — guessing units is exactly the classic
510/// AMD-parsing bug this module's tests exist to prevent.
511fn parse_fdinfo(contents: &str) -> FdinfoDrm {
512 let mut out = FdinfoDrm::default();
513 for line in contents.lines() {
514 let Some((key, val)) = line.split_once(':') else {
515 continue;
516 };
517 let val = val.trim();
518 match key.trim() {
519 "drm-pdev" => out.pdev = Some(val.to_ascii_lowercase()),
520 "drm-memory-vram" => out.vram_kib = parse_suffixed(val, "KiB"),
521 "drm-engine-gfx" => out.gfx_ns = parse_suffixed(val, "ns"),
522 "drm-engine-compute" => out.compute_ns = parse_suffixed(val, "ns"),
523 _ => {}
524 }
525 }
526 out
527}
528
529fn parse_suffixed(val: &str, unit: &str) -> Option<u64> {
530 val.strip_suffix(unit)?.trim().parse().ok()
531}
532
533/// KiB → bytes, overflow-checked: a count that exceeds u64 bytes cannot be real memory.
534/// Unchecked `* 1024` would panic a debug build (taking down the scan for EVERY device,
535/// since blobs parse before the pdev filter) and silently wrap to a fabricated number in
536/// release — exactly the confidently-wrong output this product must never emit.
537fn kib_to_bytes(kib: u64) -> Option<u64> {
538 kib.checked_mul(1024)
539}
540
541/// fdinfo engine counters are cumulative busy-ns; utilization is the delta between two
542/// sightings over wall time. No baseline (first sighting) or a counter that went
543/// backwards (pid reuse re-created the client) → `None`, never a guess.
544fn engine_util_pct(prev_ns: u64, prev_ts_ms: u64, cur_ns: u64, cur_ts_ms: u64) -> Option<f32> {
545 let wall_ms = cur_ts_ms.checked_sub(prev_ts_ms)?;
546 if wall_ms == 0 {
547 return None;
548 }
549 let busy_ns = cur_ns.checked_sub(prev_ns)?;
550 let pct = busy_ns as f64 / (wall_ms as f64 * 1_000_000.0) * 100.0;
551 Some(pct.min(100.0) as f32)
552}
553
554/// `/proc/self/status` says whether the fdinfo scan can see every user's processes:
555/// euid 0, or CAP_SYS_PTRACE (bit 19) in the effective capability mask.
556fn status_grants_full_proc_scan(status: &str) -> bool {
557 const CAP_SYS_PTRACE: u32 = 19;
558 for line in status.lines() {
559 if let Some(uids) = line.strip_prefix("Uid:") {
560 // Fields: real, effective, saved, fs — effective is what access checks use.
561 if uids.split_whitespace().nth(1) == Some("0") {
562 return true;
563 }
564 }
565 if let Some(mask) = line.strip_prefix("CapEff:") {
566 if let Ok(bits) = u64::from_str_radix(mask.trim(), 16) {
567 if bits & (1 << CAP_SYS_PTRACE) != 0 {
568 return true;
569 }
570 }
571 }
572 }
573 false
574}
575
576/// `StaticInfo::process_hint` for unprivileged runs: other users' fdinfo is unreadable
577/// without root/CAP_SYS_PTRACE, so the process table is honestly incomplete — say so up
578/// front instead of pretending it covers the machine. An unreadable status file reads as
579/// unprivileged: overstating incompleteness is safe, understating it would be a lie.
580fn fdinfo_process_hint(root: &Path) -> Option<String> {
581 let full = fs::read_to_string(root.join("proc/self/status"))
582 .is_ok_and(|s| status_grants_full_proc_scan(&s));
583 (!full)
584 .then(|| "showing your processes only — others need root or CAP_SYS_PTRACE (fdinfo)".into())
585}
586
587/// One `amdgpu.ids` line is `DEVICE_ID,\tREV_ID,\tname` (hex ids, no 0x); comment and
588/// version lines have no commas and fall through.
589fn amdgpu_ids_name(ids: &str, device: &str, revision: &str) -> Option<String> {
590 for line in ids.lines() {
591 let line = line.trim();
592 if line.is_empty() || line.starts_with('#') {
593 continue;
594 }
595 let mut fields = line.splitn(3, ',');
596 let (Some(dev), Some(rev), Some(name)) = (fields.next(), fields.next(), fields.next())
597 else {
598 continue;
599 };
600 if dev.trim().eq_ignore_ascii_case(device) && rev.trim().eq_ignore_ascii_case(revision) {
601 let name = name.trim();
602 if !name.is_empty() {
603 return Some(name.to_string());
604 }
605 }
606 }
607 None
608}
609
610/// Marketing name from libdrm's `amdgpu.ids` (keyed by device id + revision id) when the
611/// file ships on the system; otherwise a recognizable PCI-id fallback — never an error.
612fn gpu_name(root: &Path, dev_path: &Path) -> String {
613 let device = read_hex_id(&dev_path.join("device"));
614 if let (Some(dev_id), Some(rev_id)) = (&device, read_hex_id(&dev_path.join("revision"))) {
615 if let Ok(ids) = fs::read_to_string(root.join("usr/share/libdrm/amdgpu.ids")) {
616 if let Some(name) = amdgpu_ids_name(&ids, dev_id, &rev_id) {
617 return name;
618 }
619 }
620 }
621 match device {
622 Some(id) => format!("AMD GPU [1002:{id}]"),
623 None => "AMD GPU".into(),
624 }
625}
626
627/// Process name from `{root}/proc/<pid>/comm` (kernel-truncated to 15 chars); a pid
628/// placeholder when even that is unreadable.
629fn comm_name(root: &Path, pid: u32) -> String {
630 if let Ok(comm) = fs::read_to_string(root.join(format!("proc/{pid}/comm"))) {
631 let comm = comm.trim();
632 if !comm.is_empty() {
633 return comm.to_string();
634 }
635 }
636 format!("pid {pid}")
637}
638
639/// PCI address from the uevent's PCI_SLOT_NAME, lowercased — the same identity fdinfo's
640/// `drm-pdev` carries and the registry dedupes on. An empty value is no identity at all
641/// (`None`), so the card is skipped per `discover`'s contract rather than registered as
642/// a ghost device with a blank id.
643fn pci_slot_name(dev_path: &Path) -> Option<String> {
644 fs::read_to_string(dev_path.join("uevent"))
645 .ok()?
646 .lines()
647 .find_map(|l| l.strip_prefix("PCI_SLOT_NAME="))
648 .map(|s| s.trim().to_ascii_lowercase())
649 .filter(|s| !s.is_empty())
650}
651
652/// First hwmon dir under the device. hwmon indices are not stable across boots, so it is
653/// resolved through the device dir at init; absence (APUs, fixtures) is normal.
654fn first_hwmon(dev_path: &Path) -> Option<PathBuf> {
655 let entries = fs::read_dir(dev_path.join("hwmon")).ok()?;
656 let mut dirs: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
657 dirs.sort(); // read_dir order is arbitrary
658 dirs.into_iter().next()
659}
660
661/// One enumerated amdgpu PCI device, resolved at init.
662struct AmdDevice {
663 id: DeviceId,
664 /// `{root}/sys/class/drm/cardN/device` — the PCI dir all metric files hang off.
665 dev_path: PathBuf,
666 hwmon: Option<PathBuf>,
667}
668
669/// Enumerate `{root}/sys/class/drm/cardN/device` dirs whose vendor id is AMD (0x1002).
670/// Connector nodes ("card1-DP-1") and render nodes are skipped; a card without a
671/// PCI_SLOT_NAME has no stable identity and is skipped rather than guessed.
672fn discover(root: &Path) -> Vec<AmdDevice> {
673 let Ok(entries) = fs::read_dir(root.join("sys/class/drm")) else {
674 return Vec::new();
675 };
676 let mut cards: Vec<(u32, PathBuf)> = entries
677 .flatten()
678 .filter_map(|e| {
679 let name = e.file_name().into_string().ok()?;
680 let idx: u32 = name.strip_prefix("card")?.parse().ok()?;
681 Some((idx, e.path().join("device")))
682 })
683 .collect();
684 cards.sort_by_key(|(idx, _)| *idx); // deterministic device order
685
686 let mut devs = Vec::new();
687 for (_, dev_path) in cards {
688 if read_trim(&dev_path.join("vendor")).as_deref() != Some("0x1002") {
689 continue;
690 }
691 let Some(pci) = pci_slot_name(&dev_path) else {
692 continue;
693 };
694 let hwmon = first_hwmon(&dev_path);
695 devs.push(AmdDevice {
696 id: DeviceId(pci),
697 dev_path,
698 hwmon,
699 });
700 }
701 devs
702}
703
704pub struct AmdBackend {
705 root: PathBuf,
706 devs: Vec<AmdDevice>,
707 /// Per-(device, pid) fdinfo gfx-engine watermark: (cumulative busy-ns, wall ms).
708 last_gfx: HashMap<(DeviceId, u32), (u64, u64)>,
709 /// Set once at init: explanation for a known-incomplete process list, if any.
710 process_hint: Option<String>,
711 /// Turns the kernel's cumulative per-PID CPU counter into a per-tick rate. The CPU%/
712 /// container columns come from `/proc` (never the device root: a fixture tree's pids are
713 /// not real processes), shared with the other Linux backends via `crate::proc_meta`.
714 #[cfg(target_os = "linux")]
715 cpu: crate::proc_meta::CpuTracker,
716}
717
718impl AmdBackend {
719 /// Production entry point: the live sysfs/procfs under `/`.
720 pub fn init() -> Result<Self, BackendError> {
721 Self::with_root("/")
722 }
723
724 /// Fixture entry point: every path below derives from `root`, so tests run against
725 /// committed trees (see `tests/fixtures/`).
726 pub fn with_root(root: impl Into<PathBuf>) -> Result<Self, BackendError> {
727 let root = root.into();
728 let devs = discover(&root);
729 if devs.is_empty() {
730 return Err(BackendError::Unavailable(
731 "no amdgpu devices under sys/class/drm".into(),
732 ));
733 }
734 let process_hint = fdinfo_process_hint(&root);
735 Ok(Self {
736 root,
737 devs,
738 last_gfx: HashMap::new(),
739 process_hint,
740 #[cfg(target_os = "linux")]
741 cpu: crate::proc_meta::CpuTracker::new(),
742 })
743 }
744
745 fn device(&self, dev: &DeviceId) -> Result<&AmdDevice, BackendError> {
746 self.devs
747 .iter()
748 .find(|d| &d.id == dev)
749 .ok_or_else(|| BackendError::DeviceNotFound(dev.clone()))
750 }
751}
752
753impl GpuBackend for AmdBackend {
754 fn name(&self) -> &'static str {
755 "amd"
756 }
757
758 fn devices(&mut self) -> Vec<DeviceId> {
759 self.devs.iter().map(|d| d.id.clone()).collect()
760 }
761
762 fn static_info(&mut self, dev: &DeviceId) -> Result<StaticInfo, BackendError> {
763 let d = self.device(dev)?;
764 let p = &d.dev_path;
765
766 Ok(StaticInfo {
767 id: dev.clone(),
768 vendor: Vendor::Amd,
769 name: gpu_name(&self.root, p),
770 backend: "amd".into(),
771 mem_total_bytes: read_parse(&p.join("mem_info_vram_total")),
772 power_limit_mw: d.hwmon.as_deref().and_then(power_cap_mw),
773 max_sm_clock_mhz: fs::read_to_string(p.join("pp_dpm_sclk"))
774 .ok()
775 .as_deref()
776 .and_then(dpm_max_mhz),
777 // hwmon's temp1_crit is the shutdown-adjacent critical point, not the knee
778 // where the SMU starts pulling clocks — claiming it as the slowdown threshold
779 // would mis-narrate throttle events. Honest absence until the gpu_metrics
780 // decoder provides the real limit.
781 temp_slowdown_c: None,
782 // amdgpu is an in-tree driver: there is no driver version distinct from the
783 // kernel, and the uevent DRIVER= field is a name, not a version.
784 driver_version: None,
785 process_hint: self.process_hint.clone(),
786 // sysfs numbers carry their plain meanings — nothing to qualify.
787 source_caveat: None,
788 })
789 }
790
791 fn refresh_dynamic(&mut self, dev: &DeviceId) -> Result<DynamicSample, BackendError> {
792 let d = self.device(dev)?;
793 let p = &d.dev_path;
794 let hwmon = d.hwmon.as_deref();
795
796 Ok(DynamicSample {
797 ts_ms: now_ms(),
798 // SMU activity metric — duty-cycle-flavored like every vendor's "util".
799 util_pct: read_parse(&p.join("gpu_busy_percent")),
800 util_engine: None, // device-wide number, not an engine headline
801 mem_used_bytes: read_parse(&p.join("mem_info_vram_used")),
802 power_mw: hwmon.and_then(power_mw),
803 temp_c: hwmon.and_then(edge_temp_c),
804 fan_pct: hwmon.and_then(fan_pct),
805 sm_clock_mhz: fs::read_to_string(p.join("pp_dpm_sclk"))
806 .ok()
807 .as_deref()
808 .and_then(dpm_current_mhz),
809 mem_clock_mhz: fs::read_to_string(p.join("pp_dpm_mclk"))
810 .ok()
811 .as_deref()
812 .and_then(dpm_current_mhz),
813 // VCN (enc/dec) activity also lives in gpu_metrics, but its per-version offsets
814 // and units are a separate job — absent until that decoder lands.
815 encoder_pct: None,
816 decoder_pct: None,
817 // Throttle status lives in the versioned `gpu_metrics` binary node. An
818 // absent/unreadable file (APUs/older kernels without the node) means the
819 // source cannot observe throttling at all → `None` (§5.4), never an asserted
820 // all-false. A present blob the decoder cannot trust (truncated, lying
821 // structure_size, unknown future revision) is equally unobservable — the
822 // decoder returns None for those, so Some here always means "decoded".
823 throttle: fs::read(p.join("gpu_metrics"))
824 .ok()
825 .and_then(|buf| decode_gpu_metrics_throttle(&buf)),
826 })
827 }
828
829 fn refresh_processes(&mut self, dev: &DeviceId) -> Result<Vec<ProcessSample>, BackendError> {
830 let pci = self.device(dev)?.id.0.clone();
831 // One wall timestamp for the whole scan (one timestamp per frame, per CLAUDE.md).
832 let ts = now_ms();
833
834 // pid → max-merged fdinfo values across that pid's fds on this device.
835 let mut by_pid: HashMap<u32, FdinfoDrm> = HashMap::new();
836 if let Ok(entries) = fs::read_dir(self.root.join("proc")) {
837 for entry in entries.flatten() {
838 let Some(pid) = entry
839 .file_name()
840 .to_str()
841 .and_then(|s| s.parse::<u32>().ok())
842 else {
843 continue;
844 };
845 // Other users' fdinfo is unreadable without root/CAP_SYS_PTRACE — skip
846 // silently; the static-info hint already explains the incompleteness.
847 let Ok(fds) = fs::read_dir(entry.path().join("fdinfo")) else {
848 continue;
849 };
850 for fd in fds.flatten() {
851 let Ok(contents) = fs::read_to_string(fd.path()) else {
852 continue;
853 };
854 let info = parse_fdinfo(&contents);
855 if info.pdev.as_deref() != Some(pci.as_str()) {
856 continue;
857 }
858 by_pid.entry(pid).or_default().merge_max(info);
859 }
860 }
861 }
862
863 let mut out: Vec<ProcessSample> = Vec::with_capacity(by_pid.len());
864 for (&pid, agg) in &by_pid {
865 // Engine-ns watermark: busy-ns delta over wall time = util%. First sighting
866 // has no baseline → None.
867 let util_pct = agg.gfx_ns.and_then(|cur| {
868 let prev = self.last_gfx.insert((dev.clone(), pid), (cur, ts));
869 prev.and_then(|(p_ns, p_ts)| engine_util_pct(p_ns, p_ts, cur, ts))
870 });
871 out.push(ProcessSample {
872 pid,
873 name: comm_name(&self.root, pid),
874 // A nonzero compute engine is decisive. (ROCm/KFD compute is known to
875 // show ~0 engine-ns in fdinfo — the /sys/class/kfd cover comes later.)
876 kind: if agg.compute_ns.unwrap_or(0) > 0 {
877 ProcessKind::Compute
878 } else {
879 ProcessKind::Graphics
880 },
881 mem_bytes: agg.vram_kib.and_then(kib_to_bytes),
882 util_pct,
883 cpu_pct: None,
884 container: None,
885 });
886 }
887 // Drop watermarks for pids that vanished from this device (exited processes).
888 self.last_gfx
889 .retain(|(d, pid), _| d != dev || by_pid.contains_key(pid));
890
891 // CPU% and container identity come from /proc, mirroring the NVIDIA backend. The
892 // CpuTracker holds per-PID state, so prune it to the PIDs we still see to keep it
893 // from growing across a long session; container_of is stateless.
894 #[cfg(target_os = "linux")]
895 {
896 for p in &mut out {
897 p.cpu_pct = self.cpu.sample(p.pid);
898 p.container = crate::proc_meta::container_of(p.pid);
899 }
900 let live: Vec<u32> = out.iter().map(|p| p.pid).collect();
901 self.cpu.prune(&live);
902 }
903
904 out.sort_by_key(|p| p.pid); // deterministic order for the table and tests
905 Ok(out)
906 }
907}
908
909#[cfg(test)]
910mod tests {
911 use super::*;
912
913 #[test]
914 fn dpm_table_parses_current_and_max_levels() {
915 let table = "0: 500Mhz\n1: 1138Mhz *\n2: 2890Mhz\n";
916 assert_eq!(dpm_current_mhz(table), Some(1138));
917 assert_eq!(dpm_max_mhz(table), Some(2890));
918 assert_eq!(dpm_current_mhz("garbage"), None);
919 assert_eq!(dpm_max_mhz(""), None);
920 }
921
922 #[test]
923 fn fdinfo_unit_suffixes_are_mandatory() {
924 let blob = "drm-pdev:\t0000:03:00.0\ndrm-engine-gfx:\t123 ns\ndrm-memory-vram:\t456 KiB\n";
925 let f = parse_fdinfo(blob);
926 assert_eq!(f.pdev.as_deref(), Some("0000:03:00.0"));
927 assert_eq!(f.gfx_ns, Some(123));
928 assert_eq!(f.vram_kib, Some(456));
929 assert_eq!(f.compute_ns, None, "absent key stays None");
930 // A value with the wrong/missing suffix is a key we do not understand.
931 assert_eq!(parse_suffixed("456", "KiB"), None);
932 assert_eq!(parse_suffixed("456 MiB", "KiB"), None);
933 }
934
935 #[test]
936 fn engine_util_needs_baseline_and_handles_resets() {
937 // 500ms of busy-ns over 1000ms of wall = 50%.
938 assert_eq!(engine_util_pct(0, 0, 500_000_000, 1_000), Some(50.0));
939 // Counter went backwards (pid reuse re-created the client): no claim.
940 assert_eq!(engine_util_pct(900, 0, 100, 1_000), None);
941 // Zero wall delta cannot produce a rate.
942 assert_eq!(engine_util_pct(0, 1_000, 100, 1_000), None);
943 // More busy-ns than wall time (multi-queue accounting) clamps, never exceeds.
944 assert_eq!(engine_util_pct(0, 0, 10_000_000_000, 1_000), Some(100.0));
945 }
946
947 #[test]
948 fn hostile_fdinfo_vram_cannot_panic_or_wrap() {
949 assert_eq!(kib_to_bytes(456), Some(466_944));
950 // u64::MAX KiB cannot be a real byte count: None — never a debug panic, never
951 // a release-mode wrap to a fabricated number.
952 assert_eq!(kib_to_bytes(u64::MAX), None);
953 }
954
955 #[test]
956 fn fan_pct_rejects_non_physical_readings() {
957 assert_eq!(fan_pct_of_max(1650.0, 3300.0), Some(50.0));
958 // Faster than fan1_max (worn sensor, boost) clamps, never exceeds.
959 assert_eq!(fan_pct_of_max(4000.0, 3300.0), Some(100.0));
960 // "nan"/"inf" parse as f32 but are not fan readings.
961 assert_eq!(fan_pct_of_max(f32::NAN, f32::NAN), None);
962 assert_eq!(fan_pct_of_max(f32::INFINITY, 3300.0), None);
963 assert_eq!(fan_pct_of_max(1650.0, f32::NAN), None);
964 // A negative RPM is a broken sensor, not a negative percentage.
965 assert_eq!(fan_pct_of_max(-500.0, 3300.0), None);
966 assert_eq!(fan_pct_of_max(1650.0, 0.0), None);
967 }
968
969 #[test]
970 fn proc_status_privilege_detection() {
971 // euid is the second Uid field — root euid grants the full scan.
972 assert!(status_grants_full_proc_scan(
973 "Uid:\t1000\t0\t1000\t1000\nCapEff:\t0000000000000000"
974 ));
975 // CAP_SYS_PTRACE (bit 19) alone suffices.
976 assert!(status_grants_full_proc_scan(
977 "Uid:\t1000\t1000\t1000\t1000\nCapEff:\t0000000000080000"
978 ));
979 assert!(!status_grants_full_proc_scan(
980 "Uid:\t1000\t1000\t1000\t1000\nCapEff:\t0000000000000000"
981 ));
982 assert!(!status_grants_full_proc_scan(""));
983 }
984
985 // ---- gpu_metrics throttle decoding -------------------------------------------------
986 //
987 // The blob builders below write every field at the SAME offset the decoder reads, and
988 // deliberately plant a DECOY nonzero value in the field adjacent to each word we care
989 // about. An off-by-N decoder would read the decoy and produce the wrong category, so
990 // these tests fail loudly on an offset slip rather than silently passing.
991
992 /// Build a naturally-aligned `gpu_metrics_v1_3` blob (size 120) with the given
993 /// `indep_throttle_status`. Plants a thermal-bit decoy 8 bytes BEFORE indep (the voltage
994 /// block) and a nonzero legacy throttle_status, so a correct decoder must read indep at
995 /// exactly @112 and must prefer it over the legacy word.
996 fn build_v1_3(indep: u64) -> Vec<u8> {
997 let mut b = vec![0u8; 120];
998 b[0..2].copy_from_slice(&120u16.to_le_bytes()); // structure_size
999 b[2] = 1; // format_revision
1000 b[3] = 3; // content_revision
1001 b[68..72].copy_from_slice(&0x0000_0080u32.to_le_bytes()); // legacy decoy (nonzero)
1002 b[104..112].copy_from_slice(&(1u64 << 32).to_le_bytes()); // off-by-8 thermal decoy
1003 b[112..120].copy_from_slice(&indep.to_le_bytes()); // the real word
1004 b
1005 }
1006
1007 /// Build a `gpu_metrics_v2_3` blob (size 152, APU) with the given indep word. indep lives
1008 /// at @120; the off-by-8 decoy goes at @112 (fan_pwm/padding region) and a legacy decoy
1009 /// at @108.
1010 fn build_v2_3(indep: u64) -> Vec<u8> {
1011 let mut b = vec![0u8; 152];
1012 b[0..2].copy_from_slice(&152u16.to_le_bytes());
1013 b[2] = 2;
1014 b[3] = 3;
1015 b[108..112].copy_from_slice(&0x0000_0080u32.to_le_bytes()); // legacy decoy
1016 b[112..120].copy_from_slice(&(1u64 << 32).to_le_bytes()); // off-by-8 thermal decoy
1017 b[120..128].copy_from_slice(&indep.to_le_bytes());
1018 b
1019 }
1020
1021 /// Build a legacy-only `gpu_metrics_v2_1` blob (size 120, no indep word): throttle_status
1022 /// at @108, with a nonzero decoy in the adjacent fan_pwm/padding bytes.
1023 fn build_v2_1(throttle_status: u32) -> Vec<u8> {
1024 let mut b = vec![0u8; 120];
1025 b[0..2].copy_from_slice(&120u16.to_le_bytes());
1026 b[2] = 2;
1027 b[3] = 1;
1028 b[112..114].copy_from_slice(&0xBEEFu16.to_le_bytes()); // fan_pwm decoy (adjacent)
1029 b[108..112].copy_from_slice(&throttle_status.to_le_bytes());
1030 b
1031 }
1032
1033 /// Build a `gpu_metrics_v2_2` blob (size 128): legacy@108, indep@120. Cyan Skillfish
1034 /// (BC-250 class) ships exactly this revision with `indep_throttle_status` never
1035 /// written by firmware — it reads as the SMU's 0xFF-memset sentinel — so the builder
1036 /// takes both words to let tests model that hardware.
1037 fn build_v2_2(indep: u64, legacy: u32) -> Vec<u8> {
1038 let mut b = vec![0u8; 128];
1039 b[0..2].copy_from_slice(&128u16.to_le_bytes());
1040 b[2] = 2;
1041 b[3] = 2;
1042 b[108..112].copy_from_slice(&legacy.to_le_bytes());
1043 b[112..114].copy_from_slice(&0xBEEFu16.to_le_bytes()); // fan_pwm decoy (adjacent)
1044 b[120..128].copy_from_slice(&indep.to_le_bytes());
1045 b
1046 }
1047
1048 /// Build a `gpu_metrics_v2_4` blob at the kernel's real `sizeof` = **168**: 164 data
1049 /// bytes (v2.3's 152 + 6×u16 avg voltage/current @152) plus 4 tail-pad bytes from the
1050 /// struct's u64 alignment, with structure_size = sizeof (smu_cmn.h). Offsets are
1051 /// hardcoded literals cross-checked against the audit's compiled offsetof — never
1052 /// derived from `throttle_layout()`. Kernel-true decoys: the tail pad @164-167 is 0xFF
1053 /// (the SMU memset) and the voltage/current block is nonzero, so a decoder gating on
1054 /// 164 or misreading an offset fails loudly.
1055 fn build_v2_4(indep: u64, legacy: u32) -> Vec<u8> {
1056 let mut b = vec![0u8; 168];
1057 b[0..2].copy_from_slice(&168u16.to_le_bytes());
1058 b[2] = 2;
1059 b[3] = 4;
1060 b[108..112].copy_from_slice(&legacy.to_le_bytes()); // legacy throttle_status
1061 b[112..120].copy_from_slice(&(1u64 << 32).to_le_bytes()); // off-by-8 thermal decoy
1062 b[120..128].copy_from_slice(&indep.to_le_bytes()); // the real indep word
1063 b[152..164].copy_from_slice(&[0xAB; 12]); // avg voltage/current decoys (nonzero)
1064 b[164..168].copy_from_slice(&[0xFF; 4]); // 0xFF'd tail pad — kernel-true
1065 b
1066 }
1067
1068 /// Build a `gpu_metrics_v3_0` blob (sizeof 264) with the named residency ACCUMULATORS
1069 /// set at the compiler-verified offsets 228..252 — a 2-byte pad follows
1070 /// `current_gfx_maxfreq` u16 @224. Kernel-true decoy: that pad @226-227 is 0xFF (the
1071 /// SMU memset), exactly what an off-by−2 decoder would misread as a 0xFFFF prochot.
1072 fn build_v3_0(prochot: u32, spl: u32, thm_gfx: u32) -> Vec<u8> {
1073 let mut b = vec![0u8; 264];
1074 b[0..2].copy_from_slice(&264u16.to_le_bytes());
1075 b[2] = 3;
1076 b[3] = 0;
1077 b[224..226].copy_from_slice(&2900u16.to_le_bytes()); // current_gfx_maxfreq decoy
1078 b[226..228].copy_from_slice(&[0xFF, 0xFF]); // 0xFF'd pad — the killer decoy
1079 b[228..232].copy_from_slice(&prochot.to_le_bytes()); // throttle_residency_prochot
1080 b[232..236].copy_from_slice(&spl.to_le_bytes()); // throttle_residency_spl
1081 b[248..252].copy_from_slice(&thm_gfx.to_le_bytes()); // throttle_residency_thm_gfx
1082 b
1083 }
1084
1085 #[test]
1086 fn indep_thermal_bit_decodes_to_thermal_only() {
1087 // TEMP_HOTSPOT (bit 36) is purely thermal.
1088 let t = decode_gpu_metrics_throttle(&build_v1_3(1 << SMU_THROTTLER_TEMP_HOTSPOT_BIT))
1089 .expect("well-formed v1_3 must decode");
1090 assert!(t.thermal);
1091 assert!(!t.power_cap && !t.hw_slowdown && !t.sync_boost && !t.other);
1092 }
1093
1094 #[test]
1095 fn indep_ppt_bit_decodes_to_power_cap_only() {
1096 // PPT0 (bit 0) is a package-power limit; the legacy/off-by-8 decoys must be ignored.
1097 let t = decode_gpu_metrics_throttle(&build_v1_3(1 << SMU_THROTTLER_PPT0_BIT))
1098 .expect("well-formed v1_3 must decode");
1099 assert!(t.power_cap);
1100 assert!(!t.thermal && !t.hw_slowdown && !t.sync_boost && !t.other);
1101 }
1102
1103 #[test]
1104 fn indep_prochot_bit_decodes_to_hw_slowdown() {
1105 let t = decode_gpu_metrics_throttle(&build_v1_3(1 << SMU_THROTTLER_PROCHOT_GFX_BIT))
1106 .expect("well-formed v1_3 must decode");
1107 assert!(t.hw_slowdown);
1108 assert!(!t.thermal && !t.power_cap && !t.sync_boost && !t.other);
1109 }
1110
1111 #[test]
1112 fn indep_combined_bits_decode_to_multiple_reasons() {
1113 // Thermal + power + prochot + an unknown future bit (bit 60) at once.
1114 let bits = (1 << SMU_THROTTLER_TEMP_MEM_BIT)
1115 | (1 << SMU_THROTTLER_TDC_GFX_BIT)
1116 | (1 << SMU_THROTTLER_PROCHOT_CPU_BIT)
1117 | (1u64 << 60);
1118 let t =
1119 decode_gpu_metrics_throttle(&build_v2_3(bits)).expect("well-formed v2_3 must decode");
1120 assert!(t.thermal && t.power_cap && t.hw_slowdown);
1121 // The unrecognised bit lands in `other` (tolerant decoding), not dropped.
1122 assert!(t.other);
1123 assert!(!t.sync_boost);
1124 }
1125
1126 #[test]
1127 fn legacy_only_nonzero_is_coarse_other_never_a_guessed_cause() {
1128 // v2_1 has no indep word: a nonzero ASIC-specific throttle_status is real, but its
1129 // per-bit meaning is unknowable cross-ASIC, so it surfaces as `other` alone.
1130 let t = decode_gpu_metrics_throttle(&build_v2_1(0x0000_00FF))
1131 .expect("well-formed v2_1 must decode");
1132 assert!(t.other);
1133 assert!(!t.thermal && !t.power_cap && !t.hw_slowdown && !t.sync_boost);
1134 // Zero legacy status in a decoded struct is an OBSERVED quiet — Some(all-false).
1135 assert_eq!(
1136 decode_gpu_metrics_throttle(&build_v2_1(0)),
1137 Some(ThrottleReasons::default())
1138 );
1139 }
1140
1141 #[test]
1142 fn v2_4_decodes_at_kernel_sizeof_168() {
1143 // SPPT_APU (bit 7) is an APU power limit — the Van Gogh / Steam Deck case. The
1144 // legacy decoy, off-by-8 thermal decoy, and 0xFF tail pad must all be ignored.
1145 let t = decode_gpu_metrics_throttle(&build_v2_4(1 << SMU_THROTTLER_SPPT_APU_BIT, 0x40))
1146 .expect("a real 168-byte v2_4 blob must decode");
1147 assert!(t.power_cap);
1148 assert!(!t.thermal && !t.hw_slowdown && !t.sync_boost && !t.other);
1149 }
1150
1151 #[test]
1152 fn v2_4_blob_claiming_164_is_rejected() {
1153 // 164 was this decoder's old (wrong) gate: the kernel's sizeof is 168 because the
1154 // struct's u64 members add 4 tail-pad bytes and structure_size = sizeof
1155 // (smu_cmn.h). A blob declaring 164 is therefore NOT the struct we know —
1156 // exact-size honesty rejects it (None), never best-effort decodes it.
1157 let mut b = build_v2_4(1 << SMU_THROTTLER_SPPT_APU_BIT, 0);
1158 b.truncate(164);
1159 b[0..2].copy_from_slice(&164u16.to_le_bytes());
1160 assert_eq!(
1161 decode_gpu_metrics_throttle(&b),
1162 None,
1163 "a structure_size of 164 does not match v2_4's real sizeof"
1164 );
1165 }
1166
1167 #[test]
1168 fn indep_all_ff_sentinel_falls_through_to_legacy() {
1169 // Cyan Skillfish: v2_2 with indep_throttle_status never written → 0xFF…FF from
1170 // the SMU memset. That means "word unsupported", NOT 64 simultaneous throttlers.
1171 // With a quiet legacy word the decode is an OBSERVED quiet — Some(all-false) —
1172 // never a permanent all-reasons-throttling narration.
1173 assert_eq!(
1174 decode_gpu_metrics_throttle(&build_v2_2(u64::MAX, 0)),
1175 Some(ThrottleReasons::default())
1176 );
1177 // A nonzero legacy word behind the sentinel still surfaces as coarse `other`.
1178 let t = decode_gpu_metrics_throttle(&build_v2_2(u64::MAX, 0x2))
1179 .expect("legacy word must still decode behind an indep sentinel");
1180 assert!(t.other);
1181 assert!(!t.thermal && !t.power_cap && !t.hw_slowdown && !t.sync_boost);
1182 // The dGPU path falls through the same way: v1_3 with sentinel indep reads the
1183 // legacy word at @68 (nonzero in the builder) → coarse `other`.
1184 let t = decode_gpu_metrics_throttle(&build_v1_3(u64::MAX))
1185 .expect("v1_3 legacy word must decode behind an indep sentinel");
1186 assert!(t.other);
1187 assert!(!t.thermal && !t.power_cap && !t.hw_slowdown && !t.sync_boost);
1188 }
1189
1190 #[test]
1191 fn all_words_sentinel_decodes_to_none() {
1192 // Both words carry the 0xFF memset sentinel: this firmware exposes no throttle
1193 // observation at all — unobservable (None), never Some(anything).
1194 assert_eq!(
1195 decode_gpu_metrics_throttle(&build_v2_2(u64::MAX, u32::MAX)),
1196 None
1197 );
1198 }
1199
1200 #[test]
1201 fn legacy_only_sentinel_decodes_to_none() {
1202 // v2_1 has no indep word; a 0xFF'd throttle_status means "unsupported", not
1203 // "every ASIC-specific throttler active".
1204 assert_eq!(decode_gpu_metrics_throttle(&build_v2_1(u32::MAX)), None);
1205 }
1206
1207 #[test]
1208 fn v3_residency_accumulators_never_assert_per_sample_throttle() {
1209 // v3_0 carries only firmware accumulator counters (no status word): nonzero means
1210 // "fired at some point in the firmware's window", which a single sample cannot
1211 // turn into a live state. The per-sample decode is None (unobservable) — a
1212 // historic PROCHOT must never narrate as a current hw_slowdown.
1213 assert_eq!(decode_gpu_metrics_throttle(&build_v3_0(37, 9, 12)), None);
1214 // All-zero accumulators are equally non-instantaneous — still None, not an
1215 // asserted "observed quiet" fabricated from counters we cannot interpret
1216 // per-sample. (The 0xFF'd pad @226-227 must never leak into any decode either.)
1217 assert_eq!(decode_gpu_metrics_throttle(&build_v3_0(0, 0, 0)), None);
1218 // Counters that are themselves the 0xFF memset sentinel change nothing: None.
1219 assert_eq!(
1220 decode_gpu_metrics_throttle(&build_v3_0(u32::MAX, u32::MAX, u32::MAX)),
1221 None
1222 );
1223 }
1224
1225 #[test]
1226 fn unknown_revision_decodes_to_none() {
1227 // A well-formed header with a future (format=9, content=9) revision: no offsets we
1228 // trust → None, never a guess at byte positions and never a fabricated "observed
1229 // quiet" (an asserted all-false here would be a permanent fact-grade lie on every
1230 // kernel newer than the layout table — §5.4).
1231 let mut b = build_v1_3(1 << SMU_THROTTLER_PPT0_BIT);
1232 b[2] = 9;
1233 b[3] = 9;
1234 assert_eq!(
1235 decode_gpu_metrics_throttle(&b),
1236 None,
1237 "unknown revision must be unobservable, not quiet"
1238 );
1239 }
1240
1241 #[test]
1242 fn truncated_blob_decodes_to_none_without_panic() {
1243 // The corrupt/short-sysfs honesty case: every prefix length must yield None,
1244 // never a panic, never a half-read word, never an asserted all-false.
1245 let full = build_v1_3(1 << SMU_THROTTLER_PPT0_BIT);
1246 for len in 0..full.len() {
1247 assert_eq!(
1248 decode_gpu_metrics_throttle(&full[..len]),
1249 None,
1250 "a {len}-byte prefix of a v1_3 blob must decode to None"
1251 );
1252 }
1253 // An empty buffer is the extreme of the same case.
1254 assert_eq!(decode_gpu_metrics_throttle(&[]), None);
1255 }
1256
1257 #[test]
1258 fn lying_structure_size_decodes_to_none() {
1259 // structure_size claims a smaller struct than the version's real length: we are not
1260 // looking at the struct we think we are, so decode nothing even though a PPT bit is
1261 // physically present at @112.
1262 let mut b = build_v1_3(1 << SMU_THROTTLER_PPT0_BIT);
1263 b[0..2].copy_from_slice(&100u16.to_le_bytes()); // real v1_3 is 120
1264 assert_eq!(
1265 decode_gpu_metrics_throttle(&b),
1266 None,
1267 "a structure_size that disagrees with the version size is not trusted"
1268 );
1269 // The mirror case: structure_size larger than the buffer (claims bytes we lack).
1270 let mut b = build_v1_3(1 << SMU_THROTTLER_PPT0_BIT);
1271 b[0..2].copy_from_slice(&200u16.to_le_bytes());
1272 assert_eq!(decode_gpu_metrics_throttle(&b), None);
1273 }
1274
1275 #[test]
1276 fn amdgpu_ids_lookup_is_keyed_by_device_and_revision() {
1277 let ids = "# header\n1.0.0\n744C,\tC8,\tAMD Radeon RX 7900 XTX\n744C,\tCC,\tAMD Radeon RX 7900 XT\n";
1278 assert_eq!(
1279 amdgpu_ids_name(ids, "744c", "c8").as_deref(),
1280 Some("AMD Radeon RX 7900 XTX")
1281 );
1282 assert_eq!(
1283 amdgpu_ids_name(ids, "744c", "cc").as_deref(),
1284 Some("AMD Radeon RX 7900 XT")
1285 );
1286 assert_eq!(amdgpu_ids_name(ids, "744c", "ff"), None);
1287 }
1288}