Decided for the first interpreter-backed profiler. This document closes the design scope tracked by #164. Implementation remains staged, and future native runtimes must preserve the same event, aggregation, security, and reporting semantics.
Jett profiling turns bounded runtime observations into compact bottleneck summaries. The first contract provides:
- one explicit CPU mode and one explicit memory mode on
jett run; - backend-neutral samples and allocation events;
- stable attribution to Jett functions, source spans, call chains, and runtime execution contexts;
- deterministic human and TOON summaries;
- partial-profile and capability reporting instead of silent approximation;
- no program values, heap contents, or unsanitized source in reports.
This contract does not define flamegraph export, always-on telemetry, production
allocator selection, native unwinding machinery, operating-system process
profiling, optimizer behavior, or profile-guided optimization. It profiles the
Jett runtime selected by jett run, not the compiler phases that precede it and
not arbitrary child processes or foreign libraries.
The canonical modes are:
jett run app.jett --profile
jett run app.jett --profile-memory
The shared controls are:
--profile-threshold <percent> minimum impact, default 5.00
--profile-limit <count> maximum bottlenecks, default 10
CPU profiling additionally accepts:
--profile-rate <hertz> requested sample rate, default 1000
percent is parsed as a decimal percentage from 0 through 100, with at
most two fractional digits, and is stored as integer basis points. count is
an integer from 1 through 100. hertz is an integer from 1 through
1000. Invalid or out-of-range values are CLI errors before compilation or
execution.
The two modes are mutually exclusive in v1. Running both at once would combine
two sources of overhead and make each result incomparable with a single-mode
run. A profile-only option without its corresponding mode is also a CLI error.
The same options work with --agent; option order has no semantic effect.
No source-level capability, declaration, annotation, or import enables the profiler. It is launcher-owned observation selected only at the command line.
The driver validates, compiles, and prepares the requested profiler before it
enters main:
- A compile failure emits ordinary compiler diagnostics and no profile.
- An unsupported mode or setup failure emits a stable profiler failure and does not execute user code.
- A normal return finalizes a complete profile.
- A handled or unhandled Jett runtime failure finalizes the observations that
precede the failure and marks
termination: runtime_error. - An interrupt that reaches the runtime's cooperative shutdown path finalizes
a partial profile with
termination: interrupted. The process retains the platform's conventional interrupted exit status, such as130for SIGINT. - An uncatchable kill, process abort, host crash, or power loss is not promised a final report.
Profiling never changes a Jett program's source-visible return value or runtime
error. If collection fails after user code starts, the runner disables the
failed collector, lets the program reach its ordinary termination when safe,
and emits profile status: unavailable or status: partial with reason
collector_failure. If the program then returns, RunOutputV1 keeps
termination: returned but selects failure.kind: profiler_failure, stable
message profiler: collector failure, and top-level TOON status: error. If
the program instead reaches a runtime error or cooperative interrupt, that
termination owns the run failure and the profile still records its collector
reason. An ordinary bounded-detail state such as cpu_metadata_limit,
retention_metadata_limit, or stack_capture_limit produces a partial profile
without a profiler run failure. Compile/runtime/interrupt failures retain their
existing exit precedence. The exact shared result and sibling order are defined
by the structured logging RunOutput composition.
All backends adapt observations to the following logical identities. These are schema concepts, not required Rust layouts:
SourceIdentity = {
origin: project | dependency | stdlib,
logical_path: normalized relative path,
source_id: stable identity within the run manifest,
}
FrameIdentity = {
namespace: qualified namespace or "<root>",
function: source-level function or stable runtime bucket,
source: SourceIdentity | unavailable,
declaration_span: byte range | unavailable,
call_site_span: byte range | unavailable,
}
ExecutionIdentity = {
task_id: runtime-assigned id | unavailable,
actor_id: runtime-assigned id | unavailable,
runtime_thread_id: run-local id | unavailable,
}
Numeric parser FileId, pointer identity, native addresses, mangled symbols,
absolute paths, host thread IDs, and allocator addresses never appear in the
report or participate in deterministic tie-breaking. Runtime IDs are run-local
metadata only. They are not compared across runs.
A stack is ordered from the root entry frame to the observed leaf frame. The
maximum recorded depth is 128. A deeper stack ends with a stable
<truncated-stack> frame and increments truncated_stacks. Missing native
symbols use <unavailable-symbol>; runtime work with no Jett frame uses a named
bucket such as <runtime> rather than being assigned to the nearest user
function.
Project paths are relative to the canonical project root. Dependency paths use
the dependency's logical name and vendored-relative path. Standard-library
paths use stdlib/. If no authorized manifest-relative path exists, the source
location is unavailable. Backends must not fall back to an absolute path.
A CPU sample event contains:
CpuSample = {
sequence: monotonic run-local integer,
requested_tick: monotonic run-local tick,
execution: ExecutionIdentity,
state: jett | runtime | waiting | unavailable,
stack: list[FrameIdentity],
}
The configured rate is a requested opportunity rate, not a promise that every tick becomes a stack. Sampling uses a monotonic clock. The timer keeps at most one pending request per runtime execution thread; another tick while one is pending is coalesced and counted. Collection must not queue an unbounded number of stack requests or stop a thread at an unsafe point.
The tree-walking interpreter acknowledges pending requests at bounded statement,
call, return, and loop safe points on its dedicated jett-runtime thread. This
makes stack capture memory-safe while still using elapsed monotonic ticks. It
must not substitute statement counts for elapsed-time sampling. A future native
runtime may use platform stack sampling or scheduler safe points, but it emits
the same logical events and reports which mechanism it used.
The timer path is constant-space and allocation-free: it updates a tick counter
and one pending bit per registered runtime thread. Stack capture visits at most
128 frames, and aggregation is online under a 32 MiB CPU-profiler metadata
budget. If new function, line, or call-chain buckets would exceed that budget,
the collector keeps total/state counters, stops adding new detail buckets, and
emits status: partial with partial_reason: cpu_metadata_limit. It does not
block indefinitely, grow an event queue, or terminate the program. The report
does not estimate a percentage overhead because that would require a second,
unprofiled execution with potentially different program behavior; it reports
the requested/observed rates and all coalesced or dropped opportunities instead.
A sample in state: jett has at least one Jett frame. runtime covers runtime
work outside a Jett frame, waiting covers a requested tick while the execution
context is blocked or idle when the backend can identify that state, and
unavailable covers a tick whose state or stack could not be recovered. These
states remain separate; blocked wall time is never charged to the last Jett
function.
For a future concurrent runtime, every Jett runtime worker participates. Samples
carry the active task, actor, and run-local runtime thread when known. Migration
between workers does not split a function identity. External threads not
registered with the Jett runtime and work performed in child processes are out
of scope. A capability call that blocks may produce waiting observations but
does not make the called Jett frame consume CPU samples.
CPU totals report at least:
- requested ticks;
- recorded samples by state;
- coalesced and collector-dropped ticks;
- attributed and unavailable stack counts;
- requested and observed effective rates;
- monotonic wall duration;
- process CPU duration when the platform supplies it, otherwise
unavailable.
Percentages for Jett bottlenecks use the count of state: jett samples as their
denominator. They are sampling estimates, not CPU utilization and not a claim
that inclusive percentages across callers sum to 100. Runtime, waiting, and
unavailable samples remain visible in totals but do not inflate a user's
function percentage.
For each function:
inclusive_samplescounts samples containing that function;self_samplescounts samples where that function is the leaf Jett frame;- hot-line counts use the leaf frame's call-site or current-statement span;
- call-chain counts use the complete normalized root-to-function chain.
Each function's displayed call chains are sorted by descending sample count, then lexically by the sequence of stable frame identities. At most three are reported. Hot lines are sorted by descending self-sample count, then source identity, line, and column; at most three are reported.
A backend that cannot obtain a monotonic clock or safely observe the active Jett
stack reports CPU profiling as unsupported before main. It must not silently
replace elapsed-time sampling with statement counting, instrumentation call
counts, a different rate, or whole-process CPU percentages.
Memory mode covers the Jett-managed runtime heap. It excludes compiler memory,
profiler metadata, stacks, executable code, native library internals, foreign
allocators, memory-mapped files, child processes, and operating-system cache.
The report says coverage: jett_heap; it never labels the result as resident set
size or total process memory.
Backends normalize allocator hooks into:
AllocationEvent = {
sequence: monotonic run-local integer,
operation: allocate | resize | free,
allocation_id: opaque run-local identity,
old_size: integer bytes | not_applicable,
new_size: integer bytes | not_applicable,
execution: ExecutionIdentity,
allocation_stack: list[FrameIdentity] | unavailable,
}
Only successful operations emit events. A new allocation creates one ID. A resize preserves that ID. A free retires it. An ID cannot be reused during one run. Addresses never leave the collector.
The normalized counters are:
allocation_count: successful creation of new allocation IDs;resize_count: successful resizes;allocated_bytes: initial allocation sizes plus positive resize deltas;freed_bytes: freed live sizes plus negative resize deltas;live_bytes: allocated bytes minus freed bytes for tracked allocations;peak_live_bytes: the greatest live byte total after any event;retained_bytes: live bytes at finalization.
A zero-size allocation may have an ID and increments allocation_count, but adds
zero bytes. A failed allocation emits no event and follows the runtime's ordinary
allocation-failure behavior. A resize from or to zero follows the same delta
rules, so host allocator conventions do not change Jett metrics.
Allocation count and initial bytes are attributed to the creation event's leaf
Jett frame and normalized call chain. Positive resize growth is allocation
pressure at the resize event's active leaf frame. The live allocation still
retains its original creation site through resizes and task migration, so freed
and retained bytes are reported against that creation site, not the resizing or
releasing frame. At the instant of the global peak, the collector snapshots
live bytes grouped by original creation site as live_at_peak_bytes.
The default memory bottleneck impact is allocated bytes. Its percentage uses total attributed allocated bytes as the denominator. Each entry also reports allocation count, resize count, freed bytes, retained bytes, and bytes live at the global peak when available. This keeps allocation churn, end-of-run retention, and peak pressure distinct. Retained memory is not automatically called a leak; reachable caches and intentionally long-lived data are valid.
Memory aggregation is online and profiler-owned allocations are excluded to avoid recursion. Exact retention requires one metadata record per live Jett allocation. V1 gives those records a fixed 64 MiB metadata budget per run. The backend may use a more compact representation, but it may not exceed that budget without an explicit future option.
If the budget is exhausted, the collector continues exact aggregate allocation
count and byte-pressure counters where possible, stops claiming exact live,
peak, freed, and retained values, and marks those fields unavailable with
partial_reason: retention_metadata_limit. It must not terminate the program,
attribute unknown frees to a guessed site, or silently present a lower bound as
exact.
A backend without hooks for Jett-managed allocations reports memory profiling as
unsupported before main. Sampling host RSS or replacing missing free events
with end-of-process guesses is not an allowed fallback.
A bottleneck is eligible when its primary impact is greater than or equal to
--profile-threshold. CPU uses inclusive Jett sample percentage. Memory uses
attributed allocated-byte percentage. Percentages are calculated from integer
counts, rounded half away from zero to two decimal places only for display, and
compared in unrounded rational form.
Eligible entries are ordered by:
- descending primary impact count;
- descending self samples for CPU or allocation count for memory;
- qualified namespace and function;
- source origin, logical path, declaration start, and declaration end.
The first --profile-limit entries are emitted and assigned ranks starting at
one. The summary reports eligible_bottlenecks, emitted_bottlenecks, and
truncated_bottlenecks. An empty or entirely below-threshold profile is valid
and emits an empty bottleneck list with totals intact.
If the relevant denominator is zero -- no state: jett samples for CPU or no
attributed allocated bytes for memory -- the profile has no eligible
bottlenecks or suggestions. It does not divide by zero, invent a percentage, or
promote runtime/unavailable observations into a user-function entry.
Unavailable-symbol and runtime buckets are included in totals. An unavailable Jett symbol may be an emitted bottleneck if it crosses the threshold, with source and suggestions marked unavailable. The output never fabricates a source location or merges two unknown native addresses into a source function merely because their display names match.
Suggestions are optional compiler-owned records, not generated prose. Every suggestion has a stable rule ID and a fixed template filled only with reported counts, percentages, qualified names, and source locations. V1 rules are:
CPU_HIGH_SELF: inspect the reported hot lines when self samples are at least half of a function's inclusive samples;CPU_CALLEE_DOMINATED: inspect the dominant reported call chain when self samples are less than half of inclusive samples;MEM_ALLOCATION_PRESSURE: reduce, reuse, or batch work at the hottest allocation site;MEM_RETAINED: review the lifetime of allocations from a site when its retained bytes divided by total attributed allocated bytes meet the same configured threshold.
Ties use the bottleneck ordering above. At most two suggestions are emitted per bottleneck, ordered by rule ID. A rule is omitted when required data is partial, unavailable, or below its exact predicate.
The first implementation does not claim that an allocation occurs in a loop, that a value can move to comptime, that a standard-library replacement is semantically equivalent, or that retained memory is a leak. Such advice may be added only when checked compiler facts prove the predicate and a versioned rule with deterministic tests owns the wording. Runtime data is never sent to an external model to generate suggestions.
Profiles record control-flow location and aggregate sizes only. They never record arguments, locals, return values, capability contents, allocation bytes, dynamic strings, environment values, or secret values.
A hot-line code excerpt is optional. When source and checked metadata are
available, the compiler derives it from the loaded run manifest, not from a new
filesystem read, and applies all of these rules:
- comments are omitted;
- string and byte literal contents become a fixed
<redacted-literal>token; - a span whose checked type contains
secretbecomes<secret-expression>; - control characters are escaped and output is limited to 160 UTF-8 bytes at a character boundary;
- source outside the authorized project, dependency, or stdlib manifest is unavailable.
The row includes source_redacted: true whenever any rule changed the excerpt.
If safe tokenization or checked metadata is unavailable, code is unavailable;
the profiler does not fall back to the raw line. Function and namespace names,
relative paths, line numbers, counts, and sizes remain visible because they are
the profiling subject, but suggestions never interpolate source text.
In human mode, user stdout remains stdout and user stderr remains stderr. The
profile summary is written to stderr after runtime finalization, with a clear
CPU profile or Memory profile heading. Human wording may improve, but it
must carry the same status, coverage, totals, partial reasons, and bottleneck
ordering as structured output.
With --agent, the CLI continues to emit one structured run envelope on stdout.
Program stdout and stderr are the always-present escaped stdout and stderr
scalars; they are never concatenated with the profile object as raw text. The
profile is the optional typed profile object after the always-present
debug, logs, log_sources, log_source_graph_path_segments, and
log_fields tables and before the optional final error_kind and error pair.
The exact sibling order, structured-log schemas, failure retention, and absence
rules are defined by the structured logging RunOutput composition.
Compiler and profiler setup failures use the ordinary pre-execution structured
failure envelope. This preserves stdout as one parseable TOON document while
preventing program output or a log record from impersonating profile fields.
The stable structured object begins:
profile:
schema: jett.profile.v1
mode: cpu
status: complete
termination: returned
backend: interpreter_safe_point
coverage: jett_runtime_threads
config:
requested_rate_hz: 1000
threshold_basis_points: 500
limit: 10
cpu_metadata_budget_bytes: 33554432
totals:
requested_ticks: 48000
recorded_samples: 47200
attributed_samples: 46800
runtime_samples: 200
waiting_samples: 100
unavailable_samples: 100
coalesced_ticks: 800
bottlenecks[1]:
rank: 1
namespace: pipeline.transform
function: process_image
path: src/transform.jett
line: 142
inclusive_samples: 16000
self_samples: 13000
cpu_percent: 34.19
suggestions[1]{rule,text}:
CPU_HIGH_SELF,"Inspect the reported hot lines in pipeline.transform.process_image; self work accounts for at least half of its samples."
Memory mode uses the same envelope and ordering fields, with
schema: jett.profile.v1, mode: memory, coverage: jett_heap, and memory
totals and entries:
profile:
schema: jett.profile.v1
mode: memory
status: complete
termination: returned
backend: interpreter_alloc_hooks
coverage: jett_heap
config:
threshold_basis_points: 500
limit: 10
retention_metadata_budget_bytes: 67108864
totals:
allocation_count: 2400000
resize_count: 120
allocated_bytes: 891289600
freed_bytes: 757071872
peak_live_bytes: 134217728
retained_bytes: 134217728
bottlenecks[1]:
rank: 1
namespace: search.indexer
function: build_index
path: src/indexer.jett
line: 88
allocation_count: 1010400
allocated_bytes: 375272960
allocation_percent: 42.11
freed_bytes: 300000000
retained_bytes: 75272960
live_at_peak_bytes: 80000000
Required unavailable numeric fields are rendered as unavailable, not zero and
not omitted. status is complete, partial, or unavailable. A partial or
unavailable profile includes one stable reason such as interrupted,
collector_failure, cpu_metadata_limit, retention_metadata_limit,
stack_capture_limit, or backend_unsupported. Renderer tests pin field names,
order, escaping, fixed-precision percentages, unavailable values, and empty
arrays.
A profiler backend declares capabilities before execution:
cpu_tick_source
safe_stack_capture
jett_runtime_thread_registration
task_attribution
source_spans
process_cpu_time
jett_heap_allocation_hooks
free_and_resize_events
The selected mode's required capabilities are checked before main. CPU
requires a monotonic tick source and safe stack capture. Memory requires Jett
heap allocation hooks plus free and resize events. Task attribution, source
spans, and process CPU duration may be unavailable and are reported as such
without invalidating function-level data when the remaining identity is sound.
The current interpreter stage owns only one registered runtime OS thread and can use AST statement/call spans. It reports task and actor IDs only where its simulation has a real active runtime identity. Future bytecode and native runtimes may use different sampling and allocator mechanisms, but no backend may change counter definitions, silently broaden coverage to host allocations, or invent a separate output schema.
jett_profiler owns backend-neutral event types, aggregation, deterministic
sorting, thresholding, source sanitization, suggestions, status, and human/TOON
rendering. It depends only on foundational source/span and diagnostic data, not
on the CLI or a concrete interpreter.
The driver owns profiler selection, capability negotiation, run-manifest and
checked-source metadata, lifecycle finalization, and composition with RunOutput.
The CLI owns argument validation and final channel/exit behavior. A runtime owns
only safe event production and excludes profiler allocations from observation.
Implementation proceeds in independently testable stages:
- Pure profile model and renderer. Add
jett_profilerwith injected CPU and allocation event streams. Pin counters, resize/free accounting, peak and retained snapshots, thresholds, ties, truncation, suggestions, redaction, partial fields, and both renderers. - CLI and run envelope. Add the mutually exclusive flags, validation, human
stderr report, structured profile object, and explicit captured program
stdout/stderr fields. Setup failures occur before
main. - Interpreter CPU adapter. Add the bounded pending-tick mechanism and safe stack snapshots to the existing dedicated runtime thread. Test coalescing, runtime/waiting buckets, interruption finalization, recursive stack limits, source attribution, and sampler unavailability.
- Interpreter memory adapter. First introduce an explicit interpreter-owned allocation boundary for semantic Jett heap objects, then normalize its allocate/resize/free events. Rust container capacity changes, temporary clones, AST storage, and other interpreter implementation allocations are not Jett events. Exclude collector metadata and test zero-size operations, failed allocation, task migration, peak snapshots, retained ownership, and metadata-budget degradation.
- Concurrent and native handoff. After HIR, MIR, allocator, and runtime work exists, register all runtime workers, preserve source identities through lowering, adapt safe native stack samples and Jett heap hooks, and run the same injected-event and end-to-end conformance scenarios.
CPU and memory may land as separate implementation PRs after stages 1 and 2. Neither mode is marked implemented merely because its CLI flags parse.
- CLI rejects both modes together, orphaned controls, malformed percentages,
rates outside
1..1000, and limits outside1..100before execution. - Compile and setup failures execute no user code and emit no fabricated profile.
- Injected CPU events pin inclusive/self counts, hot lines, dominant chains, unavailable/runtime/waiting buckets, coalescing, stack truncation, and ties.
- The timer callback allocates nothing, pending requests stay bounded per worker, and the 32 MiB CPU metadata limit yields explicit partial detail.
- Percent thresholds compare exact counts; display rounding cannot admit or reject a bottleneck.
- Concurrent events aggregate functions across workers while preserving run-local task attribution and deterministic output.
- Injected allocation events pin allocation and resize counts, positive and negative deltas, frees, zero sizes, peak snapshots, retained sites, and IDs.
- Collector metadata is excluded, recursion is impossible, and the 64 MiB limit yields explicit partial fields without changing program behavior.
- Project, dependency, and stdlib paths are normalized; absolute and unauthorized paths never appear.
- Comments, string/byte literals, secret-bearing expressions, control characters, and long excerpts follow the source-redaction policy.
- Suggestion rule IDs, predicates, template wording, ordering, and omission on partial data are snapshot tested.
- Human and TOON output contain the same totals and ordering; program output cannot alter structured profile fields.
- Profiled success and runtime-failure fixtures combine stdout, stderr, debug
rows, accepted structured logs, a failed log sequence gap, and the optional
profile in the exact
RunOutputV1/TOON sibling order; no channel consumes or impersonates another, and pre-failure captures remain available. - Normal return, runtime error, collector failure, cooperative interrupt, and backend unsupported states preserve the selected exit policy.
- Interpreter, bytecode, and native adapters consume the same deterministic
event fixtures and produce byte-identical
jett.profile.v1objects.
Future native attribution depends on the HIR and MIR boundaries tracked by #20 and #22. Those phases do not block the pure aggregation, CLI, or interpreter adapter stages.