Line | Count | Source |
1 | | // SPDX-License-Identifier: Apache-2.0 |
2 | | //! AMD and Intel detection via Linux DRM sysfs (`/sys/class/drm/card*/device`). |
3 | | //! No `ROCm`, Level Zero, or vendor libraries required. |
4 | | //! |
5 | | //! GPUs that expose `mem_info_vram_total` report dedicated VRAM with used/free. |
6 | | //! That attribute is provided by `amdgpu`; whether Intel's `i915`/`xe` drivers |
7 | | //! expose it on discrete Arc cards is unverified (untested on real hardware). |
8 | | //! Any card without it — most Intel iGPUs, and Intel discrete cards that don't |
9 | | //! implement the file — falls back to the shared system-memory ceiling with |
10 | | //! used/free `None`. NVIDIA cards are skipped here — they're handled by NVML in |
11 | | //! the `nvidia` module. |
12 | | //! |
13 | | //! AMD APUs do expose `mem_info_vram_total`, but only as a small BIOS carveout |
14 | | //! (as little as 512 MiB, for instance) — the memory such a part really |
15 | | //! allocates from is the GTT pool in `mem_info_gtt_total`, sized by the kernel's |
16 | | //! `ttm.pages_limit`. [`fold_gtt`] adds the two together for cards that look |
17 | | //! integrated, so a unified-memory part reports what it can actually hand out. |
18 | | |
19 | | /// True for a primary DRM card node (`card0`, `card1`, …) — not a connector |
20 | | /// (`card0-eDP-1`) or a render node (`renderD128`). |
21 | | #[allow(dead_code)] // used on Linux + in tests; unused on other targets |
22 | 668 | fn is_card_dir(name: &str) -> bool { |
23 | 668 | name.strip_prefix("card") |
24 | 668 | .is_some_and(|rest| !rest.is_empty()444 && rest.bytes()443 .all443 (|b| b666 .is_ascii_digit666 ())) |
25 | 668 | } |
26 | | |
27 | | /// Parse a sysfs integer file (e.g. `mem_info_vram_total`) holding a decimal |
28 | | /// byte count. |
29 | | #[allow(dead_code)] // used on Linux + in tests; unused on other targets |
30 | 9 | fn parse_bytes(content: &str) -> Option<u64> { |
31 | 9 | content.trim().parse().ok() |
32 | 9 | } |
33 | | |
34 | | /// Map a PCI vendor id (`device/vendor`, e.g. `0x1002`) to the [`Vendor`] this |
35 | | /// scanner owns. NVIDIA (`0x10de`) returns `None` — NVML handles it — as do |
36 | | /// unknown vendors. |
37 | | #[allow(dead_code)] // used on Linux + in tests; unused on other targets |
38 | 227 | fn sysfs_vendor(id: &str) -> Option<crate::Vendor> { |
39 | | use crate::Vendor; |
40 | 227 | match id.trim().to_ascii_lowercase().as_str() { |
41 | 227 | "0x1002" => Some(Vendor::Amd)2 , |
42 | 225 | "0x8086" => Some(Vendor::Intel)2 , |
43 | 223 | _ => None, |
44 | | } |
45 | 227 | } |
46 | | |
47 | | /// Parse `MemTotal:` (in kB) from `/proc/meminfo` contents into bytes. |
48 | | #[allow(dead_code)] // used on Linux + in tests; unused on other targets |
49 | 6 | fn parse_meminfo_total(content: &str) -> Option<u64> { |
50 | 6 | for line5 in content.lines() { |
51 | 5 | if let Some(rest4 ) = line.strip_prefix("MemTotal:") { |
52 | 4 | let kb1 : u641 = rest.split_whitespace().next()?1 .parse3 ().ok3 ()?2 ; |
53 | 1 | return Some(kb * 1024); |
54 | 1 | } |
55 | | } |
56 | 2 | None |
57 | 6 | } |
58 | | |
59 | | /// Largest `mem_info_vram_total` still treated as an APU carveout rather than |
60 | | /// real dedicated VRAM. |
61 | | /// |
62 | | /// There is no sysfs flag for "this is an APU" — `amdgpu` only exposes that |
63 | | /// through a debugfs file (`amdgpu_gpu_info`) that unprivileged callers can't |
64 | | /// read, and through an ioctl this crate doesn't make. Size is the practical |
65 | | /// stand-in: APU carveouts are typically 512 MiB to 2 GiB, while no current |
66 | | /// discrete AMD card ships under 4 GiB. The threshold errs toward leaving a |
67 | | /// card alone — an APU configured with a carveout above it is under-reported |
68 | | /// (the pre-existing behaviour) rather than a discrete card being inflated by |
69 | | /// system memory it shouldn't count. |
70 | | #[allow(dead_code)] // used on Linux + in tests; unused on other targets |
71 | | const INTEGRATED_VRAM_MAX: u64 = 2 * 1024 * 1024 * 1024; |
72 | | |
73 | | /// Total and used memory for a card reporting `vram_total`, folding in the GTT |
74 | | /// pool when the VRAM looks like an APU carveout. |
75 | | /// |
76 | | /// `used` is `None` unless both pools report it — summing only the half that |
77 | | /// answered would understate usage and so overstate free memory. |
78 | | /// |
79 | | /// The carveout is counted, not dropped, which is worth knowing because the two |
80 | | /// runtimes on such a part disagree. On an APU with a 512 MiB carveout over a |
81 | | /// 14 GiB GTT pool: |
82 | | /// |
83 | | /// ```text |
84 | | /// vram_total + gtt_total 15_569_256_448 what this reports |
85 | | /// Vulkan (RADV) heaps 15_569_256_448 identical, to the byte |
86 | | /// KFD memory bank 15_032_385_536 GTT alone, carveout excluded |
87 | | /// ``` |
88 | | /// |
89 | | /// `ROCm` sees the smaller figure because KFD publishes only the GTT-backed |
90 | | /// bank. Counting the carveout matches Vulkan exactly, and costs nothing in |
91 | | /// accuracy: its usage is folded into `used` as well, so a carveout consumed by |
92 | | /// the framebuffer is subtracted straight back out of free memory. |
93 | | #[allow(dead_code)] // used on Linux + in tests; unused on other targets |
94 | 8 | fn fold_gtt( |
95 | 8 | vram_total: u64, |
96 | 8 | vram_used: Option<u64>, |
97 | 8 | gtt_total: Option<u64>, |
98 | 8 | gtt_used: Option<u64>, |
99 | 8 | ) -> (u64, Option<u64>) { |
100 | 7 | match gtt_total { |
101 | 7 | Some(gtt_total5 ) if vram_total <= INTEGRATED_VRAM_MAX5 => ( |
102 | 5 | vram_total.saturating_add(gtt_total), |
103 | 5 | vram_used.zip(gtt_used).map(|(v, g)| v2 .saturating_add2 (g2 )), |
104 | | ), |
105 | 3 | _ => (vram_total, vram_used), |
106 | | } |
107 | 8 | } |
108 | | |
109 | | /// Minor number of a card's render node (`renderD128` → `128`), which is how a |
110 | | /// DRM card lines up with its KFD compute node. |
111 | | #[cfg(target_os = "linux")] |
112 | 0 | fn render_minor(device: &std::path::Path) -> Option<u32> { |
113 | 0 | std::fs::read_dir(device.join("drm")) |
114 | 0 | .ok()? |
115 | 0 | .flatten() |
116 | 0 | .find_map(|entry| { |
117 | 0 | entry |
118 | 0 | .file_name() |
119 | 0 | .to_string_lossy() |
120 | 0 | .strip_prefix("renderD")? |
121 | 0 | .parse() |
122 | 0 | .ok() |
123 | 0 | }) |
124 | 0 | } |
125 | | |
126 | | /// Read one sysfs attribute under a card's `device/` directory as a byte count. |
127 | | #[cfg(target_os = "linux")] |
128 | 0 | fn read_bytes(device: &std::path::Path, attr: &str) -> Option<u64> { |
129 | 0 | std::fs::read_to_string(device.join(attr)) |
130 | 0 | .ok() |
131 | 0 | .and_then(|s| parse_bytes(&s)) |
132 | 0 | } |
133 | | |
134 | | #[cfg(target_os = "linux")] |
135 | 0 | fn system_memory() -> Option<u64> { |
136 | 0 | std::fs::read_to_string("/proc/meminfo") |
137 | 0 | .ok() |
138 | 0 | .and_then(|s| parse_meminfo_total(&s)) |
139 | 0 | } |
140 | | |
141 | | #[cfg(target_os = "linux")] |
142 | 219 | pub(crate) fn detect() -> Vec<crate::GpuInfo> { |
143 | | use crate::GpuInfo; |
144 | | |
145 | 219 | let mut gpus = Vec::new(); |
146 | 219 | let Ok(entries) = std::fs::read_dir("/sys/class/drm") else { |
147 | 0 | return gpus; |
148 | | }; |
149 | | // Fetched lazily: only an integrated GPU needs it, and most hosts have none. |
150 | 219 | let mut sysmem: Option<u64> = None; |
151 | | // Likewise: the KFD tree is only consulted once an AMD card shows up. |
152 | 219 | let mut compute: Option<Vec<crate::kfd::ComputeNode>> = None; |
153 | | |
154 | 657 | for entry in entries219 .flatten219 () { |
155 | 657 | let file_name = entry.file_name(); |
156 | 657 | let card = file_name.to_string_lossy(); |
157 | 657 | if !is_card_dir(&card) { |
158 | 438 | continue; |
159 | 219 | } |
160 | 219 | let device = entry.path().join("device"); |
161 | 219 | let Some(vendor0 ) = std::fs::read_to_string(device.join("vendor")) |
162 | 219 | .ok() |
163 | 219 | .and_then(|v| sysfs_vendor(&v)) |
164 | | else { |
165 | 219 | continue; |
166 | | }; |
167 | | |
168 | | // AMD cards carry a `gfx` target and an ASIC codename in KFD sysfs; the |
169 | | // codename is a better name than the `card1` node path. |
170 | 0 | let node = if vendor == crate::Vendor::Amd { |
171 | 0 | let nodes = compute.get_or_insert_with(crate::kfd::nodes); |
172 | 0 | render_minor(&device) |
173 | 0 | .and_then(|minor| nodes.iter().find(|n| n.render_minor == minor)) |
174 | 0 | .cloned() |
175 | | } else { |
176 | 0 | None |
177 | | }; |
178 | 0 | let name = node.as_ref().and_then(|n| n.name.clone()).map_or_else( |
179 | 0 | || format!("{vendor} GPU ({card})"), |
180 | 0 | |asic| format!("{vendor} {asic}"), |
181 | | ); |
182 | 0 | let arch_target = match vendor { |
183 | 0 | crate::Vendor::Amd => node.map(|n| crate::ArchTarget::Gfx(n.gfx_target)), |
184 | | // Intel publishes no architecture anywhere readable, so it comes |
185 | | // from the PCI device id this same directory already exposes. |
186 | 0 | crate::Vendor::Intel => std::fs::read_to_string(device.join("device")) |
187 | 0 | .ok() |
188 | 0 | .and_then(|id| crate::intel::parse_device_id(&id)) |
189 | 0 | .and_then(crate::intel::arch_for_device_id) |
190 | 0 | .map(crate::ArchTarget::Xe), |
191 | 0 | _ => None, |
192 | | }; |
193 | | |
194 | 0 | if let Some(vram_total) = read_bytes(&device, "mem_info_vram_total") { |
195 | | // Dedicated VRAM — plus the GTT pool when this is an APU carveout. |
196 | 0 | let (total, used) = fold_gtt( |
197 | 0 | vram_total, |
198 | 0 | read_bytes(&device, "mem_info_vram_used"), |
199 | 0 | read_bytes(&device, "mem_info_gtt_total"), |
200 | 0 | read_bytes(&device, "mem_info_gtt_used"), |
201 | 0 | ); |
202 | 0 | gpus.push(GpuInfo { |
203 | 0 | name, |
204 | 0 | vendor, |
205 | 0 | total_bytes: total, |
206 | 0 | free_bytes: used.map(|u| total.saturating_sub(u)), |
207 | 0 | used_bytes: used, |
208 | 0 | arch_target, |
209 | | }); |
210 | | } else { |
211 | | // Integrated GPU with no VRAM pool at all (typical Intel iGPU): |
212 | | // report the shared system-memory ceiling, with no used/free. |
213 | 0 | if sysmem.is_none() { |
214 | 0 | sysmem = system_memory(); |
215 | 0 | } |
216 | 0 | if let Some(total) = sysmem { |
217 | 0 | gpus.push(GpuInfo { |
218 | 0 | name, |
219 | 0 | vendor, |
220 | 0 | total_bytes: total, |
221 | 0 | free_bytes: None, |
222 | 0 | used_bytes: None, |
223 | 0 | arch_target, |
224 | 0 | }); |
225 | 0 | } |
226 | | } |
227 | | } |
228 | 219 | gpus |
229 | 219 | } |
230 | | |
231 | | #[cfg(not(target_os = "linux"))] |
232 | | pub(crate) fn detect() -> Vec<crate::GpuInfo> { |
233 | | Vec::new() |
234 | | } |
235 | | |
236 | | #[cfg(test)] |
237 | | mod tests { |
238 | | use super::*; |
239 | | use crate::Vendor; |
240 | | |
241 | | #[test] |
242 | 1 | fn card_dir_matches_only_primary_nodes() { |
243 | 1 | assert!(is_card_dir("card0")); |
244 | 1 | assert!(is_card_dir("card12")); |
245 | 1 | assert!(!is_card_dir("card0-eDP-1")); |
246 | 1 | assert!(!is_card_dir("renderD128")); |
247 | 1 | assert!(!is_card_dir("controlD64")); |
248 | 1 | assert!(!is_card_dir("card")); |
249 | 1 | assert!(!is_card_dir("cardX")); |
250 | 1 | } |
251 | | |
252 | | #[test] |
253 | 1 | fn parses_sysfs_byte_count() { |
254 | 1 | assert_eq!(parse_bytes("17163091968\n"), Some(17_163_091_968)); |
255 | 1 | assert_eq!(parse_bytes(" 8589934592 "), Some(8_589_934_592)); |
256 | 1 | assert_eq!(parse_bytes("nope"), None); |
257 | 1 | assert_eq!(parse_bytes(""), None); |
258 | 1 | } |
259 | | |
260 | | #[test] |
261 | 1 | fn vendor_ids_amd_and_intel_only() { |
262 | 1 | assert_eq!(sysfs_vendor("0x1002"), Some(Vendor::Amd)); |
263 | 1 | assert_eq!(sysfs_vendor("0x8086\n"), Some(Vendor::Intel)); |
264 | 1 | assert_eq!(sysfs_vendor("0x10DE"), None, "NVIDIA is handled by NVML"); |
265 | 1 | assert_eq!(sysfs_vendor("0xffff"), None); |
266 | 1 | } |
267 | | |
268 | | #[test] |
269 | 1 | fn parses_meminfo_memtotal() { |
270 | 1 | let meminfo = "MemTotal: 32789868 kB\nMemFree: 1234 kB\n"; |
271 | 1 | assert_eq!(parse_meminfo_total(meminfo), Some(32_789_868 * 1024)); |
272 | 1 | assert_eq!(parse_meminfo_total("MemFree: 100 kB"), None); |
273 | 1 | } |
274 | | |
275 | | #[test] |
276 | 1 | fn meminfo_handles_empty_and_malformed() { |
277 | 1 | assert_eq!(parse_meminfo_total(""), None); |
278 | 1 | assert_eq!(parse_meminfo_total("MemTotal:"), None); |
279 | 1 | assert_eq!(parse_meminfo_total("MemTotal: kB"), None); |
280 | 1 | assert_eq!(parse_meminfo_total("MemTotal: notanumber kB"), None); |
281 | 1 | } |
282 | | |
283 | | #[test] |
284 | 1 | fn parse_bytes_handles_zero_and_large_values() { |
285 | 1 | assert_eq!(parse_bytes("0"), Some(0)); |
286 | 1 | assert_eq!(parse_bytes(&u64::MAX.to_string()), Some(u64::MAX)); |
287 | | // Overflowing u64 must fail rather than wrap. |
288 | 1 | assert_eq!(parse_bytes("99999999999999999999999"), None); |
289 | 1 | assert_eq!(parse_bytes("-1"), None); |
290 | 1 | assert_eq!(parse_bytes("12 34"), None); |
291 | 1 | } |
292 | | |
293 | | #[test] |
294 | 1 | fn card_dir_rejects_render_and_control_nodes() { |
295 | 1 | assert!(!is_card_dir("renderD129")); |
296 | 1 | assert!(!is_card_dir("by-path")); |
297 | 1 | assert!(!is_card_dir("")); |
298 | | // Leading zeros are still all-digits, so they count as a card node. |
299 | 1 | assert!(is_card_dir("card007")); |
300 | 1 | } |
301 | | |
302 | | #[test] |
303 | 1 | fn apu_carveout_folds_in_gtt_pool() { |
304 | | // A 512 MiB carveout over a 14 GiB GTT pool, as an APU reports it. |
305 | 1 | let (total, used) = fold_gtt( |
306 | 1 | 536_870_912, |
307 | 1 | Some(522_469_376), |
308 | 1 | Some(15_032_385_536), |
309 | 1 | Some(705_626_112), |
310 | 1 | ); |
311 | 1 | assert_eq!(total, 15_569_256_448); |
312 | 1 | assert_eq!(used, Some(1_228_095_488)); |
313 | 1 | } |
314 | | |
315 | | #[test] |
316 | 1 | fn discrete_vram_ignores_gtt_pool() { |
317 | | // A 24 GiB card keeps its own total even though GTT is large. |
318 | 1 | let (total, used) = fold_gtt( |
319 | 1 | 25_757_220_864, |
320 | 1 | Some(1_073_741_824), |
321 | 1 | Some(33_483_649_024), |
322 | 1 | Some(268_435_456), |
323 | 1 | ); |
324 | 1 | assert_eq!(total, 25_757_220_864); |
325 | 1 | assert_eq!(used, Some(1_073_741_824)); |
326 | 1 | } |
327 | | |
328 | | #[test] |
329 | 1 | fn threshold_is_inclusive_at_two_gib() { |
330 | 1 | let (at, _) = fold_gtt(INTEGRATED_VRAM_MAX, None, Some(1024), None); |
331 | 1 | assert_eq!( |
332 | | at, |
333 | 1 | INTEGRATED_VRAM_MAX + 1024, |
334 | | "2 GiB still counts as a carveout" |
335 | | ); |
336 | 1 | let (over, _) = fold_gtt(INTEGRATED_VRAM_MAX + 1, None, Some(1024), None); |
337 | 1 | assert_eq!(over, INTEGRATED_VRAM_MAX + 1, "just over is dedicated VRAM"); |
338 | 1 | } |
339 | | |
340 | | #[test] |
341 | 1 | fn missing_gtt_total_leaves_card_untouched() { |
342 | 1 | let (total, used) = fold_gtt(536_870_912, Some(1024), None, Some(2048)); |
343 | 1 | assert_eq!(total, 536_870_912); |
344 | 1 | assert_eq!(used, Some(1024)); |
345 | 1 | } |
346 | | |
347 | | #[test] |
348 | 1 | fn used_needs_both_pools_to_report() { |
349 | | // Summing one pool alone would understate usage, so report neither. |
350 | 1 | assert_eq!(fold_gtt(1024, Some(512), Some(4096), None).1, None); |
351 | 1 | assert_eq!(fold_gtt(1024, None, Some(4096), Some(512)).1, None); |
352 | 1 | } |
353 | | |
354 | | #[test] |
355 | 1 | fn folded_totals_saturate_instead_of_wrapping() { |
356 | 1 | let (total, used) = fold_gtt(1024, Some(u64::MAX), Some(u64::MAX), Some(u64::MAX)); |
357 | 1 | assert_eq!(total, u64::MAX); |
358 | 1 | assert_eq!(used, Some(u64::MAX)); |
359 | 1 | } |
360 | | |
361 | | #[test] |
362 | 1 | fn sysfs_vendor_trims_and_lowercases() { |
363 | 1 | assert_eq!(sysfs_vendor(" 0x1002\n"), Some(Vendor::Amd)); |
364 | 1 | assert_eq!(sysfs_vendor("0X8086"), Some(Vendor::Intel)); |
365 | 1 | assert_eq!(sysfs_vendor(""), None); |
366 | 1 | assert_eq!(sysfs_vendor("1002"), None, "missing 0x prefix is unknown"); |
367 | 1 | } |
368 | | } |