CPU Profiler
[PROF-ACTIVATE-ENV] Activation
The profiler is compiled into every runtime archive and is off by default.
It activates only when the environment variable OSPREY_PROFILE=<path> is set
at process start; the raw profile is written to <path> at normal exit.
OSPREY_PROFILE_HZ=<n> overrides the sampling rate (clamped to 10..10000,
default 997). Native POSIX binaries can be profiled without recompilation.
Windows and Wasm builds carry inactive profiler stubs.
[PROF-COLLECT-SAMPLER] Collection
A sampler thread wakes on a per-tick interval jittered uniformly by ±30% and samples every registered thread. Each native fiber thread registers its fiber id and label.
- macOS:
thread_suspend→thread_get_state(ARM_THREAD_STATE64)→ frame-pointer walk →thread_resume. The sampler performs no allocation while a thread is suspended. - Linux: the sampler thread directs
SIGPROFat running threads viapthread_kill; the async-signal-safe handler walks its own stack fromucontext_tinto a preallocated per-thread SPSC ring drained by the sampler. A blocked thread gets one signal to capture its blocking stack; further waiting samples reuse that stack. Sleep uses an absolute deadline.
Samples record (t_ns, thread, stack, state). State is on-CPU or waiting,
derived from per-thread CPU-time deltas using THREAD_BASIC_INFO on macOS and
the thread CPU clock on Linux.
[PROF-COLLECT-UNWIND] Stack capture
Stack capture uses only a frame-pointer chain walk. Every
frame pointer is validated before dereference: 8-byte alignment, strict
monotonic growth, inside the thread's [lo, hi) stack bounds captured at
registration, bounded frame size, depth cap 128. Any failed check ends the
walk. Frame 0 is the precise PC. On arm64, lr is recorded and deduplicated
against the first chained return address, and PAC bits are stripped.
[PROF-COLLECT-REGISTRY] Thread registry
osp_prof_thread_register(fiber_id, label) / osp_prof_thread_unregister()
are no-ops when the profiler is inactive. Call sites: the main thread (label
main, fiber 0), fiber_thread_func (label fiber), and effect continuation
threads (label effect, fiber −1). Registry slots carry a generation counter.
The sampler revalidates active && generation under the registry mutex before
sampling; unregister uses the same mutex before signaling completion. Lock
order is registry then allocator, and sampling allocates only after a suspended
thread resumes.
[PROF-RAW-FORMAT] Raw profile file
JSON, written once at exit by the runtime (no symbolization in-process — symbol names, files, and lines are resolved offline):
{"version":1, "pid":0, "exe":"/path/bin", "rate_hz":997, "platform":"macos-arm64",
"start_unix_ns":0, "end_unix_ns":0,
"images":[{"path":"/path/bin","base":0,"slide":0}],
"threads":[{"fiber":0,"label":"main"}],
"stacks":[[4301231,4301100]],
"samples":[[12345,0,0,0]],
"dropped":0}
stacks are leaf-first raw return addresses. samples rows are
[t_rel_ns, thread_index, stack_index, state] with state 0 = on-CPU,
1 = waiting. A single top-level dropped counter reports samples the runtime
could not retain (sampler-ring overflow or allocation/interning failure).
[PROF-SYMBOLIZE-OFFLINE] Symbolization
The osprey-profiler crate maps each pc to its image, computes the unslid
address, and batch-symbolizes via llvm-symbolizer (file/line plus inline
expansion), falling back to atos on macOS and raw hex names when no
symbolizer is present. Return addresses (every frame except the leaf) are
adjusted by −1 so samples attribute to the call line, not the next line.
Osprey symbols are unmangled, so names map 1:1 to source functions.
[PROF-BUILD-MODE] --profile builds
--profile compiles with debug metadata (DWARF line tables) at full
optimization — unlike --debug, which forces -O0. Driver flags:
-g -fno-omit-frame-pointer with the release -O2. On macOS the pipeline is
.ll → .o → link → dsymutil. Codegen emits "frame-pointer"="all" on every
generated function [PROF-CODEGEN-FP].
[PROF-CLI-RUN] CLI pipeline
osprey <file> --run --profile (--profile implies --run when no other
mode is given): compile with [PROF-BUILD-MODE], execute with OSPREY_PROFILE
pointing at a scratch raw file, then post-process:
- Write
<stem>.speedscope.json— one sampled profile per fiber with samples, sharing an interned frame table and root-first stacks. - Write
<stem>.cpuprofile— V8 format with microsecond deltas, a node call tree, and 0-based lines. - Write
<stem>.folded— collapsed stacks with the fiber as a synthetic root frame (fiber-1;main;fib). - Write
<stem>.profile.json— summary for editor integration: totals, per-fiber state split, hot functions (self/total), hot lines. - Print the terminal report [PROF-CLI-REPORT].
[PROF-CLI-REPORT] Terminal report
Printed after the program exits (never in the harness path — only under
--profile): a header line (wall, CPU, samples, rate, fibers), a fiber-state
split (running / waiting), and a top-10 table with columns
SELF% TOTAL% SELF TOTAL FUNCTION LOCATION, Unicode eighth-block bars in the
self gutter, and color thresholds (≥5% red/bold, ≥0.5% yellow),
honoring NO_COLOR and non-TTY stdout. Sampling does not produce call counts,
so the report has no calls column. Below about 100 samples, the report flags low
confidence.
[PROF-VSCODE-FLAME] Editor integration
The VS Code extension provides an Osprey: Profile Current File command that
runs the CLI pipeline and renders a canvas flame graph webview
(zoom, pan, hover tooltips, substring search with match dimming, click-to-
source), with Left Heavy and Time Order views, a per-fiber filter, and a
self/total hot-function table. It also adds after-line heat decorations
(NN.N% · M samples) with overview-ruler marks [PROF-VSCODE-HEAT], driven by
<stem>.profile.json.
[PROF-TEST] Testing
- The C runtime suite verifies thread registration, sample capture, stack bounds, and raw JSON output.
osprey-profilertests verify parsing, aggregation, symbolization, exporters, and terminal formatting.- The end-to-end profiler script runs an example under
--profileand parses every export. The ordinary example harness runs with profiling disabled.