Coverage Report

Created: 2026-08-20 05:46

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
src/metal.rs
Line
Count
Source
1
// SPDX-License-Identifier: Apache-2.0
2
//! Apple/macOS detection via `system_profiler SPDisplaysDataType`, `sysctl
3
//! hw.memsize` and `vm_stat`. No Metal framework linkage required. Apple
4
//! Silicon uses a unified memory architecture (no dedicated VRAM), so
5
//! `total_bytes` falls back to total physical memory and the used/free split
6
//! comes from system-wide paging statistics.
7
8
/// Map a vendor id as printed by `system_profiler` (e.g. `0x106b`) to a
9
/// [`Vendor`](crate::Vendor).
10
#[allow(dead_code)] // used on macOS + in tests; unused on other targets
11
10
fn vendor_from_id(id: &str) -> crate::Vendor {
12
    use crate::Vendor;
13
10
    match id.trim().to_ascii_lowercase().as_str() {
14
10
        "0x106b" => 
Vendor::Apple3
,
15
7
        "0x1002" => 
Vendor::Amd3
,
16
4
        "0x10de" => 
Vendor::Nvidia1
,
17
3
        "0x8086" => 
Vendor::Intel2
,
18
1
        _ => Vendor::Unknown,
19
    }
20
10
}
21
22
/// Parse a `system_profiler` VRAM value like `"8 GB"` or `"1536 MB"` into bytes
23
/// (binary units).
24
#[allow(dead_code)] // used on macOS + in tests; unused on other targets
25
12
fn parse_vram(value: &str) -> Option<u64> {
26
12
    let (
num9
,
unit9
) = value.trim().split_once(' ')
?3
;
27
9
    let 
amount8
:
u648
= num.trim().parse().ok()
?1
;
28
8
    let 
mult7
:
u647
= match unit.trim().to_ascii_uppercase().as_str() {
29
8
        "GB" => 
1024 * 1024 * 10243
,
30
5
        "MB" => 
1024 * 10243
,
31
2
        "KB" => 
10241
,
32
1
        _ => return None,
33
    };
34
7
    Some(amount * mult)
35
12
}
36
37
/// Parse `sysctl -n hw.memsize` output (a decimal byte count).
38
#[allow(dead_code)] // used on macOS + in tests; unused on other targets
39
4
fn parse_memsize(content: &str) -> Option<u64> {
40
4
    content.trim().parse().ok()
41
4
}
42
43
/// Pull the page size out of the `vm_stat` header, which reads
44
/// `Mach Virtual Memory Statistics: (page size of 16384 bytes)`.
45
///
46
/// Taken from the report itself rather than `hw.pagesize` so the counts and
47
/// their multiplier always come from the same source: Apple Silicon pages are
48
/// 16 KiB where Intel Macs use 4 KiB, and mixing the two would be off by 4x.
49
#[allow(dead_code)] // used on macOS + in tests; unused on other targets
50
9
fn parse_page_size(text: &str) -> Option<u64> {
51
9
    let 
rest7
= text.split_once("page size of ")
?2
.1;
52
7
    rest.split_once(" bytes")
?1
.0.
trim6
().
parse6
().
ok6
()
53
9
}
54
55
/// Look up one `Pages ...: <count>.` row in `vm_stat` output. The trailing
56
/// period is part of the format, not the number.
57
#[allow(dead_code)] // used on macOS + in tests; unused on other targets
58
12
fn page_count(text: &str, key: &str) -> Option<u64> {
59
61
    for line in 
text12
.
lines12
() {
60
61
        if let Some(
value10
) = line.trim().strip_prefix(key) {
61
10
            return value.trim().trim_end_matches('.').trim().parse().ok();
62
51
        }
63
    }
64
2
    None
65
12
}
66
67
/// Bytes in use, as macOS itself accounts for them: resident anonymous and
68
/// kernel pages (`active` + `wired down`) plus the compressor's footprint.
69
/// This is the figure Activity Monitor labels "Memory Used".
70
///
71
/// Apple Silicon shares one pool between CPU and GPU, so system-wide usage *is*
72
/// the GPU's usage; there is no separate VRAM to account for. Callers apply
73
/// this only to unified-memory GPUs — a discrete card on an Intel Mac reports
74
/// its own VRAM and must not be described by these numbers.
75
///
76
/// Reads *occupied by* rather than *stored in* the compressor: the former is
77
/// the compressor's real physical footprint, while the latter counts pages as
78
/// they were before compression and routinely exceeds installed memory.
79
#[allow(dead_code)] // used on macOS + in tests; unused on other targets
80
4
fn parse_vm_stat_used(text: &str) -> Option<u64> {
81
4
    let 
page_size3
= parse_page_size(text)
?1
;
82
3
    let active = page_count(text, "Pages active:")
?0
;
83
3
    let 
wired2
= page_count(text, "Pages wired down:")
?1
;
84
2
    let compressor = page_count(text, "Pages occupied by compressor:")
?0
;
85
2
    active
86
2
        .checked_add(wired)
?0
87
2
        .checked_add(compressor)
?0
88
2
        .checked_mul(page_size)
89
4
}
90
91
/// Parse plain-text `system_profiler SPDisplaysDataType` output into one
92
/// Metal GPU family for an Apple Silicon chip name — `Apple M2 Pro` is
93
/// `apple8`.
94
///
95
/// Derived from the name rather than queried, because the real source is
96
/// `MTLDevice.supportsFamily()`, which means linking Metal. Chips this table
97
/// does not know report `None`: a wrong family would be acted on, an absent one
98
/// would not. Non-Apple chipsets (`AMD Radeon Pro 5500M` on an Intel Mac) fall
99
/// out naturally, since the prefix will not match.
100
///
101
/// The rows come from Apple's published Metal Feature Set Tables (May 21,
102
/// 2026), which list M1 as `Apple7`, M2 as `Apple8`, M3 and M4 as `Apple9`, and
103
/// M5 as `Apple10`. Apple documents these per *series*, so one row covers a
104
/// generation's Pro, Max and Ultra variants — which is what falls out of
105
/// reading only the leading digits.
106
#[allow(dead_code)] // used on macOS + in tests; unused on other targets
107
22
fn apple_family(name: &str) -> Option<crate::AppleFamily> {
108
22
    let 
rest12
= name.strip_prefix("Apple M")
?10
;
109
12
    let end = rest
110
19
        .
find12
(|c: char| !c.is_ascii_digit())
111
12
        .unwrap_or(rest.len());
112
12
    let chip: u32 = rest[..end].parse().ok()
?0
;
113
12
    let 
generation10
= match chip {
114
2
        1 => 7,
115
2
        2 => 8,
116
        // M3 introduced apple9 and M4 stayed on it; M5 moved to apple10.
117
2
        3 | 4 => 9,
118
4
        5 => 10,
119
2
        _ => return None,
120
    };
121
10
    Some(crate::AppleFamily::new(generation))
122
22
}
123
124
/// [`GpuInfo`](crate::GpuInfo) per "Chipset Model:" block. `total_bytes` is `0`
125
/// when no VRAM line is present (Apple Silicon unified memory); callers
126
/// backfill it from physical memory.
127
#[allow(dead_code)] // used on macOS + in tests; unused on other targets
128
8
fn parse_system_profiler(text: &str) -> Vec<crate::GpuInfo> {
129
    use crate::{GpuInfo, Vendor};
130
131
8
    let mut gpus: Vec<GpuInfo> = Vec::new();
132
29
    for raw in 
text8
.
lines8
() {
133
29
        let line = raw.trim();
134
29
        if let Some(
name7
) = line.strip_prefix("Chipset Model:") {
135
7
            let name = name.trim();
136
7
            gpus.push(GpuInfo {
137
7
                name: name.to_string(),
138
7
                vendor: Vendor::Apple,
139
7
                total_bytes: 0,
140
7
                free_bytes: None,
141
7
                used_bytes: None,
142
7
                arch_target: apple_family(name).map(crate::ArchTarget::Apple),
143
7
            });
144
22
        } else if let Some(
gpu16
) = gpus.last_mut() {
145
16
            if let Some(
v7
) = line.strip_prefix("Vendor:") {
146
                // e.g. "Apple (0x106b)" — pull out the parenthesized id.
147
7
                if let Some(
id6
) = v.split('(').nth(1).and_then(|s|
s.split(')')6
.
next6
()) {
148
6
                    gpu.vendor = vendor_from_id(id);
149
6
                
}1
150
9
            } else if let Some(
v5
) = line
151
9
                .strip_prefix("VRAM (Total):")
152
9
                .or_else(|| 
line6
.
strip_prefix6
("VRAM (Dynamic, Max):"))
153
5
                && let Some(
bytes4
) = parse_vram(v)
154
4
            {
155
4
                gpu.total_bytes = bytes;
156
5
            }
157
6
        }
158
    }
159
8
    gpus
160
8
}
161
162
#[cfg(target_os = "macos")]
163
pub(crate) fn detect() -> Vec<crate::GpuInfo> {
164
    use crate::{GpuInfo, Vendor};
165
166
    let mut gpus = std::process::Command::new("system_profiler")
167
        .arg("SPDisplaysDataType")
168
        .output()
169
        .ok()
170
        .filter(|o| o.status.success())
171
        .map(|o| parse_system_profiler(&String::from_utf8_lossy(&o.stdout)))
172
        .unwrap_or_default();
173
174
    // Apple Silicon reports no VRAM line — backfill from physical memory, and
175
    // with it the unified pool's usage. A zero total is what marks a GPU as
176
    // unified here, so a discrete card that already reported its own VRAM keeps
177
    // the `None` split rather than being handed system-wide figures.
178
    let memsize = sysctl_memsize();
179
    // Read lazily, the way the DRM probe defers its system-memory lookup: the
180
    // split costs a subprocess, and an Intel Mac with only a discrete card
181
    // never needs one.
182
    let mut split: Option<(Option<u64>, Option<u64>)> = None;
183
    for gpu in &mut gpus {
184
        if gpu.total_bytes == 0
185
            && let Some(mem) = memsize
186
        {
187
            gpu.total_bytes = mem;
188
            let (used, free) = *split.get_or_insert_with(|| unified_split(mem));
189
            gpu.used_bytes = used;
190
            gpu.free_bytes = free;
191
        }
192
    }
193
    // Nothing was parsed, so the loop above never ran and never read the split.
194
    if gpus.is_empty()
195
        && let Some(mem) = memsize
196
    {
197
        let (used, free) = unified_split(mem);
198
        gpus.push(GpuInfo {
199
            name: "Apple GPU".to_string(),
200
            vendor: Vendor::Apple,
201
            total_bytes: mem,
202
            free_bytes: free,
203
            used_bytes: used,
204
            arch_target: None,
205
        });
206
    }
207
    gpus
208
}
209
210
/// The `(used, free)` split of a unified pool of `total` bytes, or `(None,
211
/// None)` when `vm_stat` is unavailable or reports more than is installed —
212
/// a total that small would make `free` underflow, and a partial answer is
213
/// worse than admitting the split is unknown.
214
#[cfg(target_os = "macos")]
215
fn unified_split(total: u64) -> (Option<u64>, Option<u64>) {
216
    let Some(used) = vm_stat_used().filter(|&used| used <= total) else {
217
        return (None, None);
218
    };
219
    (Some(used), Some(total - used))
220
}
221
222
#[cfg(target_os = "macos")]
223
fn vm_stat_used() -> Option<u64> {
224
    let output = std::process::Command::new("vm_stat").output().ok()?;
225
    output
226
        .status
227
        .success()
228
        .then(|| parse_vm_stat_used(&String::from_utf8_lossy(&output.stdout)))
229
        .flatten()
230
}
231
232
#[cfg(target_os = "macos")]
233
fn sysctl_memsize() -> Option<u64> {
234
    let output = std::process::Command::new("sysctl")
235
        .args(["-n", "hw.memsize"])
236
        .output()
237
        .ok()?;
238
    output
239
        .status
240
        .success()
241
        .then(|| parse_memsize(&String::from_utf8_lossy(&output.stdout)))
242
        .flatten()
243
}
244
245
#[cfg(not(target_os = "macos"))]
246
219
pub(crate) fn detect() -> Vec<crate::GpuInfo> {
247
219
    Vec::new()
248
219
}
249
250
#[cfg(test)]
251
mod tests {
252
    #[test]
253
1
    fn maps_apple_silicon_chips_to_metal_families() {
254
        use crate::AppleFamily;
255
1
        assert_eq!(apple_family("Apple M1"), Some(AppleFamily::new(7)));
256
1
        assert_eq!(apple_family("Apple M1 Max"), Some(AppleFamily::new(7)));
257
1
        assert_eq!(apple_family("Apple M2 Pro"), Some(AppleFamily::new(8)));
258
1
        assert_eq!(apple_family("Apple M3 Ultra"), Some(AppleFamily::new(9)));
259
1
        assert_eq!(apple_family("Apple M4"), Some(AppleFamily::new(9)));
260
        // M5 is the first generation on apple10.
261
1
        assert_eq!(apple_family("Apple M5"), Some(AppleFamily::new(10)));
262
1
        assert_eq!(apple_family("Apple M5 Pro"), Some(AppleFamily::new(10)));
263
1
        assert_eq!(apple_family("Apple M5 Max"), Some(AppleFamily::new(10)));
264
1
    }
265
266
    #[test]
267
1
    fn two_digit_families_render_unpacked() {
268
        // `apple10` must not collapse to `apple1`.
269
1
        assert_eq!(
270
1
            apple_family("Apple M5").map(|family| family.to_string()),
271
1
            Some("apple10".to_string())
272
        );
273
1
    }
274
275
    #[test]
276
1
    fn unknown_chips_report_no_family() {
277
        // A generation this table predates must not be guessed at.
278
1
        assert_eq!(apple_family("Apple M9"), None);
279
        // Multi-digit chips must not be read as their first digit.
280
1
        assert_eq!(apple_family("Apple M10"), None, "not M1");
281
        // Intel Macs and the sysctl fallback name.
282
1
        assert_eq!(apple_family("AMD Radeon Pro 5500M"), None);
283
1
        assert_eq!(apple_family("Intel Iris Plus Graphics"), None);
284
1
        assert_eq!(apple_family("Apple GPU"), None);
285
1
        assert_eq!(apple_family(""), None);
286
1
    }
287
288
    use super::*;
289
    use crate::Vendor;
290
291
    #[test]
292
1
    fn apple_silicon_block_has_no_vram() {
293
1
        let text = "Graphics/Displays:\n\n    Apple M2 Pro:\n\n      Chipset Model: Apple M2 Pro\n      Type: GPU\n      Vendor: Apple (0x106b)\n      Metal Support: Metal 3\n";
294
1
        let gpus = parse_system_profiler(text);
295
1
        assert_eq!(gpus.len(), 1);
296
1
        assert_eq!(gpus[0].name, "Apple M2 Pro");
297
1
        assert_eq!(gpus[0].vendor, Vendor::Apple);
298
1
        assert_eq!(
299
1
            gpus[0].total_bytes, 0,
300
            "unified memory; backfilled in detect()"
301
        );
302
1
    }
303
304
    #[test]
305
1
    fn intel_mac_discrete_gpu_reports_vram() {
306
1
        let text = "      Chipset Model: AMD Radeon Pro 5500M\n      Type: GPU\n      Bus: PCIe\n      VRAM (Total): 8 GB\n      Vendor: AMD (0x1002)\n";
307
1
        let gpus = parse_system_profiler(text);
308
1
        assert_eq!(gpus.len(), 1);
309
1
        assert_eq!(gpus[0].vendor, Vendor::Amd);
310
1
        assert_eq!(gpus[0].total_bytes, 8 * 1024 * 1024 * 1024);
311
1
    }
312
313
    #[test]
314
1
    fn dynamic_vram_line_is_parsed() {
315
1
        let text = "      Chipset Model: Intel Iris Pro\n      VRAM (Dynamic, Max): 1536 MB\n      Vendor: Intel (0x8086)\n";
316
1
        let gpus = parse_system_profiler(text);
317
1
        assert_eq!(gpus[0].vendor, Vendor::Intel);
318
1
        assert_eq!(gpus[0].total_bytes, 1536 * 1024 * 1024);
319
1
    }
320
321
    #[test]
322
1
    fn vram_and_memsize_parsers() {
323
1
        assert_eq!(parse_vram("8 GB"), Some(8 * 1024 * 1024 * 1024));
324
1
        assert_eq!(parse_vram("1536 MB"), Some(1536 * 1024 * 1024));
325
1
        assert_eq!(parse_vram("weird"), None);
326
1
        assert_eq!(parse_memsize("17179869184\n"), Some(17_179_869_184));
327
1
    }
328
329
    #[test]
330
1
    fn vendor_id_mapping() {
331
1
        assert_eq!(vendor_from_id("0x106b"), Vendor::Apple);
332
1
        assert_eq!(vendor_from_id("0x10DE"), Vendor::Nvidia);
333
1
        assert_eq!(vendor_from_id("0x1002"), Vendor::Amd);
334
1
        assert_eq!(vendor_from_id("0xbeef"), Vendor::Unknown);
335
1
    }
336
337
    #[test]
338
1
    fn parses_multiple_gpu_blocks() {
339
1
        let text = "      Chipset Model: AMD Radeon Pro 5500M\n      VRAM (Total): 8 GB\n      Vendor: AMD (0x1002)\n      Chipset Model: Intel UHD Graphics 630\n      VRAM (Dynamic, Max): 1536 MB\n      Vendor: Intel (0x8086)\n";
340
1
        let gpus = parse_system_profiler(text);
341
1
        assert_eq!(gpus.len(), 2);
342
1
        assert_eq!(gpus[0].vendor, Vendor::Amd);
343
1
        assert_eq!(gpus[0].total_bytes, 8 * 1024 * 1024 * 1024);
344
1
        assert_eq!(gpus[1].vendor, Vendor::Intel);
345
1
        assert_eq!(gpus[1].total_bytes, 1536 * 1024 * 1024);
346
1
    }
347
348
    #[test]
349
1
    fn empty_output_yields_no_gpus() {
350
1
        assert!(parse_system_profiler("").is_empty());
351
        // Lines before any "Chipset Model:" have no GPU to attach to.
352
1
        assert!(parse_system_profiler("Graphics/Displays:\n      VRAM (Total): 8 GB\n").is_empty());
353
1
    }
354
355
    #[test]
356
1
    fn vendor_line_without_parens_keeps_default() {
357
1
        let text = "      Chipset Model: Mystery GPU\n      Vendor: sieve\n";
358
1
        let gpus = parse_system_profiler(text);
359
1
        assert_eq!(gpus.len(), 1);
360
        // No "(id)" to parse, so the Apple default placed at block start stands.
361
1
        assert_eq!(gpus[0].vendor, Vendor::Apple);
362
1
    }
363
364
    #[test]
365
1
    fn malformed_vram_leaves_total_at_zero() {
366
1
        let text =
367
1
            "      Chipset Model: Broken\n      VRAM (Total): lots\n      Vendor: Apple (0x106b)\n";
368
1
        let gpus = parse_system_profiler(text);
369
1
        assert_eq!(gpus[0].total_bytes, 0);
370
1
    }
371
372
    #[test]
373
1
    fn parse_vram_rejects_unknown_units_and_bad_numbers() {
374
1
        assert_eq!(parse_vram("8 TB"), None);
375
1
        assert_eq!(parse_vram("8"), None);
376
1
        assert_eq!(parse_vram("eight GB"), None);
377
1
        assert_eq!(
378
1
            parse_vram("512 kb"),
379
1
            Some(512 * 1024),
380
            "unit is case-insensitive"
381
        );
382
1
    }
383
384
    #[test]
385
1
    fn parse_memsize_rejects_non_numeric() {
386
1
        assert_eq!(parse_memsize("nope"), None);
387
1
        assert_eq!(parse_memsize(""), None);
388
1
        assert_eq!(parse_memsize("0"), Some(0));
389
1
    }
390
391
    /// A representative `vm_stat` report on a 16 KiB-page host, trimmed to the
392
    /// rows the parser reads plus the compressor pair it must tell apart.
393
    const VM_STAT: &str = "Mach Virtual Memory Statistics: (page size of 16384 bytes)\n\
394
                           Pages free:                                     3480.\n\
395
                           Pages active:                                  77803.\n\
396
                           Pages inactive:                                74322.\n\
397
                           Pages speculative:                              2300.\n\
398
                           Pages wired down:                             137287.\n\
399
                           Pages purgeable:                                   0.\n\
400
                           Pages stored in compressor:                  1263882.\n\
401
                           Pages occupied by compressor:                 192142.\n";
402
403
    #[test]
404
1
    fn reads_vm_stat_page_size_and_counts() {
405
1
        assert_eq!(parse_page_size(VM_STAT), Some(16384));
406
1
        assert_eq!(page_count(VM_STAT, "Pages active:"), Some(77803));
407
1
        assert_eq!(page_count(VM_STAT, "Pages wired down:"), Some(137_287));
408
1
        assert_eq!(page_count(VM_STAT, "Pages free:"), Some(3480));
409
1
        assert_eq!(page_count(VM_STAT, "Pages absent:"), None);
410
1
    }
411
412
    #[test]
413
1
    fn sums_used_pages_the_way_activity_monitor_does() {
414
        // active + wired + compressor, at 16 KiB per page.
415
1
        let expected = (77803 + 137_287 + 192_142) * 16384;
416
1
        assert_eq!(parse_vm_stat_used(VM_STAT), Some(expected));
417
        // Sums to a plausible fraction of installed memory, not a multiple.
418
1
        assert!(expected < 8 * 1024 * 1024 * 1024);
419
1
    }
420
421
    #[test]
422
1
    fn used_counts_the_compressor_footprint_not_its_contents() {
423
        // "stored in" is the pre-compression count and dwarfs installed memory;
424
        // reading it instead of "occupied by" overshoots by several times.
425
1
        let stored = 1_263_882u64 * 16384;
426
1
        assert!(
427
1
            stored > 8 * 1024 * 1024 * 1024,
428
            "fixture must expose the trap"
429
        );
430
1
        assert!(parse_vm_stat_used(VM_STAT).is_some_and(|used| used < stored));
431
1
    }
432
433
    #[test]
434
1
    fn vm_stat_parsing_needs_every_field() {
435
        // A report missing any row the sum needs yields nothing, rather than
436
        // silently undercounting.
437
1
        assert_eq!(parse_vm_stat_used(""), None);
438
1
        assert_eq!(parse_page_size("Pages free: 1.\n"), None);
439
1
        let no_wired = "Mach Virtual Memory Statistics: (page size of 16384 bytes)\n\
440
1
                        Pages active:                                  77803.\n\
441
1
                        Pages occupied by compressor:                 192142.\n";
442
1
        assert_eq!(parse_vm_stat_used(no_wired), None);
443
1
    }
444
445
    #[test]
446
1
    fn page_size_survives_a_four_kib_intel_header() {
447
1
        let intel = "Mach Virtual Memory Statistics: (page size of 4096 bytes)\n";
448
1
        assert_eq!(parse_page_size(intel), Some(4096));
449
1
        assert_eq!(parse_page_size("(page size of many bytes)"), None);
450
1
        assert_eq!(parse_page_size("(page size of 4096)"), None);
451
1
    }
452
}