Coverage Report

Created: 2026-08-20 05:46

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
src/drm.rs
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_MA
X5
=> (
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
}