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
54108fn 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" ) ]
145234fn 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