014. Tracing and Profiling
- Status: Implemented
- Author: @ornew
- Date: 2026-10-08
Summary
A parse can report every rule call to a function (pego.WithTrace): the rule, its position and binding level, the
nesting depth, and at the end of the call its outcome, end position, whether the memo answered it, how many times it
evaluated the body, how far it examined the input, and the farthest failure it recorded. A per-rule profile
(pego.Profile, pego.WithProfile) is built on the same events. The pego command gains trace, profile and
explain. Tracing works on the closure backend and both bytecode VMs, in recognition mode, in stream parses and in
Document; generated Go parsers do not have it. When tracing is off, an ordinary parse pays one nil check per call
that goes through the general call path, and nothing on the plain call path. The user guide is
docs/guide/debugging.md.
Motivation
Grammar authors could see only the result of a parse: a tree or a syntax error listing what was expected at the
farthest failure. Why that list contains what it does, which alternatives a choice tried, and why a grammar is slow
could only be found by editing the grammar and parsing again, or by reading Go CPU profiles in which every rule is the
same closure or the same VM loop. The engine already counts evaluations and memo hits per parse (Stats), but not per
rule, and nothing tells where in the input the work happens.
The requirements were:
- Report rule calls with their positions, binding levels, outcomes and memo use, and optionally what failed calls expected, for people (an indented call tree) and tools (JSON).
- A per-rule profile with counts, consumed and wasted input and time, sorted by cost, with hints.
- No measurable cost when tracing is off, so ordinary parses stay as fast: at most one branch per rule call, none per expression.
- Identical results with tracing on and off, on every supported backend.
Design
Where calls are observed
Every rule call of the engine goes through one of two paths:
parser.call, used by all backends (the closure backend's rule references, the recursive VM'sCALL, the start rule, streams and documents), which decides between an unmemoized call, a memo lookup, and left-recursion growth; the iterative VM performs the same steps in itsbodyFrame, pushing acallFrameor aplainCallFrame.- The plain call path: calls of plain rules (never memoized outside
Document, no captures) bypasscalland runinvokePlain(orplainCallFrame) directly. This path was added for speed (performance.md, changes 19, 38, 42, 43) and is the hottest in the engine.
Tracing is attached to the first path only. The call sites of the plain path test !p.noPlain where they tested
!p.memoAll; noPlain is set by Document (as memoAll is) and by tracing. A traced parse therefore sends every call
through call, where plain rules take the same steps they take on the plain path (call falls back to invokePlain
for them), and an untraced parse runs exactly the instructions it ran before.
call begins with if p.tr != nil. In a traced parse it hands the call to tracedCall, which reports the start,
sets tr.direct and calls call again; call sees direct, clears it and proceeds as usual, so the call logic is not
duplicated and calls nested in the body are traced in turn. In the iterative VM, bodyFrame tests p.tr after the
plain-call test and pushes a traceFrame, which reports the start, pushes the frame that bodyFrame would have pushed
(callChild), and reports the end when that frame returns.
Calls are reported before the memo is consulted, so a memo hit is a call like any other, with Memo set in its exit
event.
What an exit event knows
- Body evaluations (
Evals) come from the parse's evaluation counter (Stats.Evaluated, incremented once per body evaluation byinvokeBegin,invokePlainandplainCallFrame): the difference across the call, minus the difference across the calls nested in it. A memo hit is a call with no evaluation at all, soMemoneeds no hook in the memo code. A left-recursion leader evaluates its body once per growth step; the recursive calls in its body are memo hits that return the seed. - Examined input (
Examined): the parser already tracks the end of the input examined by the current memoized call (hw, kept forDocument), as a maximum that every character test raises. A traced call saves it, sets it to its start, reads it at the end and restores the maximum of both, exactly ascallBeginandcallEnddo for memoized calls. Since only the maximum is ever used, this leaves every memo entry's examined range unchanged. - Expectations (
Failure): likewise, a traced call records the expectations made during it separately (isolate) and merges them back (mergeExpected) when it ends, as memoized calls already do. The merge replays the recorded expectations into the caller's record, which keeps only those at the farthest position, so the final error is the same. The event hands the separate record toFailure, which formats it like a syntax error only when called.
Because the events expose the parser (LineCol, Text and Failure read it), they are valid for those methods only
during the trace function; the fields can be kept.
Profile
Profile.Trace is an ordinary trace function. It keeps a stack of calls with their start times and the time of their
nested calls, and per rule name (so that a value-free twin counts as its rule) it adds up:
- calls, memo hits, matches, failures and body evaluations;
- consumed: the input matched by matching calls;
- wasted: for each call that evaluated its body and failed, the input from its position to the end of what it examined. This is precise for what it counts: input the rule examined in a call whose result was a failure. It does not count successful calls whose result a caller later discarded (a choice that backtracks out of an alternative after some of its calls matched), because a call boundary cannot tell whether its caller will keep its result; the re-evaluation that such backtracking causes shows instead as repeats;
- repeats: calls that evaluated the body at a (position, binding level) where the rule had been evaluated before in the same parse, and the most such evaluations at one position. Since memoization is deferred to the second call at a position (performance.md, change 18), a memoized rule has at most one repeat per position; more points at a rule that is not memoized and whose callers are retried. The positions evaluated are kept in a map, from which those that a stream parse has discarded (and so can never return to) are dropped whenever it doubles, so profiling a stream keeps its memory bounded;
- time: inclusive time, counted only for the outermost call of a rule in progress (so that recursion does not count the same time twice), and self time, the inclusive time minus that of the nested calls.
A new parse begins with an enter event at depth 1, which resets the per-parse state (the call stack and the
positions evaluated), so one Profile collects any number of parses, including those of a Document.
Hints reports the rules with the most self time, rules evaluated more than twice at one position, failed calls that
examined at least 16 positions, and rules whose failed calls examined more input in all than the parse did. They are
deliberately few and name what to look at.
pego explain
explain parses once without tracing to get the syntax errors, then parses again with a trace that watches only the
errors' positions. When a call ends with a failure at such a position, the expectations it recorded itself are those
of its Failure that no nested call recorded at that position (each call passes everything it recorded on to its
caller). Calls with expectations of their own are listed with the stack of calls they were nested in.
A #recover that recovers takes the expectations of the expression it recovered from out of the record of the call
that contains it, so they are not in that call's Failure. The exit event gives them as Recovered, the errors
recovered during the call; the first call to report an error (the innermost, as calls end innermost first) is the one
whose body holds the #recover, and its own expectations are again those no nested call recorded. What remains
unattributed is the end of the input that the parser expects after the start rule returns, which the output states
separately.
Command-line output
pego trace prints a call whose nested calls are not shown on one line (rule line:col -> result) and other calls as
an enter line and an exit line around their nested calls. It buffers only the last enter event, so the output streams.
-rule and -max-depth select calls by rule and by depth relative to the top call shown; -f json prints one object
per event. pego profile prints the table with text/tabwriter and the hints; -f json prints the same data.
Cost
What an ordinary parse executes in addition, exactly:
| Place | Before | After |
|---|---|---|
parser.call (closure, recursive VM, start rule, streams, documents) |
one p.tr != nil test at entry |
|
Plain call sites (closure rule reference, recursive VM CALL, iterative VM bodyFrame) |
test p.memoAll |
test p.noPlain (same cost) |
Iterative VM bodyFrame, non-plain calls |
one p.tr != nil test |
|
parser struct |
one pointer (tr) and one bool (noPlain) |
Nothing changes in invoke, invokePlain, callBegin, callEnd, the memo, or the code of expressions. The
generated parsers' runtimes (genrt) are untouched.
With tracing on, every call also goes through tracedCall, isolates its expectations and examined range, and calls the
trace function; plain calls lose their shortcut. The trace function itself (formatting, or the profile's map of
positions and clock reads) dominates.
Testing
check, which nearly every engine test uses, now also parses each input with a trace on every backend, for the grammar and its unmemoized variant, and compares the results (trees, positions, errors) with untraced parses. It also checks that the events nest, that each exit matches its enter, and thatMemoandEvalsagree. Each parse is also repeated through a copy ofParseWiththat exposes the parser (parseWork), and the work it did must be the same traced and untraced:Stats(evaluations and memo reuses), the number of memo entries, the first calls recorded and the rules memoized eagerly, that is, the memoization decisions.TestTraceKeepsResultsdoes the same for the backend corpus (genCorpus: the example grammars with their test inputs, the typed-runtime cases and others), in both position units and in recognition mode. For each input it also parses aDocument, traced and untraced, before and after three edits, and compares the results andDocument.Stats.TestTraceEventschecks the exact event sequences of small grammars on every backend: memo hits, left recursion, Pratt levels, lookaheads and#recover. Stream andDocumentparses are checked against their untraced results, and the evaluations and memo hits the events report againstDocument.Stats.- Profile tests check the counts, repeats, wasted input and hints on grammars where they are known; the CLI tests check the commands' output.
Removing the merge of a traced call's expectations into its caller (one line of traceExit) makes
TestTraceKeepsResults fail on 516 parses, so the equivalence tests do detect a tracer that disturbs the parse.
Likewise, a tracer that memoized every rule (setting memoAll) fails the comparison of the work on 1,338 parses, and
one that gave a Document parse a fresh memo fails the document comparison 573 times.
Alternatives considered
- Hooks in
invokeandinvokePlain. They see every body evaluation, but they are on the plain path, which would then pay a branch per call, and they do not see memo hits. - An instrumented copy of the program compiled when tracing is requested. It would make the untraced path free of even the nil check, but it doubles compilation work and memory for a debugging feature, and the bytecode programs are shared and lazily built, so every backend would need a traced twin.
- Wrapping rule bodies (
rule.body). Bodies belong to the compiled program, which is shared by concurrent parses, so a per-parse hook cannot live there; they also do not see memo hits. - Tracing expressions. Reporting every choice alternative and repetition would show backtracking inside a rule, but would put a branch on every expression; rule calls are the unit grammar authors reason in.
- Go CPU profiles with labels per rule.
pproflabels are per goroutine and costly to switch, and they cannot attribute memo hits, repeats or wasted input.
Limitations
- Generated Go parsers have no tracing.
- The backends skip different calls by first-character dispatch, so their traces can differ in calls that fail at once; results and memoization decisions do not.
wastedcounts failed calls only; work thrown away by a backtracking choice in a successful call shows as repeats.- Profile times include the cost of tracing, which is large relative to small rules: they are meaningful relative to each other.
- The steps of a Pratt expression (operands, operators, the binding-power loop) are part of its rule's body; only calls of rules from them are reported.
- An aborted parse (a runtime error in an action, an error from a stream's emit function or reader, the nesting limit, a panic in the trace function) ends the trace without the exit events of the calls in progress. A panic in the trace function is carried through the parse's recovery as an internal value and raised again unchanged, so that a runtime error there is not reported as invalid bytecode on the bytecode backends.
- A
Profilemust not be shared by concurrent parses. - In a stream parse,
LineColknows the lines of the input still held only; for other positions it gives 0, 0 (and a profileLocationprints asposition N). Keeping the line starts of discarded input would make a stream's memory grow with its length.