Repository navigation
AST nodes as handles, offsets into areanas - #64698
Draft
Jake Bailey (jakebailey) wants to merge 14 commits into
Draft
Jake Bailey (jakebailey) wants to merge 14 commits into
Jake Bailey (jakebailey) wants to merge 14 commits into
Conversation
Member
Author
|
TypeScript Bot (@typescript-bot) perf test this |
Contributor
|
Starting jobs; this comment will be updated as builds start and complete.
|
A data-oriented AST cannot hand out addressable header fields: kind, flags, location, and parent will live in per-kind tables rather than in a Go struct per node. Routing every consumer through accessors first lets the storage change later without touching callers again, and keeps this step reviewable on its own while the pointer representation is still in place. The rewrite was performed by a type-aware codemod keyed on the field objects, so unrelated Kind/Flags/Parent fields (symbols, types, flow nodes) are untouched.
Moving to a data-oriented AST requires that node storage be free to change shape. As long as callers reach into exported struct fields, the storage is the API and cannot be replaced by records in per-kind tables. Make every per-kind node field private and route all reads and writes through generated getters and setters, so a later change can swap the backing representation without touching consumers again.
Once AST nodes become value handles rather than pointers, absence is tested with IsNil() instead of comparing against nil. Early returns guarded that way only fire when the child is missing, so they must not be reported as skipping child checks.
Coalesce, CheckEachDefined, and SingleElementSlice only accepted pointers. AST nodes are about to become small value handles whose absent state is the zero value, so these helpers need to treat the zero value of any comparable type as "missing".
The goal is an AST whose nodes are compact records in per-file storage, addressed by index rather than by pointer, so that the garbage collector has far fewer pointers to trace and node data can be laid out by kind. That is impossible while every API traffics in *ast.Node and per-kind structs that callers can reach into directly. Make Node a small value type wrapping its storage, and make each per-kind type (Identifier, CallExpression, ...) a typed view over a Node rather than a struct with fields. All field access goes through generated methods on the views, absence is expressed by the zero handle and IsNil(), and the backing data structs become private to the ast package. Storage is still one allocation per node for now; changing the representation behind the handle no longer requires touching any code outside the ast package.
Node handles still pointed at individually allocated nodes, so every
child, parent, and sibling link was a pointer the GC had to trace and
each node was its own allocation. Moving each factory's nodes into
per-layout record tables owned by a store lets node-to-node links be
32-bit references, with a Node being just the store plus a row.
Parsed stores are compacted into exactly sized tables once the parse
finishes; rows keep their numbers, so outstanding Nodes stay valid.
Kind aliases now clone into the other kind instead of mutating it,
since a node's kind determines its record table.
The extra indirection on every field access is not yet paid for. On
VS Code's src:
│ main │ store │
│ check-sec │ check-sec vs base │
VSCode 7.171 ± 18% 8.725 ± 2% +21.66% (p=0.002 n=6)
│ main │ store │
│ total-sec │ total-sec vs base │
VSCode 8.198 ± 22% 9.907 ± 2% +20.83% (p=0.041 n=6)
│ main │ store │
│ mem-B │ mem-B vs base │
VSCode 3.302Gi ± 0% 3.505Gi ± 0% +6.15% (p=0.002 n=6)
Node records still held strings, node lists, symbols, flow nodes, and
locals, so the garbage collector had to scan all node memory even
though node-to-node links were already plain integers. Moving every
pointer-valued field into per-store side tables, addressed by a slot
in the record, leaves the records pointer-free and lets them be
allocated noscan. Slots are only allocated for non-zero values, and
the side tables are compacted after parsing and binding like the
records.
GC mark work on VS Code's src drops from about 10.7s to 8.3s of CPU,
but the extra side-table lookups cost the mutator more than the
concurrent collector saves:
│ main │ store │ noscan │
│ check-sec │ check-sec vs base │ check-sec vs base │
VSCode 7.171 ± 18% 8.725 ± 2% +21.66% (p=0.002 n=6) 9.049 ± 2% +26.19% (p=0.002 n=6)
│ main │ store │ noscan │
│ mem-B │ mem-B vs base │ mem-B vs base │
VSCode 3.302Gi ± 0% 3.505Gi ± 0% +6.15% (p=0.002 n=6) 3.598Gi ± 0% +8.95% (p=0.002 n=6)
String fields were kept as string headers in a side table, so every identifier and literal still cost a pointer for the collector to trace and a 16-byte slot. Nearly all of that text is a substring of the file being parsed, so it can be referenced as an offset and length into the source; the rest is copied into a per-store byte arena that holds no pointers. Reading a field builds the string in place, without allocating.
Every AST field read goes through the store to find a node's record.
Before, that lookup chose between a flat array and a segmented tail,
and the helpers involved were over the inlining budget, so each read
made several calls. Profiles showed the lookup among the hottest code
in the checker.
Records now live in fixed-size pages that never move, so building a
store cannot invalidate a record. Once a parse completes, each layout
is compacted into one exactly sized array and sealed, and record
lookup for a sealed layout is a single multiply-add from a per-store
base. Side tables become plain slices, and foreign refs are read
without a lock. The lookup, deref, and side-table and text getters
are now all inlined into the accessors.
Sealing means a parsed file's store can no longer grow after its
parse; only the pinned layouts (SourceFile and flow data) still
accept new records.
vscode, --noEmit, compared with main:
│ main │ base │
│ check-sec │ check-sec vs base │
VSCode 7.164 ± 2% 8.712 ± 1% +21.62% (p=0.000 n=10)
│ main │ base │
│ total-sec │ total-sec vs base │
VSCode 8.111 ± 2% 9.921 ± 1% +22.32% (p=0.000 n=10)
│ main │ base │
│ mem-B │ mem-B vs base │
VSCode 3.302Gi ± 0% 3.428Gi ± 0% +3.84% (p=0.000 n=10)
The previous commit measured +23.90% check time and +8.16% memory
against the same baseline.
Indexing records by (store, row) made every field read a multi-step
table lookup that the compiler could not inline, so accessor time grew
tenfold while the GC savings were small. Pointing the handle straight
at its record keeps reads as cheap as on main, while the records stay
pointer-free so the GC still skips them.
Records are bump-allocated into chunks whose type has a single pointer
word (the owning store), so the GC scans one word per chunk. Child
references inside a chunk are self-relative offsets; anything else
goes through a small per-store table, which keeps deref inlinable.
Chunks grow by 1.25x rather than from size hints, since record size
per source byte varies too much between files for hints to be useful.
│ main │ arena │
│ check-sec │ check-sec vs base │
VSCode 7.164 ± 2% 7.614 ± 1% +6.29% (p=0.000 n=10)
│ main │ arena │
│ total-sec │ total-sec vs base │
VSCode 8.111 ± 2% 8.504 ± 1% +4.85% (p=0.000 n=10)
│ main │ arena │
│ mem-B │ mem-B vs base │
VSCode 3.302Gi ± 0% 3.455Gi ± 0% +4.66% (p=0.000 n=10)
Self-relative refs needed the referring record's own position and a
branch to tell near refs from far ones, and anything outside the chunk
went through a locked far table. That bookkeeping cost 6-8% of
check time against main, more than the garbage collector saved.
A ref is now the target record's address stored as a uintptr, so
following one is a single load. The collector cannot see these refs,
so each store keeps its chunks and records the other stores and
standalone records its fields point into. The cost is 8-byte refs
instead of 4-byte ones.
│ relative │ absolute │
│ check-sec │ check-sec vs base │
VSCode 7.847 ± 2% 7.408 ± 1% -5.59% (p=0.000 n=10)
│ relative │ absolute │
│ mem-B │ mem-B vs base │
VSCode 3.455Gi ± 0% 3.623Gi ± 0% +4.85% (p=0.000 n=10)
Records never move, so the unused end of each store's last chunk is
lost for the life of the file. Growing by a quarter of the store, with
a 4KB minimum, left a lot of that tail, especially in the many tiny
stores created for lazily parsed JSDoc. Growing by an eighth from a
512-byte minimum, with finer size classes for small chunks, keeps the
tail short without measurably more allocation work.
│ quarter │ eighth │
│ check-sec │ check-sec vs base │
VSCode 7.408 ± 1% 7.335 ± 1% ~ (p=0.190 n=10)
│ quarter │ eighth │
│ mem-B │ mem-B vs base │
VSCode 3.623Gi ± 0% 3.527Gi ± 0% -2.63% (p=0.000 n=10)
Every node carries the header, so its padding was paid millions of
times over. Reordering the fields and storing the record's chunk offset
in 16 bits removes the padding; chunks are capped at 512KB so every
offset fits. An assertion keeps the header from growing again.
│ hdr40 │ hdr32 │
│ check-sec │ check-sec vs base │
VSCode 7.335 ± 1% 7.344 ± 1% ~ (p=0.912 n=10)
│ hdr40 │ hdr32 │
│ mem-B │ mem-B vs base │
VSCode 3.527Gi ± 0% 3.412Gi ± 0% -3.28% (p=0.000 n=10)
Records embedded shared base structs (declaration, function-like, and
so on), each padded to its own alignment, and every base field read
went through a switch on the node kind to find the right embedded
struct. Laying each record out as the header followed by all of its
fields, sorted by alignment, removes the padding between the parts,
and a per-kind table of field offsets replaces the switch with a
single indexed load.
│ nested │ flat │
│ check-sec │ check-sec vs base │
VSCode 7.344 ± 1% 7.391 ± 2% ~ (p=0.542 n=10)
│ nested │ flat │
│ mem-B │ mem-B vs base │
VSCode 3.412Gi ± 0% 3.398Gi ± 0% -0.39% (p=0.000 n=10)
Jake Bailey (jakebailey)
force-pushed
the
arena-ast
branch
from
October 15, 2026 06:27
64c72a5 to
55a5e79
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
WIP, this is unchecked slop
Built on top of #64063