097. Exact Document Snapshot Record Sharing
- Status: Adopted
- Date: 2026-10-14
Problem and Mechanism
Document snapshots previously copied every record in the immutable result
graph. The selected implementation reuses exact immutable records from the
previous live snapshot while preserving the public mutable Node and owned
snapshot contracts.
Every capture first freezes the current parse result into a new graph. It then compares records at corresponding traversal indices with the previous snapshot graph. A record is reusable only when its interned kind, absolute start/end, text, internal flags, nil-versus-empty state, ordered child IDs, and ordered field names and values match. String and field comparisons verify exact contents; hashes alone are insufficient. Reused records are immutable. New or changed records are copied into the new graph. If the graph has fewer than 128 records or fewer than one quarter of records match, the capture keeps the contiguous copy representation instead of paying for a per-version reference directory.
The Document keeps a weak reference to its previous snapshot graph, so the
Document alone does not retain an old snapshot. A new snapshot may retain
immutable records still referenced by its own graph. Snapshot text is copied
separately; parser Nodes, input buffers, memo state, and mutable slices are
never shared. Absolute spans remain stored directly, so a length-changing edit
that shifts positions naturally falls back to copying where records no longer
match. The same mechanism applies to Snapshot capture after any supported core
Document backend; it does not affect ordinary Parse or generated parsers.
Applicability
| Execution profile | Applied / Partial / Not applied / Not applicable | Reason |
|---|---|---|
| Closure-backed Document snapshots | Applied | Exact frozen-record comparison and low-reuse fallback |
| Bytecode-backed Document snapshots | Applied | Sharing is over the backend-independent frozen graph |
| Iterative-Bytecode-backed Document snapshots | Applied | Sharing is over the backend-independent frozen graph |
| Ordinary Parse/Recognize and generated parsers | Not applicable | These paths do not expose Document snapshots |
The retained-memory figures below cover Closure/CodePoints fixtures. Capture timing was measured only for Closure/CodePoints; no performance result is claimed for other backends or units.
Reproduction
The measurements used Go 1.27.1 on an Apple M3 Max (darwin/arm64), with
GOMAXPROCS=16 and GOGC=100. The default build uses snapshot sharing; the
pego_snapshot_sharing_reference build tag selects the copy baseline. Build
binaries before timing and alternate fresh processes. Snapshot capture cases
use three alternating 300 ms pairs. Retention cases use three alternating
process pairs with forced GC.
GOMAXPROCS=16 GOGC=100 go test -tags pego_snapshot_sharing_reference \
./internal/engine -run '^$' \
-bench '^BenchmarkDocumentSnapshotCapture$' -benchmem -benchtime=300ms -count=1
GOMAXPROCS=16 GOGC=100 go test ./internal/engine -run '^$' \
-bench '^BenchmarkDocumentSnapshotCapture$' \
-benchmem -benchtime=300ms -count=1
GOMAXPROCS=16 GOGC=100 go run -tags pego_snapshot_sharing_reference \
./bench/snapshot-retained \
-mode snapshot -edit replace-first -size 4096 -versions 64
GOMAXPROCS=16 GOGC=100 go run \
./bench/snapshot-retained -mode snapshot -edit replace-first \
-size 4096 -versions 64
For the 64 KiB retained case use -size 65536 -versions 16; for the
length-changing prefix fallback use -edit prepend. Keep reference and
default-sharing processes alternating. The prefix-edit benchmark is
BenchmarkDocumentSnapshotPrefixEdit. Routine raw logs remain outside Git.
Results
After a same-length one-character edit, the forced-GC retention harness warmed
and parsed the Document, recorded baseline HeapAlloc, then forced GC after
capturing versions and measured the HeapAlloc delta. With -retain=true, all
captured versions stayed alive through measurement. The values below are
additional post-GC heap per version over the warmed-Document baseline, not
total live heap:
| Workload | Copy mode | Sharing mode | Measurement |
|---|---|---|---|
| 4 KiB input, 64 retained versions | 455.2–455.6 KB/version | 84.6–84.7 KB/version | Three alternating process pairs |
| 64 KiB input, 16 retained versions | 7.792–7.794 MB/version | 1.540–1.541 MB/version | Three alternating process pairs |
Three alternating 300 ms Closure/CodePoints capture pairs showed about 12%
higher median capture time for 4 KiB inputs (paired results 10.9–14.8% higher)
and about 6% for 64 KiB inputs (3.7–14.4% higher), with about 2% more B/op.
Materialize was broadly similar. With a length-changing prefix insertion, the
low-reuse fallback measured an additional 8.386–8.388 MB/version in both modes
for 16 retained 64 KiB versions. Snapshot-plus-prefix-edit timing differed by
0–3% across the 4 KiB and 64 KiB cases, within observed noise, with similar
B/op. With -retain=false, each captured snapshot is discarded immediately;
the post-GC heap delta over baseline was only tens of KB in both modes. Five
alternating 300 ms ordinary Document creation/Parse pairs with sharing disabled
versus main had the same allocation counts and B/op,
with no consistent timing difference.
Correctness and Tradeoffs
Correctness depends on exact comparison of the complete immutable record,
including absolute positions and graph edges. Matching only node pointers,
generations, spans, or hashes could reuse stale values because legacy Node
fields remain caller-mutable and Document reparses can move Nodes in place.
The implementation still freezes and scans the whole graph, so this mechanism
reduces retained record duplication rather than eliminating capture work.
The copy fallback avoids a reference directory when the graph is small or reuse is low. A prefix shift demonstrates that absolute-span changes can make sharing ineligible without adding a material retention cost in the tested case. Capture was slower in the measured same-length-edit cases; adoption accepts that scoped cost for the substantial retained-memory reduction in those fixtures. Traversal, serialization, larger inputs, other edit patterns, other units and cross-backend performance remain unmeasured. Do not extrapolate these figures to arbitrary Documents.
Decision and Limits
Adopt exact immutable record sharing for Document snapshots, with the low-reuse copy fallback. Keep absolute spans, graph identity, cycles, nil/empty shape, diagnostics and snapshot ownership unchanged. Relative spans and persistent child sequences are separate designs and are not implied by this mechanism.