Skip to content

AST nodes as handles, offsets into areanas - #64698

Draft
Jake Bailey (jakebailey) wants to merge 14 commits into
microsoft:mainfrom
jakebailey:arena-ast
Draft

Jake Bailey (jakebailey) wants to merge 14 commits into
microsoft:mainfrom
jakebailey:arena-ast

Conversation

@jakebailey

Copy link
Copy Markdown
Member

WIP, this is unchecked slop

Built on top of #64063

@typescript-automation typescript-automation Bot added Author: Team For Uncommitted Bug PR for untriaged, rejected, closed or missing bug labels Oct 8, 2026
@jakebailey

Copy link
Copy Markdown
Member Author

TypeScript Bot (@typescript-bot) perf test this

@typescript-automation

typescript-automation Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Starting jobs; this comment will be updated as builds start and complete.

Command Status Results
perf test this ✅ Started

@jakebailey Jake Bailey (jakebailey) changed the title AST arenas AST nodes as handles, offsets into areanas Oct 8, 2026
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)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Author: Team For Uncommitted Bug PR for untriaged, rejected, closed or missing bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant