1# AST allocation and copying, 2026-09-25
2
3The existing AST classes already provide cheap allocation with stable field layouts. The first architectural change
4worth investigating is a shared, inexpensive path for immutable updates. Descriptor-based copying is substantially
5more expensive than creating the nodes themselves. Specialized constructors and a generic value-copy prototype both
6remove most of this cost in the measured workloads. The measurements do not establish that rewriting every node into
7a new class hierarchy would improve schema startup further.
8
9This is an investigation of `SchemaAST.ts` at `80670a2b6f`, after the duplicate-check and `mapOrSame` optimizations.
10The initial experiments used isolated source copies. Their patches, scripts and raw measurements are retained under
11`tmp/ast-layout-study/` and `tmp/ast-layout-classes-vs-assign/` locally. The final section records the subsequent
12implementation and its validation.
13
14## Current representation
15
16All 21 AST variants already use classes derived from `ASTNodeImpl`. Their class expressions execute once at module
17initialization. Renaming them to `ObjectsImpl`, for example, would help internal references but would not itself make
18allocation faster. Common fields are present even when their values are `undefined`, and methods live on prototypes.
19
20| Node family | Construction work | Implication for copying |
21| -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
22| `Null`, `Undefined`, `Void`, `Never`, `Unknown`, `Any`, `String`, `Number`, `Boolean`, `BigInt`, `Symbol`, `ObjectKeyword` | Store common fields and `_tag` | Natural candidates for a direct allocation path; checks on scalar schemas use these frequently |
23| `Objects`, `Union`, `Declaration` | Store common fields and child references, options or declaration callbacks | Share children and callbacks; preserve `encodingChecks` and `encodingRun` |
24| `Literal`, `Enum` | Validate finite numeric values, then store payload | Copies with unchanged payload should not need repeated validation |
25| `UniqueSymbol` | Store the symbol | Share its identity |
26| `Arrays` | Store children and validate optional/required ordering | Metadata updates must not rescan an unchanged tuple |
27| `TemplateLiteral` | Validate parts, allocate encoded parts and literals, compute suffix lengths | Metadata copies should share prepared data; rerunning the public constructor would redo work |
28| `Suspend` | Reject checks and wrap the thunk in a memoizing closure | Copies must share the existing thunk; recreating its wrapper changes sharing and adds closures |
29
30`PropertySignature`, `IndexSignature`, `Context`, `Link`, `Filter` and `FilterGroup` also already use classes.
31`IndexSignature` validates its parameter on the type and encoded sides and checks optionality. `PropertySignature`
32only stores its name and type. The fixed shape of an `Objects` node does not depend on its number of fields: those live
33in `propertySignatures`, rather than becoming JavaScript properties of the node itself.
34
35The expensive generic path is `modifyOwnPropertyDescriptors`, used by `replaceChecks`, `replaceEncoding`,
36`replaceContext`, `annotate` and check promotion inside `toType`. It creates the descriptor table and descriptor
37objects, modifies values, and defines the properties on another object.
38
39`toType` can first copy a node to remove encoding, then rebuild it after visiting children, then copy it again to
40promote encoding checks. `flipEncoding` also removes encoding through a copy before recursion. These are opportunities
41to reduce the number of allocations, independently of how each allocation is implemented.
42
43`Schema.make` remains a separate source of construction work. It creates a function object, sets its prototype, adds
44options and adapters, and calls `SchemaAST.toType` through `SchemaParser.make`. Making AST allocation cheap does not
45remove all of this work.
46
47## Direct V8 inspection
48
49Environment: Node v24.12.0, V8 13.6.233.17-node.37, Apple M3, Darwin 23.6.0 arm64.
50The diagnostic used `%HaveSameMap`, `%HasFastProperties` and `%DebugPrint` in a separate process with
51`--allow-natives-syntax`. That flag was not used for timing runs.
52
53For the tested `String`, `Number`, `Literal`, `Objects`, `Arrays` and `Union` nodes:
54
55- Repeated constructor calls share a V8 map in the tested fixtures.
56- A descriptor-based copy and its constructor-created source have different maps.
57- Copies still have fast properties. Descriptor copying did not put these nodes into dictionary mode.
58
59For the inspected `Objects` node, after constructor slack tracking:
60
61| Allocation | Fields inside the object | Additional property storage | Same map as the original |
62| ------------------------------- | -----------------------: | ----------------------------------- | ------------------------ |
63| `new Objects(...)` | 9 | None | Yes |
64| Descriptor copy | 4 | `PropertyArray[6]`, five slots used | No |
65| Copy through `new Objects(...)` | 9 | None | Yes |
66
67An `Object.create` plus `Object.assign` copy also has a different map from the constructor-created node. This supports
68investigating constructor copies, but does not prove a material end-to-end advantage over all generic copies.
69See the retained `v8-layout.txt` for the actual dump. These details are observations of this V8 version, not language
70guarantees. V8 describes the relevant mechanisms in [fast properties](https://v8.dev/blog/fast-properties) and
71[hidden classes](https://v8.dev/docs/hidden-classes).
72
73## Allocation experiment
74
75The constructor prototype replaces metadata copies with explicit constructor calls for 18 variants. `Enum`,
76`TemplateLiteral` and `Suspend` retain descriptor copying. It then updates the requested field on the new, unpublished
77object. This prototype deliberately assumes standard built-in nodes. It is not a compatible replacement for arbitrary
78AST objects.
79
80Each case has 20 paired rounds in fresh Node processes. Pair order alternates and case order rotates. AST cases retain
8110,000 results; Schema cases retain 1,000 new root schemas. There is no explicit warmup, but imports can warm shared
82code, and V8 can optimize during the batch. Imports, process startup and decoding are excluded from elapsed time.
83Valid decode, invalid input rejection and type-side construction run in separate validation processes. Prepared
84property signatures are reused only in the two AST-only cases. Nested and composed Schema cases include child creation.
85
86All 280 measured processes and 14 validation processes completed. Delta is the median paired ratio, with a 95%
87process-bootstrap interval. It is not the ratio of the two separately reported medians. The improvement threshold is 2%.
88
89| Case | Baseline batch ms | Constructor-copy batch ms | Paired change | 95% interval |
90| ------------------------------------------- | ----------------: | ------------------------: | ------------: | ---------------------------------------- |
91| Raw `Objects`, 8 ready properties | 0.863 | 0.816 | -6.6% | -9.6% to -0.8%, below evidence threshold |
92| Copy `Objects` to change checks | 23.126 | 1.091 | -95.4% | -95.4% to -95.2% |
93| `Schema.Struct`, 8 fields | 1.382 | 1.350 | -1.6% | -10.7% to +0.8%, inconclusive |
94| Nested small structs | 4.172 | 4.314 | +2.0% | -0.3% to +5.0%, inconclusive |
95| Structs, checks, arrays and optional keys | 16.334 | 8.716 | -47.2% | -47.9% to -45.6% |
96| New string-to-number transformations | 11.992 | 6.000 | -44.2% | -51.1% to -40.5% |
97| Union of 8 newly constructed tagged members | 20.030 | 16.824 | -15.0% | -17.9% to -12.3% |
98
99The raw constructor is unchanged between versions, so its apparent improvement is not evidence of a better constructor.
100The candidate also changes module-initialization objects and therefore can change V8 feedback before timing starts.
101The large gains demonstrate the potential of cheaper copying in these fixtures. They do not measure complete application
102startup or identify how much of the gain comes from fewer descriptors versus different object layouts.
103
104## Do specialized classes beat a simpler copy?
105
106A second experiment substitutes `Object.assign(Object.create(Object.getPrototypeOf(ast)), ast)` for the prototype's
107constructor switch. This uses the same metadata update callers. Twenty paired rounds compare it directly with the
108constructor prototype, with the same timing boundaries. All 160 measured and eight validation processes completed.
109
110| Case | Assign relative to constructor copying | 95% interval |
111| ------------------------------- | -------------------------------------: | --------------- |
112| Copy `Objects` to change checks | +5.9% | +3.8% to +10.5% |
113| Composed schema | -0.7% | -3.0% to +2.7% |
114| Encoded schema | +0.2% | -1.1% to +2.9% |
115| Union of 8 members | +1.6% | +0.4% to +3.6% |
116
117None meets the configured 5% regression threshold with its entire interval. In these Schema workloads, specialized
118constructor copies have no demonstrated material advantage over this small generic helper. This matters for the
119preference for simple code and a small bundle. Bundle size was not measured for these diagnostic prototypes.
120
121## Proposed design
122
123Keep one class per AST kind, defined once, with fixed own fields, immutable child references and prototype methods.
124The existing representation is a useful foundation. A uniform class containing every variant's fields would increase
125node size and does not have evidence supporting it here. Moving every parser method off the prototypes or replacing
126string tags with numbers also has no measured justification from this study.
127
128Make node allocation and immutable updates one coherent internal mechanism:
129
1301. Separate validation and preparation from storage. Public construction still rejects invalid literals, tuple ordering
131 and template parts at the current time. An internal allocation path can reuse already validated payloads. It must
132 not skip validation when an operation actually changes the relevant children.
1332. Route metadata updates through that path, preserving unchanged arrays, callbacks, template preparation and suspended
134 thunks. Cover scalar nodes as well as composite nodes. Restricting this to `Objects`, `Arrays` and `Union` would miss
135 `.check` and encoding copies on common scalar schemas.
1363. Build at most one final node for a projection when possible. `toType` should compute the final children, encoding
137 and checks together, while preserving idempotence, structural-check promotion and context ownership. It should
138 return the original node when nothing changes. This is a separate unmeasured optimization.
1394. Keep parser preparation and derived caches outside ordinary node construction. Existing identity-based caches are
140 useful for DAG sharing. Adding cache properties after construction, eagerly allocating more per-node caches, or
141 recreating closure wrappers during copying would work against this goal. Moving template preprocessing to first
142 use is a separate startup/first-use tradeoff, not an automatic improvement.
143
144Start with the smallest common copy mechanism whose contract is acceptable. The generic value-copy candidate deserves
145consideration before adding a per-kind copying method or constructor switch. If specialized copies are adopted, use
146direct calls to the relevant internal class; dynamically invoking `this.constructor` is not equivalent to a known
147constructor, and does not safely handle subclass contracts.
148
149There is a concrete compatibility decision before adopting either prototype. The current descriptor path preserves
150extra non-enumerable properties, getter definitions, descriptor flags and custom prototypes. The local contract probe
151confirmed that constructor copies drop extensions and subclasses; assign preserves enumerable symbols and the prototype
152but drops non-enumerable properties, invokes getters, and changes descriptor flags. Frozen source nodes also expose
153different property flags in the resulting copy. Neither prototype is therefore a drop-in implementation.
154
155A fast path must have a defensible definition of a standard internal node. Merely checking `_tag`, its prototype or
156membership in a WeakSet does not prove that descriptors have not been changed after construction. Scanning every
157descriptor to establish this on each copy could consume the intended saving. That contract should be settled explicitly
158when implementing the shared copy path; the measured upper potential is not a reason to silently narrow it.
159
160## Artifacts and validation limits
161
162- `tmp/ast-layout-study/results.json`: baseline versus constructor copies, hashes, environment and raw paired timings.
163- `tmp/ast-layout-classes-vs-assign/results.json`: constructor copies versus generic value copies.
164- Each directory contains the measured `SchemaAST.ts` sources, patches, runner, statistics code and fixtures.
165- `tmp/ast-layout-study/v8-layout.txt` and `contract.json`: representation and observable compatibility diagnostics.
166
167The diagnostic workers checked normal fixture behavior before measurement in separate processes. Those prototype
168checks are not a full Schema/compiler regression test or a claim of production compatibility. The prototype
169measurements establish no bundle-size conclusion. Their temporary worktrees and runnable scratch files were removed
170after archiving the evidence.
171
172## Implemented value copies
173
174The implementation replaces the descriptor helper with
175`Object.assign(Object.create(Object.getPrototypeOf(ast)), ast)`. Checks, encoding, context, annotations and the existing
176check-promotion step in `toType` copy through this helper and update the new node before publishing it. The class
177hierarchy and traversal algorithm are unchanged.
178
179The copy contract preserves the prototype and enumerable values, including symbol keys. It shares child arrays,
180prepared template data and suspended thunks. It does not preserve extra non-enumerable properties or descriptor flags;
181enumerable getters are evaluated and copied as values. This is an intentional change from descriptor copying.
182
183Validation passed: `pnpm lint-fix`, `pnpm check`, and 988 tests across `SchemaAST.test.ts`, `Schema.test.ts`,
184`toCodec.test.ts`, `toIso.test.ts`, `SchemaCompilerConstruction.test.ts` and `SchemaJITCompiler.test.ts`. Six added tests
185cover frozen source nodes and custom prototypes, unchanged-node identity, encoding removal, context ownership,
186template preparation and shared suspended thunks. After type-only corrections, the 69 AST tests also passed again.
187
188The runtime comparison uses `pnpm runtimeperf-compare` against `HEAD` at
189`80670a2b6f881f846c3945bb0cfcc77b87a47161`, with the modified working tree as head. Environment is Node v24.12.0,
190V8 13.6.233.17-node.37, Darwin arm64 on Apple M3. Every comparison completed all 20 paired rounds in separate processes,
191alternating base/head order. Each worker validates the fixture before and after timing. Initial runs use 150 ms warmup
192and 500 ms timing. These measure repeated construction of fresh schemas after warmup, excluding imports, process
193startup and decoding. They do not measure complete serverless startup.
194
195| Case | Base median | Updated median | Paired change | 95% interval | Classification |
196| ---------------------------------------------------- | ----------: | -------------: | ------------: | ------------------ | -------------- |
197| Product with small nested structs, checks and arrays | 63.96 µs | 40.90 µs | -33.79% | -37.71% to -32.87% | Improvement |
198| Record with a newly constructed key transformation | 6.62 µs | 3.95 µs | -40.60% | -45.23% to -34.76% | Improvement |
199| Plain two-field Struct, initial run | 1.38 µs | 1.41 µs | +6.26% | -2.84% to +14.61% | Inconclusive |
200| Plain two-field Struct, longer run | 1.33 µs | 1.39 µs | +3.24% | -2.50% to +7.72% | Inconclusive |
201
202The longer control run uses 500 ms warmup and 1,500 ms timing per worker. It was run because the initial control was
203too variable. Both results are retained; a small regression on plain Struct construction cannot be excluded. The
204paired change is computed from paired log ratios, not the ratio of the two displayed medians. Confidence intervals
205use 10,000 deterministic bootstrap samples, with the standard 2% improvement and 5% regression thresholds.
206
207Reproduction commands:
208
209```sh
210pnpm runtimeperf-compare schema-benchmarks/initialization-schema --rounds 20 --time 500 --warmup-time 150
211pnpm runtimeperf-compare schema/schema-creation-encoded-record-effect --rounds 20 --time 500 --warmup-time 150
212pnpm runtimeperf-compare schema/schema-creation-object-2-effect --rounds 20 --time 500 --warmup-time 150
213pnpm runtimeperf-compare schema/schema-creation-object-2-effect --rounds 20 --time 1500 --warmup-time 500
214```
215
216`tmp/schema-ast-copy/results.json` summarizes the runs and names each full report under `tmp/runtimeperf/results/`.
217The directory also retains the measured `SchemaAST.ts` and the two investigation-local bundle fixtures.
218
219The selected bundle comparison rebuilt both the current working tree and the same baseline commit with `pnpm build`:
220
221```sh
222pnpm bundle-compare-selected --base HEAD scratchpad/schema-ast-copy-struct.ts scratchpad/schema-ast-copy-composed.ts
223```
224
225| Fixture | Base gzip size | Updated gzip size | Difference |
226| ----------------------------------------------------------------------- | -------------: | ----------------: | ---------------: |
227| Exported eight-field Struct | 17.90 KB | 17.88 KB | -0.02 KB, -0.12% |
228| Exported composed schema with checks, annotations, context and encoding | 19.30 KB | 19.28 KB | -0.02 KB, -0.10% |
229
230Sizes use the tool's decimal KB units and rounding. The full output is `tmp/schema-ast-copy/bundle.log`. Both builds
231and comparisons passed. The workflow removed its temporary base checkout; the scratch fixtures and summary script
232were removed after archiving the measurements.