Skip to content

Commit 1cb13f8

Browse files
feat: complete macOS support and cover it in CI
Every CI job ran on ubuntu-latest, so nothing exercised the macOS code paths. Building on an M2 surfaced a lint failure, an example that had drifted, and a memory field that was never populated. - kfd: `ComputeNode` and the non-Linux `nodes()` stub are only reachable from the Linux-gated `drm::detect`, so the lib target — built without `cfg(test)`, where the only caller lives — failed `-D warnings` on macOS. - metal: report used/free on Apple Silicon from `vm_stat`, counting active + wired + compressor pages, the figure Activity Monitor labels "Memory Used". Unified memory means system-wide usage is the GPU's usage; a discrete card on an Intel Mac reported its own VRAM and is left untouched. Read lazily, so such a Mac spawns no subprocess. - metal: map M5 to apple10, per Apple's Metal Feature Set Tables (May 21, 2026). Apple documents these per series, so one row covers M5, M5 Pro and M5 Max, as `1 => 7` already does for M1. - watch: print `vulkan_host()`, missing since the probe landed in #17. - ci: add a macos-latest job. The Linux jobs structurally cannot cover these paths — the modules are cfg'd per target, so code that is dead or broken only on macOS still compiles clean on ubuntu. Verified on an M2 MacBook Air (macOS 26.5): detects Apple M2 / apple8 / 8.0 GiB, with used/free matching an independent vm_stat calculation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a387e77 commit 1cb13f8

6 files changed

Lines changed: 235 additions & 17 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,50 @@ jobs:
7373
- name: Run clippy
7474
run: cargo clippy --all-targets --all-features --workspace -- -W clippy::pedantic -D warnings -W unused_must_use
7575

76+
# The Linux jobs structurally cannot cover this: `metal` and `kfd` are cfg'd
77+
# per target, so a module that is dead or broken only on macOS still compiles
78+
# clean on ubuntu. `macos-latest` is Apple Silicon, so the unified-memory
79+
# path is the one that actually runs here.
80+
macos:
81+
name: macOS
82+
runs-on: macos-latest
83+
steps:
84+
- uses: actions/checkout@v4
85+
86+
- uses: dtolnay/rust-toolchain@stable
87+
with:
88+
components: clippy
89+
90+
- name: Cache dependencies
91+
uses: actions/cache@v6
92+
with:
93+
path: |
94+
~/.cargo/registry
95+
~/.cargo/git
96+
target
97+
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
98+
99+
- name: Run clippy
100+
run: cargo clippy --all-targets --all-features --workspace -- -W clippy::pedantic -D warnings -W unused_must_use
101+
102+
- name: Run tests
103+
run: cargo test --all-features --verbose
104+
105+
- name: Run doctests
106+
run: cargo test --doc --all-features --verbose
107+
108+
# `nvidia` is a default feature that pulls in NVML, which no Mac can use.
109+
# Apple-only consumers build with it off, so that configuration is checked
110+
# on the platform those consumers are actually on.
111+
- name: Run tests (nvidia disabled)
112+
run: cargo test --no-default-features --verbose
113+
114+
# Runs system_profiler, sysctl and vm_stat for real and prints what the
115+
# runner detected, so a regression in the probes is visible in the log
116+
# rather than hidden behind an empty-result code path.
117+
- name: Probe the runner's GPU
118+
run: cargo run --example detect --all-features
119+
76120
coverage:
77121
name: Coverage
78122
runs-on: ubuntu-latest

‎README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Cross-platform GPU memory (VRAM) detection for Rust — no vendor SDKs, nothing
1212
| NVIDIA | ✅ | ✅ | ✅<sup>†</sup> | NVML · `system_profiler` |
1313
| AMD | ✅ | — | ✅<sup>†</sup> | DRM sysfs · KFD · `system_profiler` |
1414
| Intel | ✅ | — | ✅<sup>†</sup> | DRM sysfs · `system_profiler` |
15-
| Apple | — | — | ✅ | `system_profiler` + `sysctl` |
15+
| Apple | — | — | ✅ | `system_profiler` · `sysctl` · `vm_stat` |
1616

