043. Generated Parser Tracing and Profiling
- Status: Proposed
- Author: @ornew
- Date: 2026-10-11
Summary
Add opt-in rule-call tracing and per-rule profiling to standalone generated Go, typed Go and TypeScript parsers. Instrument generated direct-call paths as well as runtime-dispatched calls, and report optimized skips as skips rather than pretending they were calls. Generated traces and profiles observe each generated parser's execution strategy; they do not promise event-for-event equivalence with engine traces or between backends.
Motivation
Design 014 provides pego.WithTrace, pego.Profile, pego.WithProfile, and the trace, profile and explain CLI commands for the engine. Generated Go Node, typed Go, Go Document and TypeScript batch parsers support generation-time opt-in tracing and profiling. Generated APIs have their own event/profile contracts and do not promise engine equivalence. The engine event reports rule, position, binding level, depth, match, memo use, body evaluations and examined input; callbacks are synchronous, and their panic is re-raised unchanged. Profiles accumulate per-rule call/evaluation/memo/match/failure counts, consumed positions, repeats and inclusive/self time.
Generated Go and TypeScript runtimes specialize calls into direct generated methods/functions and common call sites bypass the generic call path (internal/gen/gogen/generator.go, internal/gen/gogen/flowrule.go, internal/gen/tsgen/generator.go, runtime/runtime.go, internal/gen/tsgen/tsrt/runtime.ts). Generated Go has ordinary and typed runtimes; TypeScript has its own runtime and can convert node results to typed values. Literal/first-character dispatch can skip a call, and deferred memoization changes which calls evaluate a body. Adding a hook only to parser.call would therefore miss optimized calls and produce incomplete counts. The Go and TypeScript slices have focused synthetic measurements below; broader workload measurements remain unavailable.
Go Node Batch Trace
The initial Go batch implementation is generation-time opt-in tracing for
standalone generated Go Node parsers. Generation uses
Observability: true in gen.Go or gen.TypeScript, or pego gen -observability; enabled
parsers trace Parse, ParseRule and Recognize. Events cover actual plain,
direct, general, memoized and left-recursive calls, plus first-character
dispatch skips only where the generated call site can identify the target.
ExaminedKnown is false in batch parses. Named filters include descendants; unknown
names select nothing. The start rule has depth one, and nonpositive maximum
depth is unlimited. Callback panics propagate unchanged after cleanup, while
aborted parses may omit exits. Go Node and typed batch packages also expose the
aggregate Profile API described below.
Typed Go batch tracing is described below; Document tracing and profiling have
their own contracts below.
Typed Go Batch Trace Extension
With Observability and Types, generated Go packages
trace ParseAST and ParseRuleAST as well as Node Parse, ParseRule and
optional Recognize. This covers direct typed execution and Node-conversion
fallback. Events describe parser calls made by the selected route; converting
a Node result to typed values is not a rule call and emits no separate event.
The same event, filter, depth, interruption and callback-panic contract
applies, though Node and typed traces can differ because their execution paths
differ. The typed package also exposes Profile.
TypeScript Batch Trace
Generation-time observability also supports TypeScript batch parsers, with or
without -types, and with -recognize when recognition is generated. The
generated module adds trace, traceRules and traceMaxDepth to
ParseOptions; parseWithOptions, parseRuleWithOptions and
recognizeWithOptions accept these options. With -types, the configured-start
parseASTWithOptions does too. The legacy positional-unit APIs do not accept a
trace callback, and TypeScript does not generate a separately named typed-rule
API.
TypeScript exports TraceKind ("enter" | "exit" | "skipped") and a
TraceEvent with camel-case fields: kind, rule, level, depth, pos,
matched, end, memo, evals, examined, examinedKnown and
skipReason. Positions use the selected unit. As with generated Go,
examinedKnown is always false, depth starts at one, and nonpositive
traceMaxDepth is unlimited. An omitted or empty traceRules delivers all
rules; a selected rule includes its descendants, and an unknown filter selects
no rules. Events are copied snapshots, so callback
mutation cannot change parser state. Callbacks run synchronously; thrown values
are rethrown unchanged after cleanup, and aborted parses can omit exit events.
Typed-result conversion does not create a separate rule event. TypeScript
batch profiling is described below. TypeScript Document event tracing and
examined-input metrics remain open.
Goals
- Expose explicit, opt-in trace/profile support in generated Go, typed Go and TypeScript batch parsers.
- Observe direct, generic, value-free, typed and recognition calls with one documented event contract per generated backend.
- Distinguish entered calls, memo hits, left-recursive body evaluations, and calls skipped by generated dispatch.
- Define position units, filtering, callback panic/throw, reentrancy, concurrency, interruption and profile ownership.
- Preserve parsing results and errors with instrumentation enabled; measure generation size and default/opt-in runtime costs before choosing how commonly instrumentation is available.
- State where generated traces differ from engine traces and leave existing engine dispatch and Document-resumption observability gaps as separate work.
Non-Goals
- Making generated parsers event-equivalent to engine backends or claiming portable timing equivalence between Go and JavaScript.
- Adding stream tracing to generated parsers. Generated stream parsers are not available.
- Changing memoization, first-character dispatch, rule evaluation order, parse results, or serialized
.pegocformat. - Providing expression-level tracing or tracing action internals.
Design
Generation-Time Opt-In and Public API
Generation-time opt-in keeps ordinary generated output free of observability fields, callbacks, clock reads, or trace branches. Go Node, typed batch, and Document parsers support gen.Go{Observability: true} and pego gen -observability; TypeScript batch tracing and profiling support gen.TypeScript{Observability: true} and the same flag. Generated Go profiling is available for Node, typed batch, and Document parsers.
Generated Go batch API:
type TraceKind uint8
const (
TraceEnter TraceKind = iota
TraceExit
TraceSkipped
)
type TraceEvent struct {
Kind TraceKind
Rule string
Level, Depth int
Pos int
Matched bool // exit only
End int // exit only
Memo bool // exit only; true when a memo result was reused
Evals int // exit only; includes left-recursive growth evaluations
Examined int // exit only; exclusive end in this parse's position unit
ExaminedKnown bool // false when the generated path cannot account for it
SkipReason string // skipped only, for example "first-character"
}
func WithTrace(func(TraceEvent)) ParseOption
func WithProfile(*Profile) ParseOption
func WithTraceFilter(rules ...string) ParseOption
func WithTraceMaxDepth(n int) ParseOption
func Parse(input string, opts ...ParseOption) (*Node, error)
func ParseRule(name, input string, opts ...ParseOption) (*Node, error)
func Recognize(input string, opts ...ParseOption) error
For the Go Node and typed batch slices, tracing options compose with unit and depth options. Typed packages expose ParseAST and ParseRuleAST with the same ParseOption values, and optional Recognize remains available. Positions are half-open; Examined is an exclusive end where the backend can report it.
The Go Node slice supports Parse, ParseRule and generated Recognize; the typed Go slice also supports ParseAST and ParseRuleAST. Both export TraceEvent, WithTrace, WithTraceFilter, WithTraceMaxDepth and WithProfile. TypeScript tracing and profiling use per-call ParseOptions fields instead of Go functional options; TypeScript profiles use Profile and ProfileSnapshot.
The option callback receives a copied value event containing scalar/string fields, not a pointer into parser state. Callbacks run synchronously on the parsing goroutine/JavaScript stack. There is no retained input or parser-state accessor in this API. Per-invocation trace/profile state is private; concurrent or reentrant parses of the same generated parser have independent state. A Profile aggregates across calls and is not safe to share across concurrent parses.
Event Semantics
TraceEnterprecedes the generated call's memo lookup or body dispatch. Every enter has one matchingTraceExitunless parsing is aborted.Depthstarts at one for the start rule;Levelis the Operators Rule binding level, zero for calls without one.TraceExitdescribes one entered call.Memo=truemeans the call reused an existing memo result without evaluating its body, soEvals=0. Calls that evaluate left-recursion growth attempts haveMemo=false; theirEvalscounts their own body attempts, including growth attempts, not nested calls. Recursive calls that reuse the growing seed haveMemo=trueandEvals=0.MatchedandEnddescribe the rule call, not whether the whole-input parse passed its end-of-input check.Examinedis emitted only where the generated runtime can account for a truthful range; it must not be guessed fromEndwhen a lookahead or failed dispatch examined farther.TraceSkippedis an unpaired event for a generated call site whose existing dispatch condition proves that the target rule was not entered. It identifies the target rule, start position, depth, level and skip reason, and has no exit, result, memo or evaluation count. It is not included in entered-call or body-evaluation totals. Only instrumented generated dispatch sites that can name the skipped target emit it. An implementation that cannot recover a truthful target and position omits the skip event rather than fabricating one.- A deferred-memo first evaluation produces an exit without a memo hit; a later lookup reports the memo event. Recognition events report recognition work without promising all actions are skipped: predicate-dependent actions and values still execute when required, as in ordinary recognition.
- Typed direct Go and Node-conversion Go report the parser's rule execution. Typed conversion time and allocations are outside per-rule event time unless a future API explicitly adds a conversion event. TypeScript typed-result conversion has the same boundary.
Generated backends can skip different candidate calls due to first-character dispatch and other generated specialization. Their event streams must be labeled as generated-runtime traces and can differ in which TraceSkipped events are representable. They must never be described as equivalent to engine traces. First-character trace differences between engine backends remain a separate limitation. Generated Go Document events report calls made during that invocation; a memo hit reports the reused call without replaying nested events from prior parses. Repetition-resumption events remain out of scope because generated Documents do not resume repetitions.
Filtering and Profiling
WithTraceFilter / traceRules select rule names, and WithTraceMaxDepth / traceMaxDepth limit delivered events by depth. Filtering suppresses callback delivery only; it must not change parsing, dispatch or parent-depth numbering. Include calls nested within a selected rule, following engine pego trace -rule behavior. JSON/text formatting can be supplied as small generated-independent helpers or by adapting events in user code; generated parsers do not add CLI commands.
The generated Go Profile zero value is ready to use. Pass WithProfile(&profile) to accumulate measurements across calls, then call profile.Snapshot() for an owned snapshot. Snapshot rule rows and per-rule skip-reason rows are sorted by name/reason, so json.Marshal(snapshot) is deterministic. A nil profile disables collection. The profile sink is not safe for concurrent use; reentrant parses on the same goroutine keep independent per-invocation accounting.
The snapshot contains total Parses, Entered, completed Calls, body Evals, MemoHits, Skipped and root Time, plus per-rule entered/completed calls, evaluations, memo hits, matches, failures, consumed positions, skips by reason, repeats and the maximum evaluation count/location. Entered includes calls interrupted before exit; completed-call counters do not. Skipped dispatch targets are separate from entered calls and do not depend on trace filters. Repeats and MaxEvals count completed non-memo calls with Evals > 0 at the same (rule, position, level) within an invocation; Evals itself counts body attempts, including left-recursive growth. When maximum counts tie, MaxEvalsAt keeps the first observed location. In batch parses Examined and Wasted are unavailable and their Known flags are false; they are not inferred from matched ranges. Document parses supply them, as described below. Position counts use each invocation's selected unit and are not normalized across calls that mix units.
Root Time sums completed root rule calls and excludes input setup, whole-input end checks and typed-result conversion. Per-rule Time counts recursive calls of that rule once within each invocation; SelfTime subtracts child time. Both include instrumentation and synchronous enter/exit callback time. Reentrant parses have independent accounting and are included in the outer callback interval as well as their own invocation totals. A completed-exit update is published after the exit callback returns or panics, so a snapshot taken inside that callback can omit that exit until it unwinds. Callback panics still propagate unchanged after cleanup. An exit callback panic counts that completed call; an enter callback panic does not synthesize an exit. The measurements below include the overall WithProfile path; they do not isolate individual counter or clock costs.
Generated Go Document Tracing and Profiling
With -document -observability, generated Go Documents support Parse and,
with -types, ParseAST tracing and profiling. NewDocument accepts
WithTrace and WithProfile as retained defaults; construction itself emits
no events or profile counts. Per-call options override those defaults for that
invocation only and do not change later calls. Edit does not parse and emits
no rule events.
Events report calls made during the current invocation, including Node calls,
direct typed calls and Node-conversion fallback calls. Conversion itself is not
a rule call. Reusing a memoized whole call reports that call with Memo=true
and Evals=0; nested calls from earlier parses are not replayed. Generated
Documents do not resume repetition bodies, so there is no ReusedElements
counter.
Exit events of Document parses report ExaminedKnown=true, with Examined
set to the frontier the Document keeps for edit reuse. A call that records a
memo result reports the frontier stored with it, and a memo hit reports the
stored frontier it reuses, but at least one position past its start. Each wrapper starts the call's frontier at its
position and merges it back into the caller's afterwards, so the caller's
earlier lookahead is excluded and the Document's own frontier is unchanged.
The frontier is conservative rather than the engine's exact examined range:
calls that record or reuse memo results count at least one position past their
start and end, which keeps edit reuse safe without tracking every read for
validity. Reporting this frontier makes the trace explain reuse decisions
exactly; an exact range would need a second high-water mark and an extra field
per memo entry. A recursive call that reuses a growing left-recursive seed sees
the frontier stored with an earlier seed, so any call completing in such a
provisional context reports at least its end; the raise affects only the
report, not the Document's frontier. Values can be one position past the end
of the input.
The examined frontier was measured against the previous observed source on the
same generated Document fixture with 4,096 records: node and typed drivers,
both units, batch, cold, warm and edit parses, each traced and profiled, in
eight alternating rounds of 400 iterations. Median time ratios were
0.974–1.017, except for the profiled warm byte-unit node parse, a 0.4 µs memo
hit whose ratio was 1.069 (0.97–1.48). Allocations per parse were unchanged.
An earlier version that kept unknown rules in a map measured up to 1.040 for
profiled batch parses; a flag on each rule row removed that lookup. Without
observability the same hooks are no-ops behind a nil observation. Profiles sum Examined - Pos per
rule over completed calls into Examined, and over completed failed non-memo
calls into Wasted; a rule's values are known only when every completed call
in the profile was a Document call.
After a completed parse, the invocation's increases in profile Evals and
MemoHits match Document.Stats().Evaluated and .Reused, respectively.
Stats has no call counter. Interrupted
parses can leave statistics for calls that began but did not complete. Temporary
observer and profile state is released after return or panic; constructor
defaults remain for later calls. While a Document parses, its Parse,
ParseAST and Edit return the generated ErrDocumentBusy without changing
it, so a callback cannot re-enter the same Document; callers can schedule later
work or use another Document. Every generated Document has the guard, but only
observability callbacks, the documented way to run caller code during a parse,
can trigger it, so only sources generated with observability export
ErrDocumentBusy. A context.Context passed with
WithContext also runs caller code, but re-entry from it is not detected. The
check precedes option validation, so a re-entrant call with invalid options
still reports ErrDocumentBusy.
The guard was measured against the same observed source without it, on the generated Document benchmark fixture: 4,096 records, node and typed drivers, both units, cold, warm and edit parses, with and without a trace, in ten alternating rounds of 2,000 iterations. Median time ratios were 0.986–1.036; the largest ratios came from the sub-microsecond warm byte-unit parse, whose rounds ranged 0.73–1.49. Bytes and allocations per parse were unchanged.
On the same fixture, observed source without a trace or profile was compared with ordinary generated source. Median time ratios were 1.000–1.044, except for the warm byte-unit parse: it is a 0.2 µs memo hit, and its ratio was 1.18 (0.83–1.28). Warm parses allocate 48 more bytes per call; cold and edit parses allocate within a few bytes of the ordinary source. Sources generated without observability share the runtime: each observed call path is a separate method selected by a nil check of the parse's observation, and the other hooks return at once when it is nil.
Generated Document race tests with Go Profile behavior, regeneration coverage, full root, nested-module and site validation pass. The broader design remains proposed.
TypeScript Batch Profiling
With generation-time observability enabled, TypeScript batch parsers export
Profile and ProfileSnapshot, and add profile?: Profile | null to
ParseOptions. Pass a Profile to parseWithOptions, parseRuleWithOptions,
recognizeWithOptions, or, with -types, parseASTWithOptions. Legacy
positional-unit APIs do not accept a profile. Each invocation keeps private
accounting state; the sink aggregates completed calls across invocations and
retains no input, callback, parser history or per-call frames. A profile is not
safe for concurrent use. snapshot() returns independent rule rows sorted by
name, skip reasons sorted by reason, and an owned maxEvalsAt location for
deterministic JSON serialization.
Snapshot fields use camel case and correspond to the Go profile counters.
entered includes calls that began; completed-call counters and timings omit
unfinished calls. examinedKnown and wastedKnown are false because these
ranges are unavailable. time and selfTime are milliseconds from the host's
monotonic performance.now() clock; valid clocks with coarse resolution may
produce zero durations. Root time excludes input setup,
whole-input end checks and typed-result conversion; per-rule time counts
recursive calls of that rule once per invocation, and selfTime excludes child
time. Both include synchronous tracing callbacks. When the exit-time clock
read succeeds, completed-call counters are published after an exit callback
returns or throws. A snapshot made inside that callback may omit the current
exit until it unwinds. Callback exceptions retain their original thrown value
after cleanup; an enter callback exception does not synthesize an exit.
Profiling requires the host's performance.now(). If it is unavailable,
enabling a profile returns an Error; there is no Date.now() fallback.
Clock access or invocation failures preserve the thrown value. Non-finite or
backward clock readings fail explicitly and can leave a partial profile. If a
trace callback and a later clock read both fail, the callback's original
exception takes precedence and that exit may be omitted from the profile.
The implementation uses no DOM types. Profile timing has been measured under
Node 24.19; JavaScript allocations, retained
heap, browser timing and other hosts remain unmeasured.
Callback Failure, Cancellation and Lifecycle
Batch callbacks may reenter the same parser; they must not access or mutate parser internals. Document callbacks MUST NOT re-enter the same Document. Go callback panics and TypeScript callback exceptions propagate with their original value after runtime cleanup; they must not be converted into syntax errors, invalid bytecode, or stack-overflow errors. This design does not add an error-returning callback, cancellation token, or asynchronous parse. A callback can deliberately abort by panicking/throwing. Event sequences can end without exit events when an action, depth limit, callback, or other runtime failure aborts parsing; this must be documented just as in design 014.
Each parse owns trace stack, counters, event filter and clock state. Generated shared rule tables remain immutable. Pooled Go parser state, if used, must clear the observer and all profile state before reuse, including after panic; observer state must never leak between invocations. TypeScript state belongs to its parser instance and is released on return or throw.
Compatibility and Implementation Sequence
Observability types are emitted only with the generation option. Existing generated source and no-option APIs retain their source behavior; observability-enabled output is a distinct generated source form, with no .pegoc change. Tracing covers Go Node, typed Go, and Document calls, including recognition and typed direct/conversion paths, plus TypeScript Node and typed-result calls. Profiling covers Go Node, typed batch, and Document calls, plus TypeScript Node and typed-result batch calls. Go and TypeScript generated parsers keep their current position-unit defaults and result/error contracts.
- Implemented: generation-time opt-in and event sinks for standalone Go Node and typed Go batch parsing, covering actual rule calls, memo results, typed direct/conversion paths, recognition and representable first-character skips.
- Implemented: TypeScript batch events and typed-result tracing preserve exception identity and per-invocation isolation.
- Implemented for generated Go Node and typed batch parsers; implemented for TypeScript batch parsers: aggregate per-rule profiles over completed calls, with owned sorted snapshots. Focused synthetic Go and TypeScript profile costs are measured below; broader workload costs remain unmeasured.
- Resolve whether the runtime detects and rejects same-Document callback re-entry; callers must not re-enter a Document from its callback and can use another Document or schedule later work.
Alternatives Considered
- Add a nil-checked observer to every generated parser by default. It gives one API regardless of generation options, but adds state and branches to all generated parsers. No measurement establishes that this overhead is acceptable; defer until it is measured.
- Instrument only generic
call. This misses inlined typed/direct rules and first-character skips, so call/evaluation counts would be incomplete. Instrument generated call sites and runtime paths together. - Disable dispatch and direct-call optimizations whenever tracing is enabled. This can produce a simpler stream but changes the executed strategy and hides the performance shape users need to inspect. Keep the generated strategy and report skips explicitly.
- Emit a normal failed call for a first-character dispatch skip. No call body ran and no memo result exists. Use a distinct unpaired
TraceSkippedevent. - Promise the engine's complete event stream. Generated optimizations omit candidate calls, while engine backends already differ in such calls. Document generated-runtime observations and keep engine trace equivalence separate.
- Generation-time opt-in. This adds a generation flag and source variant, but lets ordinary generated code omit tracing machinery. Recommend it until default-path measurements support another choice.
Testing
Generate fixtures for Go node, typed direct, typed conversion, recognition, TypeScript node and TypeScript typed output. For each, compare parsing values, syntax errors, recovered errors, positions and action behavior with instrumentation disabled and enabled in both position units. Assert exact small event streams for ordinary calls, memo hits, deferred memoization, left-recursion growth, failed calls, lookahead, recognition, typed dispatch, filtered nesting and each supported skipped-call reason.
Verify every emitted enter has one exit on success; verify abort cases may end with open calls and never emit fabricated exits. Test profile counts against event-derived counts where available, deterministic serialization, unknown filter names, depth boundaries, unavailable metrics and callback timing. Test callback panic/throw identity, panic after nested calls, parse-pool reuse after panic, callback reentrancy, concurrent Go parses sharing one parser, TypeScript reentrant calls, and that no observer leaks to a later no-trace parse. Test generation with and without the option, stable output, Go compilation/standalone module use and strict TypeScript checking. The Go Node, typed and Document slices have focused fixture, ordinary-reference consumer and race coverage for tracing and profiling across direct and Node-conversion routes. The TypeScript slice has focused Node and typed-result fixtures and ordinary-output parity checks for tracing and profiling. The same-Document callback re-entry enforcement choice remains unresolved.
Performance and Results
Node Batch Measurements
The Go Node focused measurements use Go 1.27.1 on an Apple M3 Max (darwin/arm64), GOMAXPROCS=1, GOGC=100, and no GOMEMLIMIT, with three alternating 200 ms samples. For JSON (64 objects), XML (64 items), and calculator (32 repetitions), generation with observability disabled produced source byte-identical to baseline commit 64e4b93. Runtime ratios below compare ordinary and observed packages generated by the same candidate generator; each row reports its position unit.
| Fixture | Parse, no callback | Recognize, no callback | Parse, full callback | Parse, root-depth filter | Parse, unknown-name filter |
|---|---|---|---|---|---|
| JSON, CodePoints | 1.055× | 1.200× | 2.286× | 1.624× | 1.805× |
| JSON, Bytes | 1.046× | 1.130× | 2.207× | 1.560× | 1.751× |
| XML, CodePoints | 1.037× | 1.088× | 2.168× | 1.570× | 1.680× |
| XML, Bytes | 1.038× | 1.057× | 2.110× | 1.537× | 1.659× |
| Calculator, CodePoints | 1.016× | 1.018× | 1.535× | 1.266× | 1.305× |
| Calculator, Bytes | 1.009× | 1.016× | 1.504× | 1.257× | 1.302× |
The no-callback cases kept allocation counts unchanged and added 48 B/op. Full and root-filter callbacks added 4–6 allocations and 896–4,017 B/op; the unknown-name filter added two allocations and 256 B/op beyond full tracing while delivering no events. These short synthetic fixtures do not represent logging callbacks or establish workload-wide costs.
Typed Batch Measurements
Typed Go tracing was measured separately against ordinary typed packages from
the same generator, with direct typed execution and forced Node-conversion
fallback across ParseAST and ParseRuleAST. Three alternating 200 ms pairs
used the same synthetic JSON (64 objects), XML (64 items) and calculator
(32 repetitions) inputs in both position units. The table gives
observed-to-ordinary median ratio ranges. No-callback ranges span
ParseAST/ParseRuleAST and both units; callback ranges cover ParseAST in
both units. Individual paired ratios were noisy (0.934–1.171 across
no-callback profiles), so these data do not support a significance claim.
| Fixture / path | No callback | Root-only callback | Full scalar-store callback |
|---|---|---|---|
| Calculator / direct | 1.001–1.008× | 1.316–1.336× | 1.617–1.674× |
| Calculator / conversion | 0.995–1.008× | 1.169–1.179× | 1.349–1.352× |
| JSON / direct | 1.052–1.075× | 1.964–2.117× | 2.958–3.164× |
| JSON / conversion | 1.030–1.042× | 1.389–1.430× | 1.825–1.886× |
| XML / direct | 1.021–1.062× | 1.729–1.791× | 2.418–2.601× |
| XML / conversion | 1.014–1.040× | 1.395–1.438× | 1.818–1.835× |
No-callback cases added about 48 B/op with allocation counts unchanged.
Callbacks stored scalar fields rather than logging. Relative to ordinary typed
parsing, they added about 944 B and 4 allocations for calculator, 4,016 B and
6 allocations for JSON, and 4,016–4,017 B and 6 allocations for XML. Ordinary
generated output remained byte-identical to baseline bc0e13a; opt-in source
grew 5.02%/4.39% for direct/conversion calculator, 9.14%/7.12% for JSON, and
10.04%/8.00% for XML. Broad workload, binary-size, retained-heap and generation
costs were not measured for this typed extension. The source is
internal/engine/gogen_typed_observe_bench_test.go; exported temporary fixtures
and raw measurement logs are not repository artifacts.
Generated Go Profile Measurements
Profile costs were measured with Go 1.27.1 on an Apple M3 Max (darwin/arm64),
GOMAXPROCS=16 and the default garbage collector, using three serial
alternating 150 ms pairs on the same JSON (64 objects), XML (64 items) and
calculator (32 repetitions) fixtures in both position units. Node sources
timed Parse and Recognize; typed sources timed Parse, Recognize and
ParseAST. Typed Node-conversion fallback was tested but not timed. Ordinary
output from all six Node/typed fixtures remained byte-identical to baseline
6592f45.
Without WithProfile, the current observed generator compared with the prior
trace-only generator had per-profile paired median time ratios of 0.961–1.038
for Node and 0.980–1.032 for typed parsers. Paired ranges were 0.941–1.113 and
0.906–1.077, respectively; these noisy samples do not support a significance
claim. Allocation counts were unchanged, with paired B/op differences from
−40 to +44.
For a separate trace-only comparison, a full scalar-store WithTrace callback
was measured on Node Parse against the prior trace-enabled generator. Paired
median time ratios were 1.026–1.114 for Node-generated packages and
1.011–1.160 for typed-generated packages using their Node Parse entry point;
individual-pair ranges were 1.022–1.160 and 0.969–1.161. Allocation counts
were unchanged and paired B/op increased by 9–25. These are six focused
fixtures/routes in both units, not a statistical or workload-wide claim, and
they do not measure typed ParseAST tracing.
With WithProfile, the table compares the profiled parser against the current
ordinary package (no WithTrace callback). This measures the opt-in profile
path, not the cost of an individual counter or clock operation.
| API | Profiled / ordinary time ratio |
|---|---|
Node Parse |
3.879–9.731× |
Node Recognize |
5.302–26.860× |
Typed Parse |
3.821–10.212× |
Typed Recognize |
5.397–25.639× |
Typed ParseAST |
4.117–14.432× |
Across all profiled paths and fixtures, the measured additions ranged from
44,990 to 395,085 B/op and 25 to 50 allocations/op; these ranges are not
per-API estimates. Each benchmark reused one Profile per case and excluded
Snapshot. Generated source grew versus the prior trace-enabled generator by
0.870–4.530% for Node and 0.545–3.018% for typed output, depending on fixture.
Binary size, retained heap and generation time were not measured. The benchmark
source is internal/engine/gogen_profile_bench_test.go; exported fixtures and
raw results are not repository artifacts.
To reproduce the candidate measurements, export the fixtures and run each ordinary/observed package in a separate process, alternating their order for each fixture and unit:
PEGO_PROFILE_BENCH_DIR=/tmp/pego-profile-bench go test ./internal/engine -run '^TestExportGeneratedGoProfileBenchmarks$' -count=1
cd /tmp/pego-profile-bench/json/node/ordinary || exit 1
go test -run '^$' -bench '^(Benchmark(Parse|ParseAST|Recognize|TraceParse)|BenchmarkProfile(Parse|Recognize|ParseAST))$' -benchmem -benchtime=150ms
# Repeat from json/node/observed and the typed routes, then alternate order for xml and calc.
The comparison with the prior trace-enabled generator used the same exporter
with gen.go, gen_observe.go, gen_types.go and runtime/trace.go under
internal/engine/ overlaid from 6592f45, and PEGO_PROFILE_BASE=1 to omit
profile-specific benchmark references.
Node Generation Cost
Node generation cost was measured on already-parsed grammar ASTs; it includes compile, generate and format work but excludes grammar-source parsing. Three alternating 200 ms samples gave median observed/ordinary time ratios of 1.081× (JSON), 1.038× (XML) and 1.086× (calculator). Generated source sizes grew from 257,998 to 279,914 B (JSON), 698,882 to 763,573 B (XML), and 139,068 to 146,639 B (calculator). This is a focused candidate comparison, not a workload-wide performance estimate. Export the generated runtime fixtures, then run each package benchmark in separate processes, alternating ordinary and observed order across repeated samples:
PEGO_TRACE_BENCH_DIR=/tmp/pego-trace-bench go test ./internal/engine -run '^TestExportGeneratedTraceBenchmarks$' -count=1
cd /tmp/pego-trace-bench/json/ordinary || exit 1
go test -run '^$' -bench '^(BenchmarkParse|BenchmarkRecognize|BenchmarkTrace)$' -benchmem -benchtime=200ms
# Repeat from json/observed, then alternate order for xml and calc.
From the repository root, measure generation separately. For paired samples, filter the ordinary and observed subbenchmarks into separate processes and alternate their order:
go test ./internal/engine -run '^$' -bench '^BenchmarkGeneratedObservabilityGeneration$' -benchmem -benchtime=200ms -count=3
The benchmark source is internal/engine/gogen_observe_bench_test.go; the
generated fixtures and raw results are not repository artifacts.
TypeScript Batch Measurements
The TypeScript measurements use Node.js 24.19.0 on an Apple M3 Max
(darwin/arm64), with explicit garbage collection after warmup. Each profile used
three alternating 200 ms pairs and a 100 ms warmup. Inputs were JSON with 64
objects, XML with 64 items and a calculator expression with 32 repetitions.
All timing profiles used modules generated with -types and -recognize,
with string input in both position units. Ratios compare enabled and ordinary
packages from the same generator and workload. Source-size and default-source
comparisons additionally include Node-only modules. No-callback option-API ranges include parseWithOptions,
parseRuleWithOptions, parseASTWithOptions and recognizeWithOptions; the
legacy ranges include positional-unit parse, parseAST and recognize.
Callback ranges cover parseWithOptions and parseASTWithOptions only.
| Fixture | Options APIs, no callback | Legacy positional APIs, no callback | Full scalar-store callback | Root-only callback | Unknown-name filter |
|---|---|---|---|---|---|
| Calculator | 0.991–1.018× | 1.006–1.030× | 1.093–1.128× | 1.052–1.114× | 1.068–1.136× |
| JSON | 1.006–1.044× | 1.006–1.023× | 1.183–1.257× | 1.158–1.196× | 1.186–1.288× |
| XML | 0.996–1.018× | 1.000–1.011× | 1.139–1.187× | 1.095–1.156× | 1.141–1.188× |
These are ranges across per-profile median enabled/ordinary ratios, not a
workload-wide estimate. The paired no-callback ratios spanned 0.906–1.358,
showing substantial noise; the measurements do not establish a significant
change. Callback bodies stored scalar event fields and did not log. The six
ordinary generated Node/typed sources were byte-identical to baseline
005ae81 with observability disabled. Generated source grew by 5.286%/5.029% for
calculator Node/typed, 7.455%/7.147% for JSON and 8.541%/8.187% for XML.
Allocations, retained memory, binary size and generation time were not measured
for this TypeScript slice. Benchmark sources are
internal/engine/tsgen_observe_bench_test.go and the generated
bench.mjs; raw results and generated fixtures are not repository artifacts.
Export the controls, then alternate ordinary and observed processes across
three pairs for each fixture:
PEGO_TS_TRACE_BENCH_DIR=/tmp/pego-ts-trace go test ./internal/engine -run '^TestExportGeneratedTSTraceBenchmarks$' -count=1
cd /tmp/pego-ts-trace/json/typed/ordinary || exit 1
node --expose-gc bench.mjs
# Repeat from json/typed/observed, then alternate order for xml and calc.
Each process warms every profile for 100 ms and measures it for 200 ms by
default; PEGO_TRACE_WARM_MS and PEGO_TRACE_BENCH_MS override those durations.
TypeScript Profile Measurements
Profile timing used Node 24.19.0 on an Apple M3 Max (darwin/arm64), three
serial alternating 150 ms pairs, 75 ms warmup and explicit garbage collection
after warmup. Inputs were JSON with 64 éclair records, XML with 64 items and
a calculator expression with 32 repetitions; each fixture used both position
units. Ratios compare profile mode with the separately generated ordinary
package for the same grammar and workload. The table reports ranges across
fixture/route medians, not a workload-wide estimate.
| API | Profile / ordinary time ratio |
|---|---|
Node Parse |
1.623–2.718× |
Node Recognize |
1.872–4.016× |
Typed-generated Node Parse |
1.742–3.193× |
Typed Recognize |
1.866–4.021× |
Typed ParseAST |
1.581–2.760× |
Individual paired ratios ranged from 1.492–4.313×. One Profile was reused
per case and snapshot() was outside the timed region. The runs do not measure
JavaScript allocations, retained heap, binary size, browser timing or Deno/Bun
behavior. No-profile timing was near parity with the parent observed generator
at 871e5f7: medians were 0.965–1.020× for no-trace calls and 0.973–1.091× for trace-only
calls. Paired ranges were wide and do not support a significance claim. All
six ordinary generated sources were byte-identical to parent 871e5f7. Source
size increased by 1.641–5.414% for Node and 1.577–5.159% for typed packages
versus the parent observed generator at 871e5f7.
Export the ordinary and observed packages and profile driver, then run each package in a separate Node process, alternating ordinary and observed order for three pairs:
PEGO_TS_TRACE_BENCH_DIR=/tmp/pego-ts-profile go test ./internal/engine -run '^TestExportGeneratedTSTraceBenchmarks$' -count=1
cd /tmp/pego-ts-profile/json/node/ordinary || exit 1
node --expose-gc profile-bench.mjs
cd /tmp/pego-ts-profile/json/node/observed || exit 1
PEGO_PROFILE_MODES=none,trace,profile node --expose-gc profile-bench.mjs
Repeat for typed JSON, XML and calculator fixtures. The driver defaults to
75 ms warmup and 150 ms timing; PEGO_PROFILE_WARM_MS and
PEGO_PROFILE_BENCH_MS override these durations. The exporter is
TestExportGeneratedTSTraceBenchmarks; the benchmark source is committed in internal/engine/tsgen_profile_bench_test.go. Exported
sources and raw results are not repository artifacts.
Limitations and Open Questions
- Exact
Examinedand failed-work metrics may not be available on every generated direct path. The event must expose only what the implementation can account for, with explicit availability metadata or omission. - TypeScript profiling requires
performance.now()and has no wall-clock fallback. Timing has been measured under Node 24.19; availability and behavior across other supported Node/browser targets still need validation. - Instrumentation availability: Go and TypeScript use generation-time opt-in; always-emitted, nil-disabled instrumentation would require separate code-size and parse-time evidence.
- Proposed skip visibility: use explicit skip events only for call sites where code generation can identify the exact target and dispatch reason. A complete synthetic-call stream is an alternative, but would redefine call counts and risk presenting events as executed calls.
- Cross-engine trace differences and
Documentresume/profile omissions remain independent limitations; this record does not resolve them.