Unified Memory on Apple Silicon: Kernel Insights and Profiling Tips
Apple's M-series unified memory architecture lets CPU and GPU share a single RAM pool, but performance hinges on fault-in behavior. Learn how to diagnose with sysctl and Instruments, and tune a Metal kernel for better throughput.
13 Dec 2025, 09:07 UTC

You’re likely opened a Metal performance report on an M‑series Mac and wondered why the GPU timeline looks the way it does. The culprit is often hidden in how unified memory is allocated, tracked, and faulted in by the kernel. Unlike discrete GPUs with their own VRAM, Apple’s M‑series SoCs place the CPU, integrated graphics, and GPU into a single physical memory pool. The macOS kernel manages this pool with token‑based ownership, allowing the GPU to read and write directly while keeping cache coherency through hardware‑managed domains. But performance doesn’t come for free: if your code doesn’t align with the OS’s paging and fault‑in behavior, you can end up with unnecessary latency or even increased power draw.
The kernel’s view of unified memory
The macOS I/O Kit presents the unified memory as a single heap that both the CPU and GPU can address. The kernel’s memory manager assigns tokens to regions that the GPU is permitted to touch. These tokens live in coherency domains that the hardware enforces; when the GPU writes to a region without a valid token, the kernel faults it in, potentially stalling the graphics pipeline. This design eliminates explicit data movement across buses, but it means any page fault crosses the kernel boundary, and the cost depends on whether the page is already resident or needs to be paged in from swap.
Diagnosing unified memory usage
- Where: Terminal on any macOS system
- Permissions: No elevated privileges needed for
sysctl;kextstatruns as your user - Expected check:
sysctlreturns a decimal byte count matching your device’s advertised RAM;kextstat | grep -i memorylists kernel extensions related to memory management - Relevant risks: Minimal—both commands are read‑only and cannot modify system state
For a deeper view, kextstat | grep -i memory can confirm that the relevant memory‑management kernel extensions are loaded—this is useful after an OS update or when troubleshooting unexpected paging behavior.
$ sysctl hw.memsizeIf you have Xcode installed, the real insight comes from Instruments. Add a GPU Timeline or System Trace instrument to your running app. Look for fault‑in events marked in yellow or red; these indicate the kernel had to bring a page into the unified pool on the fly. Pair that with the Metal API Validation instrument to see if any draw calls are triggering implicit transfers. The practical check: if you see frequent fault‑ins on textures or buffers that you expected to be resident, consider pre‑zeroing or pinning those regions using .storageModeShared or adjusting buffer storage modes.
Worked example: tuning a Metal compute kernel
Suppose you’re running a matrix multiplication kernel on an M2 Ultra and noticing a noticeable stall between threadgroups. Here’s a minimal adjustment pattern:
let buffer = device.makeBuffer(length: size, options: .storageModeShared)The .storageModeShared flag hints to the kernel that the GPU will access this buffer frequently, allowing it to keep the backing pages resident in the unified pool rather than faulting them in on every dispatch. Couple that with a one‑time sysctl hw.memsize check before and after to confirm the pool size hasn’t shifted, and you have a measurable before/after baseline.
Trade‑offs and limitations
Not every M‑series chip exposes the same GPU‑addressable window. M1 and M2 devices have a smaller unified space than M3, so high‑resolution textures or large compute grids may hit the address space limit sooner. Additionally, macOS versions before Monterey lack some of the finer‑grained kernel APIs for tracking unified region ownership, so kextstat output may look different on older systems. Power draw is another consideration: keeping large unified regions active can increase idle power compared to segmented allocations, so if your app has intermittent GPU workloads, releasing buffers explicitly with buffer.release() when they’re no longer needed can help.
Actionable closing
Next time you profile a Metal or Swift concurrency workload on Apple Silicon, open Terminal and run sysctl hw.memsize to confirm your unified pool size, then launch Instruments with a GPU Timeline instrument and watch for fault‑in events. If those events cluster around specific buffers, try switching their storage mode to shared and re‑measure. The kernel’s unified memory design is a competitive advantage, but only when your code works with its fault‑in rhythm rather than against it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.