Debugging and Profiling Grammars
This guide is about finding out what a grammar does with an input: which rules it calls where, why an input is
rejected, and where the time of a parse goes. It covers three commands, pego explain, pego trace and
pego profile, and the Go API behind them (pego.WithTrace, pego.WithProfile).
Contents:
- The example grammar
- Why does an input fail?
pego explain - What does the parser do?
pego trace - Why is a parse slow?
pego profile - Reading the profile
- From Go
- Costs and limits
The examples use the pego command; build it with go build -o pego ./cmd/pego. Every command below was run as shown,
and its output is copied from the terminal (standard output and standard error together). Times in profiles will
differ on your machine.
The Example Grammar
A small configuration language with assignments, updates and calls:
// config.pego
@start
rule main = ws (stmt ws)* $$
rule stmt = assign / update / call
rule assign = ident ws "=" ws value ";"
rule update = ident ws "+=" ws value ";"
rule call = ident ws "(" ws args? ws ")" ";"
rule args = value (ws "," ws value)*
rule value = [0-9]+ #atomic / ident
rule ident = ([a-z_]+ ("." [a-z_]+)*) #atomic
rule ws = [ \t\n]*
Two inputs, the second with a missing comma:
cat small.txt
server.port = 8080;
log.rotate(7, size);
cat bad.txt
server.port = 8080;
log.rotate(7 size);
Why Does an Input Fail?
A PEG parser backtracks, so a syntax error does not come from one place: it lists everything that was expected at the
farthest position any alternative reached (see error messages).
When the list is long or surprising, pego explain tells you which rule calls contributed each item, and from where
they were called:
pego explain -g config.pego < bad.txt
2:14: syntax error: expected ")", ",", [ \t\n]
Calls that recorded what was expected at 2:14 (innermost first):
ws 2:13: matched " ", then expected [ \t\n]
in args 2:12
in call 2:1
in stmt 2:1
in main 1:1
args 2:12: matched "7", then expected ","
in call 2:1
in stmt 2:1
in main 1:1
ws 2:13: matched " ", then expected [ \t\n]
in call 2:1
in stmt 2:1
in main 1:1
call 2:1: expected ")"
in stmt 2:1
in main 1:1
Read it from the bottom of each entry: main called stmt, which tried call at 2:1; inside it, args matched "7"
and then wanted a , (the separator of its repetition), the ws calls would have taken more white space, and call
itself wanted the closing ). Each entry shows only what that call recorded itself; what nested calls recorded is
shown under them. So the input is a call whose argument list stopped after 7, which is the missing comma.
Some things to know:
- An entry says
matched ..., then expectedwhen the call succeeded but tried to go further; repetitions and optional parts always do. Such items are why error messages mention white space: here[ \t\n]comes fromws. Writingwsas(&[ \t\n] .)*keeps it out of the lists (see writing readable messages). [memo]marks a call answered from the memo: its expectations were recorded when the rule was first evaluated at that position, perhaps for another caller.- Calls inside a lookahead (
&,!) record no expectations, so they never appear. - If the start rule has no
$$, the parser itself expects the end of the input after the start rule returns. When that is part of the error, the last line says so:The start rule matched up to L:C, but the input does not end there. - Errors recovered with
#recoverare explained one by one, each marked(recovered). The expectations of the expression a#recoverrecovered from are listed under the call that contains it, marked(recovered by #recover). -nlimits the number of entries per error (default 10).
What Does the Parser Do?
pego trace prints every rule call of a parse as an indented tree. A call whose nested calls are shown takes two
lines, its start (rule line:col) and its end (rule -> result); a call without shown nested calls takes one:
pego trace -g config.pego < small.txt
main 1:1
ws 1:1 -> matched 1:1-1:1 ""
stmt 1:1
assign 1:1
ident 1:1 -> matched 1:1-1:12 "server.port"
ws 1:12 -> matched 1:12-1:13 " "
ws 1:14 -> matched 1:14-1:15 " "
value 1:15 -> matched 1:15-1:19 "8080"
assign -> matched 1:1-1:20 "server.port = 8080;"
stmt -> matched 1:1-1:20 "server.port = 8080;"
ws 1:20 -> matched 1:20-2:1 "\n"
stmt 2:1
assign 2:1
ident 2:1 -> matched 2:1-2:11 "log.rotate"
ws 2:11 -> matched 2:11-2:11 ""
assign -> failed
update 2:1
ident 2:1 -> matched 2:1-2:11 "log.rotate"
ws 2:11 -> matched 2:11-2:11 ""
update -> failed
call 2:1
ident 2:1 -> matched 2:1-2:11 "log.rotate"
ws 2:11 -> matched 2:11-2:11 ""
ws 2:12 -> matched 2:12-2:12 ""
args 2:12
value 2:12 -> matched 2:12-2:13 "7"
...
Ranges are start-end with the end exclusive, in lines and columns of the position unit (-unit). Already this short
trace shows a cost: on the second line ident reads log.rotate three times, once per alternative of stmt.
Why Is a Parse Slow? comes back to it.
Other marks on a result:
-
[memo]: the result came from the memo, without evaluating the rule. -
[3 evaluations]: a left-recursive rule evaluated its body three times while growing its match. The recursive calls inside it show as memo hits that return the match so far:pego trace -g lr.pego -i '1+2' # rule sum = sum "+" n / nmain 1:1 sum 1:1 sum 1:1 -> failed [memo] n 1:1 -> matched 1:1-1:2 "1" sum 1:1 -> matched 1:1-1:2 "1" [memo] n 1:3 -> matched 1:3-1:4 "2" sum 1:1 -> matched 1:1-1:4 "1+2" [memo] n 1:1 -> matched 1:1-1:2 "1" sum -> matched 1:1-1:4 "1+2" [3 evaluations] main -> matched 1:1-1:4 "1+2" -
(level n)after a rule name: an Operators Rule call with a binding level (e(name));[lookahead]: a call inside&or!.
Traces grow quickly, so narrow them down:
-
-max-depth nhides calls nested deeper thann:pego trace -g config.pego -max-depth 2 < small.txtmain 1:1 ws 1:1 -> matched 1:1-1:1 "" stmt 1:1 -> matched 1:1-1:20 "server.port = 8080;" ws 1:20 -> matched 1:20-2:1 "\n" stmt 2:1 -> matched 2:1-2:21 "log.rotate(7, size);" ws 2:21 -> matched 2:21-3:1 "\n" stmt 3:1 -> failed main -> matched 1:1-3:1 "server.port = 8080;\nlog.rotate(7, siz..." -
-rule nameshows only the calls of that rule and the calls nested in them; repeat it or separate names with commas. With-rule,-max-depthcounts from the rule's calls:pego trace -g config.pego -rule stmt -max-depth 2 -i 'a.b.c(1);'stmt 1:1 assign 1:1 -> failed update 1:1 -> failed call 1:1 -> matched 1:1-1:10 "a.b.c(1);" stmt -> matched 1:1-1:10 "a.b.c(1);" stmt 1:10 assign 1:10 -> failed update 1:10 -> failed call 1:10 -> failed stmt -> failed -
-failuresadds, to each failed call, the farthest position it reached and what it expected there:pego trace -g config.pego -rule args -failures < bad.txtargs 2:12 value 2:12 -> matched 2:12-2:13 "7" ws 2:13 -> matched 2:13-2:14 " " args -> matched 2:12-2:13 "7" pego: 2:14: syntax error: expected ")", ",", [ \t\n](
argsmatched, so there is no failure to show; the error comes after the trace.)
For tools, -f json prints one JSON object per event and line. Enter events have event, rule, level (if not 0),
depth (the absolute nesting depth, 1 for the start rule), pos, line, col and lookahead; exit events add
matched, end, endLine, endCol, memo, evals (body evaluations), examined (the end of the input the call
looked at), reusedElements when nonzero, and, with -failures, failure:
pego trace -g config.pego -f json -rule call -max-depth 1 -failures < bad.txt
{"event":"enter","rule":"call","depth":3,"pos":20,"line":2,"col":1}
{"event":"exit","rule":"call","depth":3,"pos":20,"line":2,"col":1,"matched":false,"end":20,"endLine":2,"endCol":1,"evals":1,"examined":34,"failure":{"pos":33,"line":2,"col":14,"expected":["\")\"","\",\"","[ \\t\\n]"]}}
pego: 2:14: syntax error: expected ")", ",", [ \t\n]
trace takes the flags of parse that select what is parsed (-s, -i, -unit, -backend) and exits with status
1 when the input has a syntax error. When a call reuses repetition elements, text output appends
[N reused elements]; JSON exit events append reusedElements. Both omit the addition for zero reuse, and JSON
enter events never include it. The trace formatter can display these events from a Document reparse; the ordinary
trace command parses its input once and normally has no repetition reuse. The backends do not all make the same calls: each skips some calls that cannot
match the next character (first-character dispatch), not always the same ones. Their traces can differ only in calls
that fail having examined no more than the next character, with the calls inside them; every other event is the same
on each backend, and results never differ.
Why Is a Parse Slow?
pego profile parses the input once with tracing and prints the cost of each rule. On 900 statements (35 KB):
pego profile -g config.pego -n 5 < config.txt
result: ok
examined 35291 positions in 3.87ms (profiling included)
12009 calls, 12009 body evaluations, 0 memo hits (0% of calls)
0 reused repetition elements (completed root calls)
rule calls evals memo matched failed repeats consumed wasted time self self% reused
args 300 300 0 300 0 0 4690 0 1.02ms 726.8µs 18.8 0
ident 2103 2103 0 2100 3 902 50700 3 602.7µs 602.7µs 15.6 0
ws 5401 5401 0 5401 0 1200 3000 0 548.6µs 548.6µs 14.2 0
update 601 601 0 300 301 0 10500 7801 741.7µs 434.1µs 11.2 0
assign 901 901 0 300 601 0 10800 17101 878.0µs 426.6µs 11.0 0
(4 more rules; -n 0 shows all)
What to look at:
- Most time is spent in args (19%), ident (16%), ws (14%), not counting the rules they call.
- Evaluated again where they had been evaluated before: ws (1200 of 5401 evaluations, up to 3 times at 3:26), ident (902 of 2103 evaluations, up to 3 times at 3:1). A memoized rule is usually evaluated at most twice at a position; a rule that is not is evaluated whenever its caller is retried there. Look for choices whose alternatives begin with the same calls, and factor the common part out.
- Failed after examining much input: assign (31 positions from 2:1), update (26 positions from 3:1). A call that fails late throws its work away, and what is tried next often matches the same input again. Check whether an alternative shares a long prefix with a later one, and whether a cut (#cut) after the part that identifies an alternative can stop the parser from trying the others.
The hints point at the same problem from two sides. ident and ws call no other rule, so the engine does not memoize
them (evaluating such a rule again only reads its input again), and every alternative of stmt reads the target again:
ident consumed 50,700 positions of a 35,290-position input. And assign and update fail only after reading the
whole target, throwing away 17,101 and 7,801 positions of work (wasted). The fix is to read the target once and then
choose:
// config_fast.pego (changed rules)
rule stmt = ident ws (assign / update / call)
rule assign = "=" ws value ";"
rule update = "+=" ws value ";"
rule call = "(" ws args? ws ")" ";"
pego profile -g config_fast.pego -n 5 < config.txt
result: ok
examined 35291 positions in 3.16ms (profiling included)
9304 calls, 9304 body evaluations, 0 memo hits (0% of calls)
0 reused repetition elements (completed root calls)
rule calls evals memo matched failed repeats consumed wasted time self self% reused
stmt 901 901 0 900 1 0 34390 1 2.57ms 778.0µs 24.6 0
ws 4501 4501 0 4501 0 300 2700 0 634.8µs 634.8µs 20.1 0
args 300 300 0 300 0 0 4690 0 756.4µs 425.7µs 13.5 0
main 1 1 0 1 0 0 35290 0 3.16ms 367.8µs 11.6 0
ident 1201 1201 0 1200 1 0 27000 1 357.3µs 357.3µs 11.3 0
(4 more rules; -n 0 shows all)
What to look at:
- Most time is spent in stmt (25%), ws (20%), args (13%), not counting the rules they call.
- Nothing else stands out: no rule is evaluated more than twice at a position, and no failed call examines much input.
The calls fell from 12,009 to 9,304, ident reads each position once, and nothing is wasted. The remaining time is
spread over rules that each do their share; ws is called often because the grammar asks for white space in many
places, which is normal.
Flags: -sort chooses the column to sort by (self, the default, time, calls, evals, memo, failed,
repeats, consumed, wasted or name), -n the number of rules shown (default 30, 0 for all), -f json prints the
same data as JSON (times in nanoseconds), and -s, -i, -unit and -backend work as for parse. The profile is
printed even when the input has a syntax error (the result line shows it), and the exit status is 0.
Reading the Profile
| Column | Meaning |
|---|---|
calls |
Calls of the rule, including calls of its value-free twin (the copy used where the value is discarded) |
evals |
Body evaluations: one per call that was not answered from the memo, plus one per step of a growing left recursion |
memo |
Calls answered from the memo |
matched, failed |
Calls that matched or failed |
repeats |
Evaluating calls at a position (and binding level) where the rule had been evaluated already in the same parse |
consumed |
Input matched by the calls that matched, summed (a call nested in a call of the same rule counts too) |
wasted |
Input examined by evaluating calls that failed, summed: from the call's position to the end of what it looked at, nested calls included |
time |
Time in the rule's calls, nested calls included; a call within a call of the same rule counts once |
self, self% |
Time in the rule's calls minus the time of the calls they made, and its share of the parse |
reused |
Repetition elements reused during completed calls, including nested calls; these per-rule counts are inclusive and must not be summed. |
How to use them:
- Start from
self. It is where the time goes;timetells you which rule's subtree is expensive. Times include the overhead of measuring every call, which is large for small rules, so compare them with each other rather than with an unprofiled parse. repeatsis redundant work. The engine memoizes most rules from the second call at a position, so a memoized rule normally has at most one repeat per position. Rules that call no other rule, and rules called from a single place, are not memoized (see memoization): they are evaluated again whenever their caller is. A rule evaluated more than twice at a position is flagged. The fix is usually in the caller: a choice whose alternatives begin alike, as in Why Is a Parse Slow?.wastedis backtracking. Failing is normal (every choice fails before it succeeds), and failing on the first character costs one position. A largewasted, or a hint about a call that "failed after examining much input", means a rule recognized much of a construct before deciding it was the wrong one. Factor the common prefix, reorder the alternatives, or put a cut (#cut) after the part that identifies the alternative.wastedis inclusive: a failed call counts what its nested calls examined, so look at the innermost of the rules flagged first.memolow andevalsclose tocallsis the normal state of a grammar without much backtracking. Many memo hits mean the memo is saving work, typically for rules shared by alternatives of a choice.callsmuch larger than the input for a rule that mostly fails means it is tried at many positions where it cannot match.
Work that is thrown away by a choice in a rule that still matches (the first alternative matched a few calls, then the
alternative failed, and a later one matched) is not counted in wasted: it shows as repeats of the calls the later
alternative makes again, or as failed calls of the alternatives themselves when they are rules, as assign and
update are here.
From Go
pego.WithTrace passes every event to a function; the methods of the event read the parser, so call them from the
function:
_, err = p.Parse("a=1;b=x", pego.WithTrace(func(e pego.TraceEvent) {
indent := strings.Repeat(" ", e.Depth-1)
line, col := e.LineCol(e.Pos)
switch {
case e.Kind == pego.TraceEnter:
fmt.Printf("%s%s at %d:%d\n", indent, e.Rule, line, col)
case e.Matched:
fmt.Printf("%s%s matched %q\n", indent, e.Rule, e.Text())
default:
fmt.Printf("%s%s failed: %v\n", indent, e.Rule, e.Failure())
}
}))
For the grammar main = pair (";" pair)* $$, pair = key "=" value this prints (the full program is
ExampleWithTrace in example_debug_test.go):
main at 1:1
pair at 1:1
key at 1:1
key matched "a"
value at 1:3
value matched "1"
pair matched "a=1"
pair at 1:5
key at 1:5
key matched "b"
value at 1:7
value failed: 1:7: syntax error: expected [0-9]
pair failed: 1:7: syntax error: expected [0-9]
main failed: 1:7: syntax error: expected [0-9]
1:7: syntax error: expected [0-9]
A TraceEvent has Kind (TraceEnter or TraceExit), Rule, Level, Depth, Pos and Lookahead; exit events
also have Matched, End, Memo, Evals, ReusedElements and Examined, and the methods Text (the input matched), Failure
(the farthest failure recorded during the call, as a *SyntaxError, or nil) and Recovered (the errors #recover
recovered from during the call, whose expectations Failure does not include). LineCol converts a position.
pego.WithProfile adds a parse to a pego.Profile, which can collect any number of parses, for example a whole test
corpus:
var prof pego.Profile
for _, input := range inputs {
p.Parse(input, pego.WithProfile(&prof))
}
for _, r := range prof.Rules {
fmt.Println(r.Rule, r.Calls, r.Evals, r.Repeats, r.Wasted, r.SelfTime)
}
for _, h := range prof.Hints() {
fmt.Println(h)
}
TraceExit.ReusedElements counts repetition elements reused during that call, including nested calls.
RuleProfile.ReusedElements accumulates the same inclusive counts; parent and child rows can describe the same
reuse, so do not sum them. Profile.ReusedElements counts reuse once, from completed root exits. The profile JSON
key is reusedElements. These counts describe reuse in the current parse; memo hits do not replay earlier reuse.
Profile text prints the root-only total on its own summary line and the inclusive per-rule counts in the final
reused column. JSON includes reusedElements in the profile and each rule row, including zero values.
Profile.Rules lists the rules in the order they were first called; sort them as you need. Engine tracing works with
every backend, with RecognizeOnly, ParseStream and Document (the option given to NewDocument traces each
Document.Parse), and several WithTrace and WithProfile options can be combined. A Profile is not safe for
concurrent parses. Generated Go Node and typed parsers have separate opt-in
trace and profile APIs described below; they do not provide the engine
trace/profile API.
Generated Go Node and Typed Traces
Generate a standalone Go parser with tracing hooks using pego gen -observability. Add -types to trace typed entry points too:
pego gen -g pairs.pego -module example.com/pairs/pairsparser -observability -o pairsparser
pego gen -g pairs.pego -module example.com/pairs/pairsparser -types -observability -o pairsparser
A parser generated without -observability has no tracing: it returns an error for the trace and profile options
(WithTrace, WithProfile, WithTraceFilter, WithTraceMaxDepth) instead of delivering partial data.
The generated package provides its own TraceEvent and options. Its callbacks
run synchronously and receive events for actual generated calls:
_, err := pairsparser.Parse(input,
pairsparser.WithTrace(func(e pairsparser.TraceEvent) {
switch e.Kind {
case pairsparser.TraceEnter:
fmt.Printf("enter %s depth=%d pos=%d\n", e.Rule, e.Depth, e.Pos)
case pairsparser.TraceExit:
fmt.Printf("exit %s matched=%t end=%d\n", e.Rule, e.Matched, e.End)
case pairsparser.TraceSkipped:
fmt.Printf("skip %s at %d (%s)\n", e.Rule, e.Pos, e.SkipReason)
}
}),
pairsparser.WithTraceFilter("pair"),
pairsparser.WithTraceMaxDepth(4),
)
Filtering a rule includes its named descendants, and unknown names match nothing. Depth starts at one for the start
rule. TraceSkipped is not paired with an exit event, and in batch parses Examined is not a complete input range
(ExaminedKnown is false). The specification defines the
events, the filters and what an aborted parse omits.
With -types, ParseAST and ParseRuleAST accept the same trace options. This
applies to both direct typed execution and Node-conversion fallback. Events
describe the rule calls made by the selected path; typed conversion itself is
not a separate rule event. The Node and typed traces can therefore differ.
For example, the same callback can observe typed parsing:
_, err := pairsparser.ParseAST(input,
pairsparser.WithTrace(func(e pairsparser.TraceEvent) {
fmt.Printf("%v %s depth=%d pos=%d\n", e.Kind, e.Rule, e.Depth, e.Pos)
}),
)
This Go mode supports Node batch Parse, ParseRule and, when generated
with -recognize, Recognize; with -types, it also supports ParseAST and
ParseRuleAST. It can be combined with -document; see Tracing and Profiling
Generated Go Documents. With
-stream, ParseStream accepts the same options for the whole stream; its
events report no examined range, and a profile counts the stream as one parse.
Generated Go traces also report their generated execution strategy rather than promise
event-for-event equivalence with engine traces. Generated TypeScript traces
and profiles have their own contracts and do not promise event-for-event
equivalence with engine traces. See code generation
for the generation option. Focused synthetic measurements cover Go Node and
typed batch parsers. Typed no-callback runs added about 48 B/op with unchanged allocation
counts; callback cost depends on parser path and workload, and the measured
callbacks store scalar event fields rather than logging. These are not
workload-wide estimates. See
design 043.
Profiling Generated Go Batch Parsers
With -observability, generated Go Node, typed batch and Document packages
expose Profile and WithProfile. Use a zero-value profile to collect several
parses, then take an owned, deterministic snapshot. For Document usage, see
Tracing and Profiling Generated Go Documents.
var profile pairsparser.Profile
_, err := pairsparser.Parse(input, pairsparser.WithProfile(&profile))
if err != nil {
log.Fatal(err)
}
snapshot := profile.Snapshot()
data, err := json.Marshal(snapshot)
Snapshot sorts rule rows and skip reasons and copies their slices. The
snapshot separates entered calls from calls that completed, counts skips
independently of trace filters, and reports ExaminedKnown/WastedKnown as
false for rules called in batch parses, because their examined ranges are not
tracked.
Position counts use the unit of each parse; avoid combining profiles collected
with different units. A Profile is not safe for concurrent use, though
reentrant parses on one goroutine have independent invocation state.
Times include synchronous enter and exit callback durations when tracing is
also enabled. Root time excludes input setup, whole-input end checks and typed
conversion. A same-rule recursive call is counted once per invocation for its
rule time; self time subtracts child time. Completed-call counters are
published after the exit callback returns or panics, so a snapshot inside that
callback can omit that call until the callback unwinds. Callback panics still
propagate unchanged after cleanup. In focused synthetic runs, WithProfile
was 3.8–26.9× the ordinary package time depending on API and fixture, adding
about 45–395 KB/op and 25–50 allocations/op across the measured cases. The
current generator without a profile was near parity with the prior
trace-only generator. These are noisy, workload-specific comparisons, not a
general estimate; see design 043.
TypeScript batch profiling has its own Profile API, described below.
Tracing and Profiling Generated Go Documents
Generate the Go Document with observability enabled:
pego gen -g pairs.pego -module example.com/pairs/pairsparser -document -observability -o pairsparser
Constructor trace and profile options are defaults for later parses. They do
not run during construction, and an option passed to Parse overrides that
call only. Editing does not parse or emit events.
var profile pairsparser.Profile
doc, err := pairsparser.NewDocument(input,
pairsparser.WithTrace(func(e pairsparser.TraceEvent) {
fmt.Printf("%v %s depth=%d pos=%d memo=%t evals=%d\n",
e.Kind, e.Rule, e.Depth, e.Pos, e.Memo, e.Evals)
}),
pairsparser.WithProfile(&profile),
)
if err != nil {
log.Fatal(err)
}
node, err := doc.Parse()
if err != nil {
log.Fatal(err)
}
_ = node
_ = profile.Snapshot()
The trace reports calls made during each parse. A warm memoized call is
reported as a memo hit (Memo=true, Evals=0); its earlier nested calls are
not replayed. Direct typed parsing and Node-conversion fallback report their
actual parser calls, without a separate conversion event. For completed parses,
the invocation's increases in profile Evals and MemoHits match
doc.Stats().Evaluated and .Reused, respectively; an interrupted parse can
include calls that began but did not complete. Exit events report
ExaminedKnown=true, and Examined is the end of the input the Document
treats the call as having read: an edit starting before that position
discards the call's memo result, as does an edit at or before the call's
position. It is conservative, typically one position past what the call looked
at and possibly one past the end of the input, so use it to see why reuse
stopped rather than as an exact read count. Edits that complete a UTF-8
sequence just before them, and left-recursive results still growing, also
prevent reuse. Profiles of Document parses sum it into per-rule Examined and
Wasted (failed calls only); mixing batch parses into the same Profile makes
those values unknown. Generated Documents do not resume repetition bodies, so there is no
ReusedElements count.
A trace callback cannot call Parse, ParseAST or Edit on the Document
being parsed: those calls return ErrDocumentBusy and leave the Document
unchanged, while the outer parse continues. Schedule that work after the
current call or use another Document.
Per-call overrides do not change the constructor defaults, and temporary
observer/profile state is released when a parse returns or panics.
Generated TypeScript Traces
Generate tracing hooks with pego gen -lang ts -observability; add -types to
trace typed-result parsing and -recognize to trace recognition calls. Tracing
is delivered through the per-call options APIs, not the legacy positional-unit
functions:
pego gen -lang ts -g pairs.pego -observability -types -recognize -o pairs
import { parseASTWithOptions } from "./pairs/parser.ts";
const result = parseASTWithOptions(input, {
trace: (event) => console.log(event.kind, event.rule, event.depth, event.pos),
traceRules: ["pair"],
traceMaxDepth: 4,
});
parseWithOptions, parseRuleWithOptions and recognizeWithOptions accept the
same trace options. With -types, the configured-start parseASTWithOptions
does as well; TypeScript does not add a named typed-rule API. The legacy
positional-unit functions do not accept trace callbacks. traceMaxDepth must
be a safe integer; zero and negative values mean unlimited depth. Events use
camel-case fields and the selected position unit. The "enter" and "exit"
kinds describe actual calls; "skipped" currently identifies first-character
dispatch skips. Omitted or empty traceRules delivers all rules; a selected
rule includes all descendant calls, while unknown rule names select none.
examinedKnown is always false. Callback
events are copied values, callbacks run synchronously, and thrown values
propagate unchanged after cleanup. Typed conversion does not add a separate
event, and an aborted parse may have no exit for active calls. Generated
TypeScript traces do not promise event-for-event equivalence with the engine.
In focused synthetic runs,
no-callback option APIs ranged from 0.991–1.044× ordinary time, while parse
callback profiles ranged from 1.052–1.288× depending on workload and filter; paired
results were noisy and are not a general performance estimate. See
design 043
for the measurement scope and limits.
Profiling Generated TypeScript Batch Parsers
Generate with -observability to accept profiles: Profile and ProfileSnapshot are exported by every parser, but a
parser generated without -observability returns an Error for the profile and trace options. Pass one profile to a per-call options API and read its owned snapshot after
the parse:
import { Profile, parseWithOptions } from "./pairs/parser.ts";
const profile = new Profile();
const result = parseWithOptions(input, { profile });
const snapshot = profile.snapshot();
console.log(JSON.stringify(snapshot));
The same option works with parseRuleWithOptions, recognizeWithOptions and, with -types, parseASTWithOptions.
A Profile aggregates across parses but cannot be shared by concurrent calls, and a snapshot is a copy that later
parses do not change. The counters are the camel-case counterparts of the generated Go profile; time and selfTime
are in milliseconds, measured with the host's performance.now() clock, and the flags examinedKnown
and wastedKnown are false. The specification
defines every counter, including when a snapshot taken inside a trace callback leaves out the current call.
Profiling requires performance.now(); without it, a profiled invocation returns an Error, and there is no
Date.now() fallback. Profiled runs took 1.58 to 4.02 times as long as ordinary ones in focused Node 24.19
measurements; see design 043 for the
conditions and limits. The specification covers clock failures and callback errors.
Costs and Limits
These costs and limits describe engine tracing and profiling; generated-parser measurements are linked above.
- Off, tracing costs nothing measurable. A parse without
WithTracepays one pointer check per rule call that goes through the engine's general call path; plain calls of small rules skip that path and pay nothing. See design 014 for the details. - On, a parse is several times slower, mostly in the trace function. Profiles therefore show relative times.
- The trace shows the calls the backend makes. The backends do not skip the same calls that cannot match the next
character, so their traces can differ. With and without tracing, the results and errors are the same on every
backend, and so are the number of evaluations and memo reuses, the memo entries and the memoization decisions; the
test suite checks this on all its grammars, and for
Documentchecks the results andDocument.Statsacross edits on the backend corpus. - A
Documentreparse skips calls. It reuses the unchanged elements of long repetitions without calling their rules (see incremental parsing), so those calls do not become synthetic trace events, calls, evaluations or memo hits.ReusedElementsreports the number of repetition elements reused separately; memo hits still appear as ordinary entered calls. A reused element can contain several omitted calls, so this is an element count rather than a count of skipped rule calls. - Aborted parses (a runtime error in an action, an error from a stream's
emitor reader, the nesting limit, a panic in the trace function, which propagates out of the parse) end the trace without the exit events of the calls in progress. Without a root exit, the parse contributes no reuse toProfile.ReusedElements, even if a completed child row already reported reuse. - Streams forget lines. A stream parse discards the input it has consumed, and with it the line structure:
LineColgives 0, 0 for a position that is no longer (or not yet) held, and a profile location that could not be converted prints asposition N. pego explainparses twice (once to find the errors, once traced), so it takes about as long astrace.