102. Read Text Struct Fields Without a Struct Lookup
- Status: Removed. Tokens are named node types (
type Name = node[string]), which predicates compare withtext($e) == text($n)through the unboxed comparison of M120, so no struct field is read. - Date: 2026-10-19
Problem and Mechanism
Terminal types were replaced by text structs, type Name = { Text string }, and the
repository grammars were rewritten to build them: rule name Name = t:(...) #atomic => Name{Text: text($t)}. A predicate that compared two names with text($e) == text($n)
now reads $e.(Name).Text == $n.Text. Measured against the last revision before the
migration, every workload stayed within noise except XML, whose end-tag predicate
makes that comparison once per element: recognition became 3–19% slower depending on
the backend.
A text struct already keeps its text unboxed in the node. The field read, however,
first tested whether the receiver was a node, then looked the struct type up by name
(in the program's type table, or in the generated structFields map) to reject an
undeclared field, before reaching the text. A text struct has exactly the field
Text, so a read of Text from a text struct needs none of that: the closure, both
VMs and generated Go Node parsers now return the text first. Under work controls the
read still takes the full route, which charges the field lookup as before, so step
counts are unchanged. Typed generated Go already read string fields directly.
Applicability
| Execution profile | Status | Reason |
|---|---|---|
| Closure, bytecode and iterative bytecode | Applied | Shared evalCtx.memberOperand |
| Generated Go Node parsers and recognizers | Applied | actx.memberOperand in the runtime template |
| Generated typed Go | Not applicable | String fields are already read without a lookup |
| TypeScript | Not applied | member still looks the struct up in structFields; not measured |
| Work-controlled parses | Not applied | Keeps the charged lookup |
Reproduction
All runs use Apple M3 Max, Go 1.27.1, GOMAXPROCS=16. The migration comparison builds
the bench test binary at 9bd2d2d (before the migration, with its own grammars) and
at d4fd8c0 (after it, without this change), and runs each from its own tree, since
the benchmarks read grammars at run time. Three alternating rounds of two samples each
give six samples per case:
go test -c -trimpath -o old.test ./bench # in each tree
cd bench && ../old.test -test.run '^$' -test.benchmem -test.count 2 \
-test.bench '^BenchmarkParse$/./^(closure|bytecode)$/^codepoints$'
with the same pattern for generated/codepoints and generated_ast of
BenchmarkParse, for the closure, bytecode and generated cases of
BenchmarkRecognize, and for all of BenchmarkIncremental, BenchmarkStream and
BenchmarkPrepare. The change itself is measured the same way on BenchmarkParse/XML
and BenchmarkRecognize/XML, d4fd8c0 against the candidate. Raw results stay local.
Results
Migration, before this change. Median time of d4fd8c0 relative to 9bd2d2d.
Bytes and allocations per operation are unchanged within 0.3% on every parse,
recognition, incremental and stream case (XML generated_ast bytes +1.7%).
| Workload | Parse closure | Parse bytecode | Parse generated | Recognize closure | Recognize bytecode | Recognize generated |
|---|---|---|---|---|---|---|
| JSON | 0.983 | 1.000 | 1.029 | 1.019 | 1.005 | 0.989 |
| CSV | 1.016 | 1.023 | 1.017 | 1.001 | 1.008 | 0.999 |
| XML | 1.054 | 1.010 | 1.025 | 1.077 | 1.034 | 1.186 |
| Minilang | 0.981 | 1.024 | 1.023 | 0.995 | 0.985 | 0.988 |
| Recovery | 1.065 | 0.992 | 0.967 | 0.999 | 0.989 | 0.984 |
| Outline | 0.990 | 1.004 | 0.963 | 0.989 | 1.001 | 1.008 |
| Arith (Operators Rule) | 1.004 | 1.022 | 1.006 | 1.001 | 0.994 | 1.006 |
| Arith (left recursion) | 1.007 | 0.989 | 1.006 | 1.002 | 1.003 | 1.006 |
Incremental Document edits and fresh parses are 0.983–1.029, streaming 1.007–1.017.
Preparing from source takes 7–8% longer for JSON and minilang, whose rewritten
grammars have more actions to check and compile; loading .pegoc files is
0.94–1.08 with no consistent direction.
This change, on XML. Median time, d4fd8c0 → candidate:
| Case | Before | After | Change |
|---|---|---|---|
| Recognize generated | 2.992 ms | 2.693 ms | −10.0% |
| Recognize closure | 9.587 ms | 9.241 ms | −3.6% |
| Recognize bytecode | 11.936 ms | 11.631 ms | −2.6% |
| Recognize iterative | 13.353 ms | 13.069 ms | −2.1% |
| Parse generated | 6.703 ms | 6.255 ms | −6.7% |
| Parse bytecode | 15.771 ms | 14.966 ms | −5.1% |
| Parse closure | 13.768 ms | 13.781 ms | +0.1% |
The generated XML recognizer, 2.55 ms before the migration, is within about 6% of it
again; the rest is building the Name struct values that the predicate reads, which
the terminal-based grammar did not need.
Correctness and Tradeoffs
The fast path applies only to a node marked as a text struct and only to the name
Text, which such a struct always declares, so it cannot accept a read that the full
route rejects. Results, errors and work-control step counts are unchanged; the
equivalence suites, the generated-parser tests and the work-control accounting tests
pass.
Decision and Limits
Adopted. Grammars that read a text struct's Text in a hot predicate still build the
struct; keeping such predicates on a capture of the text (t:(...) #atomic read with
text($t)) avoids that cost entirely.