| 1 | # AST allocation and copying, 2026-09-25 |
| 2 | |
| 3 | The existing AST classes already provide cheap allocation with stable field layouts. The first architectural change |
| 4 | worth investigating is a shared, inexpensive path for immutable updates. Descriptor-based copying is substantially |
| 5 | more expensive than creating the nodes themselves. Specialized constructors and a generic value-copy prototype both |
| 6 | remove most of this cost in the measured workloads. The measurements do not establish that rewriting every node into |
| 7 | a new class hierarchy would improve schema startup further. |
| 8 | |
| 9 | This is an investigation of `SchemaAST.ts` at `80670a2b6f`, after the duplicate-check and `mapOrSame` optimizations. |
| 10 | The 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 |
| 12 | implementation and its validation. |
| 13 | |
| 14 | ## Current representation |
| 15 | |
| 16 | All 21 AST variants already use classes derived from `ASTNodeImpl`. Their class expressions execute once at module |
| 17 | initialization. Renaming them to `ObjectsImpl`, for example, would help internal references but would not itself make |
| 18 | allocation 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` |
| 32 | only stores its name and type. The fixed shape of an `Objects` node does not depend on its number of fields: those live |
| 33 | in `propertySignatures`, rather than becoming JavaScript properties of the node itself. |
| 34 | |
| 35 | The expensive generic path is `modifyOwnPropertyDescriptors`, used by `replaceChecks`, `replaceEncoding`, |
| 36 | `replaceContext`, `annotate` and check promotion inside `toType`. It creates the descriptor table and descriptor |
| 37 | objects, 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 |
| 40 | promote encoding checks. `flipEncoding` also removes encoding through a copy before recursion. These are opportunities |
| 41 | to 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 |
| 44 | options and adapters, and calls `SchemaAST.toType` through `SchemaParser.make`. Making AST allocation cheap does not |
| 45 | remove all of this work. |
| 46 | |
| 47 | ## Direct V8 inspection |
| 48 | |
| 49 | Environment: Node v24.12.0, V8 13.6.233.17-node.37, Apple M3, Darwin 23.6.0 arm64. |
| 50 | The diagnostic used `%HaveSameMap`, `%HasFastProperties` and `%DebugPrint` in a separate process with |
| 51 | `--allow-natives-syntax`. That flag was not used for timing runs. |
| 52 | |
| 53 | For 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 | |
| 59 | For 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 | |
| 67 | An `Object.create` plus `Object.assign` copy also has a different map from the constructor-created node. This supports |
| 68 | investigating constructor copies, but does not prove a material end-to-end advantage over all generic copies. |
| 69 | See the retained `v8-layout.txt` for the actual dump. These details are observations of this V8 version, not language |
| 70 | guarantees. 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 | |
| 75 | The 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 |
| 77 | object. This prototype deliberately assumes standard built-in nodes. It is not a compatible replacement for arbitrary |
| 78 | AST objects. |
| 79 | |
| 80 | Each case has 20 paired rounds in fresh Node processes. Pair order alternates and case order rotates. AST cases retain |
| 81 | 10,000 results; Schema cases retain 1,000 new root schemas. There is no explicit warmup, but imports can warm shared |
| 82 | code, and V8 can optimize during the batch. Imports, process startup and decoding are excluded from elapsed time. |
| 83 | Valid decode, invalid input rejection and type-side construction run in separate validation processes. Prepared |
| 84 | property signatures are reused only in the two AST-only cases. Nested and composed Schema cases include child creation. |
| 85 | |
| 86 | All 280 measured processes and 14 validation processes completed. Delta is the median paired ratio, with a 95% |
| 87 | process-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 | |
| 99 | The raw constructor is unchanged between versions, so its apparent improvement is not evidence of a better constructor. |
| 100 | The candidate also changes module-initialization objects and therefore can change V8 feedback before timing starts. |
| 101 | The large gains demonstrate the potential of cheaper copying in these fixtures. They do not measure complete application |
| 102 | startup 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 | |
| 106 | A second experiment substitutes `Object.assign(Object.create(Object.getPrototypeOf(ast)), ast)` for the prototype's |
| 107 | constructor switch. This uses the same metadata update callers. Twenty paired rounds compare it directly with the |
| 108 | constructor 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 | |
| 117 | None meets the configured 5% regression threshold with its entire interval. In these Schema workloads, specialized |
| 118 | constructor copies have no demonstrated material advantage over this small generic helper. This matters for the |
| 119 | preference for simple code and a small bundle. Bundle size was not measured for these diagnostic prototypes. |
| 120 | |
| 121 | ## Proposed design |
| 122 | |
| 123 | Keep one class per AST kind, defined once, with fixed own fields, immutable child references and prototype methods. |
| 124 | The existing representation is a useful foundation. A uniform class containing every variant's fields would increase |
| 125 | node size and does not have evidence supporting it here. Moving every parser method off the prototypes or replacing |
| 126 | string tags with numbers also has no measured justification from this study. |
| 127 | |
| 128 | Make node allocation and immutable updates one coherent internal mechanism: |
| 129 | |
| 130 | 1. 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. |
| 133 | 2. 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. |
| 136 | 3. 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. |
| 139 | 4. 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 | |
| 144 | Start with the smallest common copy mechanism whose contract is acceptable. The generic value-copy candidate deserves |
| 145 | consideration before adding a per-kind copying method or constructor switch. If specialized copies are adopted, use |
| 146 | direct calls to the relevant internal class; dynamically invoking `this.constructor` is not equivalent to a known |
| 147 | constructor, and does not safely handle subclass contracts. |
| 148 | |
| 149 | There is a concrete compatibility decision before adopting either prototype. The current descriptor path preserves |
| 150 | extra non-enumerable properties, getter definitions, descriptor flags and custom prototypes. The local contract probe |
| 151 | confirmed that constructor copies drop extensions and subclasses; assign preserves enumerable symbols and the prototype |
| 152 | but drops non-enumerable properties, invokes getters, and changes descriptor flags. Frozen source nodes also expose |
| 153 | different property flags in the resulting copy. Neither prototype is therefore a drop-in implementation. |
| 154 | |
| 155 | A fast path must have a defensible definition of a standard internal node. Merely checking `_tag`, its prototype or |
| 156 | membership in a WeakSet does not prove that descriptors have not been changed after construction. Scanning every |
| 157 | descriptor to establish this on each copy could consume the intended saving. That contract should be settled explicitly |
| 158 | when 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 | |
| 167 | The diagnostic workers checked normal fixture behavior before measurement in separate processes. Those prototype |
| 168 | checks are not a full Schema/compiler regression test or a claim of production compatibility. The prototype |
| 169 | measurements establish no bundle-size conclusion. Their temporary worktrees and runnable scratch files were removed |
| 170 | after archiving the evidence. |
| 171 | |
| 172 | ## Implemented value copies |
| 173 | |
| 174 | The implementation replaces the descriptor helper with |
| 175 | `Object.assign(Object.create(Object.getPrototypeOf(ast)), ast)`. Checks, encoding, context, annotations and the existing |
| 176 | check-promotion step in `toType` copy through this helper and update the new node before publishing it. The class |
| 177 | hierarchy and traversal algorithm are unchanged. |
| 178 | |
| 179 | The copy contract preserves the prototype and enumerable values, including symbol keys. It shares child arrays, |
| 180 | prepared template data and suspended thunks. It does not preserve extra non-enumerable properties or descriptor flags; |
| 181 | enumerable getters are evaluated and copied as values. This is an intentional change from descriptor copying. |
| 182 | |
| 183 | Validation 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 |
| 185 | cover frozen source nodes and custom prototypes, unchanged-node identity, encoding removal, context ownership, |
| 186 | template preparation and shared suspended thunks. After type-only corrections, the 69 AST tests also passed again. |
| 187 | |
| 188 | The runtime comparison uses `pnpm runtimeperf-compare` against `HEAD` at |
| 189 | `80670a2b6f881f846c3945bb0cfcc77b87a47161`, with the modified working tree as head. Environment is Node v24.12.0, |
| 190 | V8 13.6.233.17-node.37, Darwin arm64 on Apple M3. Every comparison completed all 20 paired rounds in separate processes, |
| 191 | alternating base/head order. Each worker validates the fixture before and after timing. Initial runs use 150 ms warmup |
| 192 | and 500 ms timing. These measure repeated construction of fresh schemas after warmup, excluding imports, process |
| 193 | startup 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 | |
| 202 | The longer control run uses 500 ms warmup and 1,500 ms timing per worker. It was run because the initial control was |
| 203 | too variable. Both results are retained; a small regression on plain Struct construction cannot be excluded. The |
| 204 | paired change is computed from paired log ratios, not the ratio of the two displayed medians. Confidence intervals |
| 205 | use 10,000 deterministic bootstrap samples, with the standard 2% improvement and 5% regression thresholds. |
| 206 | |
| 207 | Reproduction commands: |
| 208 | |
| 209 | ```sh |
| 210 | pnpm runtimeperf-compare schema-benchmarks/initialization-schema --rounds 20 --time 500 --warmup-time 150 |
| 211 | pnpm runtimeperf-compare schema/schema-creation-encoded-record-effect --rounds 20 --time 500 --warmup-time 150 |
| 212 | pnpm runtimeperf-compare schema/schema-creation-object-2-effect --rounds 20 --time 500 --warmup-time 150 |
| 213 | pnpm 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/`. |
| 217 | The directory also retains the measured `SchemaAST.ts` and the two investigation-local bundle fixtures. |
| 218 | |
| 219 | The selected bundle comparison rebuilt both the current working tree and the same baseline commit with `pnpm build`: |
| 220 | |
| 221 | ```sh |
| 222 | pnpm 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 | |
| 230 | Sizes use the tool's decimal KB units and rounding. The full output is `tmp/schema-ast-copy/bundle.log`. Both builds |
| 231 | and comparisons passed. The workflow removed its temporary base checkout; the scratch fixtures and summary script |
| 232 | were removed after archiving the measurements. |