1717
<sup>†</sup> Intel Macs only — discrete and integrated GPUs are read from `system_profiler`.
1818

@@ -245,7 +245,8 @@ three.
245245

246246
## Notes
247247

248-
- `total_bytes` is dedicated VRAM on discrete GPUs. On integrated/unified GPUs (Intel iGPUs, AMD APUs, Apple Silicon) it's the shared system-memory ceiling, and `free_bytes` / `used_bytes` are usually `None`.
248+
- `total_bytes` is dedicated VRAM on discrete GPUs. On integrated/unified GPUs (Intel iGPUs, AMD APUs, Apple Silicon) it's the shared system-memory ceiling, and `free_bytes` / `used_bytes` are often `None`.
249+
- Apple Silicon is an exception: because CPU and GPU share one pool, `free_bytes` / `used_bytes` come from system-wide paging statistics (`vm_stat`), counting active + wired + compressor pages. That is the same figure Activity Monitor reports as "Memory Used", so it reflects the whole machine rather than the GPU alone. Discrete GPUs on Intel Macs report their own VRAM and are left untouched.
249250
- AMD APUs are a special case: their `mem_info_vram_total` is only a BIOS carveout (512 MiB on a BC-250), so `total_bytes` adds the GTT pool they really allocate from — sized by the kernel's `ttm.pages_limit` — and `free_bytes` / `used_bytes` cover both pools. The result matches Vulkan/RADV to the byte; ROCm reports ~512 MiB less, since KFD publishes only the GTT-backed bank.
250251
- AMD GPU names come from the KFD ASIC codename (`AMD cyan_skillfish`), falling back to the DRM node (`AMD GPU (card1)`) when KFD reports none.
251252
- `oneapi_host()` covers the **toolkit**, not the GPU runtime: a host using a distro-packaged Level Zero driver with no toolkit reports `None`, since reading that runtime's version needs linking rather than a file read. Not yet verified against a real install.

‎examples/watch.rs‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@ fn main() {
7272
println!();
7373
}
7474

75-
// Host-wide toolchains, unlike the per-GPU fields above. Both rows always
76-
// print, including when absent: "we looked and found nothing" is the point
75+
// Host-wide toolchains, unlike the per-GPU fields above. Every row always
76+
// prints, including when absent: "we looked and found nothing" is the point
7777
// of a probe tool, and a lone row for one vendor reads like it describes
7878
// the GPU above it rather than the host.
7979
//
@@ -94,4 +94,8 @@ fn main() {
9494
Some(cuda) => println!(" cuda {:>10}", cuda.driver_version.to_string()),
9595
None => println!(" cuda unavailable"),
9696
}
97+
match gpu_probe::vulkan_host() {
98+
Some(vulkan) => println!(" vulkan {:>10}", vulkan.api_version.to_string()),
99+
None => println!(" vulkan unavailable"),
100+
}
97101
}

‎src/kfd.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use crate::GfxTarget;
1515
const TOPOLOGY: &str = "/sys/class/kfd/kfd/topology/nodes";
1616

