095. Copy-On-Change Attribute Normalization
- Status: Implemented
- Date: 2026-10-14
Problem and Mechanism
normalizeAttributes builds the executable grammar before backend compilation. Its eager copying path currently allocates replacement nodes for expression trees even when normalization leaves a node and all of its children unchanged. A copy-on-change path can return the original node in that case and copy only nodes whose child, attribute, or lowering changes.
The source AST must remain unchanged. If a nested node changes, copy each affected ancestor before storing the replacement. Keep attribute order, argument binding and validation, diagnostic positions, source metadata, and existing lowering behavior intact. Reuse unchanged nodes; do not describe the normalized result as a detached deep copy. Parser.Grammar() may be identical to, or share unchanged nodes with, the source AST.
Preserve the legacy normalized nil-versus-empty slice shapes, including containers for statements, expression children, fields, operands, levels and operators. Compare against the eager path rather than deriving normalized shapes from the input. Treat opaque extension values as opaque; only the supported expression arguments undergo recursive normalization.
Applicability
| Execution profile | Applied / Partial / Not applied / Not applicable | Reason |
|---|---|---|
| Shared grammar preparation before backend compilation and generation | Applied | Normalization is shared before backend selection; input matching is unchanged. |
Reproduction
The initial 315-row comparison used baseline commit c693d51 and a candidate
source snapshot, with an eager same-source mode as a separate counterfactual.
Five alternating samples ran 1-second benchmarks on Go 1.27.1, Apple M3 Max
(darwin/arm64), GOMAXPROCS=16, GOGC=100 and GOMEMLIMIT=off. Inputs were plain,
attribute-heavy, pragma-heavy, Operators Rule, JSON, Minilang and the full source
grammar. Operations were normalization, compilation of a pre-parsed AST, and
source preparation (Parse plus Compile); source reads and setup were outside the
timed interval.
The targeted 30-row recheck used the same pinned source and binaries but selected only three compile/source-preparation conditions. Five alternating fresh-process samples ran for 3 seconds each. It was not a repeat of all 21 workload/operation combinations in the initial run. Ratios below are medians of paired candidate/baseline ratios and ranges, not ratios of aggregate medians.
| Input and operation | Initial paired time ratio | Targeted paired time ratio | Targeted B/op | Targeted allocs/op |
|---|---|---|---|---|
| Attributes — Compile | 1.065× [1.033–1.087] | 1.002× [0.991–1.002] | 8,936 → 8,760 | 106 → 103 |
| Attributes — source preparation | 1.092× [1.051–1.117] | 0.995× [0.972–1.046] | 20,248 → 20,072 | 155 → 152 |
| Full source grammar — Compile | 1.060× [0.987–1.133] | 0.985× [0.898–1.024] | 3,453,140 → 3,352,091 | 28,660 → 27,873 |
The first run used mixed condition ordering with map-order carryover. Its normalization paired-median ratios across seven inputs ranged from 0.181 to 0.973 for time, 0 to 0.919 for bytes, and 0 to 0.824 for allocations. Source preparation allocation examples from that run:
| Input | B/op | Allocations/op |
|---|---|---|
| JSON | 317,218 → 306,930 | 2,546 → 2,471 |
| Minilang | 1,013,919 → 988,444 | 7,655 → 7,461 |
| Full source grammar | 6,778,386 → 6,678,373 | 37,363 → 36,576 |
The longer targeted ranges include parity and did not reproduce a persistent attribute-heavy slowdown. Ordinary host noise is possible; retained peak heap was not measured. These comparisons do not establish a universal timing effect.
The candidate test suite includes BenchmarkAttributeNormalization in
internal/engine/normalize_attributes_test.go. From the repository root, build
the candidate test binary with:
go test -c -o /tmp/pego-copy-candidate.test ./internal/engine
Build the baseline binary from a candidate checkout using a Go overlay. The
overlay JSON maps the checkout's compile.go and normalize_attributes.go to
their original, byte-for-byte files from commit c693d51, and maps
normalize_attributes_test.go to a benchmark-only file containing its package
declaration, required imports, benchmark globals, and adapted benchmark
function. Keep calls on the original normalizeAttributes(g) and
Compile(g, Options{}) entry points; exclude the candidate's new non-benchmark
tests and methods. The overlay changes which files the compiler reads without
physically overwriting the checkout. Build the baseline binary with:
go test -overlay=/tmp/pego-copy-baseline-overlay.json -c \
-o /tmp/pego-copy-baseline.test ./internal/engine
After building both binaries, run each filtered invocation from
internal/engine so benchmark fixtures resolve relative to the package. Use a
fresh process per invocation, alternate baseline and candidate order across
five rounds, and keep the corpus and environment fixed:
cd internal/engine
GOMAXPROCS=16 GOGC=100 GOMEMLIMIT=off /tmp/pego-copy-baseline.test \
-test.run='^$' \
-test.bench='^BenchmarkAttributeNormalization$/^attributes$/^eager$/^compile$' \
-test.benchtime=3s -test.count=1
GOMAXPROCS=16 GOGC=100 GOMEMLIMIT=off /tmp/pego-copy-candidate.test \
-test.run='^$' \
-test.bench='^BenchmarkAttributeNormalization$/^attributes$/^copy-on-change$/^compile$' \
-test.benchtime=3s -test.count=1
For the targeted source-preparation recheck, replace the source/mode/operation
filter with attributes/eager/source-prepare or
attributes/copy-on-change/source-prepare; for the full grammar compile recheck,
use source-grammar/eager/compile or
source-grammar/copy-on-change/compile. The initial run additionally covers
the other five inputs and normalization operation. A same-source eager versus
copy-on-change probe is not a substitute for the exact-baseline comparison.
Pinned fingerprints (the baseline test binary includes the listed benchmark-only overlay):
| Artifact | Fingerprint |
|---|---|
| Baseline Git commit SHA | c693d51fba337aa34069acca6b50a29ddd1428a9 |
| Candidate test binary SHA-256 | d7e253fdb9830f0d2aab77fdfe6f85c15d4bdcd6ae313e729ca3da39b437e3cb |
| Baseline test binary SHA-256 | c675cd178618c8502a687f147de7f37e6ca4276803f26c9dd3547110ecc898c4 |
| Baseline overlay manifest SHA-256 | 1f4dfcea350fce3bffa94984ae64c6412f8fc261f75fba84f8de818cc0f7c1dc |
| Baseline overlay test snippet SHA-256 | c024148d25119aa868e27bb5c400f3a11b251b914f41e1d73821c9aa0b8ab35a |
Baseline overlay normalize_attributes.go SHA-256 |
a1b588bf0803bb8d642ad63cccc19e5aede65b29cac7720e69a8a996c2fdc122 |
Baseline overlay compile.go SHA-256 |
54ff0bfd44271bb4bb9cf0f4ae0bc558b7ab5cbf9061f20700a2fa27f63e5790 |
Candidate normalize_attributes.go SHA-256 |
68b7f19aaae2bd0f374e78453ee3fcfe45127c856d28b49f9c178f8fdc2640bf |
Candidate normalize_attributes_test.go SHA-256 |
5e8bcdfec6f319276ad2396c8f9ba129950fd39a0fc7208904c4d4d9d1480b5e |
Candidate compile.go SHA-256 |
9ca60a5f32554ba6e99ab6c4018bdb4836ed9445bf581f7431598c94a9871242 |
pego.pego grammar SHA-256 |
7567b967bc19ee30b7ebd0b7f09cdef6df0fe5157468b73bad22a543e57e0774 |
| JSON grammar SHA-256 | a14e28c3dce151bc42aa1d676ad020059344871af20bb8e3282947a76108db4b |
| Minilang grammar SHA-256 | bf466e1f677b9b8f00c2a4378c141545f648ecb9c22c2c33246bad4cb968f930 |
Results
Copy-on-change is implemented as the default attribute-normalization path; the eager path remains a private benchmark control. The focused checks verify source AST immutability, unchanged attribute order and validation, legacy nil/empty shapes, backend behavior, and sharing only for unchanged subtrees. Generated parser files were byte-identical after regeneration, and parser-module tests and vet passed.
The initial synthetic attribute-heavy slowdown was not reproduced by the longer targeted recheck. The allocation reductions shown above remain workload-specific; no universal compile-time speedup is claimed. A clean full-suite benchmark snapshot for a combined revision is a checkpoint, not an adoption gate or proof of this mechanism's causal effect.
Correctness and Tradeoffs
The caller's source AST is never mutated. Callers must continue to avoid concurrent mutation while the AST is being parsed, validated, formatted, serialized or compiled. Sharing does not provide an independent-copy guarantee: use Grammar.Clone() for an independent editable AST within its documented value domain.
Focused checks cover eager/copy-on-change output and error parity, normalized nil/empty shapes, source immutability, binding and validation order, backend behavior, unchanged-subtree sharing, and copied changed paths. Cases include no attributes, nested attributes, pragmas, Operators Rules, opaque extension values and normalization failures.
Decision and Limits
Implemented. This compile-stage mechanism makes no claim about parse throughput.