- Sync with latest upstream dev branch - Include all map improvements: 2D/3D toggle, shadows, labels, overlays - Include platform updates: location API, audio echo cancellation - Preserve fork-specific re-exports (gltf, csg, test)
13 KiB
macOS Sampling Profiler for Makepad Studio
Short answer
If by "real profiler" we mean:
- out-of-process sampling
- attach to or launch a target process
- capture thread stacks at a fixed interval
- collapse stacks into folded format
- render a flamegraph
- drive it from Makepad Studio
then this is feasible in Rust, but it is not a tiny feature.
My estimate:
2-4 days: wrapper around Apple tooling such assampleorxctrace1-2 weeks: native Rust MVP for Studio-launched debug builds, frame-pointer unwind only, SVG flamegraph export4-8 weeks: robust product-quality profiler with good symbolication, Apple-silicon edge cases, attach UX, cancellation, tests, and a proper Studio flamegraph tab
The key point is that the current Studio profiler is not the right substrate. It is an in-process time-series profiler. A real flamegraph profiler wants hierarchical stack samples.
Why the current Studio profiler is the wrong shape
Today Studio's profiler protocol is built around timestamped sample arrays:
QueryProfilerrequests abuild_id,sample_type, time range, and sample cap in platform/studio/src/hub_protocol.rsQueryProfilerResultsreturnsevent_samples,gpu_samples, andgc_samplesin platform/studio/src/hub_protocol.rs- the hub store only keeps those three linear sample streams in studio/hub/src/log_store.rs
- the desktop view renders charts over time in studio/desktop/src/desktop_profiler_view.rs
A flamegraph is not a time-series chart. It is an aggregate view over stacks. Trying to cram it into the existing profiler query path would be awkward and would fight the current data model.
What "real sampling profiler" means on macOS
Apple's sample(1) describes the behavior we want: it suspends the process at intervals, records call stacks for all threads, resumes the process, and later summarizes the results.
For our own implementation, the low-level building blocks are there:
task_for_pid(...)inmach_traps.hgets the task port for a PIDtask_threads(...)inmach/task.henumerates threadsthread_get_state(...)inmach/thread_act.hreads registersmach_vm_read_overwrite(...)inmach/mach_vm.hreads remote memorytask_suspend(...)/task_resume(...)andthread_suspend(...)/thread_resume(...)exist for consistent snapshotsTASK_DYLD_INFOanddyld_all_image_infosexpose loaded image metadata for symbolication
In other words: the collector itself is straightforward enough. The expensive parts are unwinding and symbolication.
The hard parts
1. Permissions and task_for_pid
This is the first real constraint.
taskgated(8) is the access-control daemon for task_for_pid. Apple's debugger entitlement docs are also explicit:
- a debugger-style tool may request task ports
- this is tied to
com.apple.security.cs.debugger - even then, protected processes remain off-limits
- for normal development, Xcode relies on target binaries having
get-task-allow
Practical consequence:
- profiling Studio-launched debug/dev builds is realistic
- attaching to arbitrary third-party or system processes is not a good v1 target
- "profile current run" is much easier than "profile anything on the machine"
For Studio specifically, this is good news: the hub already owns launched child processes through RunningChild in studio/hub/src/build_manager.rs, so "profile current run" can be a first-class action.
2. Unwinding remote stacks
Getting register state is easy. Turning that into correct call stacks is the real engineering work.
For a native sampler, the clean MVP is:
- require frame pointers in profiled builds
- unwind by walking frame chains with remote memory reads
- support
arm64/arm64eandx86_64
This is very different from a fully general unwinder.
Without frame pointers, you need reliable DWARF unwind interpretation against a remote stopped thread. That is much more work and much easier to get wrong.
3. Apple silicon / arm64e details
The SDK headers are a warning sign here. arm_thread_state64_t pointer fields may be opaque on arm64e and use accessor macros. The headers also include pointer-auth stripping helpers in mach/arm/_structs.h.
Practical consequence:
- do not treat Apple-silicon thread state as a dumb POD register dump forever
- use the platform accessors/macros or equivalent logic
- expect extra work around
pc,lr,sp, andfp
This is one reason a frame-pointer-only MVP should still be treated as non-trivial.
4. Symbolication
Sampling only gives you addresses. A usable flamegraph wants function names, and ideally file/line.
There are two reasonable routes:
- Use Apple's symbolication tooling for early versions.
- Do symbolication in Rust.
Apple's atos(1) can symbolicate addresses either against a live process (-p) or against a binary/dSYM plus load address (-o + -l). That makes it useful as a reference tool and possibly as an early fallback.
If we keep it inside Rust, the most promising path is:
- image map from
TASK_DYLD_INFO/dyld_all_image_infos - Mach-O parsing via
object - DWARF lookup via
addr2line
The current addr2line docs are encouraging here: its Loader supports Mach-O dSYM files and split DWARF files.
5. Output shape for flamegraphs
Flamegraphs want folded stacks.
The useful Rust path is:
- aggregate stacks into folded lines like
main;foo;bar 42 - feed that to
inferno - write SVG
inferno::flamegraph::from_lines explicitly accepts folded lines and produces SVG. It also supports differential flamegraphs if we want before/after comparisons later.
Recommendation
Do not build v1 as a parser for sample output.
Why:
sampleproduces a condensed textual call tree, not a stable raw stack API- parsing its report format is brittle
- it is useful as a validation oracle, but not as our foundation
Do not make xctrace the foundation either.
Why:
- it is tied to Xcode/Instruments
- it records
.tracefiles rather than a profiler-native data model - export paths are better for interoperability than for owning the feature
Build a native Rust sampler and use Apple tools only for validation and fallback symbolication.
Suggested architecture
Split it into two layers
-
libs/sampling_profiler- sampling loop
- remote unwind
- image mapping
- folded stack aggregation
- optional symbolication helpers
-
studio/profiler-cli- thin command-line wrapper
- attach/launch arguments
- output files
- JSON metadata for Studio
Then Studio hub drives the CLI.
That keeps the collector usable from:
- Makepad Studio
- AI tools
- shell scripts
- regression tests
Why the hub should own it
The hub is already the correct authority for:
- current build/run selection
- child process ownership
- starting/stopping helper processes
- shipping results to the desktop UI
The desktop should not be the process/PID authority.
MVP scope I would choose
V1 target
- macOS only
- profile Studio-launched build only
- launch from Studio hub
- fixed-duration capture
- fixed sample rate
- frame-pointer unwind only
- function-name symbolication only at first
- folded stack output
- SVG flamegraph export
Explicit non-goals for V1
- arbitrary attach to protected processes
- perfect DWARF unwinding without frame pointers
- off-CPU flamegraphs
- memory flamegraphs
- native in-Studio SVG renderer if that slows delivery too much
Rust build requirements for profiled targets
For Rust code, require:
-C force-frame-pointers=yes-C debuginfo=1or2
The Rust codegen docs also note that macOS defaults to packed split debuginfo, which means dSYM output is already the normal shape there.
This is important because the easiest native MVP assumes:
- stable frame chains
- symbol data available somewhere predictable
If we do not require frame pointers, the implementation cost jumps immediately.
Concrete collector design
Sampling loop
For each capture:
- Resolve target PID from the current Studio run.
- Get task port with
task_for_pid. - Snapshot loaded image metadata with
TASK_DYLD_INFOanddyld_all_image_infos. - Loop for
duration * hziterations:- enumerate threads with
task_threads - suspend task or suspend threads
- read thread registers with
thread_get_state - unwind each thread using frame pointers plus
mach_vm_read_overwrite - resume task or threads
- aggregate stack into a hash map keyed by normalized frame sequence
- enumerate threads with
- Symbolicate addresses.
- Write:
capture.foldedcapture.svgcapture.json
Unwind strategy
For MVP:
- arm64: seed with
pc, then walkfpchain - x86_64: seed with
rip, then walkrbpchain - validate every remote read
- stop on bad pointers, loops, non-monotonic frames, or depth cap
This will work well enough for Studio dev builds if we control the frame-pointer story.
Symbolication strategy
I would do this in two passes:
-
v1
- image UUID/load-address map
- symbol names only
- fall back to raw hex addresses if needed
-
v2
- dSYM lookup by UUID
- file/line
- inline frames
atos is useful as a correctness oracle during development, even if the product path stays in Rust.
Studio integration plan
Do not overload the current profiler protocol
Instead, add a separate sampling-profile path. For example:
- hub command:
StartSamplingProfile { build_id, duration_ms, hz } - hub message:
SamplingProfileProgress - hub message:
SamplingProfileFinished { folded_path, svg_path, metadata_path }
UI shape
There are two reasonable options:
-
Ship fast
- Studio launches capture
- Studio shows status/progress
- Studio opens the generated SVG artifact
-
Better long-term
- Studio stores folded stacks or a prebuilt frame tree
- new native
FlamegraphPanerenders directly in Makepad - SVG remains an export/share format, not the primary UI
If "lives inside Studio" is a hard requirement, I would still keep SVG export, but I would not make SVG parsing/rendering the internal source of truth.
The best long-term model is:
- folded stacks / tree as source data
- native flamegraph widget in Studio
- SVG as export
Estimated effort by area
Native MVP
- hub plumbing and command wiring:
1-2 days - macOS task/thread sampling loop:
2-3 days - frame-pointer unwind on arm64 and x86_64:
3-5 days - symbolication MVP:
2-4 days - folded output +
infernoSVG:1 day - Studio result tab / artifact flow:
2-4 days - tests, failure handling, retries:
2-4 days
Total: roughly 1-2 weeks if kept tight.
Product-quality version
Add all of the following and the timeline moves materially:
- arbitrary attach UX
- better permission story
- dSYM lookup by UUID
- inline frames and file/line
- arm64e hardening
- cancellation and long-running captures
- differential flamegraphs
- persistent history
- native interactive flamegraph widget
Total: roughly 4-8 weeks.
What I would build first
Phase 1
- native Rust sampler
- Studio-launched build only
- frame-pointer-only unwind
- folded output
- SVG via
inferno - command-line entrypoint
- Studio button: "Sample CPU"
Phase 2
- better symbolication
- native flamegraph pane
- diff captures
- filters by thread / image / symbol
Phase 3
- optional attach to existing PID
- off-CPU or wait-state profiling
- profile import/export
Bottom line
This is a good feature to build, but only if we are strict about scope.
If the requirement is:
- real out-of-process sampling
- flamegraph output
- works for the app Studio launched
then Rust is a good fit and the MVP is very doable.
If the requirement expands to:
- arbitrary macOS process attach
- no frame-pointer assumptions
- product-grade symbolication
- native Studio flamegraph UX on day one
then it becomes a much bigger project.
My recommendation is:
- build a native Rust sampler
- target Studio-launched debug builds first
- require frame pointers for v1
- use folded stacks as the core format
- export SVG with
inferno - keep the current Studio profiler separate from this feature
Sources
- Apple
sample(1)man page:man sample - Apple
taskgated(8)man page:man taskgated - Apple
atos(1)man page:man atos - Apple SDK headers:
mach/mach_traps.hmach/task.hmach/thread_act.hmach/mach_vm.hmach/task_info.hmach-o/dyld_images.hmach/arm/_structs.h
- Apple docs:
- https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.security.cs.debugger
- https://developer.apple.com/documentation/kernel/1537751-task_threads/
- https://developer.apple.com/documentation/kernel/1418576-thread_get_state
- https://developer.apple.com/documentation/kernel/mach/vm
- https://developer.apple.com/documentation/xcode/xcode-command-line-tool-reference
- Rust docs:
- https://doc.rust-lang.org/stable/rustc/codegen-options/
- https://docs.rs/inferno/latest/inferno/flamegraph/
- https://docs.rs/inferno/latest/inferno/flamegraph/fn.from_lines.html
- https://docs.rs/addr2line/
- https://docs.rs/mach2/latest/mach2/task/
- https://docs.rs/mach2/latest/mach2/thread_act/
- https://docs.rs/mach2/latest/mach2/vm/
- Flamegraph reference: