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 — AMD APUs, Intel iGPUs, and Intel discrete cards that |
9 | | //! don't implement the file — falls back to the shared system-memory ceiling |
10 | | //! with used/free `None`. NVIDIA cards are skipped here — they're handled by |
11 | | //! NVML in the `nvidia` module. |
12 | | |
13 | | /// True for a primary DRM card node (`card0`, `card1`, …) — not a connector |
14 | | /// (`card0-eDP-1`) or a render node (`renderD128`). |
15 | | #[allow(dead_code)] // used on Linux + in tests; unused on other targets |
16 | 656 | fn is_card_dir(name: &str) -> bool { |
17 | 656 | name.strip_prefix("card") |
18 | 656 | .is_some_and(|rest| !rest.is_empty()436 && rest.bytes()435 .all435 (|b| b654 .is_ascii_digit654 ())) |
19 | 656 | } |
20 | | |
21 | | /// Parse a sysfs integer file (e.g. `mem_info_vram_total`) holding a decimal |
22 | | /// byte count. |
23 | | #[allow(dead_code)] // used on Linux + in tests; unused on other targets |
24 | 9 | fn parse_bytes(content: &str) -> Option<u64> { |
25 | 9 | content.trim().parse().ok() |
26 | 9 | } |
27 | | |
28 | | /// Map a PCI vendor id (`device/vendor`, e.g. `0x1002`) to the [`Vendor`] this |
29 | | /// scanner owns. NVIDIA (`0x10de`) returns `None` — NVML handles it — as do |
30 | | /// unknown vendors. |
31 | | #[allow(dead_code)] // used on Linux + in tests; unused on other targets |
32 | 223 | fn sysfs_vendor(id: &str) -> Option<crate::Vendor> { |
33 | | use crate::Vendor; |
34 | 223 | match id.trim().to_ascii_lowercase().as_str() { |
35 | 223 | "0x1002" => Some(Vendor::Amd)2 , |
36 | 221 | "0x8086" => Some(Vendor::Intel)2 , |
37 | 219 | _ => None, |
38 | | } |
39 | 223 | } |
40 | | |
41 | | /// Parse `MemTotal:` (in kB) from `/proc/meminfo` contents into bytes. |
42 | | #[allow(dead_code)] // used on Linux + in tests; unused on other targets |
43 | 6 | fn parse_meminfo_total(content: &str) -> Option<u64> { |
44 | 6 | for line5 in content.lines() { |
45 | 5 | if let Some(rest4 ) = line.strip_prefix("MemTotal:") { |
46 | 4 | let kb1 : u641 = rest.split_whitespace().next()?1 .parse3 ().ok3 ()?2 ; |
47 | 1 | return Some(kb * 1024); |
48 | 1 | } |
49 | | } |
50 | 2 | None |
51 | 6 | } |
52 | | |
53 | | #[cfg(target_os = "linux")] |
54 | 0 | fn system_memory() -> Option<u64> { |
55 | 0 | std::fs::read_to_string("/proc/meminfo") |
56 | 0 | .ok() |
57 | 0 | .and_then(|s| parse_meminfo_total(&s)) |
58 | 0 | } |
59 | | |
60 | | #[cfg(target_os = "linux")] |
61 | 215 | pub(crate) fn detect() -> Vec<crate::GpuInfo> { |
62 | | use crate::GpuInfo; |
63 | | |
64 | 215 | let mut gpus = Vec::new(); |
65 | 215 | let Ok(entries) = std::fs::read_dir("/sys/class/drm") else { |
66 | 0 | return gpus; |
67 | | }; |
68 | | // Fetched lazily: only an integrated GPU needs it, and most hosts have none. |
69 | 215 | let mut sysmem: Option<u64> = None; |
70 | | |
71 | 645 | for entry in entries215 .flatten215 () { |
72 | 645 | let file_name = entry.file_name(); |
73 | 645 | let card = file_name.to_string_lossy(); |
74 | 645 | if !is_card_dir(&card) { |
75 | 430 | continue; |
76 | 215 | } |
77 | 215 | let device = entry.path().join("device"); |
78 | 215 | let Some(vendor0 ) = std::fs::read_to_string(device.join("vendor")) |
79 | 215 | .ok() |
80 | 215 | .and_then(|v| sysfs_vendor(&v)) |
81 | | else { |
82 | 215 | continue; |
83 | | }; |
84 | | |
85 | 0 | if let Some(total) = std::fs::read_to_string(device.join("mem_info_vram_total")) |
86 | 0 | .ok() |
87 | 0 | .and_then(|s| parse_bytes(&s)) |
88 | | { |
89 | | // Discrete GPU with dedicated VRAM. |
90 | 0 | let used = std::fs::read_to_string(device.join("mem_info_vram_used")) |
91 | 0 | .ok() |
92 | 0 | .and_then(|s| parse_bytes(&s)); |
93 | 0 | gpus.push(GpuInfo { |
94 | 0 | name: format!("{vendor} GPU ({card})"), |
95 | 0 | vendor, |
96 | 0 | total_bytes: total, |
97 | 0 | free_bytes: used.map(|u| total.saturating_sub(u)), |
98 | 0 | used_bytes: used, |
99 | | }); |
100 | | } else { |
101 | | // Integrated GPU (APU / iGPU): no dedicated VRAM — report the |
102 | | // shared system-memory ceiling, with no precise used/free. |
103 | 0 | if sysmem.is_none() { |
104 | 0 | sysmem = system_memory(); |
105 | 0 | } |
106 | 0 | if let Some(total) = sysmem { |
107 | 0 | gpus.push(GpuInfo { |
108 | 0 | name: format!("{vendor} GPU ({card})"), |
109 | 0 | vendor, |
110 | 0 | total_bytes: total, |
111 | 0 | free_bytes: None, |
112 | 0 | used_bytes: None, |
113 | 0 | }); |
114 | 0 | } |
115 | | } |
116 | | } |
117 | 215 | gpus |
118 | 215 | } |
119 | | |
120 | | #[cfg(not(target_os = "linux"))] |
121 | | pub(crate) fn detect() -> Vec<crate::GpuInfo> { |
122 | | Vec::new() |
123 | | } |
124 | | |
125 | | #[cfg(test)] |
126 | | mod tests { |
127 | | use super::*; |
128 | | use crate::Vendor; |
129 | | |
130 | | #[test] |
131 | 1 | fn card_dir_matches_only_primary_nodes() { |
132 | 1 | assert!(is_card_dir("card0")); |
133 | 1 | assert!(is_card_dir("card12")); |
134 | 1 | assert!(!is_card_dir("card0-eDP-1")); |
135 | 1 | assert!(!is_card_dir("renderD128")); |
136 | 1 | assert!(!is_card_dir("controlD64")); |
137 | 1 | assert!(!is_card_dir("card")); |
138 | 1 | assert!(!is_card_dir("cardX")); |
139 | 1 | } |
140 | | |
141 | | #[test] |
142 | 1 | fn parses_sysfs_byte_count() { |
143 | 1 | assert_eq!(parse_bytes("17163091968\n"), Some(17_163_091_968)); |
144 | 1 | assert_eq!(parse_bytes(" 8589934592 "), Some(8_589_934_592)); |
145 | 1 | assert_eq!(parse_bytes("nope"), None); |
146 | 1 | assert_eq!(parse_bytes(""), None); |
147 | 1 | } |
148 | | |
149 | | #[test] |
150 | 1 | fn vendor_ids_amd_and_intel_only() { |
151 | 1 | assert_eq!(sysfs_vendor("0x1002"), Some(Vendor::Amd)); |
152 | 1 | assert_eq!(sysfs_vendor("0x8086\n"), Some(Vendor::Intel)); |
153 | 1 | assert_eq!(sysfs_vendor("0x10DE"), None, "NVIDIA is handled by NVML"); |
154 | 1 | assert_eq!(sysfs_vendor("0xffff"), None); |
155 | 1 | } |
156 | | |
157 | | #[test] |
158 | 1 | fn parses_meminfo_memtotal() { |
159 | 1 | let meminfo = "MemTotal: 32789868 kB\nMemFree: 1234 kB\n"; |
160 | 1 | assert_eq!(parse_meminfo_total(meminfo), Some(32_789_868 * 1024)); |
161 | 1 | assert_eq!(parse_meminfo_total("MemFree: 100 kB"), None); |
162 | 1 | } |
163 | | |
164 | | #[test] |
165 | 1 | fn meminfo_handles_empty_and_malformed() { |
166 | 1 | assert_eq!(parse_meminfo_total(""), None); |
167 | 1 | assert_eq!(parse_meminfo_total("MemTotal:"), None); |
168 | 1 | assert_eq!(parse_meminfo_total("MemTotal: kB"), None); |
169 | 1 | assert_eq!(parse_meminfo_total("MemTotal: notanumber kB"), None); |
170 | 1 | } |
171 | | |
172 | | #[test] |
173 | 1 | fn parse_bytes_handles_zero_and_large_values() { |
174 | 1 | assert_eq!(parse_bytes("0"), Some(0)); |
175 | 1 | assert_eq!(parse_bytes(&u64::MAX.to_string()), Some(u64::MAX)); |
176 | | // Overflowing u64 must fail rather than wrap. |
177 | 1 | assert_eq!(parse_bytes("99999999999999999999999"), None); |
178 | 1 | assert_eq!(parse_bytes("-1"), None); |
179 | 1 | assert_eq!(parse_bytes("12 34"), None); |
180 | 1 | } |
181 | | |
182 | | #[test] |
183 | 1 | fn card_dir_rejects_render_and_control_nodes() { |
184 | 1 | assert!(!is_card_dir("renderD129")); |
185 | 1 | assert!(!is_card_dir("by-path")); |
186 | 1 | assert!(!is_card_dir("")); |
187 | | // Leading zeros are still all-digits, so they count as a card node. |
188 | 1 | assert!(is_card_dir("card007")); |
189 | 1 | } |
190 | | |
191 | | #[test] |
192 | 1 | fn sysfs_vendor_trims_and_lowercases() { |
193 | 1 | assert_eq!(sysfs_vendor(" 0x1002\n"), Some(Vendor::Amd)); |
194 | 1 | assert_eq!(sysfs_vendor("0X8086"), Some(Vendor::Intel)); |
195 | 1 | assert_eq!(sysfs_vendor(""), None); |
196 | 1 | assert_eq!(sysfs_vendor("1002"), None, "missing 0x prefix is unknown"); |
197 | 1 | } |
198 | | } |