macOS Performance Triage (Apple Silicon)
When to Use This Skill
| Use this skill when... |
Use something else when... |
| The Mac is hot/loud/slow and you need to find what's driving it |
The machine actually hung or panicked — use macos-incident-postmortem |
WindowServer shows high CPU/GPU and you need to attribute it to a client app |
A security extension (Kandji ESF, XProtect, an EDR) is the hot process — use endpoint-security-cpu |
| One ordinary app (Chrome/Electron, a renderer, a game) is pegging CPU or GPU |
launchservicesd is the hot process — use launchservices-health |
| You want a flame graph / deep profile of a specific process |
A disk reads near-full or you're reclaiming space — use macos-disk-usage |
| You want a repeatable "what's hot right now" snapshot |
syspolicyd/trustd/tccd/auditd are all elevated together (exec storm) — use endpoint-security-cpu |
| You need to attribute a live thermal/CPU/GPU load right now |
You want a repeatable baseline scored PASS/WARN/FAIL against thresholds — use macos-performance-benchmark |
Platform Guard
macOS-only. powermetrics, sample, spindump, Instruments, and the
Apple-Silicon power counters macmon reads are Darwin-specific.
test "$(uname -s)" = "Darwin" || { echo "macos-plugin: not Darwin, refusing"; exit 1; }
Core Insight: triage with the USE method; WindowServer is always downstream
Two ideas drive the whole flow:
- USE method (Brendan Gregg) — for each resource ask Utilization,
Saturation, Errors. A bare
ps-by-CPU only answers Utilization.
Saturation (run-queue depth, thermal throttle) and Errors (log stream)
complete the picture. The method is OS-agnostic; only the tools change.
WindowServer never starts its own work. Every cycle it burns was
requested by a client app's draw / animation / compositing. Apps build their
UI as CALayer hierarchies, hand them to WindowServer via IOSurface, and
WindowServer composites (positioning, clipping, masks, shadows, effects) synced
to the display refresh. So a hot WindowServer is a symptom — the job is to
find the app feeding it (continuous video, GIF/Lottie animation, a software /
non-GPU-accelerated render path, layout thrashing).
The deepest tier of Gregg's Linux approach does not transfer: there is no
eBPF / bpftrace on macOS. DTrace is the analogue but SIP blocks it from
tracing Apple-signed/system processes (including WindowServer) unless SIP is
disabled — not worth it for desktop triage. The flow below stays within a
normally-secured Mac.
Step 1 — Quick triage: what's hot
Start with a process snapshot, sorted by CPU. -c shows the short command name:
ps -Aceo pid,pcpu,pmem,comm -r | head -16
Then get the Apple-Silicon power/thermal picture. macmon (Rust) is the
preferred tool — it reads the same data as powermetrics (per-cluster P/E-core
load + frequency, GPU, ANE, package power, temps) but without sudo, via a
private IOReport API. One-shot, scriptable snapshot:
macmon pipe -s 1 | jq -c '{cpu_W:.cpu_power, gpu_W:.gpu_power, ane_W:.ane_power, ecpu_usage, pcpu_usage, gpu_usage, temp}'
Live TUI: macmon. Process/thread breakdown TUI: btm (bottom, Rust).
If the tools are missing, offer to install (all Rust, all sudo-free):
brew install macmon bottom samply hyperfine
Read the snapshot:
- A high non-system process at the top → go to Step 3 (profile it) or just
identify/close it.
WindowServer high → Step 2 (attribute it).
kernel_task high → thermal management throttling a real load; find that load.
- The whole security stack hot together → this is an exec storm, hand off to
endpoint-security-cpu.
Common phantom offenders
OrbStack Helper — quitting the OrbStack app does not stop the VM; the
helper reparents to launchd (PPID 1) and keeps eating ~40–50% CPU / ~20% RAM
with no visible app. Confirm with ps -Ao pid,ppid,comm | grep -i '[o]rb' (a
PPID of 1 is the tell) and stop it with orb stop, not kill. (Disk side of
the same tool: macos-disk-usage.)
- A backgrounded Electron app (Slack, Discord, VS Code) animating off-screen.
- A browser tab running video/WebRTC/canvas — see Step 2.
Step 2 — Attribute WindowServer / GPU load to the driving app
There is no single first-party "blame" button; attribute in layers, cheapest
first:
- Per-process GPU cost (the real lever):
sudo powermetrics --samplers tasks --show-process-gpu -n 1 -i 1000
The app with high GPU here is what's loading WindowServer's compositor.
- Activity Monitor → add the GPU column, sort descending; the Window Spy
picker maps a visible window back to its owning process.
- Instruments → Core Animation template — layer counts, dropped frames,
compositing time (developer-side, if you own the app).
- Instruments → Metal System Trace — catches software-fallback (non-GPU)
render paths and frame-pacing anomalies.
- Quartz Debug (Xcode Additional Tools) — live "flash dirty regions /
overdraw"; visually shows which surfaces re-composite every frame.
Pattern to expect: N live video/WebRTC streams (e.g. a multi-cam call inside a
browser/VTT) = N continuously-invalidating Core Animation layers → per-frame
recomposition. That is a genuine workload floor, not a fault — confirmed when
WindowServer stays hot after you close other GPU consumers.
sample WindowServer 5 and spindump can sample WindowServer itself, but they
show its internal compositing stacks, not which client drove the work — use
powermetrics --show-process-gpu for attribution, not sample.
Step 3 — Deep profiling: flame graph a specific process
For a CPU-heavy process you own, samply (Rust) is the modern path — samples
via Apple's Mach interface (on- and off-CPU), opens in the Firefox Profiler UI
(flame graph + timeline + call tree), no sudo, no SIP changes for your own
binaries:
samply record ./my-program # launch + record
samply record --pid 12345 # attach to a running PID
- Instruments is more capable (adds GPU, ANE, Metal, PMC counters) but the UI
and
xctrace CLI are sluggish, and attaching to other apps needs entitlements.
cargo flamegraph / FlameGraph + dtrace works for your own code, but SIP
makes dtrace-based graphs low-fidelity for anything system-touching, and can't
graph WindowServer at all without disabling SIP.
- A Chrome/Electron renderer is sandboxed —
samply can't see in usefully;
use the app's own chrome://tracing / DevTools Performance tab instead.
For A/B comparing two builds or commands (Gregg-style workload characterization),
hyperfine (Rust) gives warmup + statistics:
hyperfine --warmup 3 './old-build args' './new-build args'
Linux → macOS tool map (for transferring Gregg's playbook)
| Linux (Gregg) |
macOS-native |
Modern add-on |
top/htop, vmstat |
top -o cpu, vm_stat, sysctl |
macmon, bottom (Rust) |
perf |
sample, spindump, Instruments |
samply (Rust) |
bcc / bpftrace / eBPF |
dtrace (SIP-limited) |
— (no eBPF on macOS) |
ftrace |
ktrace / os_signpost + Instruments |
— |
turbostat/power |
sudo powermetrics |
macmon (sudo-free) |
hyperfine |
hyperfine |
hyperfine (Rust, cross-platform) |
The toolkit (Rust-forward, all sudo-free unless noted)
| Tool |
Lang |
Measures |
sudo/SIP |
Tier |
| macmon |
🦀 Rust |
P/E-core, GPU, ANE, power(W), temp, fans, RAM; JSON + Prometheus |
none |
triage |
bottom (btm) |
🦀 Rust |
procs, CPU, mem, net, disk, temp |
none |
triage |
powermetrics |
C (Apple) |
per-process GPU/CPU/ANE, power |
sudo |
attribution |
| samply |
🦀 Rust |
sampling profiler → Firefox Profiler |
none (own procs) |
profiling |
| hyperfine |
🦀 Rust |
CLI benchmark, A/B, stats |
none |
benchmarking |
| Instruments |
Apple |
GPU/Metal/ANE/Core Animation/PMC |
entitlements |
deep |
sample/spindump |
Apple |
user-stack call-graph |
sudo for system procs |
profiling |
macmon, bottom, samply, hyperfine install with one line:
brew install macmon bottom samply hyperfine. asitop/mactop are older
equivalents that require sudo; macmon supersedes them.
Output
Report: the hot process(es) with CPU/GPU/power figures; for a hot WindowServer,
the attributed client app and why (animation/video/software-render); the
recommended action (close/stop the driver, lower its settings, or profile it); and
which tier of tool produced the verdict. Distinguish a genuine workload floor (a
live video call) from a fixable runaway (an orphaned VM, a stuck animation).
Related
macos-performance-benchmark — the proactive baseline companion: run a
repeatable, threshold-scored benchmark + diagnostic suite (macmon-first, same
toolkit) to establish what "normal" is before triaging a deviation from it.
1---
2name: macos-performance-triage
3description: Live macOS/Apple-Silicon performance triage — find what's heating the Mac (hot WindowServer, GPU compositing, a CPU-heavy browser/Electron app) and attribute it to the driving process. Use when fans spin up, the Mac is hot/slow, or WindowServer/an app shows high CPU/GPU.
4user-invocable: false
5allowed-tools: Bash(uname *), Bash(ps *), Bash(top *), Bash(pgrep *), Bash(uptime *), Bash(vm_stat*), Bash(sysctl *), Bash(macmon *), Bash(btm *), Bash(sample *), Bash(spindump *), Bash(sudo powermetrics *), Bash(samply *), Bash(hyperfine *), Bash(brew install *), Bash(brew list *), Read, Grep, Glob
6created: 2026-06-30
7modified: 2026-06-30
8reviewed: 2026-06-30
9---
10
11# macOS Performance Triage (Apple Silicon)
12
13## When to Use This Skill
14
15| Use this skill when... | Use something else when... |
16|------------------------|----------------------------|
17| The Mac is hot/loud/slow and you need to find what's driving it | The machine actually hung or panicked — use `macos-incident-postmortem` |
18| `WindowServer` shows high CPU/GPU and you need to attribute it to a client app | A *security* extension (Kandji ESF, XProtect, an EDR) is the hot process — use `endpoint-security-cpu` |
19| One ordinary app (Chrome/Electron, a renderer, a game) is pegging CPU or GPU | `launchservicesd` is the hot process — use `launchservices-health` |
20| You want a flame graph / deep profile of a specific process | A disk reads near-full or you're reclaiming space — use `macos-disk-usage` |
21| You want a repeatable "what's hot right now" snapshot | `syspolicyd`/`trustd`/`tccd`/`auditd` are *all* elevated together (exec storm) — use `endpoint-security-cpu` |
22| You need to attribute a live thermal/CPU/GPU load right now | You want a **repeatable baseline** scored PASS/WARN/FAIL against thresholds — use `macos-performance-benchmark` |
23
24## Platform Guard
25
26**macOS-only.** `powermetrics`, `sample`, `spindump`, Instruments, and the
27Apple-Silicon power counters `macmon` reads are Darwin-specific.
28
29```bash
30test "$(uname -s)" = "Darwin" || { echo "macos-plugin: not Darwin, refusing"; exit 1; }
31```
32
33## Core Insight: triage with the USE method; WindowServer is always downstream
34
35Two ideas drive the whole flow:
36
371. **USE method** (Brendan Gregg) — for each resource ask **U**tilization,
38 **S**aturation, **E**rrors. A bare `ps`-by-CPU only answers *Utilization*.
39 Saturation (run-queue depth, thermal throttle) and Errors (`log stream`)
40 complete the picture. The method is OS-agnostic; only the tools change.
412. **`WindowServer` never starts its own work.** Every cycle it burns was
42 *requested* by a client app's draw / animation / compositing. Apps build their
43 UI as `CALayer` hierarchies, hand them to WindowServer via IOSurface, and
44 WindowServer composites (positioning, clipping, masks, shadows, effects) synced
45 to the display refresh. So a hot WindowServer is a **symptom** — the job is to
46 find the app feeding it (continuous video, GIF/Lottie animation, a software /
47 non-GPU-accelerated render path, layout thrashing).
48
49The deepest tier of Gregg's Linux approach does **not** transfer: **there is no
50eBPF / bpftrace on macOS.** DTrace is the analogue but **SIP blocks it from
51tracing Apple-signed/system processes** (including WindowServer) unless SIP is
52disabled — not worth it for desktop triage. The flow below stays within a
53normally-secured Mac.
54
55## Step 1 — Quick triage: what's hot
56
57Start with a process snapshot, sorted by CPU. `-c` shows the short command name:
58
59```bash
60ps -Aceo pid,pcpu,pmem,comm -r | head -16
61```
62
63Then get the Apple-Silicon power/thermal picture. **`macmon` (Rust) is the
64preferred tool** — it reads the same data as `powermetrics` (per-cluster P/E-core
65load + frequency, GPU, ANE, package power, temps) but **without sudo**, via a
66private IOReport API. One-shot, scriptable snapshot:
67
68```bash
69macmon pipe -s 1 | jq -c '{cpu_W:.cpu_power, gpu_W:.gpu_power, ane_W:.ane_power, ecpu_usage, pcpu_usage, gpu_usage, temp}'
70```
71
72Live TUI: `macmon`. Process/thread breakdown TUI: `btm` (bottom, Rust).
73
74If the tools are missing, offer to install (all Rust, all sudo-free):
75
76```bash
77brew install macmon bottom samply hyperfine
78```
79
80**Read the snapshot:**
81- A high non-system process at the top → go to Step 3 (profile it) or just
82 identify/close it.
83- `WindowServer` high → Step 2 (attribute it).
84- `kernel_task` high → thermal management throttling a real load; find that load.
85- The whole security stack hot together → this is an **exec storm**, hand off to
86 `endpoint-security-cpu`.
87
88### Common phantom offenders
89
90- **`OrbStack Helper`** — quitting the OrbStack *app* does **not** stop the VM; the
91 helper reparents to launchd (`PPID 1`) and keeps eating ~40–50% CPU / ~20% RAM
92 with no visible app. Confirm with `ps -Ao pid,ppid,comm | grep -i '[o]rb'` (a
93 `PPID` of `1` is the tell) and stop it with `orb stop`, not `kill`. (Disk side of
94 the same tool: `macos-disk-usage`.)
95- A backgrounded Electron app (Slack, Discord, VS Code) animating off-screen.
96- A browser tab running video/WebRTC/canvas — see Step 2.
97
98## Step 2 — Attribute WindowServer / GPU load to the driving app
99
100There is **no single first-party "blame" button**; attribute in layers, cheapest
101first:
102
1031. **Per-process GPU cost (the real lever):**
104 ```bash
105 sudo powermetrics --samplers tasks --show-process-gpu -n 1 -i 1000
106 ```
107 The app with high GPU here is what's loading WindowServer's compositor.
1082. **Activity Monitor → add the GPU column**, sort descending; the **Window Spy**
109 picker maps a visible window back to its owning process.
1103. **Instruments → Core Animation template** — layer counts, dropped frames,
111 compositing time (developer-side, if you own the app).
1124. **Instruments → Metal System Trace** — catches software-fallback (non-GPU)
113 render paths and frame-pacing anomalies.
1145. **Quartz Debug** (Xcode Additional Tools) — live "flash dirty regions /
115 overdraw"; *visually* shows which surfaces re-composite every frame.
116
117**Pattern to expect:** N live video/WebRTC streams (e.g. a multi-cam call inside a
118browser/VTT) = N continuously-invalidating Core Animation layers → per-frame
119recomposition. That is a genuine workload floor, not a fault — confirmed when
120WindowServer stays hot after you close *other* GPU consumers.
121
122> `sample WindowServer 5` and `spindump` can sample WindowServer itself, but they
123> show its *internal compositing* stacks, not which client drove the work — use
124> `powermetrics --show-process-gpu` for attribution, not `sample`.
125
126## Step 3 — Deep profiling: flame graph a specific process
127
128For a CPU-heavy process you own, **`samply` (Rust)** is the modern path — samples
129via Apple's Mach interface (on- and off-CPU), opens in the Firefox Profiler UI
130(flame graph + timeline + call tree), **no sudo, no SIP changes** for your own
131binaries:
132
133```bash
134samply record ./my-program # launch + record
135samply record --pid 12345 # attach to a running PID
136```
137
138- **Instruments** is more capable (adds GPU, ANE, Metal, PMC counters) but the UI
139 and `xctrace` CLI are sluggish, and attaching to *other* apps needs entitlements.
140- **`cargo flamegraph` / FlameGraph + dtrace** works for your own code, but SIP
141 makes dtrace-based graphs low-fidelity for anything system-touching, and can't
142 graph WindowServer at all without disabling SIP.
143- A **Chrome/Electron renderer** is sandboxed — `samply` can't see in usefully;
144 use the app's own `chrome://tracing` / DevTools Performance tab instead.
145
146For A/B comparing two builds or commands (Gregg-style workload characterization),
147**`hyperfine`** (Rust) gives warmup + statistics:
148
149```bash
150hyperfine --warmup 3 './old-build args' './new-build args'
151```
152
153## Linux → macOS tool map (for transferring Gregg's playbook)
154
155| Linux (Gregg) | macOS-native | Modern add-on |
156|---|---|---|
157| `top`/`htop`, `vmstat` | `top -o cpu`, `vm_stat`, `sysctl` | **macmon**, **bottom** (Rust) |
158| `perf` | `sample`, `spindump`, Instruments | **samply** (Rust) |
159| `bcc` / `bpftrace` / eBPF | `dtrace` (SIP-limited) | — (no eBPF on macOS) |
160| `ftrace` | `ktrace` / `os_signpost` + Instruments | — |
161| `turbostat`/power | `sudo powermetrics` | **macmon** (sudo-free) |
162| `hyperfine` | `hyperfine` | **hyperfine** (Rust, cross-platform) |
163
164## The toolkit (Rust-forward, all sudo-free unless noted)
165
166| Tool | Lang | Measures | sudo/SIP | Tier |
167|---|---|---|---|---|
168| **macmon** | 🦀 Rust | P/E-core, GPU, ANE, power(W), temp, fans, RAM; JSON + Prometheus | none | triage |
169| **bottom** (`btm`) | 🦀 Rust | procs, CPU, mem, net, disk, temp | none | triage |
170| `powermetrics` | C (Apple) | per-process GPU/CPU/ANE, power | **sudo** | attribution |
171| **samply** | 🦀 Rust | sampling profiler → Firefox Profiler | none (own procs) | profiling |
172| **hyperfine** | 🦀 Rust | CLI benchmark, A/B, stats | none | benchmarking |
173| Instruments | Apple | GPU/Metal/ANE/Core Animation/PMC | entitlements | deep |
174| `sample`/`spindump` | Apple | user-stack call-graph | sudo for system procs | profiling |
175
176`macmon`, `bottom`, `samply`, `hyperfine` install with one line:
177`brew install macmon bottom samply hyperfine`. `asitop`/`mactop` are older
178equivalents that require sudo; `macmon` supersedes them.
179
180## Output
181
182Report: the hot process(es) with CPU/GPU/power figures; for a hot WindowServer,
183the attributed client app and *why* (animation/video/software-render); the
184recommended action (close/stop the driver, lower its settings, or profile it); and
185which tier of tool produced the verdict. Distinguish a genuine workload floor (a
186live video call) from a fixable runaway (an orphaned VM, a stuck animation).
187
188## Related
189
190- `macos-performance-benchmark` — the proactive baseline companion: run a
191 repeatable, threshold-scored benchmark + diagnostic suite (`macmon`-first, same
192 toolkit) to establish what "normal" is before triaging a deviation from it.