Performance Tuning Log
This document records every performance change made to the PEGO runtime: what was changed, why, and what it bought. It also records experiments that did not pay off, so they are not repeated, and the hotspots that remain. Update it in the same commit as any performance-related change, including the table of where each optimization applies.
The measured results of every benchmark are in benchmarks.md, which is generated from a run; this document describes the benchmarks, analyzes the results, and records how they came about.
- Where PEGO stands: an analysis of the latest results
- How to measure: the benchmarks, their workloads and method, and tools for measuring a change
- Where each optimization applies, the change log and the experiments that did not pay off
Where PEGO stands
An analysis of the results in benchmarks.md as measured on 2026-10-08 at commit b03fd59 (Apple M3 Max, Go 1.27.1). The figures below are rounded from that run; regenerating the results does not update this analysis, so check the commit above against the one in benchmarks.md before relying on it.
Backends. Generated Go parsers take 0.55–0.72× the time of the closure backend. The recursive bytecode VM takes 1.07–1.33× and the iterative VM 1.28–1.94×; the iterative VM pays most on Pratt expressions (1.94×), whose loop runs as a state machine on its own stack. The closure backend is the default for this reason (see the runtime guide); the VMs are for grammars without their AST and, iteratively, for deep nesting.
Typed values. Where ParseAST builds the grammar's Go types directly (JSON, XML, the calculators, outline), it
takes 0.47–0.89× the time of the generated Parse and a quarter to a third of its memory (0.29–0.85 of its
allocations; outline keeps most of them, in lists). On JSON and XML it takes 0.86× and 1.02× the time of encoding/json and encoding/xml, with about as
many bytes and 200–300 times fewer allocations (231 against 53,340 for JSON), while also recording positions and the
expectations for errors. Where the values include CST nodes (CSV, minilang), ParseAST converts the tree of Parse:
the same time for CSV, whose result is the tree itself, and 1.10× the time and 1.27× the memory for minilang.
Allocations. Every backend makes a few hundred to about two thousand allocations per parse (319 for CSV, 767 for
JSON, 2,247 for outline), against 10,000–80,000 for the standard-library parsers: nodes, lists and fields come from
slabs, and the memo, the input buffers and the stacks are reused across parses. The bytes are another matter: a tree
of Nodes takes 26–131 bytes per input byte (left recursion the most, through the intermediate results it grows), which
is what typed values save.
Recognition. RecognizeOnly takes 0.61–0.81× the time of a full parse on the closure backend and allocates almost
nothing, except where predicates read values (XML's tag names, outline's indentation: 4.9 MB and 1.9 MB, which build
those values) and where left recursion grows (1 MB). The generated Recognize takes 0.45–0.74× the time of the
generated Parse, yet it is slower than the typed ParseAST on JSON (1.22×), XML (1.33×) and the left-recursive
calculator (1.03×): ParseAST runs direct rules, one method per rule, while Parse and Recognize still run a method
per expression. Direct rules for the Node runtime and the recognizer are the most promising optimization left (see
the backlog).
The standard library is 1.8–6.6 times as fast as generated Parse where it applies. The gap is widest where
PEGO's work is structurally different: left recursion (6.6×, against go/parser's hand-written precedence climbing)
and CSV (3.4×, encoding/csv builds no tree at all). json.Valid, a hand-written state machine, is 12 times as fast
as the generated Recognize.
Grammar style. The same expressions take 2.6 times as long with left recursion as with a Pratt expression on the closure backend (2.4 generated, 1.7 typed), as you would expect from growing a seed at every level. Error recovery costs nothing measurable: the minilang input with one line in seven broken parses in 0.91× the time of the clean one, since recovered statements are skipped rather than parsed.
Position units make no consistent difference: byte positions take 0.95–1.09× the time of code points.
Incremental parsing. An edit and a reparse of the 300-function minilang program take 143–154 µs, 89–158 times less than a full parse, on every backend. On the 50,000-record CSV document they take 2.5–2.8 ms, most of it moving the reused records after the edit; there the VMs allocate 2.6 times as much as the closure backend (7.1 MB against 2.7 MB).
Streaming keeps memory bounded but runs at 0.55–0.63 of the throughput of batch parsing, and allocates 70 bytes per input byte and 6 allocations per record, against 26 bytes per input byte and 0.06 allocations per record for a batch parse: the per-element scratch is not reused across elements the way a batch parse reuses it.
Preparation. Loading a .pegoc file takes 0.39–0.60× the time of compiling the grammar from source for the
closure backend, 0.49–0.79× for bytecode, and 0.22–0.28× for a file without the AST.
How to measure
The benchmarks are in bench/. go run ./bench/report runs all of them (about eight minutes) and regenerates
benchmarks.md; see the benchmark workloads for what they measure.
# Full benchmark suite (all backends, both position units, stdlib counterparts), and the results document
go run ./bench/report
# A single workload, with CPU and allocation profiles
go test ./bench -run '^$' -bench 'BenchmarkParse/JSON/closure/codepoints' -benchtime 20x \
-cpuprofile cpu.out -memprofile mem.out -o bench.test
go tool pprof -top -nodecount 30 bench.test cpu.out
go tool pprof -sample_index=alloc_space -top -nodecount 30 bench.test mem.out
Notes:
- Wall-clock numbers vary by a few percent between runs on an idle machine, and by 10–20% on a busy one. Prefer
B/opandallocs/op, which are nearly deterministic, and confirm time changes with interleaved runs of the binaries before and after the change (-count 3or more each). - Results must not change: every optimization is covered by the equivalence tests (
go test ./...), which compare all backends, both position units, memoization on and off, and recognition against full parses. - A change to shared runtime code (input, memo table, calls) should be measured on the incremental and stream
benchmarks too (
BenchmarkIncremental,BenchmarkStream), not only on batch parsing: they reach code paths the batch benchmarks do not (see the pitfall in change 30). go test -benchsplits the pattern at/and matches each part against one level of the benchmark name, so an alternation that spans levels ((Parse/JSON|Recognize/CSV)) matches nothing; run such sets separately.- The engine benchmarks read the grammars from
examples/andparsers/when they run, so when comparing a change to a grammar, run the benchmark binary while the old grammar is checked out (for example betweengit stashandgit stash pop), not just a binary built from the old code. Generated parsers embed their grammar. - The numbers in the change log are for the closure backend with code-point positions unless stated otherwise.
Benchmark workloads
TestWorkloads in bench/ checks, on reduced inputs, that every input can be parsed by every backend and that the
PEGO backends return the same results.
- Inputs are generated by
bench/inputs.gofrom a fixed random seed, so they are identical across runs. They include multi-byte characters such as Japanese text. - The grammars are taken unchanged from
parsers/(JSON, CSV, XML) andexamples/(the calculator in its Pratt and left-recursive forms, minilang, and outline). The generated parsers inbench/gen/are generated from them (go generate ./bench). - Each benchmark runs three times and the median is reported.
- Comparisons with the standard library measure different outputs. Every PEGO backend builds a tree whose nodes carry
positions (start and end) and rule names, and records the expectations at the farthest failure. The standard-library
parsers produce:
encoding/json: decoding intoany(no positions).encoding/csv:ReadAll(a list of field strings with quotes removed).encoding/xml: reading tokens withDecoder.Token(no tree).go/parser.ParseExpr: a Go expression AST (with positions).
| Workload | Grammar | Input | Main focus |
|---|---|---|---|
| JSON | parsers/json |
Array of objects, 262 KB | Lexical repetition, AST of union types |
| CSV | parsers/csv |
5,000 rows (211 KB); fields with quotes, commas and newlines | Simple repetition, many terminals |
| XML | parsers/xml |
Nesting, attributes, character references, comments, CDATA; 262 KB | Nesting, a predicate comparing start-tag and end-tag names |
| Arith_Pratt | examples/calculator/calc.pego |
Expression with 20,000 terms (133 KB), parentheses nested up to depth 40 | Pratt loop and longest match |
| Arith_LeftRec | examples/calculator/calc_lr.pego |
Same as above | Left-recursion seed growing |
| Minilang | examples/minilang |
300 functions (89 KB) | PEG statements nested with Pratt expressions, keyword exclusion |
| Recovery | examples/minilang |
Same as above, with one line in seven broken | #recover, expectation recording |
| Outline | examples/outline |
5,000 lines (83 KB), depth up to 10 | Predicates and variables (non-memoized rules) |
| Incremental | examples/minilang |
Repeated one-character insertions and deletions in 300 functions | Memo reuse with Document (compared with a full parse each time) |
| Incremental, long | parsers/csv |
Repeated one-character insertions and deletions in the middle of 50,000 records | Resuming long repetitions with Document |
| Stream | parsers/csv |
50,000 rows (2.2 MB) | Reading and discarding with ParseStream |
| Prepare | JSON, minilang | None | Compiling from source and loading .pegoc (with and without the AST) |
The CSV and XML workloads used the simpler grammars of examples/csv and examples/xml (since removed in favor of
parsers/csv and parsers/xml) until the benchmarks were pointed at parsers/; results measured before that, and the
analysis in "Where PEGO stands", are for those grammars until the next full run.
The entries of the change log record the effect of each change when it was made; entries before change 13 were measured on a 4-vCPU Intel Xeon virtual machine and are several times slower in absolute terms.
Starting point (first benchmark run, on the virtual machine): JSON took ~510 ms and allocated 206 MB in 2.6 M allocations.
Where each optimization applies
The runtimes are separate code: the closure backend (compile.go, runtime.go), the recursive and iterative bytecode
VMs (vm.go, ivm.go), the generated parsers' runtime (genrt/runtime.go, behind Parse and Recognize) and the
typed runtime of generated parsers (genrt/typed.go, behind ParseAST). An optimization made in one of them is not
automatically in the others. This table records, for every change in the log below, where it is in effect.
✓ applied · ✗ not applied (see the note) · – not applicable (the backend has no such code path or feature)
| # | Optimization | Closure | VM | Iter. VM | Generated | Typed | Notes |
|---|---|---|---|---|---|---|---|
| 1 | Value-free bodies, twins, transient rules | ✓ | ✓ | ✓ | ✓ | ✓ | generated since 11 |
| 2 | Pooled frames of the iterative VM | – | – | ✓ | – | – | |
| 3 | Node fields as a slice | ✓ | ✓ | ✓ | ✓ | – | the typed runtime builds no nodes for its results |
| 4 | Expectations as interned IDs | ✓ | ✓ | ✓ | ✓ | ✓ | generated since 11 |
| 5 | Memo table with per-position chains | ✓ | ✓ | ✓ | ✓ | ✓ | |
| 6 | Slab allocation (nodes, lists, frames) | ✓ | ✓ | ✓ | ✓ | ✓ | typed: pooled arenas (48) |
| 7 | Recognition mode | ✓ | ✓ | ✓ | ✓ | – | generated: -recognize (47) |
| 8 | Unmemoized fast path, scan loops, nesting limit | ✓ | ✓ | ✓ | ✓ | ✓ | VMs: SCAN |
| 9 | Value-free Pratt lines | ✓ | ✓ | ✓ | ✓ | ✓ | |
| 10 | Zero-copy token text, reused evaluation context | ✓ | ✓ | ✓ | ✓ | ✓ | VMs: text only (their evaluator has its own stack, 14) |
| 12 | Memoizing rules that read variables | ✓ | ✓ | ✓ | ✓ | ✓ | |
| 13 | Pratt attempts by value | ✓ | ✓ | ✓ | ✓ | ✓ | iterative VM: pooled Pratt frames |
| 14, 27 | Operands on one expression stack | ✓ | ✓ | ✓ | – | – | generated code evaluates actions as Go expressions |
| 15, 28 | Allocation-free action plumbing (field chunks, list built-ins on a stack) | ✓ | ✓ | ✓ | ✓ | ✓ | |
| 16 | Start positions and lambdas from chunks | – | ✓ | ✓ | – | – | |
| 17, 31 | Fast character-class tests | ✓ | ✓ | ✓ | ✓ | ✓ | closure: specialized tests (31); VMs: ASCII bitmaps (17); generated: inline comparisons (11) |
| 18 | Memoizing from the second call at a position | ✓ | ✓ | ✓ | ✓ | ✓ | not for Document and streams, which keep every entry; generated since 21 |
| 19 | Plain call path (invokePlain) |
✓ | ✓ | ✓ | ✓ | ✓ | iterative VM since 24 (plainCallFrame, 34) |
| 20, 23, 30, 45, 56 | Document edits: memo and text spliced, offsets on demand, trees moved in place, memo entries brought up to date lazily |
✓ | ✓ | ✓ | – | – | generated parsers have no Document |
| 22 | Bounded memory in streams | ✓ | ✓ | ✓ | – | – | generated parsers have no streams |
| 26 | Lambda calls without allocation | ✓ | ✓ | ✓ | – | – | VMs: lambdas from chunks (16); generated lambdas are Go function literals |
| 32 | Literals compared in one loop | ✓ | ✓ | ✓ | ✓ | ✓ | generated: its own loop |
| 33 | Smaller VM entries | – | ✓ | ✓ | – | – | |
| 35 | Left-recursion growth without allocation | ✓ | ✓ | ✓ | ✓ | ✓ | |
| 36, 37 | Unboxed text comparison, no intermediate lists in concat |
✓ | ✓ | ✓ | ✓ | ✓ | VMs since 44 (instruction set 2) |
| 38, 42, 43, 46 | Direct calls of plain rules | ✓ | ✓ | ✓ | ✓ | ✓ | generated: a method per rule (46); typed: a method per rule for every rule (48) |
| 39 | Line table for error positions | ✓ | ✓ | ✓ | ✓ | ✓ | streams still scan from the committed position |
| 40, 41 | Offset table built while decoding | ✓ | ✓ | ✓ | ✓ | ✓ | engine: full parses only; recognition builds it on demand (30) |
| 48 | Struct constructors per type, frames reused, scratch pooled across parses | ✗ | ✗ | ✗ | ✗ | ✓ | see below |
| 52, 53 | Projected repetitions (map($rest, (r) => $r.f)) |
✓ | ✓ | ✓ | ✓ | ✓ | VMs: NEXT mode 3 (instruction set 3) |
| 49–51 | First-character dispatch in choices | ✓ | ✓ | ✓ | ✓ | ✓ | VMs: GUARD (instruction set 3) |
| 54, 59 | Document: resuming long repetitions |
✓ | ✓ | ✓ | – | – | VMs since 59 (sites found in the bytecode); generated parsers have no Document |
| 55 | Tracing hook (cost only) | ✓ | ✓ | ✓ | – | – | generated parsers have no tracing |
| 57, 58 | Scratch memory pooled across whole-input parses (input, offsets, memo, value stack) | ✓ | ✓ | ✓ | ✓ | ✓ | typed: since 48; generated Parse and Recognize: 58 |
| 63, 64 | Smaller Node (88 bytes: int32 positions, interned type and rule names) |
✓ | ✓ | ✓ | ✓ | – | generated Parse since 64; the typed runtime builds no nodes |
| 60 | Direct rules: a rule's body inlined into its call method, captures in Go variables, the action in place | – | – | – | ✗ | ✓ | typed: not for rules with a cut or #recover, Pratt rules and left-recursion leaders; not tried for generated Parse |
| 61 | Character tests read code points without calling peek |
– | – | – | ✗ | ✓ | typed: direct rules only; generated Parse still calls peek |
| 66 | No memo key allocated for variables where none is defined | ✓ | ✓ | ✓ | ✓ | ✓ | |
| 62 | Short literals compared in place | – | – | – | ✗ | ✓ | typed: direct rules, up to 4 code points, code points only (the other backends match literals with their own loop, 32) |
Not applied, and why:
- Reusing frames when their rule returns (48): in the Node runtimes, capture frames share their chunks with the child lists of the result, so they cannot be freed or pooled without separating them; freeing frames in the engine was measured slower (see the experiments table). The scratch memory that results do not share is pooled (57, 58).
- Per-rule call methods for generated
Parse(the typed runtime'stypedCall) measured within noise (48). - Experiments that did not pay off in some backends are in the experiments table at the end.
Change log
Each entry lists the commit, the change, the reason, and the measured effect at that point.
1. Value-free rule bodies, value-free twins, transient rules (0b2c903)
- Value-free bodies. The body of a terminal-type rule, or of a rule whose action does not use
$n, is compiled without building values. Captures still build theirs. - Twins. A CST rule (no action, no terminal type) called where its value is discarded (
@,-,!, value-free bodies) gets a value-free twin with its own memo entries. Rules in left-recursive cycles never get twins, because mixing a valued and a value-free version inside one cycle would change seed-growing semantics. - Transient rules. Rules that call no other rule, or that are referenced only once in the grammar, are not memoized
in normal parses: their entries can never be reused (a single-reference rule is re-invoked at a position only when its
unique caller is, and the caller chain ends at a memoized rule or the start rule).
Documentstill memoizes them so edits can reuse results. - Shared empty frame for capture-free scopes.
- Effect: JSON 442 → 144 ms, 183 → 76 MB.
2. Frame pooling in the iterative VM (8fab402)
- Call frames, body frames and body state were heap objects per rule call. They now come from a per-parser free list.
- Effect: iterative VM allocation went from ~2.3× the recursive VM to parity.
3. Node fields as a slice (54a5774)
Node.Fieldswas amap[string]any; each struct node paid for a map (several hundred bytes). It is now a slice of name/value pairs withGet; JSON output (sorted keys) andNode.Fieldare unchanged.- Effect: JSON 72 → 64 MB.
4. Expected sets as interned IDs on a shared stack (11a6ce3)
- Error reporting records what was expected at the farthest failure. Every memoized call and every
#error/#recoverregion used to start a fresh[]string. - Expectations are now compile-time IDs into a per-backend description table. Isolated regions are windows of one stack; only sets kept by the memo or a pending recovery are copied into an append-only arena.
- Effect: minilang 59 → 42 MB, ~128 → ~103 ms. Error messages are identical.
5. Memo table with per-position chains (cf77e4f)
- The memo was a Go map keyed by (rule, position, level): hashing, map growth and one allocation per entry.
- Entries now hang off a slice indexed by position and are allocated in slabs of 256. Stream pruning drops a prefix of the slice. Kept expectation sets are copied into fixed-size chunks, and a repeat of the previous set is shared.
- Effect: JSON ~130 → ~85 ms, minilang ~103 → ~65 ms; about a third of parse time.
6. Slab allocation for nodes, child lists and frames (5d03bf2)
- Nodes, child slices and capture frames come from per-parser chunks, in the spirit of an arena: a chunk stays alive while any of its nodes is referenced (the lifetime of the parse result). Repetition children are gathered on a shared stack and copied out once. Memo entries were packed into 128 bytes.
- Effect: allocations per parse dropped by about three quarters (JSON 940 k → 250 k); time −5 to −10%.
7. Recognition mode (3f7e9bf)
RecognizeOnly(pego parse -check) runs a separate program derived from the same grammar that builds no values and runs no actions, and returns the same syntax errors. Rules whose values predicates read still run normally.- Effect: 1.5–1.8× faster than a full parse (JSON 78 → 44 ms). Compare it with
json.Valid, not with parsers that build trees.
8. Fast path for unmemoized calls, SCAN, nesting limit (d2db87d)
- Unmemoized calls skip the memo bookkeeping: the examined range only grows, so it needs no save/restore, and expectations need no isolation.
- Single-character repetitions (
(?class)*,.+, ...) in value-free positions run as a scan loop without per-iteration backtracking state (closure:scanRepeat; bytecode: the newSCANinstruction). - Nesting limit. Rule-call depth is capped (
WithMaxDepth, default 100,000; 10,000,000 for the iterative VM). This is a robustness fix rather than a speedup: a corrupted.pegoc(for example a cleared left-recursion flag) used to recurse until the Go stack overflowed. - Effect: recognition JSON 39 → ~32 ms, CSV 24 → 18 ms, minilang 40 → ~31 ms; full parses a few percent.
9. Value-free Pratt lines; discarding trivia in example grammars (936857c)
- A Pratt operator line with an action never uses the line's value (
$opis built from the matched range and$nis not allowed there), and neither does an operand line whose action does not use$n. Such lines are now compiled without values (closureleanLine; bytecode uses the same predicate). - The example grammars captured repetitions such as
rest:(ws "," ws m:member)*. A captured value keeps its whole CST (it can be inspected with.childrenortext), so every whitespace character became aMatchnode. They now discard trivia with-ws(see the authoring guideline below). The resulting ASTs are identical. - Effect: JSON 50.5 → 42.8 MB, minilang 32.8 → 23.0 MB per parse.
10. Zero-copy token text; reusable evaluation context
- Token text.
Match.Textand terminal texts were built by converting[]rune(code points) or[]byteback to a string. The input now keeps the source string, plus a rune-to-byte offset table in code-point mode, and token text is a substring. Inputs with invalid UTF-8 in code-point mode keep copying, so U+FFFD replacement is unchanged; stream inputs also keep copying. Trade-off: token strings keep the whole input alive, like any Go substring. - Evaluation context. Each action and predicate allocated an
evalCtx. Evaluations never nest and no reference to the context survives (function values cannot be stored in fields or variables), so one context per parser is reused. - Effect: allocations per parse JSON 230 k → 153 k, minilang 176 k → 137 k, CSV 166 k → 131 k.
11. Generated parsers catch up with changes 4–10
-
Generated Go parsers (
internal/engine/genrtplus the code fromgen.go) had kept the old runtime:[]stringexpected sets copied per memoized call, a Go map for the memo, a heap object per node, child list and frame, and token text copied out of[]rune. They had become 1.5–2.3× slower than the closure backend while allocating up to 5× as often. -
Ported to the generated runtime:
- expected sets as interned IDs; the generator emits the description table (
descs), and isolated records,keepwith its arena chunks and the reuse of the last kept set work as in the engine (change 4); - the memo table with per-position chains and slab-allocated, packed entries; there is no
Documentor stream, so no examined range, shift or pruning (change 5); - slabs for nodes, child lists and frames, and
kidStackfor repetition children (change 6); - the unmemoized fast path in
call, scan loops for value-free single-character repetitions, and the nesting limit (nesting too deep: more than 100000 rule calls, the engine's default) (change 8); - value-free Pratt lines (
leanLine), and captures in value-free positions returning no value (changes 1, 9); - token text as substrings of the input with a rune-to-byte offset table (copying only for invalid UTF-8), and one reused action context per parser (change 10).
- expected sets as interned IDs; the generator emits the description table (
-
Beyond the engine:
- character classes are generated as inline range comparisons instead of a loop over a range table, both in single matches and in scan loops;
- Pratt
longestreturns the best attempt by value and saves captures acrossresetin one buffer per parser, so trying an operator line without captures allocates nothing (the engine allocates aprattAttemptper match and a slice per try). On minilang this alone took allocations from 82 k to 19 k per parse.
-
TestGeneratedParsersMatchEnginenow also covers scan loops and the nesting limit (at and one beyond it). -
Effect (generated parser, code points; closure backend in the same run in parentheses):
Workload Time Allocated per parse Allocations per parse JSON ~110 → ~40 ms (~68 ms) 53.1 → 35.5 MB (39.8 MB) 798 k → 54 k (153 k) minilang ~152 → ~33 ms (~50 ms) 51.1 → 17.8 MB (21.5 MB) 710 k → 19 k (137 k) CSV ~71 → ~25 ms (~52 ms) 28.0 → 26.8 MB (31.3 MB) 388 k → 46 k (131 k) XML ~117 → ~37 ms (~47 ms) 32.2 → 28.4 MB (30.4 MB) 490 k → 32 k (64 k) Generated parsers again allocate less than the closure backend (in bytes and in count) and are the fastest backend again, about 1.3–2× faster than the closure backend (times are noisy on the shared VM; the closure numbers in this run were higher than in earlier entries).
12. Memoizing rules that read variables
- Rules that read variables, directly or through callees, were never memoized, because their results depend on the environment. Context-sensitive grammars (indentation-based ones in particular) could then take exponential time, and the Python example had to be written in a parse-once-then-fold style to avoid it.
- The memo key now includes the values of the variables the rule may read (
rule.vars, computed statically and sorted); definitions made inside a rule are undone when it returns, so these values determine the result. Generated parsers do the same (the generator emits each rule's variable list). - Effect: removes the exponential worst case for variable-reading rules; no change for grammars without variables.
13. Allocation-free Pratt attempts and pooled Pratt frames in the engine and VMs
- Ports the generated parsers' Pratt technique (change 11) to the engine and both VMs.
parser.longestreturns the winningprattAttemptby value instead of allocating one per candidate, and saves captures across a reset in a reusable buffer (parser.saved) instead of a fresh slice. - The iterative VM pools its skip, longest, nud and Pratt frames (generic
reuse[T], released inrelease), holds attempts by value, and pushes no skip frame when the Pratt expression has noskip. - Pitfall: putting the
prattAttemptintoiresult(returned on every VM step) made the iterative VM about 20% slower on every grammar, Pratt or not. The attempt is passed throughparser.attemptinstead, soiresultstays small. - Effect (min of 6, codepoints, allocs/op before → after):
Workload closure bytecode iterative Arith_Pratt 173k → 134k 269k → 231k 484k → 231k; 79 → 67 ms Minilang 137k → 75k 203k → 140k 274k → 140k; 92 → 75 ms JSON (no Pratt) unchanged unchanged unchanged; 107 → 101 ms
14. One operand stack for VM expression code
-
The bytecode backends evaluate actions and predicates with stack-based expression code (
vmProgram.eval). Each evaluation started with a nil[]anyand grew it by appending,ENewcopied the field values andECallthe arguments into fresh slices,ENewbuilt its field-name list each time, every lambda call (vmFunc.apply) built a new locals slice, and every Pratt operator action a 3-element one for$lhs,$rhs,$op. On JSON these accounted for about half of the VM's allocations, and were the whole difference from the closure engine. -
Operands now live on one stack per parser (
parser.estack). An evaluation works above the current top and returns the stack to its height.ENewandECallpass the top of the stack tonewStructand the built-ins, which do not retain it; during a built-in call the arguments stay on the stack and lambdas called by it push above them. Lambda and operator locals are pushed onto the same stack. Field-name lists are resolved once when the program is loaded. -
EFUNCcopies the locals when there are any, because a function can outlive the lambda that created it (a lambda may return it) while its locals are popped. Lambdas in rule actions have no locals and do not copy. -
Effect (min of 6 interleaved runs, codepoints, Apple M3 Max; allocs/op and time before → after):
Workload bytecode iterative closure (unchanged) JSON 292k → 147k; 28.5 → 27.2 ms 292k → 147k; 40.0 → 39.0 ms 153k CSV 201k → 116k; 13.7 → 12.8 ms 201k → 116k; 16.5 → 15.8 ms 131k XML 165k → 72k; 25.9 → 24.3 ms 165k → 72k; 35.9 → 34.5 ms 64k Arith_Pratt 231k → 94k; 20.2 → 17.6 ms 231k → 95k; 29.0 → 26.6 ms 134k Minilang 140k → 68k; 26.3 → 24.8 ms 140k → 68k; 33.6 → 32.4 ms 75k The VMs now allocate less often than the closure engine on JSON, CSV, Pratt and minilang.
15. Allocation-free action plumbing in all backends
-
With change 14 the VMs' remaining allocations were mostly in code shared with the closure engine, around actions rather than in the actions' own values:
- an action whose body is not a sequence got its element list (
$1) as a fresh one-element slice (finish,lineResult); on JSON this was over a third of all allocations; - field lists of struct nodes (
newStruct) and of captures (attachCaptures) were separatemakecalls; function.apply(args ...any)is an interface call, so the variadic argument slice escaped on every lambda call;list,mapandconcatbuilt their element slices withmake/appendandnewListthen copied them;- a Pratt operator action allocated a
localfor each of$op,$lhs,$rhs.
- an action whose body is not a sequence got its element list (
-
Now: the one-element list is a buffer in the parser (
parser.one; the list is only read during the action, and$0copies it); field lists come from chunks like nodes (parser.fields, with capacity limited to the list so an append past it reallocates instead of overwriting a neighbour);applytakes two arguments; the list built-ins gather elements onkidStackand hand the copied-out slice to the list node; the operator locals live in the parser (parser.oplocals), like the evaluation context. -
Effect (min of 6 interleaved runs, codepoints, Apple M3 Max; allocs/op before → after, time in parentheses):
Workload closure bytecode iterative JSON 153k → 38k (22.7 → 22.0 ms) 147k → 32k (27.2 → 26.5 ms) 147k → 32k (38.3 → 38.1 ms) CSV 131k → 56k (11.3 → 10.8 ms) 116k → 41k (12.9 → 12.3 ms) 116k → 41k (15.7 → 15.2 ms) XML 64k → 39k (19.0 → 19.2 ms) 72k → 47k (24.8 → 24.0 ms) 72k → 47k (34.4 → 34.0 ms) Arith_Pratt 134k → 41k (14.1 → 12.9 ms) 94k → 0.7k (17.8 → 17.0 ms) 95k → 0.8k (27.0 → 26.4 ms) Minilang 75k → 21k (19.0 → 18.2 ms) 68k → 14k (24.6 → 23.9 ms) 68k → 14k (32.3 → 31.6 ms) Bytes per parse drop by 2–13% (XML +0.3%: partly used field chunks).
16. Start positions and lambdas from chunks in the VMs
PUSHPOSpushed the start position of a sequence, repetition or atomic expression onto the value stack ([]any), and converting anintto an interface allocates for values above 255. It now pushes a*inttaken from a chunk (parser.newPos); the consumers (SEQ,ATOMIC,ENDREPEAT) dereference it.EFUNCallocated eachvmFunc; lambdas never outlive the evaluation, and they now come from a chunk too (parser.newFunc).- Effect (min of 6 interleaved runs, codepoints, Apple M3 Max): allocations per parse JSON 32k → 1.9k, CSV 41k → 1.2k, XML 47k → 19k, minilang 14k → 1.4k, for both VMs. Bytes and time are unchanged (within noise); the gain is fewer objects for the GC.
- The remaining XML allocations are
text(...)results: a string converted to an interface needs a header on the heap (comparisons of two texts avoid it since change 36).
17. ASCII bitmaps for character classes in the VMs
CLASSandSCANtested a character against a class by looping over its ranges (Class.has). The VM now precomputes, per class, a 128-bit bitmap of the characters below 128 (negation included) when the program is loaded, and tests ASCII characters with one bit lookup; other characters still use the ranges.- The same idea in the closure engine measured no gain (see the experiments table); in the VMs, where the class is reached through the module's class table, it is a small but consistent gain.
- Effect (min of 8 interleaved runs, Apple M3 Max): recognition 1.3–4.6% faster on JSON, CSV, XML and minilang for both VMs (e.g. CSV bytecode 8.81 → 8.40 ms, XML 22.3 → 21.4 ms); full parses −1 to −4% (within noise on some).
18. Memoizing from the second call at a position
-
Counting memo entries and reuses showed that most entries are never used: JSON stored 68k entries per parse and reused none, CSV 30k and none, the Pratt calculator 6.6k and none, minilang reused 1.8k of 46k. Only grammars that really backtrack over rules (left recursion: 110k reuses; XML: 12k) use them. Each entry costs 128 bytes, its slot, and the isolation of its expectations.
-
In whole-input parses (
ParseWith, including recognition), a memoized rule is now not memoized on its first call at a position: a bit set (memoTable.seen, one bit per position and per rule that can be deferred) records the call, and the rule is memoized when it is called there again. A rule is therefore evaluated at most twice per position and parse time stays linear. -
Deferring costs an extra evaluation where a rule is called again. Per rule and per parse, the calls and repeated calls are counted, and once more than one call in sixteen is a repeat, the rule is memoized from the first call for the rest of the parse (the threshold was one in eight with repeats counted twice until change 24). Without this, the left-recursive calculator was 20–33% slower; with it, it is within 0–5%. Thresholds of 8 and 32 measured the same.
-
Left-recursion leaders are always memoized (the memo drives seed growing).
Document(which needs every entry for reuse after edits) and streams keep memoizing on the first call. -
Consequences found in review: results that had depended on which call was memoized changed. Rule labels on nodes returned by actions (fixed: action results are final), errors recovered inside and outside lookaheads (fixed: such entries are reused only in the same context), node identity across separate calls (
==on nodes; now stated as unspecified in the specification), and the nesting limit near its value (a memo hit does not nest; documented onWithMaxDepth). -
Effect (min of 6 interleaved runs, Apple M3 Max, code points; time before → after, bytes per parse in parentheses):
Workload closure bytecode iterative JSON 22.5 → 18.1 ms (41.2 → 25.8 MB) 26.0 → 22.0 ms 37.7 → 34.2 ms CSV 11.0 → 9.2 ms (31.5 → 22.5 MB) 12.0 → 10.1 ms 14.7 → 13.2 ms XML 19.3 → 18.2 ms (32.0 → 26.8 MB) 23.6 → 21.6 ms 33.4 → 32.4 ms Arith_Pratt 13.2 → 12.0 ms (19.4 → 14.2 MB) 16.8 → 15.7 ms 25.9 → 24.7 ms Arith_LeftRec 33.3 → 35.2 ms (unchanged) 34.5 → 35.6 ms 45.8 → 45.7 ms Minilang 18.6 → 18.0 ms (20.8 → 18.9 MB) 24.0 → 24.3 ms 31.7 → 32.3 ms Recognition gains more, because memo entries were most of its allocations: JSON 15.9 → 12.0 ms and 17.7 → 2.4 MB (closure), CSV 6.8 → 4.8 ms and 10.6 → 1.6 MB. (CSV and Arith_Pratt were measured before the per-rule switch was added; with no repeated calls the switch never fires on them.)
19. A plain call path for unmemoized rules without captures
- A rule call went through
call→invoke→invokeBegin/ body /invokeEnd: a capture frame was allocated or shared and switched to, aninvokeStatewas built and copied, and the failure handling ofcallcame on top. In profiles of recognition this chain was about a fifth of the time. - An unmemoized call (transient rules, and first calls since change 18) of a rule whose scope has no captures now goes
through
invokePlain, which does the same steps inline and leaves the caller's frame current (a body without captures never writes one). Depth limit, cut, environment, trail, recovered errors andfinishbehave as before. The closure backend and the recursive VM use it; the iterative VM has its own call frames. - Effect (min of 6 interleaved runs, Apple M3 Max): full parses 2.5–9.5% faster (JSON closure 17.7 → 16.5 ms, bytecode 21.8 → 19.8 ms; XML closure 17.8 → 16.2 ms), recognition 5.5–15.6% faster (JSON closure 11.8 → 10.0 ms, bytecode 16.4 → 13.8 ms).
20. Splicing the memo table on Document edits
Document.Editrebuilt the memo table on every edit: it walked all entries andputeach kept one into a new table, which allocated a new slot array and searched each chain for an existing key. In the incremental benchmark (a one-character edit to minilang followed by a reparse) this was about 30% of the time.- The table is now spliced in place (
memoTable.splice): each chain is filtered with the same keep/shift/drop rules, the slots from the end of the edit on move by the length difference (onecopy), and the few entries that stay at the edit position (rules that examined no input) are put back.TestDocumentEditKeepsMemochecks after random edits that exactly the entries the rules allow are kept, at the right positions; it fails if those entries are lost. - Effect (min of 6 interleaved runs, Apple M3 Max): edit plus reparse 3.50 → 2.29 ms (closure), 3.37 → 2.34 ms (bytecode), 3.46 → 2.33 ms (iterative); 6.0 → 3.9 MB allocated per edit.
- What remains is mostly shifting reused subtrees (
shiftNodecopies a reused node tree with its positions moved) and copying the input.
21. Changes 18 and 19 in the generated parsers
- The generated runtime (
internal/engine/genrt) now defers memoization to the second call at a position with the same per-rule switch to eager memoization (the generator emits each rule'sseennumber and their count), and calls unmemoized rules without captures throughinvokePlain. The generated files inbench/genandexamples/json/generatedwere regenerated. - Effect (min of 8 interleaved runs, Apple M3 Max, code points; time and bytes per parse):
Workload Before After JSON 14.8 ms, 37.0 MB 11.2 ms, 23.8 MB CSV 7.2 ms, 27.4 MB 5.7 ms, 19.4 MB XML 11.8 ms, 29.4 MB 10.7 ms, 25.3 MB Arith_Pratt 8.9 ms, 16.9 MB 7.6 ms, 11.9 MB Arith_LeftRec 18.2 ms 18.8 ms (unchanged bytes) Minilang 13.2 ms, 18.6 MB 12.8 ms, 17.2 MB
22. Bounded memory and less copying in stream parsing
- Retention. A stream parse is meant to hold only its working set, but the reachable heap grew with the number of
elements on several grammars (found while writing the streaming guide): about 78 MB per 100,000 records for a grammar
with a header and captured fields, on every backend. Nodes, child lists, frames and field lists come from chunks
(change 6), a chunk stays alive while anything in it is referenced, and it keeps alive what its objects point to.
Consecutive elements shared chunks, so the chunk being filled reached the previous element's chunks, and so on back
to the first element. At an element boundary, once the node chunk is nearly used up or the elements since the last
split filled more than one chunk, the parser now starts new chunks of every kind (
splitChunks), so chains stay within a group of elements.TestParseStreamMemoryIsBoundedchecks the reachable heap on every backend; it fails without the split. - Copying.
discardcopied the whole read-ahead buffer into a new slice at every committed element. It now compacts in place, and only once at least half of the buffer can go. - Effect (min of 6 interleaved runs, Apple M3 Max): streaming 50,000 CSV records 168.9 → 117.7 ms (closure), 180.6 →
129.9 ms (bytecode), 215.3 → 160.8 ms (iterative), and 686 → 351 MB allocated per parse. The reachable heap no longer
grows: 233 MB → 0.5 MB after 300,000 records of the grammar above. A 27 MiB, 1,000,000-record CSV file now streams with
a peak resident size of about 61 MB (0.8 GB before; a whole-input
Parsetakes 2.2 GB).
23. Splicing the text of a Document in place
- After change 20, most of an edit's cost was the text:
Editcopied the code points into a new slice, converted them back to a string, and rebuilt the code-point-to-byte offset table by decoding the whole string. It now replaces the edited range in the code points (or bytes) in place, builds the new string by concatenating substrings of the old one, and splices the offset table, adding the byte difference to the entries after the edit (input.replace). Nodes keep referring to the old string, which does not change.TestDocumentEditTextchecks after random edits with multi-byte characters and invalid UTF-8, in both units, that the text and offsets equal those of the edited text read from scratch. - Effect (the program in the streaming guide, 100,000 lines, Apple M3 Max):
Edit12.3 → 2.7 ms (one rule per line), 16.5 → 3.4 ms (Pratt lines), 9.2 → 0.3 ms (no memo entries); JSON document of 283 KB 6.6 → 2.7 ms per edit.
24. The plain call path in the iterative VM
- The iterative VM sent every rule call, memoized or not, through
callBeginandcallEnd(examined-range bookkeeping, state copies) and through the fullinvokeBegin/invokeEnd. Its call frame now takes the same paths asparser.call: unmemoized calls of rules without captures run the steps ofinvokePlain, other unmemoized calls skip the memo bookkeeping, and only memoized calls usecallBeginandcallEnd. callBeginused to decide again whether to memoize, so the recursive backends calledfirstCalltwice for each memoized call and counted every repeat twice (change 18). The caller now decides once. The threshold for eager memoization went from 8 to 16 so that rules switch as before (minilang allocates the same as before).- Effect (min of 6 interleaved runs, Apple M3 Max), iterative VM: full parses JSON 33.2 → 26.0 ms, XML 31.6 → 25.5 ms, Arith_Pratt 24.1 → 21.7 ms, Arith_LeftRec 44.7 → 39.8 ms, minilang 30.7 → 28.2 ms; recognition JSON 28.5 → 20.3 ms, XML 29.7 → 23.2 ms. Other backends unchanged.
25. Discarding separators in the example grammars
- Counting the nodes allocated against those in the final tree showed that about half of the JSON parser's nodes were
thrown away. Among them, every element after the first in
rest:(-ws "," -ws m:member)*kept the","as aMatchchild of the iteration'sSeq(a captured repetition keeps its whole CST, see the guideline below): 15,550 nodes per parse in JSON and 25,005 in CSV. The JSON, CSV and minilang examples now discard the separator (-","); the ASTs are unchanged. - The rest of the discarded nodes are the iteration
Seqnodes themselves and intermediate lists built bylist,mapandconcatin actions. - Effect (min of 6 runs alternating the old and new grammars, Apple M3 Max, code points): bytes per parse JSON 25.8 → 23.6 MB, CSV 22.5 → 19.0 MB, minilang 18.9 → 18.6 MB (closure); CSV 3.5% faster on the closure and bytecode backends, JSON and minilang unchanged within noise. Generated parsers: JSON 11.2 → 10.9 ms, CSV 5.5 → 5.2 ms.
26. Lambda calls without allocation in the closure backend
- Each lambda call (
closure.apply) copied the evaluation context and allocated alocalfor each parameter, and each lambda expression allocated aclosure. On JSON this was 36k of the closure backend's 37k allocations per parse. - Contexts and parameters of a call now come from LIFO arenas in the parser (
arena[T]), freed when the call returns; closures come from an arena freed at the next evaluation (useCtx), since a lambda cannot outlive the action or predicate that created it (lambdas are only arguments ofmap,foldlandfoldr, and functions cannot be stored). A lambda created inside another lambda's call keeps a heap copy of that call's context (keep). - Effect (min of 8 interleaved runs, Apple M3 Max, closure backend, code points): JSON 15.9 → 15.3 ms, 37k → 1.3k allocations, 23.6 → 21.4 MB; CSV 8.0 → 7.3 ms, 56k → 0.8k allocations, 19.0 → 15.6 MB; minilang 21k → 8k allocations. XML and Arith_Pratt use no lambdas and are unchanged.
27. Struct and call operands on the expression stack in the closure backend
- The closure backend's evaluator built two slices for every
new T{...}(field names and values) and one for every built-in call (arguments). Values and arguments now go on the parser's expression stack (parser.estack, as in the VMs since change 14) and field names into a reused buffer;newStructand the built-ins retain neither. - Effect (min of 8 interleaved runs, Apple M3 Max, closure backend): Arith_Pratt 11.1 → 10.6 ms, 40.6k → 0.6k
allocations, 14.2 → 12.2 MB; XML 38.9k → 19.1k allocations; minilang 8.3k → 1.1k allocations; times otherwise
within noise. All backends now allocate fewer than 1.5k objects per parse on JSON, CSV, Pratt and minilang; XML's
remaining 19k are
text(...)results (a string stored in an interface needs a heap header).
28. Field lists and list built-ins without allocation in the generated parsers
- The generated runtime still allocated a field list per struct node and per node with captures, and a slice per
list,mapandconcatcall (changes 15 and 27 in the engine). Field lists now come from chunks (parser.fields), and the list built-ins gather their elements onkidStackand hand the copied-out slice to the list node. A predicate that fails with an evaluation error pops what a failed built-in gathered. - Effect (min of 8 interleaved runs, Apple M3 Max, generated parsers, code points): JSON 10.8 → 10.3 ms, 53.9k → 1.3k allocations; CSV 5.2 → 4.9 ms, 45.6k → 0.8k; XML 10.5 → 10.0 ms, 31.5k → 19.1k; Arith_Pratt 7.5 → 7.2 ms, 22.8k → 0.6k; minilang 12.2 → 11.9 ms, 19.3k → 1.1k. Bytes change by −1% to +3% (partly used chunks).
29. Shifting reused subtrees into the parser's chunks
- After an edit, a memo result after the edit is reused with its positions shifted:
shiftNodecopies its node tree. Every copied node, child list and field list was a separate heap allocation, and every copy made a new map of the nodes already copied (to keep shared subtrees shared). In the incremental benchmark this was 89% of the bytes allocated per edit and reparse. - Copies now come from the parser's chunks (
newNode,nodes,fields), and the map is kept by the parser and cleared between copies (and dropped when it grew past 1,024 entries, so clearing stays cheap). - Effect (min of 8 interleaved runs, Apple M3 Max, one-character edit to minilang plus reparse): 1.72 → 1.20 ms (closure), 1.76 → 1.20 ms (bytecode), 1.77 → 1.25 ms (iterative); 20k → 0.15k allocations, 3.0 → 2.4 MB.
30. Building the code-point offset table on demand
- In code-point mode, preparing the input made three passes over it: converting it to code points, checking that it is valid UTF-8, and building the code-point-to-byte offset table that token text uses (change 10). In profiles of recognition this was about 4.5% of the time, though recognition rarely needs token text.
- The input is now decoded and checked in one pass, and the offset table is built the first time
textneeds it (and before aDocumentedit splices it). - Effect (min of 6 interleaved runs, Apple M3 Max): recognition JSON 9.6 → 9.1 ms (closure), 13.6 → 12.9 ms (bytecode), CSV 4.2 → 3.8 ms (closure), 5.2 → 4.8 ms (bytecode), with 1 MB less allocated per parse; full parses and XML recognition (whose predicates read token text) unchanged within noise.
- Pitfall:
Document.Parsegives the parser a copy of the input, so the table built during a parse was lost and every edit and every reparse of aDocumentrebuilt it from the whole text (an edit to a 100,000-line document went from 0.3 to 2.2 ms). The incremental benchmarks were not in the measurements of this change. TheDocumentnow keeps the tables a parse builds (TestDocumentKeepsInputTables).
31. Specialized character-class tests in the closure backend
- The closure backend tested a character against a class by looping over the class's ranges in the grammar AST. Classes with one or two ranges (most of them) now compare against constants captured by the test function; larger classes test ASCII characters with a bitmap and others against a copy of the ranges.
- An ASCII bitmap alone had measured no gain (see the experiments table); removing the loop and the AST access for the common small classes is what pays.
- Effect (min of 10 interleaved runs, Apple M3 Max, closure backend): full parses 1.2–4.9% faster (CSV 7.1 → 6.8 ms, XML 15.1 → 14.7 ms), recognition 1.6–9.4% faster (CSV 3.7 → 3.3 ms, XML 12.7 → 12.1 ms).
32. Matching literals against loaded input in one loop
matchLiteralchecked one character at a time, each time recording the examined range, checking that the input was loaded far enough (stream input is read lazily) and indexing relative to the discarded prefix. When the whole input is loaded (every parse except streams), it now compares the literal in one loop and updates the position and the examined range once, with the same results (a mismatch still leaves the position after the matched prefix and examines the first differing character).- Effect (min of 8 interleaved runs, Apple M3 Max): closure and recursive bytecode backends 1.5–7% faster (minilang closure 16.2 → 15.3 ms, Arith_Pratt closure 10.4 → 9.8 ms, recognition of Arith_Pratt 7.5 → 7.0 ms), except CSV on bytecode (unchanged within noise).
33. Smaller VM entries
- Every choice and every repetition iteration pushes an entry onto the VM's entry stack. Entries were 112 bytes,
mostly fields that only
#errorlabels,#recoverand skip entries use; in profiles, pushing them was the most expensive line of the VM loop. Those fields moved to a separate stack (parser.labs, indexed from the entry), and the stack heights in the saved state areint32: an entry is now 56 bytes. - Effect (min of 6 interleaved runs, Apple M3 Max): both VMs 1–5% faster on full parses (JSON bytecode 18.8 → 18.0 ms, XML 19.0 → 18.2 ms) and 2–6% on recognition, including the error-recovery workload.
34. One frame per plain call in the iterative VM
- A rule call in the iterative VM pushed a call frame, which pushed a body frame: two frame pushes, pops and dynamic
dispatches per call. The most common call, an unmemoized call of a rule without captures (change 24), is now run
by the body frame alone (
plainCallFrame), which does the call's first steps when it is pushed and the last ones when the body finishes. The caller decides once whether to memoize and passes the decision to the call frame. - Effect (min of 8 interleaved runs, Apple M3 Max, iterative VM): full parses JSON 24.3 → 22.0 ms, CSV 9.9 → 9.2 ms, XML 24.1 → 21.9 ms, Arith_Pratt 20.3 → 19.6 ms; recognition JSON 18.4 → 14.5 ms, CSV 6.1 → 5.3 ms, XML 21.2 → 18.9 ms; minilang unchanged within noise.
35. Left-recursion growth without allocation
- Growing the seed of a left-recursive rule allocated a
growStateper call of the leader and a memo entry on the heap for the seed and for every longer result, instead of taking entries from the memo table's chunks: 84k of the 86k allocations per parse of the left-recursive calculator. - The grow state is now a value (in the caller's frame, or in the iterative VM's call frame) and the entries come from the memo table's chunks.
- Effect (min of 10 interleaved runs, Apple M3 Max, Arith_LeftRec): full parses 6–9% faster (closure 28.9 → 26.6 ms), recognition 6–10% faster (closure 21.8 → 19.8 ms); 86k → 2.2k allocations.
36. Comparing texts without boxing them
text(...)returns a string as an interface value, which allocates for the string header. The XML example compares the name of every end tag with its start tag ([text($e) == text($n)]), which was 18k of its 19k allocations per parse. A comparisontext(a) == text(b)(or!=) is now evaluated as a string comparison by the closure backend and emitted as one by the code generator. The VMs still box the strings (avoiding it there would take a new instruction).- Effect (min of 12 interleaved runs, Apple M3 Max, XML): closure 14.1 → 13.8 ms, generated 9.9 → 9.6 ms, recognition 11.7 → 11.4 ms; 19k → 1.1k allocations.
37. No intermediate lists in concat
concat(list($first), map($rest, (r) => $r.x)), the usual way to build a list from a first element and the rest, built two lists only forconcatto copy their elements: about 19k list nodes per JSON parse. When an argument ofconcatis itself a call oflist,maporconcat, the closure backend now pushes that call's elements directly onto the stackconcatcollects on, and the code generator emits the same pushes. This is done only when every argument is such a call (the common case): with any other argument,concatchecks it only after evaluating all of them, and fusing would report a different error when two arguments fail. The VMs still build the intermediate lists (fusing them there would take new instructions).- Effect (min of 12 interleaved runs, Apple M3 Max): JSON 14.4 → 14.1 ms (closure), 10.0 → 9.8 ms (generated), 21.4 → 20.0 MB per parse; CSV 6.6 → 6.2 ms (closure), 4.8 → 4.5 ms (generated), 15.6 → 14.0 MB; minilang unchanged within noise.
38. Calling plain rules directly from closure call sites
- Rules that are never memoized in an ordinary parse and have no captures always end up in
invokePlain(change 19), but every call went throughcall's checks first. Such rules are now marked when the program is built (rule.plain), and the closure backend's call sites callinvokePlaindirectly unless aDocumentparses (which memoizes them). - Effect (min of 12 interleaved runs, Apple M3 Max, closure backend): full parses 0–3% faster, recognition 2–4% (JSON 8.4 → 8.1 ms).
39. A line table for error positions
- Every syntax error, recovered ones included, computed its line and column by scanning the input from the start. With many recovered errors this is quadratic: on the error-recovery benchmark (minilang with one line in seven broken) it was 14% of the time. On fully loaded input the line and column now come from a table of line starts, built at the first error, by binary search; stream input, whose start is discarded, keeps scanning from the committed position. Generated parsers do the same.
- Effect (min of 10–12 interleaved runs, Apple M3 Max, Recovery workload): full parses 15–32% faster (closure 19.2 → 14.6 ms, generated 16.9 → 11.5 ms), recognition 17–30% faster (closure 15.5 → 10.9 ms).
40. One pass to prepare code-point input in generated parsers
- Generated parsers prepared code-point input in three passes, as the engine did before change 30: converting to code points, checking UTF-8 validity, and building the offset table for token text. Full parses always need the table, so it is still built eagerly, but now in the same pass as the conversion and the check.
- Effect (min of 16 interleaved runs, Apple M3 Max, generated parsers): JSON 10.1 → 9.6 ms, CSV 4.6 → 4.2 ms, XML 10.1 → 9.7 ms.
41. Building the offset table while decoding, for full parses
- Since change 30 the code-point offset table is built on demand, which spares recognition a pass but costs full parses
(which nearly always need token text) a separate pass at their first token. Full parses and
Documentnow build it in the decoding pass (newTextInputwith offsets), as generated parsers do (change 40); recognition keeps building it on demand. - Effect (min of 10 interleaved runs, Apple M3 Max, code points): full parses 1–5% faster on the closure and bytecode backends (XML closure 15.3 → 14.5 ms, JSON closure 15.4 → 14.7 ms); recognition and incremental parsing unchanged.
42. Generated calls of plain rules
- Generated parsers called every rule through
call, which checks whether the rule is memoized before reachinginvokePlainfor rules that never are (change 21). The generator knows which rules those are (rule.plain, change 38; generated parsers have noDocument), so their call sites now callinvokePlaindirectly. - Effect (min of 12 interleaved runs, Apple M3 Max, generated parsers): JSON 9.7 → 9.3 ms, XML 9.7 → 9.1 ms, others 0–2% faster.
43. Direct calls of plain rules in the VMs
- As in the closure backend (change 38) and generated parsers (change 42), the recursive VM calls rules that are
never memoized in an ordinary parse and have no captures through
invokePlaindirectly, and the iterative VM starts them withplainCallFramewithout asking whether to memoize. - Effect (min of 10 interleaved runs, Apple M3 Max): both VMs 0.5–2.5% faster on full parses and 1.4–4.6% on recognition (JSON bytecode 12.2 → 11.7 ms).
44. Changes 36 and 37 in the VMs (instruction set 2)
- The VMs now compare
text(a) == text(b)without boxing the strings and build no intermediate lists for aconcatoflist,mapandconcatcalls, through six new expression instructions (ETEXTCHK,ETEXTEQ,ELISTBEGIN,ELISTPUSH,EMAPPUSH,ELISTEND; see bytecode.md). The instruction-set version of compiled files is now 2, and files of version 1 still load. - Effect (min of 20 interleaved runs, Apple M3 Max, recursive VM, during a busy day): XML 19.2k → 1.2k allocations, JSON 22.0 → 20.5 MB and CSV 16.2 → 14.6 MB per parse; time within ±3% (CSV and XML 2% faster, JSON 3% slower).
45. Moving reused trees in place after document edits
- A
Documentreparse copied the node tree of every reused result after an edit with shifted positions (shiftNode), so it allocated in proportion to everything after the edit. Nodes are now moved in place (moveResult): each node records how many document edits its positions account for (Node.gen, in padding, soNodestays 120 bytes), and the document keeps its edits. A non-empty node moves by the edits that lie before it, which its positions tell; empty nodes at an insertion point are ambiguous and are copied instead. Edits that keep the length no longer mark entries as shifted. - A review found that the first version collapsed the descendants of an empty result (captures made in a lookahead) onto one point, and that it replayed every edit since the last parse for every node, so a parse after 5,000 edits took 56 ms instead of 0.7 ms on 20,000 lines. Empty nodes now move their descendants by the same shift, and a memo entry's own accumulated shift (with the generation at which its result was last current) gives the shift of most nodes; edits are replayed only for nodes moved through another result since, once per generation.
- Trees returned by earlier parses now change when the document is parsed again;
Node.Clonekeeps a copy. - Effect (min of 12 interleaved runs, Apple M3 Max, minilang, one-character edit and reparse): 1.34 → 0.90 ms on the
closure backend, 1.30 → 0.92 ms (recursive VM), 1.24 → 0.94 ms (iterative VM); 2.4 MB → 0.34 MB and 151 → 49
allocations per edit. Most of what remains is the
madvisecalls of the Go runtime reusing scavenged memory, which is specific to macOS, andmemoTable.splicewalking every memo entry.
46. Direct calls of plain rules in generated parsers
- Generated parsers called rules that are never memoized and have no captures (
rule.plain) throughinvokePlain, which runs the body through the rule's function value (a closure that calls the body's method). The generator now writes a method per such rule (r<id>) that does whatinvokePlaindoes and calls the body's method directly; for a rule without a value it also skipsfinish. Pratt rules still useinvokePlain. - Effect (min of 20 interleaved runs, Apple M3 Max): 0.3–6% faster on every workload (JSON 9.36 → 8.80 ms, XML 9.22 → 8.95 ms). Generated files grow by a few lines per such rule.
47. Recognition in generated parsers
- Generated parsers had no recognition mode.
pego gen -recognize(pego.WithRecognize()) now also generates the recognizer (Program.recognizer, the program in which no rule builds a value) into a second rule table, behindRecognize(input) error. The generator follows the engine there: captures that no predicate reads are not recorded, and Pratt lines of rules without a value build nothing (the generated runtime'slineResultandprattBuildnow checknovalue, as the engine's do). - Effect (min of 3 runs, Apple M3 Max): JSON 4.9 ms against 8.8 ms for
Parseand 8.6 ms for the closure backend's recognition; CSV 1.8 ms (Parse4.0 ms), XML 7.3 ms (8.9 ms), Outline 1.7 ms (3.9 ms). The parity test checks thatRecognizereturns the errors of the engine's recognition on every corpus input.
48. A typed runtime for ParseAST
ParseAST(pego gen -types) converted the tree ofParse, about 6% slower thanParse. For grammars whose results have Go types of their own, it now runs a typed runtime that builds those values directly (design record 012). Steps that mattered, on JSON (262 KB):- the runtime alone, with values as
any: 9.8 ms, 22.4 k allocations (variadic struct constructors); - generated constructors
tmk_T: 9.7 ms, 1.1 k allocations, 15 MB; - projected repetitions (
map($rest, (r) => $r.f)): 9.0 ms, 12 MB; - frames reused when their rule returns: 7.6 MB (time within noise);
- scratch memory pooled across parses: 2.4 MB (time within noise; the remaining cost is not allocation);
- rule calls with methods of their own, direct actions, one recovery per parse for action errors and constructors of action results taking the rule's range: 8.5 → 7.6 ms.
- the runtime alone, with values as
- Effect (min of 4 runs, Apple M3 Max),
ParseASTagainstParseof the same generated parser: JSON 9.9 → 7.7 ms (20.0 → 2.4 MB), XML 10.0 → 8.0 ms (25.2 → 3.2 MB), Arith_LeftRec 18.7 → 15.0 ms (39.4 → 4.1 MB), Outline 4.5 → 3.5 ms (13.5 → 2.6 MB), Arith_Pratt 7.8 → 8.0 ms (12.2 → 3.1 MB). - Later, struct constructors that make the result of a Pratt line's action also take the rule's range directly:
Arith_Pratt 7.7 → 7.2 ms (min of 12 interleaved runs), now faster than
Parse(7.8 ms). The same per-rule call methods for the Node runtime'sParsemeasured within ±4% (noise) and were not adopted. - On macOS most of the time a large allocation costs is the
madvisecalls of the Go runtime; profiles there attribute it to the code that touches new memory, and GOGC=off makes it worse (every span is new).
49. First-character dispatch in generated choices
- In generated parsers, an alternative of a choice that must begin with a given terminal (a literal or a
character class, possibly behind sequences, captures,
@,-and calls of rules that are neither left-recursive nor Pratt) is skipped when the next character cannot start it, recording the expectation it would have recorded. Such an alternative fails at once without its terminal, so nothing else is observable; the calls it skips would only have set memo bookkeeping, and the skip is not taken when those calls would exceed the nesting limit.value = object / array / string / number / bool / nullin JSON, for instance, no longer calls four rules to parse a number. - Effect (min of 12 interleaved runs, Apple M3 Max):
ParseJSON 9.1 → 7.9 ms (−13%), XML −10%, Minilang and Recovery −9%, Arith_Pratt −6%, CSV −4%;ParseASTJSON 7.3 → 6.4 ms (−12%), XML −10%, Arith_Pratt −11%; fewer bytes and allocations (fewer memo entries).Recognizeuses the same code.
50. First-character dispatch in the closure backend
- Change 49 in the closure backend. The choice peeks at the next character once (which records it as examined, so a
Documentstill invalidates the result when that character changes) and skips the alternatives that cannot start there, recording their expectations. The bytecode VMs are unchanged (it would need an instruction). Rule bodies that are skipped are no longer counted inDocument.Stats, so the counts in the incremental guide changed slightly (one fewer evaluation or reuse for a blank line's alternative). - Effect (min of 12 interleaved runs, Apple M3 Max, closure backend): JSON 14.5 → 13.4 ms (−7%), Minilang −5.5%,
Recovery −6.5%, XML and Arith_LeftRec −3%;
Documentreparse after an edit 0.95 → 0.72 ms (−23%), since skipped alternatives are neither memoized nor looked up; recognition within ±5%.
51. First-character dispatch in the VMs (instruction set 3)
- Changes 49 and 50 in the VMs: a new instruction,
GUARD c, d, l, is emitted before theCHOICEof an alternative that must begin with a given terminal (the analysis,firstTerminal, is shared by every backend). The instruction-set version of compiled files is now 3; files of versions 1 and 2 still load (they simply have no guards). - Effect (min of 10 interleaved runs, Apple M3 Max): JSON 18.5 → 16.1 ms (recursive VM, −13%) and 22.4 → 19.0 ms (iterative VM, −15%); Minilang −8% on both; Arith_Pratt −4% and −7%; XML −1% and −7%.
52. Projected repetitions in generated Parse
- The projection of change 48 (a repetition captured only to be mapped to one field of its elements gathers that
field directly) now applies to the generated parsers' Node runtime too. A review of the typed runtime found the
conditions incomplete, and they are now: the action reads the capture only as
map($x, (e) => $e.f)and uses no$n(which can reach the same list), the names involved are captured once in the rule, andfis captured in every element with a value that is never nil (otherwise an element without a record makes$e.fan error). - Effect (min of 8 interleaved runs, Apple M3 Max, generated
Parse): CSV 3.9 → 2.7 ms (−30%, 14.0 → 8.7 MB), JSON 7.8 → 7.0 ms (−10%, 18.4 → 15.1 MB), Minilang −4%; grammars without such repetitions unchanged.
53. Projected repetitions in the engine and the VMs
- Change 52 in every backend. The analysis moved to
project.go(shared by the closure backend, the bytecode compiler and the code generator). It decides from the grammar alone (the engine compiles before type checking): a rule's value is never nil when it has a terminal type or its action makes a struct or returns such a capture, through#errorand recursion. The closure backend records the projectedmapcalls and its evaluator takes the list as it is; the VMs mark the repetition withNEXTmode 3 (push the field's slot) and compile themapwith(e) => $ein place of the projection, so they need no other new instruction. - With projections in every backend, results produced with them could only be checked against a program without
them: the corpus tests now compare every backend and the generated parsers with the engine compiled with
noProjections, and fail if the conditions are loosened (a nil field,$n). - Effect (min of 10 interleaved runs, Apple M3 Max): CSV −22% (closure, 6.1 → 4.7 ms), −16% and −14% (VMs), 14.0 → 8.7 MB; JSON −9% (closure, 13.5 → 12.3 ms), −8% and −6% (VMs); Minilang within noise.
54. Resuming long repetitions in a Document
- A reparse runs again the rule that contains the edit, and any repetition in it ran again element by element: for a
file's
line*, a memo lookup per line. A profile of a 100,000-line settings file put half of the reparse in that loop (callBegin, memo lookups). A repetition of 16 elements or more now records its run in aDocumentparse (per element: positions, examined range, expectations, value); after one edit it reuses the elements before the edit, parses from there, and once an element ends where an old element after the edit began, reuses the rest of the old run with its nodes moved in place (design 007, "Resuming repetitions"). The old run's array is updated in place, and the stack that collects repetition values is kept by theDocumentacross parses instead of growing from empty each time (that growth alone was 4.3 MB per reparse at 100,000 lines). - Effect (min of 12 interleaved runs, Apple M3 Max, closure backend): one-character insert/delete in the middle of a
100,000-line settings file 11.0 → 5.1 ms (−54%, 8.3 → 3.8 MB);
BenchmarkIncrementalLong(a new benchmark: a 50,000-record CSV) 15.1 → 8.4 ms (−44%); the guide's same-length edit at 100,000 lines 9.2 → 0.6 ms for the reparse. MinilangDocument+2% (its repetitions are short and are recorded only when they reach 16 elements, but every element pays for keeping its examined range apart); batch parses unchanged. The records cost about 80 bytes per element of a long repetition (Document heap at 100,000 lines 143 → 151 MiB). - What remains of an edit at 100,000 lines is
Edititself (the memo splice visits every entry, about 30% of the insert/delete benchmark), moving the nodes after an edit that changed the length, and the new list of children.
55. Tracing hook on the call path
- Tracing (design 014) adds one
p.tr != niltest at the entry ofparser.calland one on the iterative VM's non-plain call path. The plain call sites testp.noPlaininstead ofp.memoAll(same cost);noPlainis set byDocumentand by tracing, so a traced parse sends every call throughcall. Nothing else on the untraced path changes, and generated parsers are untouched. - Effect (min of 12 interleaved runs, Apple M3 Max): JSON and Minilang, closure and recursive VM, −1.3% to +0.3% (noise); a first 8-run pass showed up to +6% that did not reproduce. Incremental and stream benchmarks within ±4% in both directions.
56. Applying a Document edit to memo entries when they are looked up
- After change 54, an edit in the middle of a long document spent most of its time in
Edit:memoTable.splicevisited every memo entry to keep, shift or drop it (about 300,000 at 100,000 lines), and moved every chain after the edit. Now the memo table is a gap buffer with the gap at the last edit, so the positions after an edit move by moving the gap (the distance from the previous edit), and the edit decides only the entries at the edited positions. Every other entry records how many edits it accounts for (memoEntry.vgen, 8 more bytes per entry), andcallBeginapplies the edits since, with the same rules in the same order, when it finds the entry (advanceEntry); one that an edit invalidated is a miss. Lookups in other parses pay one comparison. - Effect (min of 6 interleaved runs, Apple M3 Max):
BenchmarkIncremental(Minilang) 0.73 → 0.17 ms (closure, −77%), −74% and −71% (VMs);BenchmarkIncrementalLong(CSV) 8.2 → 3.2 ms (closure, −61%), −31% and −37% (VMs); the 100,000-line insert/delete 4.9 → 2.7 ms;Editalone at 100,000 lines 3 ms → 0.33 ms (most of what remains copies the text), and 2.4 ms → 70 µs on the guide's 283 KB JSON. Allocation per reparse unchanged in steady state (the first edit widens the slot array once); batch parses and streams within noise.
57. Pooling the scratch memory of whole-input parses
- Each
Parseallocated the decoded input and its offset table, the memo table (slots, entry chunks, the bit set of first calls) and the stack that collects repetition values, and dropped them when it returned: nothing in a result refers to them (node text slices the input string, errors are built before returning). AProgramnow keeps them in async.Pool(newPooledParser,releaseScratch): the memo's slots and entry chunks are cleared and reused, and buffers over 4M elements are not kept. Recognition uses its program's pool.Documentand streams keep their own memory as before. - Effect (min of 6 interleaved runs, Apple M3 Max, code points): full parses −1% to −8% (Minilang −8%/−7%/−6% on the three backends, XML −6%/−5%/−4%, JSON −2%/−1%/−1%), allocation −14% to −38% (Minilang 15.3 → 9.5 MB); recognition −5% to −11% (Arith_LeftRec −11%, 24.7 → 3.4 MB).
58. Pooling the scratch memory of generated Parse and Recognize
- Change 57 in the generated parsers' Node runtime:
parsetakes its parser from async.Pooland returns it (release), keeping the decoded input and offsets, the memo slots and entry chunks, the bit set of first calls and the stacks, and dropping the node, list, field and frame slabs, which the result uses.ParseandRecognizeshare the pool; the per-rule call counters are dropped when the rule table changes. The runtime now always importssync. - Effect (min of 6 interleaved runs, Apple M3 Max):
ParseXML −9%/−12% (code points/bytes), Arith_LeftRec −12%/−15%, Recovery −9%/−8%, CSV −8% (code points), Minilang −6%/−5%, JSON within noise (+2%);RecognizeXML −17%, Arith_LeftRec −15%, Minilang −10%, JSON −4% with one allocation per parse;ParseASTunchanged (it pooled already).
59. Resuming long repetitions in the VMs
- Change 54 in both bytecode VMs, which share
exec: the VM finds the repetitions it can resume when it loads a module, from the bytecode alone (aREPEAT, itsITER, the element code,NEXTandENDREPEAT; the element must contain noPREDorASSIGNand call no rule that reads variables, and its elements are moved past a length-changing edit only if it calls no positional rule), so the instruction set is unchanged. In aDocumentparse,REPEATtakes over the old run and reuses the elements before the edit,ITERresynchronizes and starts an element whose examined range and expectations are kept apart (a new entry kind,eRunIter, undoes that on failure, and a cut marks it like anITER),NEXTrecords the element andENDREPEATstores the run. The steps are shared with the closure backend (runStateinresume.go). - Effect (min of 6 interleaved runs, Apple M3 Max):
BenchmarkIncrementalLong(50,000-record CSV) 12.0 → 4.1 ms on the recursive VM (−66%) and 12.5 → 4.1 ms on the iterative VM (−67%);BenchmarkIncremental(Minilang) −11% and −15%; batch parses and streams within noise.
60. Direct rules in the typed runtime
- The typed runtime ran every rule as the Node runtime does: a method per expression, captures in a frame with a
trail that marks and resets undo, and the action through
useCtxandtctx.result, reading the frame. A CPU profile of JSONParseASTspread the time over those methods,setCaptureandreset,newFrameandfreeFrame, and the action plumbing (about 10%). A rule without a cut or#recoverthat is neither a Pratt rule nor a left-recursion leader is now compiled into the method that calls it (design 012, "Direct rules";gen_direct.go): failures jump to labels, captures are Go variables that backtracking points save and restore, and the action is a Go expression over them, without the checks oftctx.resultwhen it makes a struct with the rule's range. Every rule of the JSON, XML and outline grammars is direct; in the calculators, all but the Pratt expression and the three left-recursive rules. - The parity test of
ParseASTgained grammars for the new code paths (testdata/typed/direct_*.pego: captures and an environment restored by choices, optionals and repetition elements, elements with captures of their own that become records,$n, built-ins, errors in actions, recovered errors undone and replayed from the memo,#error, anchors, bounded repetitions and alternatives that can never be tried), and now runs in both position units. Leaving out any one restoration (captures, environment, recovered errors), the clearing of element captures at each iteration, or sharing one character variable among choices makes it fail. - Effect (min of 6 interleaved runs, Apple M3 Max):
ParseASTJSON 6.44 → 3.86 ms (−40%), XML 6.96 → 4.34 ms (−38%), Arith_LeftRec 14.3 → 10.3 ms (−28%), Outline 3.32 → 2.46 ms (−26%), Arith_Pratt 6.40 → 6.03 ms (−6%); XML 4.3 → 3.2 MB (no frames), the others unchanged in bytes. JSONParseASTnow takes the time ofRecognize(3.9 ms) and about half that ofParse(7.2 ms). GeneratedParseandRecognizeare unchanged.
61. Reading code points directly in direct rules
- After change 60,
peek(the character at the position, in either position unit) was 9% of a JSONParseASTprofile. The compiler does not inline it: its cost is 110 against a budget of 80, mostly for decoding a multi-byte character inBytes, and still 88 with that moved into a function of its own, because the call alone costs 57. Direct rules now read the character fromp.in[p.pos]whilep.pos < len(p.in), and callpeekotherwise, in character classes,., scan loops and the first-character tests of choices.p.inis empty inBytes, whichrunnow ensures rather than relying on how a pooled parser was cleared. - Effect (min of 8 interleaved runs, Apple M3 Max,
ParseAST): JSON 3.75 → 3.30 ms (−12%), XML 4.41 → 3.99 ms (−10%), Outline −6%, Arith_LeftRec −3%, Arith_Pratt −2% (its Pratt loop is the general code).
62. Comparing short literals in place in direct rules
matchLiteralwas the next call in the JSONParseASTprofile (about 4%), mostly for one-character literals such as","and":". Direct rules now compare a literal of up to four code points withp.inin place and callmatchLiteralonly when it does not match there (which records the expectation) or inBytes. A test grammar covers literals of one to five code points, non-ASCII ones and literals cut off by the end of the input, in both units.- Effect (min of 12 interleaved runs, Apple M3 Max,
ParseAST): JSON 3.34 → 3.16 ms (−5%), XML 3.89 → 3.80 ms (−2%), Outline 2.14 → 1.97 ms (−8%); the calculators within noise (min of 8 runs).
63. A smaller Node
Nodewas 120 bytes: two name strings (Type,Rule),intpositions, the text, children, fields and flags. The pair of names now sits behind one pointer to an interned kind (nodeKind; rules precompute the kinds they give their nodes, and programs those of the grammar's types, so a parse never looks one up), andStartandEndareint32: 88 bytes. This changes the API:Type()andRule()are methods, and positions areint32(Go accepts them as slice indexes; arithmetic withints needs a conversion). JSON andStringoutput are unchanged (MarshalJSONwrites the same members).- Measured beforehand with a synthetic tree of 400,000 nodes: building −15%, node memory −22%, walking −7%, a GC with
the tree live unchanged;
int32positions alone saved nothing, since a chunk of 256 nodes of 112 bytes falls into the same allocation size class as one of 120 bytes. - Effect (min of 6 interleaved runs, Apple M3 Max): allocation per parse −11% to −20% (JSON 13.0 → 10.8 MB, XML 16.4 → 13.5 MB, CSV 7.1 → 5.7 MB, streams 168 → 152 MB); time within ±3% on every workload and backend.
64. A smaller Node in generated parsers
- Change 63 in the generated Node runtime, whose
Nodeis a type of the generated package: the kinds of a rule's nodes are set up ininitwith the rule table, those of struct types withstructFields, and the emitted code makes CST nodes with the shared kinds of the reserved types. The typed converters readType()and convert positions to theintspans of the typed values. - Effect (min of 6 interleaved runs, Apple M3 Max):
ParseJSON −2%/−3% (code points/bytes), CSV −8%/−12%, XML −7%/−5%, Arith_Pratt −3%/−4%, Arith_LeftRec −2%/−4%, Minilang within noise, Outline and Recovery +1% to +5% (noise-level); allocation −10% to −21% (JSON 12.8 → 10.7 MB, Outline 12.8 → 10.2 MB);ParseASTandRecognizeunchanged within noise.
65. Position conversion in the language server
pego lspconverted between LSP positions (UTF-16 code units) and PEGO positions (code points) by walking each line from its start for every token, diagnostic and range: quadratic on long lines (a 64 KB line took 0.7 s to analyze and 1.25 s for semantic tokens; an 800 KB line did not open within 10 s). The text index now keeps a checkpoint every 64 bytes with the cumulative counts of UTF-16 units and code points, so a conversion walks at most 64 bytes.- Effect: a 1 MB single-line grammar (260,000 tokens) is analyzed and tokenized in 0.35 s instead of over 10 s;
BenchmarkAnalyze(go.pego and python.pego) unchanged at about 11 ms.
66. No memo key allocated for variables where none is defined
- The memo key of a call of a rule that reads variables holds their current values, and
envValuesallocated a slice for it on every memoized call. The set of variables a rule reads is transitive, so one variable used deep in a grammar (parsers/cuetracks the hashes of a raw string insv) puts it on most rules, while in most calls no variable is defined at all. Where none is, the key is now a shared slice of nils (memo entries only compare keys). - Effect (
parsers/cueParseAST, mean of 6 runs, Apple M3 Max): config 25.2 → 23.4 ms (−7%), 128,458 → 3,006 allocations, 8.9 → 6.8 MB; corpus 18.3 → 17.2 ms (−6%), 99,588 → 2,045 allocations. The engine's outline workload is unchanged within noise (its rules that read variables run where one is defined).
Grammar authoring guidelines for performance
- Inside a captured expression, discard parts the action does not need with
-x(typically whitespace and punctuation):rest:(-ws -"," -ws v:value)*. The engine cannot drop them automatically, because a captured value exposes its full CST. - A predicate that reads a capture makes the capture's rules build values even in recognition mode, and with them
every rule they call: in
parsers/cue, one predicate reading a capture that contained an expression made nearly the whole grammar build values inRecognize. Keep captures that predicates read small (a name, a token). - Prefer terminal types (
type Name terminal) or@(...)for tokens: their bodies are matched without building values. - Write actions that use captures rather than
$nwhere possible; a body whose action does not use$nis matched without building its CST.
Experiments that did not pay off
| Experiment | Result | Decision |
|---|---|---|
Go arenas (GOEXPERIMENT=arenas) for per-parse scratch memory (memo entries, memo slots, expectation chunks), freed at the end of Parse |
No measurable change in time or allocation: memo entries were already slab-allocated, and the tree must stay on the heap because it is returned | Not adopted. Requires an experimental build flag for no gain |
| Option to skip recording expected sets (report only the failure position) | After change 4: about −10% time on minilang, no change on JSON and CSV | Not adopted. Not worth an API knob; the full information is cheap enough |
| ASCII bitmap for character classes in the closure engine (instead of scanning the ranges) | No measurable change when measured against the unmodified code in alternating runs (JSON, CSV); classes in practice have one to three ranges, which scan as fast as a bitmap lookup | Not adopted then; change 31 specializes small classes instead, which does pay |
Freeing capture frames when their rule invocation returns (LIFO arenas for frames and their slots, marked in invokeBegin and freed in invokeEnd) |
−16 to −23% bytes per parse, but 1–6% slower on every workload and backend (recognition included), even with a fast path that skips freeing when nothing was allocated: the per-call bookkeeping and the clearing of freed slots cost more than the garbage collector saved | Not adopted |
Storing all-ASCII input in code points as bytes (positions are the same in both units), with len of strings still counting code points |
−9% bytes per parse on ASCII versions of the JSON, CSV and XML inputs (no rune array or offset table), but 0–4% slower: matching bytes checks for multi-byte characters at every step, which indexing code points does not | Not adopted |
| Iterative VM: holding body frames by value in the VM stack (a tagged entry instead of a pooled frame behind an interface) | 5–19% slower: each entry is about 150 bytes, and copying and clearing it on every push and pop costs more than the interface call and the pool it saves | Not adopted |
| Iterative VM: calling body frames directly (a type assertion before the interface call) and returning them to the pool without the type switch | Within ±4% (noise) | Not adopted |
Estimating a smaller Node (120 → about 88 bytes with int32 positions and interned type and rule names) by the opposite change: 32 bytes of padding |
Closure: JSON +1%, XML +4%, Arith_Pratt +1%, Minilang and the long Document within noise, about +12% bytes; so shrinking would gain a few percent at most |
Adopted later as change 63, accepting the API change for the memory it saves |
| Inlining small plain rules into direct typed rules (after change 62; a body without calls of up to 16 expressions, also tried transitively) | ParseAST JSON −1% to −7% depending on the run, the other benchmarks within ±3% |
Not adopted: no consistent gain for more generated code |
Setting the fields of the reused action context in place in direct typed rules, instead of copying a whole tctx |
ParseAST JSON −5%, Outline +5%, the others within ±2% |
Not adopted (noise) |
| Memoizing every rule (classic packrat) | 2–3× slower than the transient policy on all workloads; memo entries were never reused for leaf and single-reference rules | Replaced by the transient policy (change 1) |
Remaining hotspots and next candidates
From profiles after change 41 (JSON, XML, minilang, error recovery; full parse and recognition):
- Garbage collection and memory acquisition. With the default
GOGC, the runtime's own work (marking, andmadvisewhen spans are reused) is a large share of benchmark profiles. Allocations per parse are down to about a thousand objects, so what remains is bytes: nodes (120 bytes each, a public struct) dominate. Freeing scratch memory early did not pay (see the experiments table). - Expectation recording (
expect): 5–8% in most profiles, mostly the duplicate check against the expectations already recorded at the farthest position. An index of the last append per expectation does not help, because it goes stale whenever the farthest position advances, which is the common case. - The iterative VM dispatches every frame through an interface (the VMs got changes 36 and 37 in change 44). Two ways of avoiding it did not pay (see the experiments table): the remaining cost is the work each frame does, such as saving and restoring the parser state that the recursive model keeps in Go locals.
- Document edits walk every memo entry (
memoTable.splice) to drop or shift it, and a reparse walks the reused trees after the edit to move them (change 45). Both are linear in the document; positions relative to a parent would avoid them, at the cost of an API change (Node.StartandEndwould no longer be absolute). - Rule calls still go through a function per call; inlining small rules into their callers at compile time (closure backend) or in generated code remains a candidate.
Timing is noisy on shared machines: compare B/op and allocs/op, or take the minimum of several interleaved runs
(before/after alternated with git stash).