128. Indexed Variable Environments
- Status: Adopted
- Date: 2026-10-31
Problem and Mechanism
Every declared variable is bound from the start of a parse, so the set of names in an environment never changes. The environment was nevertheless a name-keyed linked list: a read compared names along the list, and replacing a value behind other names copied the prefix. A grammar with V variables paid O(V) per read and per changed bind, in both time and allocation, and a controlled parse charged the same walk to the budget.
The compiler now numbers the variables in declaration order and every backend addresses them by
that slot. The environment is a persistent vector: a flat copy-on-write array for up to 16
variables, and above that a 16-way trie whose depth grows by one for each factor of 16 (depth 1 up
to 256 variables, 2 up to 4096, 3 up to 65536, and so on). A read
walks levels+1 nodes; a bind copies the same number of nodes and shares the rest, so saved
environments, rollback, memo keys and Document reuse behave as before. Bytecode instructions carry
the slot as their operand (ISAVersion 27). The closure engine resolves a bind's slot when the
graph is built and a variable read through the declaration table when it evaluates (a scan below
8 variables, a map from 8); generated Go and TypeScript emit slot numbers for binds and reads, and a memo key is still the values of
the variables a rule reads, now fetched by slot.
Budget Charges
A controlled bind charges 1 unit for the old value, levels+1 units when the value changes, and the
value comparison. A read charges levels+1 units. EnvValues charges 1 unit per slot plus the
read charge of a declared slot; the generated version also charges 1 unit when it starts. Variable
names are not charged, so a cost no longer depends on name length or on the position of a name in
the environment.
The smallest budget that parses a 280-variable grammar with a shared 28-character name prefix falls
from 9,974,231 to 1,971 (closure), 1,973 (bytecode) and 1,973 (generated Go). Grammars that declare
variables change their recorded budget (budget.golden); grammars without variables do not.
Results
Medians of ten interleaved runs, before to after.
| Benchmark | Closure | Bytecode | Iterative bytecode |
|---|---|---|---|
| 280 variables, controlled | 2602 to 76 us (-97%) | 2614 to 76 us (-97%) | 2552 to 75 us (-97%) |
| 17 variables | 14.6 to 7.4 us (-49%) | 16.8 to 8.3 us (-50%) | 16.9 to 8.7 us (-49%) |
| 4 variables | 4.64 to 4.49 us (~) | 6.04 to 5.73 us (~) | 6.23 to 6.12 us (~) |
The 280-variable parse allocates 94.6% fewer bytes and 97.9% fewer objects; four variables allocate 41% to 61% fewer objects and 1% to 2% fewer bytes. Environment-binding streams of 16,000 binds run 21% to 56% faster. Distinct bindings run 75% faster at 16 names and 95% faster at 128.
Controls without variables or with few: JSON and Outline parse, recognize, incremental and stream
benchmarks show no slowdown in any backend; Outline (a few := variables) is 6% to 10% faster, and
JSON is unchanged within noise on closure and generated Go (iterative bytecode -7%).
Leaf and Fan-Out Cutoff
The flat leaf and the trie fan-out share one width. Width 8, 16 and 32 were swept on the closure and iterative bytecode engines with ten interleaved runs of 150 ms each, on a machine with other load (the baseline column has a wide spread):
| Benchmark (sec/op, 16 as the base) | Width 8 | Width 32 |
|---|---|---|
| 4, 9, 12, 16 variables | ~ | ~ |
| 17 variables, closure | -5.7% (controlled) | -5.9% to -7.8% |
| 17 variables, bytecode | ~ | ~ |
| 32 variables | ~ | ~ |
| 280 variables | -14% to -16% | ~ |
| JSON and Outline Parse, closure and iterative | ~ | ~ |
No width is significantly better at small V, and the only gain of width 8 (280 variables) comes from a base run with a spread above 100%. Width 16 stays. The generated runtime was not swept.
Limits
The TypeScript environment is verified by tests but not benchmarked. Generated Go for the five large
grammars is slightly smaller (about 0.06% of parser.go).