107. Shared Shapers and a Compact Rule Table
- Status: Implemented
- Date: 2026-10-22
Problem and Mechanism
Two parts of a generated parser were written out once per rule or per site although they carry little information.
Shapers. Every site that makes a value from the matches of an expression
(ShapeSingle, ShapeCapture, ScopeValue) and every rule of the table carried the
literal Shaper{Elems: .., K: .., Mode: .., Tuple: .., Shared: ..}. A large grammar
has about two thousand such literals but only a few dozen distinct shapers (22 in
the duckdb grammar). The generator now declares each distinct shaper once as a
package variable and the sites name it.
Rule table. init built the rule table with one composite literal per rule
({Id: .., Name: .., Scope: []string{..}, Value: Shaper{..}, Memo: .., ...}) and then
the typed table with one more literal per rule. In the duckdb parser the code that
did this was 485 KB of machine code. The generator now emits the table as static
data in a compact form: a RuleSpec per rule (name, a pointer to its shaper, a flag
set, the memo index, and indexes into a shared list of capture-name and variable
lists), plus a short list of the rare fields (Where, NodeType, OpNodeType).
NewRules builds the Rule values of the table from it in one allocation, and
NewTRules builds the typed rules that share them. The id of a rule is its position
in the table, which the generator checks.
Results
Bytes of parser.go with -typed-only:
| Parser | Before | After |
|---|---|---|
| duckdb | 2.98 MB | 2.72 MB |
| typescript | 1.94 MB | 1.83 MB |
| python | 1.07 MB | 0.99 MB |
| golang | 1.02 MB | 0.94 MB |
| cue | 0.74 MB | 0.68 MB |
| cel | 0.20 MB | 0.19 MB |
Test binaries of the parser modules (go test -c -trimpath):
| Parser | Before | After |
|---|---|---|
| duckdb | 18.09 MB | 17.76 MB |
| typescript | 11.91 MB | 11.78 MB |
| python | 13.36 MB | 13.23 MB |
| golang | 12.69 MB | 12.59 MB |
| cue | 12.08 MB | 12.02 MB |
| cel | 10.32 MB | 10.30 MB |
The duckdb init function shrinks from 485 KB of code to 73 KB; the table itself is
57 KB of data. Making the table the literal of a package variable instead (the full
Rule struct as static data) was tried and was larger in the binary, since a Rule
is about 300 bytes. Parse speed (six alternating rounds, unchanged yaml parser as the
noise reference) is within noise: language-module geometric means change by -0.1%
to +1.4% while the unchanged yaml parser shows -0.8%.
Correctness and Tradeoffs
The shapers are values, so a site copies the package variable where it used to build
the literal. A failed direct compilation rolls back the shapers it declared, like the
character-class tables. A rule with no captures or variables has a nil Scope or
Vars; the readers (such as AttachCaptures and the variable lookup) range over them or take their length, which a nil list serves the same. A RuleSpec holds Scope and Vars as
16-bit indexes and Seen as 32 bits, so the generator reports an error for a grammar
that needs more than 65536 distinct lists or more than 2^31-1 deferred memoization
indexes. The engine tests (including the checks that read the generated rule table,
its string lists and its rare fields), the root suite and parsers/test.sh pass.
Decision and Limits
Adopted for Go parsers. The per-rule Body, Action and Entry assignments of the
typed rules and the lines of Operators Rules are still statements of init.