1717
/// One GPU node from the KFD topology.
18+
#[allow(dead_code)] // constructed on Linux; unused on other targets
1819
#[derive(Debug, Clone, PartialEq, Eq)]
1920
pub(crate) struct ComputeNode {
2021
/// Minor number of the node's render device (`128` for `renderD128`).
@@ -90,6 +91,9 @@ pub(crate) fn nodes() -> Vec<ComputeNode> {
9091
nodes
9192
}
9293

94+
// Only the tests call this stub, and the lib target is built without
95+
// `cfg(test)` — so there it really is dead, and `-D warnings` fails on it.
96+
#[allow(dead_code)]
9397
#[cfg(not(target_os = "linux"))]
9498
pub(crate) fn nodes() -> Vec<ComputeNode> {
9599
Vec::new()

‎src/lib.rs‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212
//! ceiling, and AMD APUs their VRAM carveout plus GTT pool (see
1313
//! [`GpuInfo::total_bytes`]). AMD cards additionally report their `gfx`
1414
//! target from KFD sysfs — no `ROCm` install needed.
15-
//! - **Apple/macOS**: `system_profiler` + `sysctl` (Apple Silicon reports
16-
//! unified memory).
15+
//! - **Apple/macOS**: `system_profiler` + `sysctl` for the chip and its memory
16+
//! ceiling, plus `vm_stat` for the used/free split (Apple Silicon reports
17+
//! unified memory, so that split is system-wide).
1718
//!
1819
//! Host toolchain properties are reported separately from any one GPU:
1920
//! [`cuda_host`] for the CUDA driver, [`rocm_host`] for the `ROCm` install,
@@ -571,8 +572,8 @@ pub struct VulkanHost {
571572
/// Detect all GPUs visible on the host.
572573
///
573574
/// Best-effort: spawns only read-only platform queries (NVML, `system_profiler`,
574-
/// `sysctl`) and reads sysfs. Returns an empty `Vec` on unsupported platforms
575-
/// or when no GPU is found.
575+
/// `sysctl`, `vm_stat`) and reads sysfs. Returns an empty `Vec` on unsupported
576+
/// platforms or when no GPU is found.
576577
#[must_use]
577578
pub fn detect() -> Vec<GpuInfo> {
578579
let mut gpus = Vec::new();

‎src/metal.rs‎

Lines changed: 173 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
// SPDX-License-Identifier: Apache-2.0
2-
//! Apple/macOS detection via `system_profiler SPDisplaysDataType` and `sysctl
3-
//! hw.memsize`. No Metal framework linkage required. Apple Silicon uses a
4-
//! unified memory architecture (no dedicated VRAM), so `total_bytes` falls
5-
//! back to total physical memory.
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.
67
78
/// Map a vendor id as printed by `system_profiler` (e.g. `0x106b`) to a
89
/// [`Vendor`](crate::Vendor).
@@ -39,6 +40,54 @@ fn parse_memsize(content: &str) -> Option<u64> {
3940
content.trim().parse().ok()
4041
}
4142

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+
fn parse_page_size(text: &str) -> Option<u64> {
51+
let rest = text.split_once("page size of ")?.1;
52+
rest.split_once(" bytes")?.0.trim().parse().ok()
53+
}
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+
fn page_count(text: &str, key: &str) -> Option<u64> {
59+
for line in text.lines() {
60+
if let Some(value) = line.trim().strip_prefix(key) {
61+
return value.trim().trim_end_matches('.').trim().parse().ok();
62+
}
63+
}
64+
None
65+
}
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+
fn parse_vm_stat_used(text: &str) -> Option<u64> {
81+
let page_size = parse_page_size(text)?;
82+
let active = page_count(text, "Pages active:")?;
83+
let wired = page_count(text, "Pages wired down:")?;
84+
let compressor = page_count(text, "Pages occupied by compressor:")?;
85+
active
86+
.checked_add(wired)?
87+
.checked_add(compressor)?
88+
.checked_mul(page_size)
89+
}
90+
4291
/// Parse plain-text `system_profiler SPDisplaysDataType` output into one
4392
/// Metal GPU family for an Apple Silicon chip name — `Apple M2 Pro` is
4493
/// `apple8`.
@@ -49,7 +98,12 @@ fn parse_memsize(content: &str) -> Option<u64> {
4998
/// would not. Non-Apple chipsets (`AMD Radeon Pro 5500M` on an Intel Mac) fall
5099
/// out naturally, since the prefix will not match.
51100
///
52-
/// **Unverified against real hardware** — there is no Mac to test this on.
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. Detection was exercised end to end on an
106+
/// `Apple M2` (macOS 26.5).
53107
#[allow(dead_code)] // used on macOS + in tests; unused on other targets
54108
fn apple_family(name: &str) -> Option<crate::AppleFamily> {
55109
let rest = name.strip_prefix("Apple M")?;
@@ -60,8 +114,9 @@ fn apple_family(name: &str) -> Option<crate::AppleFamily> {
60114
let generation = match chip {
61115
1 => 7,
62116
2 => 8,
63-
// M3 introduced apple9; M4 supports it too, and no apple10 exists yet.
117+
// M3 introduced apple9 and M4 stayed on it; M5 moved to apple10.
64118
3 | 4 => 9,
119+
5 => 10,
65120
_ => return None,
66121
};
67122
Some(crate::AppleFamily::new(generation))
@@ -117,30 +172,64 @@ pub(crate) fn detect() -> Vec<crate::GpuInfo> {
117172
.map(|o| parse_system_profiler(&String::from_utf8_lossy(&o.stdout)))
118173
.unwrap_or_default();
119174

120-
// Apple Silicon reports no VRAM line — backfill from physical memory.
175+
// Apple Silicon reports no VRAM line — backfill from physical memory, and
176+
// with it the unified pool's usage. A zero total is what marks a GPU as
177+
// unified here, so a discrete card that already reported its own VRAM keeps
178+
// the `None` split rather than being handed system-wide figures.
121179
let memsize = sysctl_memsize();
180+
// Read lazily, the way the DRM probe defers its system-memory lookup: the
181+
// split costs a subprocess, and an Intel Mac with only a discrete card
182+
// never needs one.
183+
let mut split: Option<(Option<u64>, Option<u64>)> = None;
122184
for gpu in &mut gpus {
123185
if gpu.total_bytes == 0
124186
&& let Some(mem) = memsize
125187
{
126188
gpu.total_bytes = mem;
189+
let (used, free) = *split.get_or_insert_with(|| unified_split(mem));
190+
gpu.used_bytes = used;
191+
gpu.free_bytes = free;
127192
}
128193
}
194+
// Nothing was parsed, so the loop above never ran and never read the split.
129195
if gpus.is_empty()
130196
&& let Some(mem) = memsize
131197
{
198+
let (used, free) = unified_split(mem);
132199
gpus.push(GpuInfo {
133200
name: "Apple GPU".to_string(),
134201
vendor: Vendor::Apple,
135202
total_bytes: mem,
136-
free_bytes: None,
137-
used_bytes: None,
203+
free_bytes: free,
204+
used_bytes: used,
138205
arch_target: None,
139206
});
140207
}
141208
gpus
142209
}
143210

211+
/// The `(used, free)` split of a unified pool of `total` bytes, or `(None,
212+
/// None)` when `vm_stat` is unavailable or reports more than is installed —
213+
/// a total that small would make `free` underflow, and a partial answer is
214+
/// worse than admitting the split is unknown.
215+
#[cfg(target_os = "macos")]
216+
fn unified_split(total: u64) -> (Option<u64>, Option<u64>) {
217+
let Some(used) = vm_stat_used().filter(|&used| used <= total) else {
218+
return (None, None);
219+
};
220+
(Some(used), Some(total - used))
221+
}
222+
223+
#[cfg(target_os = "macos")]
224+
fn vm_stat_used() -> Option<u64> {
225+
let output = std::process::Command::new("vm_stat").output().ok()?;
226+
output
227+
.status
228+
.success()
229+
.then(|| parse_vm_stat_used(&String::from_utf8_lossy(&output.stdout)))
230+
.flatten()
231+
}
232+
144233
#[cfg(target_os = "macos")]
145234
fn sysctl_memsize() -> Option<u64> {
146235
let output = std::process::Command::new("sysctl")
@@ -169,6 +258,19 @@ mod tests {
169258
assert_eq!(apple_family("Apple M2 Pro"), Some(AppleFamily::new(8)));
170259
assert_eq!(apple_family("Apple M3 Ultra"), Some(AppleFamily::new(9)));
171260
assert_eq!(apple_family("Apple M4"), Some(AppleFamily::new(9)));
261+
// M5 is the first generation on apple10.
262+
assert_eq!(apple_family("Apple M5"), Some(AppleFamily::new(10)));
263+
assert_eq!(apple_family("Apple M5 Pro"), Some(AppleFamily::new(10)));
264+
assert_eq!(apple_family("Apple M5 Max"), Some(AppleFamily::new(10)));
265+
}
266+
267+
#[test]
268+
fn two_digit_families_render_unpacked() {
269+
// `apple10` must not collapse to `apple1`.
270+
assert_eq!(
271+
apple_family("Apple M5").map(|family| family.to_string()),
272+
Some("apple10".to_string())
273+
);
172274
}
173275

174276
#[test]
@@ -286,4 +388,66 @@ mod tests {
286388
assert_eq!(parse_memsize(""), None);
287389
assert_eq!(parse_memsize("0"), Some(0));
288390
}
391+
392+
/// Trimmed from a real M2 running macOS 26.5, keeping the rows the parser
393+
/// reads plus the compressor pair it must tell apart.
394+
const VM_STAT: &str = "Mach Virtual Memory Statistics: (page size of 16384 bytes)\n\
395+
Pages free: 3480.\n\
396+
Pages active: 77803.\n\
397+
Pages inactive: 74322.\n\
398+
Pages speculative: 2300.\n\
399+
Pages wired down: 137287.\n\
400+
Pages purgeable: 0.\n\
401+
Pages stored in compressor: 1263882.\n\
402+
Pages occupied by compressor: 192142.\n";
403+
404+
#[test]
405+
fn reads_vm_stat_page_size_and_counts() {
406+
assert_eq!(parse_page_size(VM_STAT), Some(16384));
407+
assert_eq!(page_count(VM_STAT, "Pages active:"), Some(77803));
408+
assert_eq!(page_count(VM_STAT, "Pages wired down:"), Some(137_287));
409+
assert_eq!(page_count(VM_STAT, "Pages free:"), Some(3480));
410+
assert_eq!(page_count(VM_STAT, "Pages absent:"), None);
411+
}
412+
413+
#[test]
414+
fn sums_used_pages_the_way_activity_monitor_does() {
415+
// active + wired + compressor, at 16 KiB per page.
416+
let expected = (77803 + 137_287 + 192_142) * 16384;
417+
assert_eq!(parse_vm_stat_used(VM_STAT), Some(expected));
418+
// ~6.2 GiB of the 8 GiB this fixture was taken from.
419+
assert!(expected < 8 * 1024 * 1024 * 1024);
420+
}
421+
422+
#[test]
423+
fn used_counts_the_compressor_footprint_not_its_contents() {
424+
// "stored in" is the pre-compression count and dwarfs installed memory;
425+
// reading it instead of "occupied by" would report ~19 GiB on 8 GiB.
426+
let stored = 1_263_882u64 * 16384;
427+
assert!(
428+
stored > 8 * 1024 * 1024 * 1024,
429+
"fixture must expose the trap"
430+
);
431+
assert!(parse_vm_stat_used(VM_STAT).is_some_and(|used| used < stored));
432+
}
433+
434+
#[test]
435+
fn vm_stat_parsing_needs_every_field() {
436+
// A report missing any row the sum needs yields nothing, rather than
437+
// silently undercounting.
438+
assert_eq!(parse_vm_stat_used(""), None);
439+
assert_eq!(parse_page_size("Pages free: 1.\n"), None);
440+
let no_wired = "Mach Virtual Memory Statistics: (page size of 16384 bytes)\n\
441+
Pages active: 77803.\n\
442+
Pages occupied by compressor: 192142.\n";
443+
assert_eq!(parse_vm_stat_used(no_wired), None);
444+
}
445+
446+
#[test]
447+
fn page_size_survives_a_four_kib_intel_header() {
448+
let intel = "Mach Virtual Memory Statistics: (page size of 4096 bytes)\n";
449+
assert_eq!(parse_page_size(intel), Some(4096));
450+
assert_eq!(parse_page_size("(page size of many bytes)"), None);
451+
assert_eq!(parse_page_size("(page size of 4096)"), None);
452+
}
289453
}

0 commit comments

Comments
 (0)