./utils/build-rs --<profiler>). Each cuts the stack at a different
point and answers a different question. Pick the cheapest one that can
fail in a way you’d care about.
| Profiler | Question it answers | Overhead | Output |
|---|---|---|---|
| samply | ”Which Rust function burns CPU?” | ~1–3% | Firefox Profiler in browser |
| dhat | ”Which Rust call allocates the most?“ | high (allocator wrap) | dhat-heap.json |
| Instruments — Time Profiler | ”Same as samply, plus correlate with system events” | low | .trace bundle |
| Instruments — Allocations | ”Where’s our RSS going, including WebKit / GPU buffers?“ | medium | .trace bundle |
| Instruments — Leaks | ”Are we leaking objects over time?“ | medium | .trace bundle |
| Instruments — Metal System Trace | ”Why is the GPU stalling / dropping frames?“ | medium | .trace bundle |
--release — debug builds have wildly different
perf characteristics, so profiling them measures the wrong code.
rust/Cargo.toml’s [profile.release] keeps line-tables-only debug
info and uses packed dSYM so function names show up in every tool.
Traces land in ./.profiling/ (gitignored) with timestamped filenames
so repeated runs don’t clobber each other.
Picking the right tool
CPU sampling: samply
samply record. Records a CPU sampling profile and
opens it in the Firefox Profiler when the app exits. Low overhead —
fine for “feel the app while profiling” sessions, including
interactive UI work.
Install once: cargo install samply.
When to reach for it: the question is “which Rust function is hot?”
and you don’t need system-level correlation. Faster turnaround than
Instruments because there’s no .trace bundle to open.
Heap allocations: dhat
dhat-heap Cargo feature, which swaps the global
allocator for dhat::Alloc. Every Rust allocation gets a backtrace.
On clean exit, dhat writes dhat-heap.json to the CWD.
Important: quit via Cmd-Q or File → Quit, not Ctrl-C. Dhat writes
its output in a Drop impl that only fires on a clean shutdown.
Ctrl-C kills the process before drop runs and you get no data.
View the result by opening
dh_view.html
and loading dhat-heap.json.
When to reach for it: “which Rust function allocates the most” or
“why does our memory grow over time.” Doesn’t see WebKit / GPU
memory — Instruments Allocations is the right tool for full-process
view.
Performance note: dhat slows the program significantly (allocator
overhead with per-alloc backtrace). Use it for capture workflows,
not for “running normally with profiling on.”
Instruments — Time Profiler
xctrace
into a .trace bundle that opens in Instruments.app. Strengths over
samply: native macOS tooling, correlates with thread state, system
calls, and (when combined in a custom template) GPU work.
Requires full Xcode (not just Command Line Tools) for xctrace.
Instruments — Allocations
Instruments — Leaks
Instruments — Metal System Trace
react_ui/src/lib/gl/ or react_ui/src/components/panels/arrangement/gl/
work that needs more signal than “feels janky.”
Custom Instruments templates
--instruments TPL accepts any template name from xctrace list templates. Names are case- and space-sensitive ("Animation Hitches",
not "animation-hitches").
You can also build your own template in Instruments.app:
File → New → Blank, drag in the instruments you want (Time
Profiler + Allocations + Metal System Trace + Thread State, say),
save it, then pass its name to --instruments. One process, one
capture, multiple correlated tracks on a unified timeline.
Attaching to a running app
If the--instruments flag launches the app under xctrace and
Instruments.app refuses to open the resulting trace (“missing
template” or similar), record by attaching to an already-running
process instead:
Combining profilers
The script enforces one heavyweight profiler at a time —--samply, --dhat, and --instruments (and its shortcuts) are
mutually exclusive in the launch command. Forcing two doesn’t give
better data:
- Two CPU samplers interfere with each other’s sampling cadence.
- Two heap trackers fight over the
mallochook. - dhat + anything else dominates the trace because dhat slows allocation 10–100×; the “hot function” you see is the allocator.
The Metal HUD
MTL_HUD_ENABLED=1 (and __XPC_MTL_HUD_ENABLED=1 for WebKit’s
out-of-process GPU service) so macOS draws its live performance HUD
in the corner of any window backed by a CAMetalLayer. Near-zero
overhead, no recording.
Caveat — often doesn’t draw for Tauri apps. The HUD attaches to
processes that own a CAMetalLayer directly. Tauri renders the
WebView through WebKit’s GPU process, which composites back into
the main window as an IOSurface-backed layer. The HUD may draw
inside the GPU process but stays invisible to you, or skip drawing
entirely. Chrome and Electron architecturally own their layer and
work fine; we don’t.
If the HUD doesn’t show up after --metal-hud, that’s expected.
Fall back to --metal-system-trace — it doesn’t depend on
CAMetalLayer ownership and gives strictly more information.
Troubleshooting
“Missing template” when opening a trace. Instruments.app shows this when the.trace bundle references a template that isn’t
installed on the machine opening it — the template name was
misspelled, or the trace came from a newer Xcode. Pass an exact
name from xctrace list templates. The default "Time Profiler"
ships with every Xcode and is the safe fallback. If you still get
the error, use the attach-mode recipe above — it uses your local
Instruments install’s template definitions instead of resolving one
out of the bundle.
Profile shows hex addresses instead of function names. Means
the release build was stripped of debug info. rust/Cargo.toml’s
[profile.release] block already sets debug = "line-tables-only"
and split-debuginfo = "packed" so this shouldn’t happen — if it
does, double-check you didn’t override the profile in a local
Cargo.toml. The “packed” setting matters: macOS’s default
“unpacked” split-debuginfo writes per-object .o debug info that
samply can’t reliably follow. Packed runs dsymutil at link time
to consolidate into a single <bin>.dSYM bundle that every macOS
profiling tool finds automatically.
dhat-heap.json is empty or missing. You almost certainly killed
the app with Ctrl-C instead of Cmd-Q. The output writes from a
Drop impl, which only runs on clean shutdown. Re-run, then quit
the app cleanly.
Instruments crashes on open. Rare but happens; usually a stale
Xcode install. Try attach-mode recording instead — it produces a
trace bundle from your current Instruments install rather than
relying on whatever xctrace baked into the previous bundle.
Output cleanup
Traces accumulate fast. The.profiling/ directory is gitignored,
so it won’t bloat the repo, but each Instruments .trace bundle
can be hundreds of MB. Wipe them periodically:
dhat-heap.json writes to CWD, not .profiling/, since dhat doesn’t
know about our directory convention. Delete by hand or find . -name dhat-heap.json -delete.